From 10d7ce89b88e7480d8144c50180a66e1af476b9d Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Thu, 20 Nov 2025 12:38:44 -0800 Subject: [PATCH 01/28] feat: add formdata to custom method in ocotillo data provider --- src/providers/ocotillo-data-provider.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/providers/ocotillo-data-provider.ts b/src/providers/ocotillo-data-provider.ts index 57463e7f..46285b6c 100644 --- a/src/providers/ocotillo-data-provider.ts +++ b/src/providers/ocotillo-data-provider.ts @@ -213,11 +213,13 @@ export const ocotilloDataProvider: DataProvider = { } }, custom: async ({ url, method, payload, headers }) => { + const isFormData = payload instanceof FormData + const config: AxiosRequestConfig = { url: `${API_URL}/${url}`, method: method || 'GET', headers: { - 'Content-Type': 'application/json', + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...headers, }, } @@ -226,12 +228,20 @@ export const ocotilloDataProvider: DataProvider = { config.data = payload } - const response = await axiosInstance(config) + try { + const response = await axiosInstance(config) - if (response.status < 200 || response.status > 299) throw response + if (response.status < 200 || response.status > 299) throw response - return { data: response.data } + return { data: response.data } + } catch (error: any) { + /** + * TODO: Add better error handling for bulk import based on API Pydantic validation errors + */ + throw error + } }, + update: async ({ resource, id, variables }) => { resource = cleanResourceName(resource) From 7bb661576d361abe4a42c5a4b416f04535168140 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Thu, 20 Nov 2025 12:39:37 -0800 Subject: [PATCH 02/28] feat: add well-inventory-bulk-import to AMPEditor list --- src/providers/access-control-provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/providers/access-control-provider.ts b/src/providers/access-control-provider.ts index 0fbe3678..6b29a248 100644 --- a/src/providers/access-control-provider.ts +++ b/src/providers/access-control-provider.ts @@ -62,6 +62,7 @@ const defineUserAbility = (groups: string[]) => { can('list', 'ocotillo.apps') can('list', 'ocotillo.water-chemistry-import') + can('list', 'ocotillo.well-inventory-bulk-import') can('list', 'ocotillo.hydrograph-corrector') } From 7d39f7759c24d4d9c6599d56e37dbac260ce9339 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Thu, 20 Nov 2025 12:40:08 -0800 Subject: [PATCH 03/28] feat: add well-inventory-bulk-import to apps resource --- src/resources/ocotillo.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/resources/ocotillo.tsx b/src/resources/ocotillo.tsx index 2e71e466..3f0514d6 100644 --- a/src/resources/ocotillo.tsx +++ b/src/resources/ocotillo.tsx @@ -15,6 +15,7 @@ import { Workspaces, MoreVertOutlined, LibraryBooksOutlined, + UploadFile, } from '@mui/icons-material' let tables: { @@ -341,6 +342,16 @@ let ocotillo = [ icon: , }, }, + { + name: 'well-inventory-bulk-import', + list: '/ocotillo/well-inventory-bulk-import', + meta: { + label: 'Well Inventory Bulk Import', + parent: 'ocotillo.apps', + nestedLevel: 2, + icon: , + }, + }, { name: 'forms', icon: , From a191eae365e6cacf399d21aaf3366f7d44d3aacf Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Thu, 20 Nov 2025 12:40:25 -0800 Subject: [PATCH 04/28] feat: add well-inventory-bulk-import route --- src/routes/ocotillo.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/ocotillo.tsx b/src/routes/ocotillo.tsx index 3814d9a3..8d72236f 100644 --- a/src/routes/ocotillo.tsx +++ b/src/routes/ocotillo.tsx @@ -69,6 +69,7 @@ import { GroundwaterLevelForm } from '@/pages/ocotillo/groundwater-level-form/st import { WellInventoryForm } from '@/pages/ocotillo/well-inventory-form' import { LexiconList } from '@/pages/ocotillo/lexicon' import { WaterChemistryApp } from '@/pages/ocotillo/water-chemistry-app' +import { WellInventoryBulkImport } from '@/pages/ocotillo/well-inventory-bulk-import' import { WellScreenCreate, WellScreenEdit, @@ -161,6 +162,9 @@ export const OcotilloRoutes = () => { } /> + + } /> + // Forms } /> From fb8cf136d99e22f1a8d16b400aa479cf33ad48b9 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Thu, 20 Nov 2025 12:40:52 -0800 Subject: [PATCH 05/28] feat: MVP well inventory bulk import page --- .../well-inventory-bulk-import/index.tsx | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 src/pages/ocotillo/well-inventory-bulk-import/index.tsx diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx new file mode 100644 index 00000000..b28d3049 --- /dev/null +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -0,0 +1,254 @@ +import { + Box, + Button, + Stack, + Typography, + Card, + Alert, + Chip, + List, + ListItem, + ListItemText, + Divider, +} from '@mui/material' +import { LoadingButton } from '@mui/lab' +import { Create } from '@refinedev/mui' +import { useNotification, useDataProvider } from '@refinedev/core' +import { useState } from 'react' +import FileUploadIcon from '@mui/icons-material/FileUpload' +import InfoIcon from '@mui/icons-material/Info' + +interface UploadResult { + validation_errors: any[] + summary: { + total_rows_processed: number + total_rows_imported: number + validation_errors_or_warnings: number + } + wells: string[] +} + +export const WellInventoryBulkImport: React.FC = () => { + const [selectedFile, setSelectedFile] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [uploadResult, setUploadResult] = useState(null) + const { open: openNotification } = useNotification() + const dataProvider = useDataProvider() + const provider = dataProvider('ocotillo') + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + setSelectedFile(file) + } + } + + const handleSubmit = async () => { + if (!selectedFile) { + openNotification({ + message: 'No file selected', + description: 'Please select a CSV file to upload.', + type: 'error', + }) + return + } + + setIsSubmitting(true) + + try { + const formData = new FormData() + formData.append('file', selectedFile) + + const result = await provider.custom({ + url: 'well-inventory-csv', + method: 'post', + payload: formData, + headers: {}, + }) + + if (result?.data) { + setUploadResult(result.data as UploadResult) + } + + openNotification({ + message: 'Upload successful', + description: 'The well inventory file has been imported successfully.', + type: 'success', + }) + setSelectedFile(null) + // Reset file input + const fileInput = document.getElementById('csv-input') as HTMLInputElement + if (fileInput) { + fileInput.value = '' + } + } catch (error: any) { + console.error('Error uploading file:', error) + const errorMessage = error.message || 'An error occurred while uploading the file.' + + openNotification({ + message: 'Upload failed', + description: errorMessage, + type: 'error', + }) + setUploadResult(null) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + setSelectedFile(null) + setUploadResult(null) + const fileInput = document.getElementById('csv-input') as HTMLInputElement + if (fileInput) { + fileInput.value = '' + } + } + + return ( + + Well Inventory Bulk Import + + } + saveButtonProps={{ + onClick: handleSubmit, + disabled: !selectedFile || isSubmitting, + loading: isSubmitting, + }} + > + + {!uploadResult && ( + <> + + + Upload a CSV file to bulk import well inventory data. + + + + + + + + )} + + {uploadResult && ( + + + + + Upload Completed Successfully! + + + The well inventory file has been imported successfully. + + + + + + Import Summary + + + + + + + + + {uploadResult.wells && uploadResult.wells.length > 0 && ( + + + Imported Wells ({uploadResult.wells.length}) + + + {uploadResult.wells.map((well, index) => ( +
+ + + + {index < uploadResult.wells.length - 1 && } +
+ ))} +
+
+ )} + + {uploadResult.validation_errors && + uploadResult.validation_errors.length > 0 && ( + + }> + + Validation Warnings + + + {uploadResult.validation_errors.map((error, index) => ( + + + + ))} + + + + )} + + + + +
+
+ )} +
+
+ ) +} + From c5c75bbe5f9294df48fee41b7da07e19a6857707 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Thu, 20 Nov 2025 12:58:18 -0800 Subject: [PATCH 06/28] fix: change button wording --- src/pages/ocotillo/well-inventory-bulk-import/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index b28d3049..1e7df771 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -113,6 +113,7 @@ export const WellInventoryBulkImport: React.FC = () => { } saveButtonProps={{ + children: 'Upload', onClick: handleSubmit, disabled: !selectedFile || isSubmitting, loading: isSubmitting, @@ -144,7 +145,7 @@ export const WellInventoryBulkImport: React.FC = () => { endIcon={} disabled={isSubmitting} > - Upload File + Select File {selectedFile && ( From d31472153d7a7a9400ce04a698f5edb96979504c Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Fri, 21 Nov 2025 09:14:55 -0800 Subject: [PATCH 07/28] feat: attempt at validation error handling --- .../well-inventory-bulk-import/index.tsx | 94 ++++++++++++++----- src/providers/ocotillo-data-provider.ts | 10 +- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 1e7df771..b52f8f4a 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -19,7 +19,12 @@ import FileUploadIcon from '@mui/icons-material/FileUpload' import InfoIcon from '@mui/icons-material/Info' interface UploadResult { - validation_errors: any[] + validation_errors: Array<{ + row: number + field: string + error: string + value?: string + }> summary: { total_rows_processed: number total_rows_imported: number @@ -83,14 +88,26 @@ export const WellInventoryBulkImport: React.FC = () => { } } catch (error: any) { console.error('Error uploading file:', error) - const errorMessage = error.message || 'An error occurred while uploading the file.' - openNotification({ - message: 'Upload failed', - description: errorMessage, - type: 'error', - }) - setUploadResult(null) + // Handle 422 validation errors from bulk import + if (error.status === 422 && error.data) { + setUploadResult(error.data as UploadResult) + const errorCount = error.data.summary?.validation_errors_or_warnings || 0 + openNotification({ + message: 'Upload failed - Validation Errors', + description: `${errorCount} validation error(s) found. No wells were imported.`, + type: 'error', + }) + } else { + const errorMessage = error.message || 'An error occurred while uploading the file.' + + openNotification({ + message: 'Upload failed', + description: errorMessage, + type: 'error', + }) + setUploadResult(null) + } } finally { setIsSubmitting(false) } @@ -161,12 +178,25 @@ export const WellInventoryBulkImport: React.FC = () => { - - Upload Completed Successfully! - - - The well inventory file has been imported successfully. - + {uploadResult.summary.total_rows_imported > 0 ? ( + <> + + Upload Completed Successfully! + + + The well inventory file has been imported successfully. + + + ) : ( + <> + + Upload Failed - Validation Errors + + + No wells were imported due to validation errors. Please review the errors below and fix your CSV file. + + + )} @@ -176,19 +206,21 @@ export const WellInventoryBulkImport: React.FC = () => { 0 ? "success" : "default"} variant="outlined" /> 0 ? "success" : "error"} variant="outlined" /> + {uploadResult.summary.validation_errors_or_warnings > 0 && ( + )} @@ -223,16 +255,32 @@ export const WellInventoryBulkImport: React.FC = () => { {uploadResult.validation_errors && uploadResult.validation_errors.length > 0 && ( - }> + 0 ? "warning" : "error"} + icon={} + > - Validation Warnings + Validation Errors {uploadResult.validation_errors.map((error, index) => ( - + + Row {error.row} - {error.field} + + } + secondary={ + + {error.error} + {error.value !== undefined && error.value !== '' && ( + (Value: "{error.value}") + )} + + } /> + {index < uploadResult.validation_errors.length - 1 && } ))} @@ -242,7 +290,7 @@ export const WellInventoryBulkImport: React.FC = () => { diff --git a/src/providers/ocotillo-data-provider.ts b/src/providers/ocotillo-data-provider.ts index 46285b6c..9a8b2ae6 100644 --- a/src/providers/ocotillo-data-provider.ts +++ b/src/providers/ocotillo-data-provider.ts @@ -238,7 +238,15 @@ export const ocotilloDataProvider: DataProvider = { /** * TODO: Add better error handling for bulk import based on API Pydantic validation errors */ - throw error + if (error.response?.status === 422 && error.response?.data?.validation_errors) { + const transformedError = new Error('Validation errors occurred during import') + ;(transformedError as any).status = error.response.status + ;(transformedError as any).data = error.response.data + ;(transformedError as any).message = 'Validation errors occurred during import' + throw transformedError + } else { + throw error + } } }, From be211f560e018a916c6b163189134c843a55c1c4 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Fri, 21 Nov 2025 12:17:58 -0800 Subject: [PATCH 08/28] feat: first attempt well inventory table entry --- .../well-inventory-bulk-import/index.tsx | 737 +++++++++++++++++- .../well-inventory-bulk-import/schema.ts | 201 +++++ .../well-inventory-bulk-import/utils.ts | 261 +++++++ 3 files changed, 1167 insertions(+), 32 deletions(-) create mode 100644 src/pages/ocotillo/well-inventory-bulk-import/schema.ts create mode 100644 src/pages/ocotillo/well-inventory-bulk-import/utils.ts diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index b52f8f4a..3de58b41 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -10,13 +10,20 @@ import { ListItem, ListItemText, Divider, + IconButton, + Tooltip, } from '@mui/material' import { LoadingButton } from '@mui/lab' import { Create } from '@refinedev/mui' import { useNotification, useDataProvider } from '@refinedev/core' -import { useState } from 'react' +import { useState, useMemo } from 'react' import FileUploadIcon from '@mui/icons-material/FileUpload' import InfoIcon from '@mui/icons-material/Info' +import AddIcon from '@mui/icons-material/Add' +import DeleteIcon from '@mui/icons-material/Delete' +import { DataGrid, type GridColDef, type GridRowModel } from '@mui/x-data-grid' +import { parseCSV, validateAllRows } from './utils' +import { wellInventoryRowSchema, type WellInventoryRow } from './schema' interface UploadResult { validation_errors: Array<{ @@ -33,26 +40,278 @@ interface UploadResult { wells: string[] } +interface TableRow extends Omit { + id: number + _errors?: string[] + utm_easting?: number | string + utm_northing?: number | string + utm_zone?: number | string + elevation_ft?: number | string + measuring_point_height_ft?: number | string +} + export const WellInventoryBulkImport: React.FC = () => { - const [selectedFile, setSelectedFile] = useState(null) + const [rows, setRows] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) const [uploadResult, setUploadResult] = useState(null) + const [validationErrors, setValidationErrors] = useState>(new Map()) + const [fieldErrors, setFieldErrors] = useState>>(new Map()) // Map of "rowId-fieldName" to error messages const { open: openNotification } = useNotification() const dataProvider = useDataProvider() const provider = dataProvider('ocotillo') - const handleFileChange = (event: React.ChangeEvent) => { + const handleCSVImport = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] - if (file) { - setSelectedFile(file) + if (!file) return + + try { + const parsedRows = await parseCSV(file) + const newRows: TableRow[] = parsedRows.map((row, index) => ({ + ...row, + id: Date.now() + index, + })) + setRows(newRows) + + // Validate imported rows + const errors = validateAllRows(newRows) + const errorMap = new Map() + const fieldErrorMap = new Map>() + + errors.forEach(({ rowIndex, errors }) => { + const tableRow = newRows[rowIndex - 1] + if (tableRow) { + errorMap.set(tableRow.id, errors) + + // Extract field-level errors + errors.forEach(error => { + const match = error.match(/^([^:]+):\s*(.+)$/) + if (match) { + const fieldName = match[1].trim() + const errorMessage = match[2].trim() + const key = `${tableRow.id}-${fieldName}` + if (!fieldErrorMap.has(key)) { + fieldErrorMap.set(key, new Set()) + } + fieldErrorMap.get(key)!.add(errorMessage) + } + }) + } + }) + setValidationErrors(errorMap) + setFieldErrors(fieldErrorMap) + + openNotification({ + message: 'CSV imported', + description: `Imported ${newRows.length} row(s). Please review and fix any validation errors.`, + type: 'success', + }) + } catch (error: any) { + openNotification({ + message: 'CSV import failed', + description: error.message || 'Failed to parse CSV file', + type: 'error', + }) + } + + // Reset file input + event.target.value = '' + } + + const handleAddRow = () => { + const newRow: TableRow = { + id: Date.now(), + // Required fields + project: '', + well_name_point_id: '', + site_name: '', + date_time: '', + field_staff: '', + utm_easting: '', + utm_northing: '', + utm_zone: '', + elevation_ft: '', + elevation_method: '', + measuring_point_height_ft: '', + // Optional fields - initialize as empty strings + field_staff_2: '', + field_staff_3: '', + contact_1_name: '', + contact_1_organization: '', + contact_1_role: '', + contact_1_type: '', + contact_1_phone_1: '', + contact_1_phone_1_type: '', + contact_1_phone_2: '', + contact_1_phone_2_type: '', + contact_1_email_1: '', + contact_1_email_1_type: '', + contact_1_email_2: '', + contact_1_email_2_type: '', + contact_1_address_1_line_1: '', + contact_1_address_1_line_2: '', + contact_1_address_1_type: '', + contact_1_address_1_state: '', + contact_1_address_1_city: '', + contact_1_address_1_postal_code: '', + contact_1_address_2_line_1: '', + contact_1_address_2_line_2: '', + contact_1_address_2_type: '', + contact_1_address_2_state: '', + contact_1_address_2_city: '', + contact_1_address_2_postal_code: '', + contact_2_name: '', + contact_2_organization: '', + contact_2_role: '', + contact_2_type: '', + contact_2_phone_1: '', + contact_2_phone_1_type: '', + contact_2_phone_2: '', + contact_2_phone_2_type: '', + contact_2_email_1: '', + contact_2_email_1_type: '', + contact_2_email_2: '', + contact_2_email_2_type: '', + contact_2_address_1_line_1: '', + contact_2_address_1_line_2: '', + contact_2_address_1_type: '', + contact_2_address_1_state: '', + contact_2_address_1_city: '', + contact_2_address_1_postal_code: '', + contact_2_address_2_line_1: '', + contact_2_address_2_line_2: '', + contact_2_address_2_type: '', + contact_2_address_2_state: '', + contact_2_address_2_city: '', + contact_2_address_2_postal_code: '', + directions_to_site: '', + specific_location_of_well: '', + repeat_measurement_permission: '', + sampling_permission: '', + datalogger_installation_permission: '', + public_availability_acknowledgement: '', + result_communication_preference: '', + contact_special_requests_notes: '', + ose_well_record_id: '', + date_drilled: '', + completion_source: '', + total_well_depth_ft: undefined, + historic_depth_to_water_ft: undefined, + depth_source: '', + well_pump_type: '', + well_pump_depth_ft: undefined, + is_open: undefined, + datalogger_possible: undefined, + casing_diameter_ft: undefined, + measuring_point_description: '', + well_purpose: '', + well_purpose_2: '', + well_hole_status: '', + monitoring_frequency: '', + sampling_scenario_notes: '', + well_measuring_notes: '', + sample_possible: undefined, } + setRows([...rows, newRow]) + } + + const handleDeleteRow = (id: number) => { + setRows(rows.filter(row => row.id !== id)) + const newErrors = new Map(validationErrors) + newErrors.delete(id) + setValidationErrors(newErrors) + + // Clear field errors for this row + const newFieldErrors = new Map(fieldErrors) + Array.from(newFieldErrors.keys()) + .filter(key => key.startsWith(`${id}-`)) + .forEach(key => newFieldErrors.delete(key)) + setFieldErrors(newFieldErrors) + } + + const processRowUpdate = (newRow: GridRowModel): GridRowModel => { + const updatedRows = rows.map((row) => (row.id === newRow.id ? (newRow as TableRow) : row)) + setRows(updatedRows) + + // Validate the updated row + const validation = wellInventoryRowSchema.safeParse(newRow) + const errorMap = new Map(validationErrors) + const fieldErrorMap = new Map(fieldErrors) + + // Clear existing errors for this row + Array.from(fieldErrorMap.keys()) + .filter(key => key.startsWith(`${newRow.id}-`)) + .forEach(key => fieldErrorMap.delete(key)) + + const errors: string[] = [] + + if (!validation.success) { + validation.error.issues.forEach(err => { + const field = err.path.join('.') + const errorMsg = `${field}: ${err.message}` + errors.push(errorMsg) + + // Track field-level error + const key = `${newRow.id}-${field}` + if (!fieldErrorMap.has(key)) { + fieldErrorMap.set(key, new Set()) + } + fieldErrorMap.get(key)!.add(err.message) + }) + } + + // Check for duplicate well_name_point_id + if (newRow.well_name_point_id) { + const duplicate = updatedRows.find( + (row) => row.id !== newRow.id && row.well_name_point_id === newRow.well_name_point_id + ) + if (duplicate) { + const errorMsg = `well_name_point_id: Duplicate value "${newRow.well_name_point_id}" found` + errors.push(errorMsg) + + const key = `${newRow.id}-well_name_point_id` + if (!fieldErrorMap.has(key)) { + fieldErrorMap.set(key, new Set()) + } + fieldErrorMap.get(key)!.add(`Duplicate value "${newRow.well_name_point_id}" found`) + } + } + + if (errors.length > 0) { + errorMap.set(newRow.id, errors) + } else { + errorMap.delete(newRow.id) + } + + setValidationErrors(errorMap) + setFieldErrors(fieldErrorMap) + return newRow } const handleSubmit = async () => { - if (!selectedFile) { + if (rows.length === 0) { openNotification({ - message: 'No file selected', - description: 'Please select a CSV file to upload.', + message: 'No data to submit', + description: 'Please add rows to the table or import a CSV file.', + type: 'error', + }) + return + } + + // Validate all rows before submission + const errors = validateAllRows(rows) + if (errors.length > 0) { + const errorMap = new Map() + errors.forEach(({ rowIndex, errors }) => { + const tableRow = rows[rowIndex - 1] + if (tableRow) { + errorMap.set(tableRow.id, errors) + } + }) + setValidationErrors(errorMap) + + openNotification({ + message: 'Validation errors found', + description: `Please fix ${errors.length} validation error(s) before submitting.`, type: 'error', }) return @@ -61,8 +320,13 @@ export const WellInventoryBulkImport: React.FC = () => { setIsSubmitting(true) try { + // Convert rows to CSV format + const csvContent = convertRowsToCSV(rows) + const blob = new Blob([csvContent], { type: 'text/csv' }) + const file = new File([blob], 'well-inventory.csv', { type: 'text/csv' }) + const formData = new FormData() - formData.append('file', selectedFile) + formData.append('file', file) const result = await provider.custom({ url: 'well-inventory-csv', @@ -80,12 +344,6 @@ export const WellInventoryBulkImport: React.FC = () => { description: 'The well inventory file has been imported successfully.', type: 'success', }) - setSelectedFile(null) - // Reset file input - const fileInput = document.getElementById('csv-input') as HTMLInputElement - if (fileInput) { - fileInput.value = '' - } } catch (error: any) { console.error('Error uploading file:', error) @@ -114,14 +372,318 @@ export const WellInventoryBulkImport: React.FC = () => { } const handleReset = () => { - setSelectedFile(null) + setRows([]) setUploadResult(null) + setValidationErrors(new Map()) const fileInput = document.getElementById('csv-input') as HTMLInputElement if (fileInput) { fileInput.value = '' } } + const convertRowsToCSV = (rows: TableRow[]): string => { + const headers = [ + // Required + 'project', + 'well_name_point_id', + 'site_name', + 'date_time', + 'field_staff', + 'utm_easting', + 'utm_northing', + 'utm_zone', + 'elevation_ft', + 'elevation_method', + 'measuring_point_height_ft', + // Optional + 'field_staff_2', + 'field_staff_3', + 'contact_1_name', + 'contact_1_organization', + 'contact_1_role', + 'contact_1_type', + 'contact_1_phone_1', + 'contact_1_phone_1_type', + 'contact_1_phone_2', + 'contact_1_phone_2_type', + 'contact_1_email_1', + 'contact_1_email_1_type', + 'contact_1_email_2', + 'contact_1_email_2_type', + 'contact_1_address_1_line_1', + 'contact_1_address_1_line_2', + 'contact_1_address_1_type', + 'contact_1_address_1_state', + 'contact_1_address_1_city', + 'contact_1_address_1_postal_code', + 'contact_1_address_2_line_1', + 'contact_1_address_2_line_2', + 'contact_1_address_2_type', + 'contact_1_address_2_state', + 'contact_1_address_2_city', + 'contact_1_address_2_postal_code', + 'contact_2_name', + 'contact_2_organization', + 'contact_2_role', + 'contact_2_type', + 'contact_2_phone_1', + 'contact_2_phone_1_type', + 'contact_2_phone_2', + 'contact_2_phone_2_type', + 'contact_2_email_1', + 'contact_2_email_1_type', + 'contact_2_email_2', + 'contact_2_email_2_type', + 'contact_2_address_1_line_1', + 'contact_2_address_1_line_2', + 'contact_2_address_1_type', + 'contact_2_address_1_state', + 'contact_2_address_1_city', + 'contact_2_address_1_postal_code', + 'contact_2_address_2_line_1', + 'contact_2_address_2_line_2', + 'contact_2_address_2_type', + 'contact_2_address_2_state', + 'contact_2_address_2_city', + 'contact_2_address_2_postal_code', + 'directions_to_site', + 'specific_location_of_well', + 'repeat_measurement_permission', + 'sampling_permission', + 'datalogger_installation_permission', + 'public_availability_acknowledgement', + 'result_communication_preference', + 'contact_special_requests_notes', + 'ose_well_record_id', + 'date_drilled', + 'completion_source', + 'total_well_depth_ft', + 'historic_depth_to_water_ft', + 'depth_source', + 'well_pump_type', + 'well_pump_depth_ft', + 'is_open', + 'datalogger_possible', + 'casing_diameter_ft', + 'measuring_point_description', + 'well_purpose', + 'well_purpose_2', + 'well_hole_status', + 'monitoring_frequency', + 'sampling_scenario_notes', + 'well_measuring_notes', + 'sample_possible', + ] + + const csvRows = [ + headers.join(','), + ...rows.map(row => + headers + .map(header => { + const value = row[header as keyof WellInventoryRow] ?? '' + // Convert undefined/null to empty string, keep numbers as strings + const stringValue = value === undefined || value === null ? '' : String(value) + // Escape quotes and wrap in quotes if contains comma or quote + if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) { + return `"${stringValue.replace(/"/g, '""')}"` + } + return stringValue + }) + .join(',') + ), + ] + + return csvRows.join('\n') + } + + const columns: GridColDef[] = useMemo(() => { + const getCellError = (rowId: number, fieldName: string): boolean => { + return fieldErrors.has(`${rowId}-${fieldName}`) + } + + return [ + // Required fields - most important + { + field: 'well_name_point_id', + headerName: 'Well Name/Point ID', + width: 180, + editable: true, + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'well_name_point_id') ? 'error-cell' : '' + }, + }, + { + field: 'project', + headerName: 'Project', + width: 150, + editable: true, + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'project') ? 'error-cell' : '' + }, + }, + { + field: 'site_name', + headerName: 'Site Name', + width: 150, + editable: true, + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'site_name') ? 'error-cell' : '' + }, + }, + { + field: 'date_time', + headerName: 'Date/Time', + width: 200, + editable: true, + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'date_time') ? 'error-cell' : '' + }, + }, + { + field: 'field_staff', + headerName: 'Field Staff', + width: 150, + editable: true, + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'field_staff') ? 'error-cell' : '' + }, + }, + { + field: 'utm_easting', + headerName: 'UTM Easting', + width: 130, + editable: true, + type: 'number', + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'utm_easting') ? 'error-cell' : '' + }, + }, + { + field: 'utm_northing', + headerName: 'UTM Northing', + width: 130, + editable: true, + type: 'number', + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'utm_northing') ? 'error-cell' : '' + }, + }, + { + field: 'utm_zone', + headerName: 'UTM Zone', + width: 100, + editable: true, + type: 'number', + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'utm_zone') ? 'error-cell' : '' + }, + }, + { + field: 'elevation_ft', + headerName: 'Elevation (ft)', + width: 130, + editable: true, + type: 'number', + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'elevation_ft') ? 'error-cell' : '' + }, + }, + { + field: 'elevation_method', + headerName: 'Elevation Method', + width: 150, + editable: true, + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'elevation_method') ? 'error-cell' : '' + }, + }, + { + field: 'measuring_point_height_ft', + headerName: 'MP Height (ft)', + width: 130, + editable: true, + type: 'number', + required: true, + cellClassName: (params) => { + return getCellError(params.row.id, 'measuring_point_height_ft') ? 'error-cell' : '' + }, + }, + // Key optional fields + { + field: 'total_well_depth_ft', + headerName: 'Total Depth (ft)', + width: 140, + editable: true, + type: 'number', + }, + { + field: 'well_purpose', + headerName: 'Well Purpose', + width: 150, + editable: true, + }, + { + field: 'well_hole_status', + headerName: 'Hole Status', + width: 130, + editable: true, + }, + { + field: 'monitoring_frequency', + headerName: 'Monitoring Freq', + width: 150, + editable: true, + }, + { + field: 'contact_1_name', + headerName: 'Contact 1 Name', + width: 150, + editable: true, + }, + { + field: 'contact_1_phone_1', + headerName: 'Contact 1 Phone', + width: 150, + editable: true, + }, + { + field: 'contact_1_email_1', + headerName: 'Contact 1 Email', + width: 180, + editable: true, + }, + { + field: 'actions', + headerName: 'Actions', + width: 100, + sortable: false, + pinned: 'right', + renderCell: (params) => ( + handleDeleteRow(params.row.id)} + color="error" + > + + + ), + }, + ] + }, [fieldErrors]) + + const hasValidationErrors = validationErrors.size > 0 + const errorCount = validationErrors.size + return ( { } saveButtonProps={{ - children: 'Upload', + children: 'Submit', onClick: handleSubmit, - disabled: !selectedFile || isSubmitting, + disabled: rows.length === 0 || isSubmitting || hasValidationErrors, loading: isSubmitting, }} > @@ -141,36 +703,147 @@ export const WellInventoryBulkImport: React.FC = () => { <> - Upload a CSV file to bulk import well inventory data. + Fill out the table below or import a CSV file to bulk import well inventory data. - + + + {rows.length > 0 && ( + + )} + {hasValidationErrors && ( + + )} + + {hasValidationErrors && ( + }> + + Validation Errors Found + + + Please fix the errors in the table before submitting. Errors are highlighted in red. + + + {Array.from(validationErrors.entries()).map(([rowId, errors]) => { + const row = rows.find(r => r.id === rowId) + const rowNumber = rows.findIndex(r => r.id === rowId) + 1 + return ( + + + Row {rowNumber} - {(row as TableRow)?.well_name_point_id || 'Unnamed'} + + {errors.map((error, index) => ( + + • {error} + + ))} + + ) + })} + + + )} + + {rows.length >= 0 && ( + + + { + console.error('Row update error:', error) + }} + getRowClassName={(params) => { + return validationErrors.has(params.row.id) ? 'error-row' : '' + }} + disableRowSelectionOnClick + sx={{ + '& .MuiDataGrid-cell': { + borderRight: "1px solid #000", + borderBottom: "1px solid #000", + }, + '& .error-row': { + backgroundColor: 'rgba(211, 47, 47, 0.08)', + '&:hover': { + backgroundColor: 'rgba(211, 47, 47, 0.12)', + }, + }, + '& .error-cell': { + backgroundColor: 'rgba(211, 47, 47, 0.2) !important', + border: '2px solid #d32f2f', + '&:focus': { + backgroundColor: 'rgba(211, 47, 47, 0.3) !important', + }, + }, + }} + columnVisibilityModel={{ + // Hide less commonly used fields by default, but they're still editable + field_staff_2: false, + field_staff_3: false, + contact_1_organization: false, + contact_1_role: false, + contact_1_type: false, + contact_1_phone_2: false, + contact_1_phone_2_type: false, + contact_1_email_2: false, + contact_1_email_2_type: false, + contact_1_address_1_line_2: false, + contact_1_address_1_type: false, + contact_1_address_1_state: false, + contact_1_address_1_city: false, + contact_1_address_1_postal_code: false, + contact_1_address_2_line_1: false, + contact_1_address_2_line_2: false, + contact_1_address_2_type: false, + contact_1_address_2_state: false, + contact_1_address_2_city: false, + contact_1_address_2_postal_code: false, + }} + /> + + + + Note: All fields are editable. Scroll horizontally to see additional columns. + + + + )} + )} diff --git a/src/pages/ocotillo/well-inventory-bulk-import/schema.ts b/src/pages/ocotillo/well-inventory-bulk-import/schema.ts new file mode 100644 index 00000000..2a15b318 --- /dev/null +++ b/src/pages/ocotillo/well-inventory-bulk-import/schema.ts @@ -0,0 +1,201 @@ +import { z } from 'zod' + +const optionalNumber = z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return undefined + const num = Number(val) + return isNaN(num) ? undefined : num + }, + z.number().positive().optional() +) + +const optionalString = z.string().optional().or(z.literal('')) + +const iso8601Date = z + .string() + .min(1, 'Date/Time is required') + .refine( + (val) => { + if (!val || val === '') return false + // ISO 8601 with timezone offset: 2025-02-15T10:30:00-08:00 + const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/ + return iso8601Regex.test(val) || !isNaN(Date.parse(val)) + }, + { message: 'Date must be a valid ISO 8601 timestamp with timezone offset (e.g., 2025-02-15T10:30:00-08:00)' } + ) + +export const wellInventoryRowSchema = z.object({ + // Required fields + project: z.string().min(1, 'Project is required'), + well_name_point_id: z.string().min(1, 'Well name/point ID is required'), + site_name: z.string().min(1, 'Site name is required'), + date_time: iso8601Date, + field_staff: z.string().min(1, 'Field staff is required'), + utm_easting: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return NaN + return Number(val) + }, + z.number('UTM Easting is required and must be a valid number').refine( + (val) => !isNaN(val), + { message: 'UTM Easting is required' } + ) + ), + utm_northing: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return NaN + return Number(val) + }, + z.number('UTM Northing is required and must be a valid number').refine( + (val) => !isNaN(val), + { message: 'UTM Northing is required' } + ) + ), + utm_zone: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return NaN + return Number(val) + }, + z.number().int().min(1).max(60, 'UTM Zone must be between 1 and 60').refine( + (val) => !isNaN(val), + { message: 'UTM Zone is required' } + ) + ), + elevation_ft: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return NaN + return Number(val) + }, + z.number('Elevation (ft) is required and must be a valid number').refine( + (val) => !isNaN(val), + { message: 'Elevation (ft) is required' } + ) + ), + elevation_method: z.string().min(1, 'Elevation method is required'), + measuring_point_height_ft: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return NaN + return Number(val) + }, + z.number('Measuring point height (ft) is required and must be a valid number').refine( + (val) => !isNaN(val), + { message: 'Measuring point height (ft) is required' } + ) + ), + + // Optional field staff + field_staff_2: optionalString, + field_staff_3: optionalString, + + // Contact 1 fields + contact_1_name: optionalString, + contact_1_organization: optionalString, + contact_1_role: optionalString, + contact_1_type: optionalString, + contact_1_phone_1: optionalString, + contact_1_phone_1_type: optionalString, + contact_1_phone_2: optionalString, + contact_1_phone_2_type: optionalString, + contact_1_email_1: optionalString, + contact_1_email_1_type: optionalString, + contact_1_email_2: optionalString, + contact_1_email_2_type: optionalString, + contact_1_address_1_line_1: optionalString, + contact_1_address_1_line_2: optionalString, + contact_1_address_1_type: optionalString, + contact_1_address_1_state: optionalString, + contact_1_address_1_city: optionalString, + contact_1_address_1_postal_code: optionalString, + contact_1_address_2_line_1: optionalString, + contact_1_address_2_line_2: optionalString, + contact_1_address_2_type: optionalString, + contact_1_address_2_state: optionalString, + contact_1_address_2_city: optionalString, + contact_1_address_2_postal_code: optionalString, + + // Contact 2 fields + contact_2_name: optionalString, + contact_2_organization: optionalString, + contact_2_role: optionalString, + contact_2_type: optionalString, + contact_2_phone_1: optionalString, + contact_2_phone_1_type: optionalString, + contact_2_phone_2: optionalString, + contact_2_phone_2_type: optionalString, + contact_2_email_1: optionalString, + contact_2_email_1_type: optionalString, + contact_2_email_2: optionalString, + contact_2_email_2_type: optionalString, + contact_2_address_1_line_1: optionalString, + contact_2_address_1_line_2: optionalString, + contact_2_address_1_type: optionalString, + contact_2_address_1_state: optionalString, + contact_2_address_1_city: optionalString, + contact_2_address_1_postal_code: optionalString, + contact_2_address_2_line_1: optionalString, + contact_2_address_2_line_2: optionalString, + contact_2_address_2_type: optionalString, + contact_2_address_2_state: optionalString, + contact_2_address_2_city: optionalString, + contact_2_address_2_postal_code: optionalString, + + // Site information + directions_to_site: optionalString, + specific_location_of_well: optionalString, + repeat_measurement_permission: optionalString, + sampling_permission: optionalString, + datalogger_installation_permission: optionalString, + public_availability_acknowledgement: optionalString, + result_communication_preference: optionalString, + contact_special_requests_notes: optionalString, + + // Well details + ose_well_record_id: optionalString, + date_drilled: optionalString, + completion_source: optionalString, + total_well_depth_ft: optionalNumber, + historic_depth_to_water_ft: optionalNumber, + depth_source: optionalString, + well_pump_type: optionalString, + well_pump_depth_ft: optionalNumber, + is_open: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return undefined + if (typeof val === 'boolean') return val + const str = String(val).toLowerCase() + return str === 'true' || str === '1' || str === 'yes' + }, + z.boolean().optional() + ), + datalogger_possible: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return undefined + if (typeof val === 'boolean') return val + const str = String(val).toLowerCase() + return str === 'true' || str === '1' || str === 'yes' + }, + z.boolean().optional() + ), + casing_diameter_ft: optionalNumber, + measuring_point_description: optionalString, + well_purpose: optionalString, + well_purpose_2: optionalString, + well_hole_status: optionalString, + monitoring_frequency: optionalString, + sampling_scenario_notes: optionalString, + well_measuring_notes: optionalString, + sample_possible: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return undefined + if (typeof val === 'boolean') return val + const str = String(val).toLowerCase() + return str === 'true' || str === '1' || str === 'yes' + }, + z.boolean().optional() + ), +}) + +export type WellInventoryRow = z.infer + +export const wellInventoryRowsSchema = z.array(wellInventoryRowSchema) + diff --git a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts new file mode 100644 index 00000000..e4fbff7f --- /dev/null +++ b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts @@ -0,0 +1,261 @@ +import { wellInventoryRowSchema } from './schema' +import type { WellInventoryRow } from './schema' + +export type { WellInventoryRow } + +// All possible field names (required + optional) +const allFieldNames = [ + // Required + 'project', + 'well_name_point_id', + 'site_name', + 'date_time', + 'field_staff', + 'utm_easting', + 'utm_northing', + 'utm_zone', + 'elevation_ft', + 'elevation_method', + 'measuring_point_height_ft', + // Optional + 'field_staff_2', + 'field_staff_3', + 'contact_1_name', + 'contact_1_organization', + 'contact_1_role', + 'contact_1_type', + 'contact_1_phone_1', + 'contact_1_phone_1_type', + 'contact_1_phone_2', + 'contact_1_phone_2_type', + 'contact_1_email_1', + 'contact_1_email_1_type', + 'contact_1_email_2', + 'contact_1_email_2_type', + 'contact_1_address_1_line_1', + 'contact_1_address_1_line_2', + 'contact_1_address_1_type', + 'contact_1_address_1_state', + 'contact_1_address_1_city', + 'contact_1_address_1_postal_code', + 'contact_1_address_2_line_1', + 'contact_1_address_2_line_2', + 'contact_1_address_2_type', + 'contact_1_address_2_state', + 'contact_1_address_2_city', + 'contact_1_address_2_postal_code', + 'contact_2_name', + 'contact_2_organization', + 'contact_2_role', + 'contact_2_type', + 'contact_2_phone_1', + 'contact_2_phone_1_type', + 'contact_2_phone_2', + 'contact_2_phone_2_type', + 'contact_2_email_1', + 'contact_2_email_1_type', + 'contact_2_email_2', + 'contact_2_email_2_type', + 'contact_2_address_1_line_1', + 'contact_2_address_1_line_2', + 'contact_2_address_1_type', + 'contact_2_address_1_state', + 'contact_2_address_1_city', + 'contact_2_address_1_postal_code', + 'contact_2_address_2_line_1', + 'contact_2_address_2_line_2', + 'contact_2_address_2_type', + 'contact_2_address_2_state', + 'contact_2_address_2_city', + 'contact_2_address_2_postal_code', + 'directions_to_site', + 'specific_location_of_well', + 'repeat_measurement_permission', + 'sampling_permission', + 'datalogger_installation_permission', + 'public_availability_acknowledgement', + 'result_communication_preference', + 'contact_special_requests_notes', + 'ose_well_record_id', + 'date_drilled', + 'completion_source', + 'total_well_depth_ft', + 'historic_depth_to_water_ft', + 'depth_source', + 'well_pump_type', + 'well_pump_depth_ft', + 'is_open', + 'datalogger_possible', + 'casing_diameter_ft', + 'measuring_point_description', + 'well_purpose', + 'well_purpose_2', + 'well_hole_status', + 'monitoring_frequency', + 'sampling_scenario_notes', + 'well_measuring_notes', + 'sample_possible', +] + +const requiredFields = [ + 'project', + 'well_name_point_id', + 'site_name', + 'date_time', + 'field_staff', + 'utm_easting', + 'utm_northing', + 'utm_zone', + 'elevation_ft', + 'elevation_method', + 'measuring_point_height_ft', +] + +export function parseCSV(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = (e) => { + try { + const text = e.target?.result as string + const lines = text.split('\n').filter(line => line.trim()) + + if (lines.length < 2) { + reject(new Error('CSV must have at least a header row and one data row')) + return + } + + // Parse header + const headers = parseCSVLine(lines[0]) + const headerMap = new Map() + headers.forEach((header, index) => { + headerMap.set(header.trim().toLowerCase(), index) + }) + + // Check for required headers + const missingHeaders = requiredFields.filter( + header => !headerMap.has(header.toLowerCase()) + ) + + if (missingHeaders.length > 0) { + reject(new Error(`Missing required headers: ${missingHeaders.join(', ')}`)) + return + } + + // Parse data rows + const rows: WellInventoryRow[] = [] + const wellNamePointIds = new Set() + + for (let i = 1; i < lines.length; i++) { + const values = parseCSVLine(lines[i]) + + // Skip empty rows + if (values.every(v => !v.trim())) continue + + const row: any = {} + allFieldNames.forEach(header => { + const index = headerMap.get(header.toLowerCase()) + if (index !== undefined && index < values.length) { + const value = values[index]?.trim() || '' + row[header] = value + } else { + // Set default empty value for missing optional fields + row[header] = '' + } + }) + + // Check for duplicate well_name_point_id + if (row.well_name_point_id && wellNamePointIds.has(row.well_name_point_id)) { + reject(new Error(`Duplicate well_name_point_id found: "${row.well_name_point_id}" at row ${i + 1}`)) + return + } + if (row.well_name_point_id) { + wellNamePointIds.add(row.well_name_point_id) + } + + rows.push(row) + } + + resolve(rows) + } catch (error) { + reject(error) + } + } + + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsText(file, 'UTF-8') + }) +} + +function parseCSVLine(line: string): string[] { + const result: string[] = [] + let current = '' + let inQuotes = false + + for (let i = 0; i < line.length; i++) { + const char = line[i] + + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + // Escaped quote + current += '"' + i++ + } else { + // Toggle quote state + inQuotes = !inQuotes + } + } else if (char === ',' && !inQuotes) { + result.push(current) + current = '' + } else { + current += char + } + } + + result.push(current) + return result +} + +export function validateRow(row: any, rowIndex: number): { isValid: boolean; errors: string[] } { + const result = wellInventoryRowSchema.safeParse(row) + + if (result.success) { + return { isValid: true, errors: [] } + } + + const errors = result.error.issues.map(err => { + const field = err.path.join('.') + return `${field}: ${err.message}` + }) + + return { isValid: false, errors } +} + +export function validateAllRows(rows: any[]): Array<{ rowIndex: number; errors: string[] }> { + const validationErrors: Array<{ rowIndex: number; errors: string[] }> = [] + const wellNamePointIds = new Set() + + rows.forEach((row, index) => { + const validation = validateRow(row, index) + const errors = [...validation.errors] + + // Check for duplicate well_name_point_id + if (row.well_name_point_id) { + if (wellNamePointIds.has(row.well_name_point_id)) { + errors.push(`well_name_point_id: Duplicate value "${row.well_name_point_id}" found`) + } else { + wellNamePointIds.add(row.well_name_point_id) + } + } + + if (errors.length > 0) { + validationErrors.push({ + rowIndex: index + 1, // 1-based for display + errors, + }) + } + }) + + return validationErrors +} + From b1375f70baf1aa6fa8be85574bea0fca8e4a4247 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Fri, 21 Nov 2025 12:58:35 -0800 Subject: [PATCH 09/28] feat: move actions and csv message --- .../well-inventory-bulk-import/index.tsx | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 3de58b41..d998ed1e 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -101,13 +101,13 @@ export const WellInventoryBulkImport: React.FC = () => { setFieldErrors(fieldErrorMap) openNotification({ - message: 'CSV imported', - description: `Imported ${newRows.length} row(s). Please review and fix any validation errors.`, + message: 'CSV data added to table', + description: `Imported ${newRows.length} row(s) to the table below. Please review and fix any validation errors.`, type: 'success', }) } catch (error: any) { openNotification({ - message: 'CSV import failed', + message: 'CSV data add to table failed', description: error.message || 'Failed to parse CSV file', type: 'error', }) @@ -502,6 +502,22 @@ export const WellInventoryBulkImport: React.FC = () => { } return [ + { + field: 'actions', + headerName: 'Actions', + width: 100, + sortable: false, + pinned: 'right', + renderCell: (params) => ( + handleDeleteRow(params.row.id)} + color="error" + > + + + ), + }, // Required fields - most important { field: 'well_name_point_id', @@ -662,22 +678,6 @@ export const WellInventoryBulkImport: React.FC = () => { width: 180, editable: true, }, - { - field: 'actions', - headerName: 'Actions', - width: 100, - sortable: false, - pinned: 'right', - renderCell: (params) => ( - handleDeleteRow(params.row.id)} - color="error" - > - - - ), - }, ] }, [fieldErrors]) @@ -793,10 +793,6 @@ export const WellInventoryBulkImport: React.FC = () => { }} disableRowSelectionOnClick sx={{ - '& .MuiDataGrid-cell': { - borderRight: "1px solid #000", - borderBottom: "1px solid #000", - }, '& .error-row': { backgroundColor: 'rgba(211, 47, 47, 0.08)', '&:hover': { From 356a81169af7b038285224dd864b955b0581a356 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Fri, 21 Nov 2025 14:55:30 -0800 Subject: [PATCH 10/28] feat: install papaparse and npm audit fix --- package-lock.json | 41 ++++++++++++++++++++++++++--------------- package.json | 2 ++ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67a5fc9a..6fe5a554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@tiptap/react": "^2.9.1", "@tiptap/starter-kit": "^2.9.1", "@turf/turf": "^7.2.0", + "@types/papaparse": "^5.5.0", "axios-auth-refresh": "^3.3.6", "d3-polygon": "^3.0.1", "echarts": "^5.6.0", @@ -46,6 +47,7 @@ "mapbox-gl": "^3.0.0", "mapbox-gl-style-switcher": "^1.0.11", "pako": "^2.1.0", + "papaparse": "^5.5.3", "proj4": "^2.15.0", "react": "^18.0.0", "react-dom": "^18.0.0", @@ -1744,9 +1746,9 @@ "license": "Python-2.0" }, "node_modules/@hey-api/json-schema-ref-parser/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -7848,6 +7850,15 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/papaparse": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.0.tgz", + "integrity": "sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -12187,9 +12198,9 @@ "license": "MIT" }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -13649,9 +13660,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -15912,9 +15923,9 @@ "license": "(MIT AND Zlib)" }, "node_modules/papaparse": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", - "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "license": "MIT" }, "node_modules/parent-module": { @@ -20110,9 +20121,9 @@ } }, "node_modules/vite": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ae6a287d..59272eef 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@tiptap/react": "^2.9.1", "@tiptap/starter-kit": "^2.9.1", "@turf/turf": "^7.2.0", + "@types/papaparse": "^5.5.0", "axios-auth-refresh": "^3.3.6", "d3-polygon": "^3.0.1", "echarts": "^5.6.0", @@ -68,6 +69,7 @@ "mapbox-gl": "^3.0.0", "mapbox-gl-style-switcher": "^1.0.11", "pako": "^2.1.0", + "papaparse": "^5.5.3", "proj4": "^2.15.0", "react": "^18.0.0", "react-dom": "^18.0.0", From 33fd3022a36c2b06d379c25eec900248985a8789 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Fri, 21 Nov 2025 15:01:43 -0800 Subject: [PATCH 11/28] feat: move csv parse to utils folder --- .../well-inventory-bulk-import/utils.ts | 131 +----------------- src/utils/ParseCSV.ts | 41 ++++++ src/utils/index.ts | 3 +- 3 files changed, 48 insertions(+), 127 deletions(-) create mode 100644 src/utils/ParseCSV.ts diff --git a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts index e4fbff7f..d2ce2d77 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts +++ b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts @@ -1,3 +1,4 @@ +import { parseCSV as parseCSVGeneric } from '@/utils/ParseCSV' import { wellInventoryRowSchema } from './schema' import type { WellInventoryRow } from './schema' @@ -97,125 +98,11 @@ const allFieldNames = [ 'sample_possible', ] -const requiredFields = [ - 'project', - 'well_name_point_id', - 'site_name', - 'date_time', - 'field_staff', - 'utm_easting', - 'utm_northing', - 'utm_zone', - 'elevation_ft', - 'elevation_method', - 'measuring_point_height_ft', -] - export function parseCSV(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - - reader.onload = (e) => { - try { - const text = e.target?.result as string - const lines = text.split('\n').filter(line => line.trim()) - - if (lines.length < 2) { - reject(new Error('CSV must have at least a header row and one data row')) - return - } - - // Parse header - const headers = parseCSVLine(lines[0]) - const headerMap = new Map() - headers.forEach((header, index) => { - headerMap.set(header.trim().toLowerCase(), index) - }) - - // Check for required headers - const missingHeaders = requiredFields.filter( - header => !headerMap.has(header.toLowerCase()) - ) - - if (missingHeaders.length > 0) { - reject(new Error(`Missing required headers: ${missingHeaders.join(', ')}`)) - return - } - - // Parse data rows - const rows: WellInventoryRow[] = [] - const wellNamePointIds = new Set() - - for (let i = 1; i < lines.length; i++) { - const values = parseCSVLine(lines[i]) - - // Skip empty rows - if (values.every(v => !v.trim())) continue - - const row: any = {} - allFieldNames.forEach(header => { - const index = headerMap.get(header.toLowerCase()) - if (index !== undefined && index < values.length) { - const value = values[index]?.trim() || '' - row[header] = value - } else { - // Set default empty value for missing optional fields - row[header] = '' - } - }) - - // Check for duplicate well_name_point_id - if (row.well_name_point_id && wellNamePointIds.has(row.well_name_point_id)) { - reject(new Error(`Duplicate well_name_point_id found: "${row.well_name_point_id}" at row ${i + 1}`)) - return - } - if (row.well_name_point_id) { - wellNamePointIds.add(row.well_name_point_id) - } - - rows.push(row) - } - - resolve(rows) - } catch (error) { - reject(error) - } - } - - reader.onerror = () => reject(new Error('Failed to read file')) - reader.readAsText(file, 'UTF-8') - }) -} - -function parseCSVLine(line: string): string[] { - const result: string[] = [] - let current = '' - let inQuotes = false - - for (let i = 0; i < line.length; i++) { - const char = line[i] - - if (char === '"') { - if (inQuotes && line[i + 1] === '"') { - // Escaped quote - current += '"' - i++ - } else { - // Toggle quote state - inQuotes = !inQuotes - } - } else if (char === ',' && !inQuotes) { - result.push(current) - current = '' - } else { - current += char - } - } - - result.push(current) - return result + return parseCSVGeneric(file, allFieldNames) } +// Parse a single row using the schema and return the errors export function validateRow(row: any, rowIndex: number): { isValid: boolean; errors: string[] } { const result = wellInventoryRowSchema.safeParse(row) @@ -231,6 +118,7 @@ export function validateRow(row: any, rowIndex: number): { isValid: boolean; err return { isValid: false, errors } } +// Validate all rows and return the errors export function validateAllRows(rows: any[]): Array<{ rowIndex: number; errors: string[] }> { const validationErrors: Array<{ rowIndex: number; errors: string[] }> = [] const wellNamePointIds = new Set() @@ -239,18 +127,9 @@ export function validateAllRows(rows: any[]): Array<{ rowIndex: number; errors: const validation = validateRow(row, index) const errors = [...validation.errors] - // Check for duplicate well_name_point_id - if (row.well_name_point_id) { - if (wellNamePointIds.has(row.well_name_point_id)) { - errors.push(`well_name_point_id: Duplicate value "${row.well_name_point_id}" found`) - } else { - wellNamePointIds.add(row.well_name_point_id) - } - } - if (errors.length > 0) { validationErrors.push({ - rowIndex: index + 1, // 1-based for display + rowIndex: index + 1, // 1-based for display due to table header row errors, }) } diff --git a/src/utils/ParseCSV.ts b/src/utils/ParseCSV.ts new file mode 100644 index 00000000..7ef05577 --- /dev/null +++ b/src/utils/ParseCSV.ts @@ -0,0 +1,41 @@ +import Papa from 'papaparse' + +export function parseCSV>( + file: File, + fieldNames: string[] +): Promise { + return new Promise((resolve, reject) => { + Papa.parse(file, { + header: true, + skipEmptyLines: true, + transformHeader: (header) => header.trim().toLowerCase(), + complete: (results) => { + try { + if (!results.data || results.data.length === 0) { + reject(new Error('CSV must have at least a header row and one data row')) + return + } + + // Map CSV data to expected field names + const rows: T[] = results.data.map((rawRow: any) => { + const row: any = {} + fieldNames.forEach((fieldName) => { + const headerKey = fieldName.toLowerCase() + const value = rawRow[headerKey] + row[fieldName] = value ? String(value).trim() : '' + }) + return row + }) + + resolve(rows) + } catch (error) { + reject(error) + } + }, + error: (error) => { + reject(new Error(`Failed to parse CSV: ${error.message}`)) + }, + }) + }) +} + diff --git a/src/utils/index.ts b/src/utils/index.ts index d9cab181..e9052b35 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,8 +5,9 @@ export * from './FallbackWithDefault' export * from './FetchLookupTable' export * from './GetFieldPathsFromLoc' export * from './GetFormattedDate' -export * from './RemoveEmptyFields' +export * from './ParseCSV' export * from './ParseWktPoint' +export * from './RemoveEmptyFields' export * from './Transform' export * from './UpdateMapView' export * from './UtmToLonLat' From 48ac8e62c61c08a281e6f9a3aee84408918c1cd9 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Fri, 21 Nov 2025 16:10:12 -0800 Subject: [PATCH 12/28] feat: use papaparse and move data grid defs --- .../well-inventory-bulk-import/grid-defs.tsx | 66 ++ .../well-inventory-bulk-import/index.tsx | 580 ++++-------------- .../well-inventory-bulk-import/utils.ts | 133 ++-- 3 files changed, 211 insertions(+), 568 deletions(-) create mode 100644 src/pages/ocotillo/well-inventory-bulk-import/grid-defs.tsx diff --git a/src/pages/ocotillo/well-inventory-bulk-import/grid-defs.tsx b/src/pages/ocotillo/well-inventory-bulk-import/grid-defs.tsx new file mode 100644 index 00000000..9e1c97da --- /dev/null +++ b/src/pages/ocotillo/well-inventory-bulk-import/grid-defs.tsx @@ -0,0 +1,66 @@ +import { GridColDef } from '@mui/x-data-grid' +import { IconButton } from '@mui/material' +import DeleteIcon from '@mui/icons-material/Delete' +import { allFieldNames, requiredFields, numericFields, booleanFields } from './utils' +import type { TableRow } from './index' + +// Helper to convert field name to display name +// Converts snake_case to Title Case (e.g., "well_name_point_id" -> "Well Name Point Id") +const getDisplayName = (fieldName: string): string => { + return fieldName + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +export function createGridColumns( + getCellError: (rowId: number, fieldName: string) => boolean, + handleDeleteRow: (id: number) => void +): GridColDef[] { + // Create columns for all fields from schema + const dataColumns: GridColDef[] = allFieldNames.map((fieldName) => { + const isRequired = requiredFields.includes(fieldName) + const isNumeric = numericFields.includes(fieldName) + const isBoolean = booleanFields.includes(fieldName) + + const baseColumn: GridColDef = { + field: fieldName, + headerName: getDisplayName(fieldName), + width: 150, + editable: true, + cellClassName: (params) => { + return getCellError(params.row.id, fieldName) ? 'error-cell' : '' + }, + } + + if (isNumeric) { + baseColumn.type = 'number' + baseColumn.width = 130 + } else if (isBoolean) { + baseColumn.type = 'boolean' + baseColumn.width = 120 + } + + return baseColumn + }) + + // Add actions column at the beginning + const actionsColumn: GridColDef = { + field: 'actions', + headerName: 'Actions', + width: 100, + sortable: false, + renderCell: (params) => ( + handleDeleteRow(params.row.id)} + color="error" + > + + + ), + } + + return [actionsColumn, ...dataColumns] +} + diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index d998ed1e..525ec0a8 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -13,7 +13,6 @@ import { IconButton, Tooltip, } from '@mui/material' -import { LoadingButton } from '@mui/lab' import { Create } from '@refinedev/mui' import { useNotification, useDataProvider } from '@refinedev/core' import { useState, useMemo } from 'react' @@ -22,8 +21,11 @@ import InfoIcon from '@mui/icons-material/Info' import AddIcon from '@mui/icons-material/Add' import DeleteIcon from '@mui/icons-material/Delete' import { DataGrid, type GridColDef, type GridRowModel } from '@mui/x-data-grid' -import { parseCSV, validateAllRows } from './utils' +import Papa from 'papaparse' +import { parseCSV } from '@/utils/ParseCSV' +import { validateAllRows, allFieldNames, createEmptyRow } from './utils' import { wellInventoryRowSchema, type WellInventoryRow } from './schema' +import { createGridColumns } from './grid-defs' interface UploadResult { validation_errors: Array<{ @@ -40,7 +42,7 @@ interface UploadResult { wells: string[] } -interface TableRow extends Omit { +export interface TableRow extends Omit { id: number _errors?: string[] utm_easting?: number | string @@ -65,7 +67,7 @@ export const WellInventoryBulkImport: React.FC = () => { if (!file) return try { - const parsedRows = await parseCSV(file) + const parsedRows = await parseCSV(file, allFieldNames) const newRows: TableRow[] = parsedRows.map((row, index) => ({ ...row, id: Date.now() + index, @@ -102,7 +104,7 @@ export const WellInventoryBulkImport: React.FC = () => { openNotification({ message: 'CSV data added to table', - description: `Imported ${newRows.length} row(s) to the table below. Please review and fix any validation errors.`, + description: `Added ${newRows.length} row(s) to the table from the CSV file. Please review and fix any validation errors.`, type: 'success', }) } catch (error: any) { @@ -120,96 +122,7 @@ export const WellInventoryBulkImport: React.FC = () => { const handleAddRow = () => { const newRow: TableRow = { id: Date.now(), - // Required fields - project: '', - well_name_point_id: '', - site_name: '', - date_time: '', - field_staff: '', - utm_easting: '', - utm_northing: '', - utm_zone: '', - elevation_ft: '', - elevation_method: '', - measuring_point_height_ft: '', - // Optional fields - initialize as empty strings - field_staff_2: '', - field_staff_3: '', - contact_1_name: '', - contact_1_organization: '', - contact_1_role: '', - contact_1_type: '', - contact_1_phone_1: '', - contact_1_phone_1_type: '', - contact_1_phone_2: '', - contact_1_phone_2_type: '', - contact_1_email_1: '', - contact_1_email_1_type: '', - contact_1_email_2: '', - contact_1_email_2_type: '', - contact_1_address_1_line_1: '', - contact_1_address_1_line_2: '', - contact_1_address_1_type: '', - contact_1_address_1_state: '', - contact_1_address_1_city: '', - contact_1_address_1_postal_code: '', - contact_1_address_2_line_1: '', - contact_1_address_2_line_2: '', - contact_1_address_2_type: '', - contact_1_address_2_state: '', - contact_1_address_2_city: '', - contact_1_address_2_postal_code: '', - contact_2_name: '', - contact_2_organization: '', - contact_2_role: '', - contact_2_type: '', - contact_2_phone_1: '', - contact_2_phone_1_type: '', - contact_2_phone_2: '', - contact_2_phone_2_type: '', - contact_2_email_1: '', - contact_2_email_1_type: '', - contact_2_email_2: '', - contact_2_email_2_type: '', - contact_2_address_1_line_1: '', - contact_2_address_1_line_2: '', - contact_2_address_1_type: '', - contact_2_address_1_state: '', - contact_2_address_1_city: '', - contact_2_address_1_postal_code: '', - contact_2_address_2_line_1: '', - contact_2_address_2_line_2: '', - contact_2_address_2_type: '', - contact_2_address_2_state: '', - contact_2_address_2_city: '', - contact_2_address_2_postal_code: '', - directions_to_site: '', - specific_location_of_well: '', - repeat_measurement_permission: '', - sampling_permission: '', - datalogger_installation_permission: '', - public_availability_acknowledgement: '', - result_communication_preference: '', - contact_special_requests_notes: '', - ose_well_record_id: '', - date_drilled: '', - completion_source: '', - total_well_depth_ft: undefined, - historic_depth_to_water_ft: undefined, - depth_source: '', - well_pump_type: '', - well_pump_depth_ft: undefined, - is_open: undefined, - datalogger_possible: undefined, - casing_diameter_ft: undefined, - measuring_point_description: '', - well_purpose: '', - well_purpose_2: '', - well_hole_status: '', - monitoring_frequency: '', - sampling_scenario_notes: '', - well_measuring_notes: '', - sample_possible: undefined, + ...createEmptyRow(), } setRows([...rows, newRow]) } @@ -259,23 +172,6 @@ export const WellInventoryBulkImport: React.FC = () => { }) } - // Check for duplicate well_name_point_id - if (newRow.well_name_point_id) { - const duplicate = updatedRows.find( - (row) => row.id !== newRow.id && row.well_name_point_id === newRow.well_name_point_id - ) - if (duplicate) { - const errorMsg = `well_name_point_id: Duplicate value "${newRow.well_name_point_id}" found` - errors.push(errorMsg) - - const key = `${newRow.id}-well_name_point_id` - if (!fieldErrorMap.has(key)) { - fieldErrorMap.set(key, new Set()) - } - fieldErrorMap.get(key)!.add(`Duplicate value "${newRow.well_name_point_id}" found`) - } - } - if (errors.length > 0) { errorMap.set(newRow.id, errors) } else { @@ -287,6 +183,9 @@ export const WellInventoryBulkImport: React.FC = () => { return newRow } + /* + * Handle submit to return rows to csv and upload to the API + */ const handleSubmit = async () => { if (rows.length === 0) { openNotification({ @@ -320,55 +219,84 @@ export const WellInventoryBulkImport: React.FC = () => { setIsSubmitting(true) try { - // Convert rows to CSV format - const csvContent = convertRowsToCSV(rows) - const blob = new Blob([csvContent], { type: 'text/csv' }) - const file = new File([blob], 'well-inventory.csv', { type: 'text/csv' }) - - const formData = new FormData() - formData.append('file', file) - - const result = await provider.custom({ - url: 'well-inventory-csv', - method: 'post', - payload: formData, - headers: {}, - }) - - if (result?.data) { - setUploadResult(result.data as UploadResult) - } - - openNotification({ - message: 'Upload successful', - description: 'The well inventory file has been imported successfully.', - type: 'success', - }) - } catch (error: any) { - console.error('Error uploading file:', error) - - // Handle 422 validation errors from bulk import - if (error.status === 422 && error.data) { - setUploadResult(error.data as UploadResult) - const errorCount = error.data.summary?.validation_errors_or_warnings || 0 - openNotification({ - message: 'Upload failed - Validation Errors', - description: `${errorCount} validation error(s) found. No wells were imported.`, - type: 'error', + // Convert rows to CSV format + const csvContent = convertRowsToCSV(rows) + const blob = new Blob([csvContent], { type: 'text/csv' }) + const file = new File([blob], 'well-inventory.csv', { type: 'text/csv' }) + + const formData = new FormData() + formData.append('file', file) + + const result = await provider.custom({ + url: 'well-inventory-csv', + method: 'post', + payload: formData, + headers: {}, }) - } else { - const errorMessage = error.message || 'An error occurred while uploading the file.' - + + if (result?.data) { + setUploadResult(result.data as UploadResult) + } + openNotification({ - message: 'Upload failed', - description: errorMessage, - type: 'error', + message: 'Upload successful', + description: 'The well inventory file has been imported successfully.', + type: 'success', }) - setUploadResult(null) + } catch (error: any) { + console.error('Error uploading file:', error) + + // Handle 422 validation errors from bulk import + if (error.status === 422 && error.data) { + const apiErrors = error.data as UploadResult + const errorCount = apiErrors.summary?.validation_errors_or_warnings || 0 + + // Map API validation errors back to table rows + const errorMap = new Map() + const fieldErrorMap = new Map>() + + if (apiErrors.validation_errors) { + apiErrors.validation_errors.forEach((apiError) => { + // API errors have row numbers (1-based), match to table rows + const tableRow = rows[apiError.row - 1] // Convert to 0-based index + if (tableRow) { + const existingErrors = errorMap.get(tableRow.id) || [] + const errorMsg = `${apiError.field}: ${apiError.error}` + errorMap.set(tableRow.id, [...existingErrors, errorMsg]) + + // Track field-level error + const key = `${tableRow.id}-${apiError.field}` + if (!fieldErrorMap.has(key)) { + fieldErrorMap.set(key, new Set()) + } + fieldErrorMap.get(key)!.add(apiError.error) + } + }) + } + + setValidationErrors(errorMap) + setFieldErrors(fieldErrorMap) + + // DON'T set uploadResult - keep table visible + + openNotification({ + message: 'Upload failed - Validation Errors', + description: `${errorCount} validation error(s) found. Please fix the errors in the table and try again.`, + type: 'error', + }) + } else { + const errorMessage = error.message || 'An error occurred while uploading the file.' + + openNotification({ + message: 'Upload failed', + description: errorMessage, + type: 'error', + }) + // Don't set uploadResult on other errors either - keep table visible + } + } finally { + setIsSubmitting(false) } - } finally { - setIsSubmitting(false) - } } const handleReset = () => { @@ -382,304 +310,35 @@ export const WellInventoryBulkImport: React.FC = () => { } const convertRowsToCSV = (rows: TableRow[]): string => { - const headers = [ - // Required - 'project', - 'well_name_point_id', - 'site_name', - 'date_time', - 'field_staff', - 'utm_easting', - 'utm_northing', - 'utm_zone', - 'elevation_ft', - 'elevation_method', - 'measuring_point_height_ft', - // Optional - 'field_staff_2', - 'field_staff_3', - 'contact_1_name', - 'contact_1_organization', - 'contact_1_role', - 'contact_1_type', - 'contact_1_phone_1', - 'contact_1_phone_1_type', - 'contact_1_phone_2', - 'contact_1_phone_2_type', - 'contact_1_email_1', - 'contact_1_email_1_type', - 'contact_1_email_2', - 'contact_1_email_2_type', - 'contact_1_address_1_line_1', - 'contact_1_address_1_line_2', - 'contact_1_address_1_type', - 'contact_1_address_1_state', - 'contact_1_address_1_city', - 'contact_1_address_1_postal_code', - 'contact_1_address_2_line_1', - 'contact_1_address_2_line_2', - 'contact_1_address_2_type', - 'contact_1_address_2_state', - 'contact_1_address_2_city', - 'contact_1_address_2_postal_code', - 'contact_2_name', - 'contact_2_organization', - 'contact_2_role', - 'contact_2_type', - 'contact_2_phone_1', - 'contact_2_phone_1_type', - 'contact_2_phone_2', - 'contact_2_phone_2_type', - 'contact_2_email_1', - 'contact_2_email_1_type', - 'contact_2_email_2', - 'contact_2_email_2_type', - 'contact_2_address_1_line_1', - 'contact_2_address_1_line_2', - 'contact_2_address_1_type', - 'contact_2_address_1_state', - 'contact_2_address_1_city', - 'contact_2_address_1_postal_code', - 'contact_2_address_2_line_1', - 'contact_2_address_2_line_2', - 'contact_2_address_2_type', - 'contact_2_address_2_state', - 'contact_2_address_2_city', - 'contact_2_address_2_postal_code', - 'directions_to_site', - 'specific_location_of_well', - 'repeat_measurement_permission', - 'sampling_permission', - 'datalogger_installation_permission', - 'public_availability_acknowledgement', - 'result_communication_preference', - 'contact_special_requests_notes', - 'ose_well_record_id', - 'date_drilled', - 'completion_source', - 'total_well_depth_ft', - 'historic_depth_to_water_ft', - 'depth_source', - 'well_pump_type', - 'well_pump_depth_ft', - 'is_open', - 'datalogger_possible', - 'casing_diameter_ft', - 'measuring_point_description', - 'well_purpose', - 'well_purpose_2', - 'well_hole_status', - 'monitoring_frequency', - 'sampling_scenario_notes', - 'well_measuring_notes', - 'sample_possible', - ] - - const csvRows = [ - headers.join(','), - ...rows.map(row => - headers - .map(header => { - const value = row[header as keyof WellInventoryRow] ?? '' - // Convert undefined/null to empty string, keep numbers as strings - const stringValue = value === undefined || value === null ? '' : String(value) - // Escape quotes and wrap in quotes if contains comma or quote - if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) { - return `"${stringValue.replace(/"/g, '""')}"` - } - return stringValue - }) - .join(',') - ), - ] - - return csvRows.join('\n') + // Convert TableRow to WellInventoryRow (remove id and _errors, handle utm_zone) + const csvData = rows.map((row) => { + const csvRow: any = {} + allFieldNames.forEach((fieldName) => { + let value = row[fieldName as keyof WellInventoryRow] + + // Append 'N' to utm_zone + if (fieldName === 'utm_zone' && value) { + value = `${value}N` as any + } + + // Convert undefined/null to empty string + csvRow[fieldName] = value === undefined || value === null ? '' : String(value) + }) + return csvRow + }) + + return Papa.unparse(csvData, { + columns: allFieldNames, + }) } - const columns: GridColDef[] = useMemo(() => { + const columns = useMemo(() => { const getCellError = (rowId: number, fieldName: string): boolean => { return fieldErrors.has(`${rowId}-${fieldName}`) } - return [ - { - field: 'actions', - headerName: 'Actions', - width: 100, - sortable: false, - pinned: 'right', - renderCell: (params) => ( - handleDeleteRow(params.row.id)} - color="error" - > - - - ), - }, - // Required fields - most important - { - field: 'well_name_point_id', - headerName: 'Well Name/Point ID', - width: 180, - editable: true, - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'well_name_point_id') ? 'error-cell' : '' - }, - }, - { - field: 'project', - headerName: 'Project', - width: 150, - editable: true, - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'project') ? 'error-cell' : '' - }, - }, - { - field: 'site_name', - headerName: 'Site Name', - width: 150, - editable: true, - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'site_name') ? 'error-cell' : '' - }, - }, - { - field: 'date_time', - headerName: 'Date/Time', - width: 200, - editable: true, - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'date_time') ? 'error-cell' : '' - }, - }, - { - field: 'field_staff', - headerName: 'Field Staff', - width: 150, - editable: true, - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'field_staff') ? 'error-cell' : '' - }, - }, - { - field: 'utm_easting', - headerName: 'UTM Easting', - width: 130, - editable: true, - type: 'number', - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'utm_easting') ? 'error-cell' : '' - }, - }, - { - field: 'utm_northing', - headerName: 'UTM Northing', - width: 130, - editable: true, - type: 'number', - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'utm_northing') ? 'error-cell' : '' - }, - }, - { - field: 'utm_zone', - headerName: 'UTM Zone', - width: 100, - editable: true, - type: 'number', - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'utm_zone') ? 'error-cell' : '' - }, - }, - { - field: 'elevation_ft', - headerName: 'Elevation (ft)', - width: 130, - editable: true, - type: 'number', - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'elevation_ft') ? 'error-cell' : '' - }, - }, - { - field: 'elevation_method', - headerName: 'Elevation Method', - width: 150, - editable: true, - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'elevation_method') ? 'error-cell' : '' - }, - }, - { - field: 'measuring_point_height_ft', - headerName: 'MP Height (ft)', - width: 130, - editable: true, - type: 'number', - required: true, - cellClassName: (params) => { - return getCellError(params.row.id, 'measuring_point_height_ft') ? 'error-cell' : '' - }, - }, - // Key optional fields - { - field: 'total_well_depth_ft', - headerName: 'Total Depth (ft)', - width: 140, - editable: true, - type: 'number', - }, - { - field: 'well_purpose', - headerName: 'Well Purpose', - width: 150, - editable: true, - }, - { - field: 'well_hole_status', - headerName: 'Hole Status', - width: 130, - editable: true, - }, - { - field: 'monitoring_frequency', - headerName: 'Monitoring Freq', - width: 150, - editable: true, - }, - { - field: 'contact_1_name', - headerName: 'Contact 1 Name', - width: 150, - editable: true, - }, - { - field: 'contact_1_phone_1', - headerName: 'Contact 1 Phone', - width: 150, - editable: true, - }, - { - field: 'contact_1_email_1', - headerName: 'Contact 1 Email', - width: 180, - editable: true, - }, - ] - }, [fieldErrors]) + return createGridColumns(getCellError, handleDeleteRow) + }, [fieldErrors, handleDeleteRow]) const hasValidationErrors = validationErrors.size > 0 const errorCount = validationErrors.size @@ -807,29 +466,6 @@ export const WellInventoryBulkImport: React.FC = () => { }, }, }} - columnVisibilityModel={{ - // Hide less commonly used fields by default, but they're still editable - field_staff_2: false, - field_staff_3: false, - contact_1_organization: false, - contact_1_role: false, - contact_1_type: false, - contact_1_phone_2: false, - contact_1_phone_2_type: false, - contact_1_email_2: false, - contact_1_email_2_type: false, - contact_1_address_1_line_2: false, - contact_1_address_1_type: false, - contact_1_address_1_state: false, - contact_1_address_1_city: false, - contact_1_address_1_postal_code: false, - contact_1_address_2_line_1: false, - contact_1_address_2_line_2: false, - contact_1_address_2_type: false, - contact_1_address_2_state: false, - contact_1_address_2_city: false, - contact_1_address_2_postal_code: false, - }} /> diff --git a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts index d2ce2d77..ab413802 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts +++ b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts @@ -1,105 +1,46 @@ -import { parseCSV as parseCSVGeneric } from '@/utils/ParseCSV' import { wellInventoryRowSchema } from './schema' import type { WellInventoryRow } from './schema' export type { WellInventoryRow } -// All possible field names (required + optional) -const allFieldNames = [ - // Required - 'project', - 'well_name_point_id', - 'site_name', - 'date_time', - 'field_staff', - 'utm_easting', - 'utm_northing', - 'utm_zone', - 'elevation_ft', - 'elevation_method', - 'measuring_point_height_ft', - // Optional - 'field_staff_2', - 'field_staff_3', - 'contact_1_name', - 'contact_1_organization', - 'contact_1_role', - 'contact_1_type', - 'contact_1_phone_1', - 'contact_1_phone_1_type', - 'contact_1_phone_2', - 'contact_1_phone_2_type', - 'contact_1_email_1', - 'contact_1_email_1_type', - 'contact_1_email_2', - 'contact_1_email_2_type', - 'contact_1_address_1_line_1', - 'contact_1_address_1_line_2', - 'contact_1_address_1_type', - 'contact_1_address_1_state', - 'contact_1_address_1_city', - 'contact_1_address_1_postal_code', - 'contact_1_address_2_line_1', - 'contact_1_address_2_line_2', - 'contact_1_address_2_type', - 'contact_1_address_2_state', - 'contact_1_address_2_city', - 'contact_1_address_2_postal_code', - 'contact_2_name', - 'contact_2_organization', - 'contact_2_role', - 'contact_2_type', - 'contact_2_phone_1', - 'contact_2_phone_1_type', - 'contact_2_phone_2', - 'contact_2_phone_2_type', - 'contact_2_email_1', - 'contact_2_email_1_type', - 'contact_2_email_2', - 'contact_2_email_2_type', - 'contact_2_address_1_line_1', - 'contact_2_address_1_line_2', - 'contact_2_address_1_type', - 'contact_2_address_1_state', - 'contact_2_address_1_city', - 'contact_2_address_1_postal_code', - 'contact_2_address_2_line_1', - 'contact_2_address_2_line_2', - 'contact_2_address_2_type', - 'contact_2_address_2_state', - 'contact_2_address_2_city', - 'contact_2_address_2_postal_code', - 'directions_to_site', - 'specific_location_of_well', - 'repeat_measurement_permission', - 'sampling_permission', - 'datalogger_installation_permission', - 'public_availability_acknowledgement', - 'result_communication_preference', - 'contact_special_requests_notes', - 'ose_well_record_id', - 'date_drilled', - 'completion_source', - 'total_well_depth_ft', - 'historic_depth_to_water_ft', - 'depth_source', - 'well_pump_type', - 'well_pump_depth_ft', - 'is_open', - 'datalogger_possible', - 'casing_diameter_ft', - 'measuring_point_description', - 'well_purpose', - 'well_purpose_2', - 'well_hole_status', - 'monitoring_frequency', - 'sampling_scenario_notes', - 'well_measuring_notes', - 'sample_possible', -] +// Derive field names from the zod schema - single source of truth +export const allFieldNames: string[] = Object.keys(wellInventoryRowSchema.shape) -export function parseCSV(file: File): Promise { - return parseCSVGeneric(file, allFieldNames) +// Fields that should be initialized as empty strings (for editable table cells) +export const requiredNumericFields = ['utm_easting', 'utm_northing', 'utm_zone', 'elevation_ft', 'measuring_point_height_ft'] +export const optionalNumericFields = ['total_well_depth_ft', 'historic_depth_to_water_ft', 'well_pump_depth_ft', 'casing_diameter_ft'] +export const booleanFields = ['is_open', 'datalogger_possible', 'sample_possible'] + +// All numeric fields (required + optional) +export const numericFields = [...requiredNumericFields, ...optionalNumericFields] + +// Required string fields (from schema - fields that are not optional and not numeric/boolean) +const requiredStringFields = ['project', 'well_name_point_id', 'site_name', 'date_time', 'field_staff', 'elevation_method'] + +// All required fields +export const requiredFields = [...requiredStringFields, ...requiredNumericFields] + +// Create an empty row with all fields initialized +export function createEmptyRow(): WellInventoryRow { + const row: any = {} + + allFieldNames.forEach((fieldName) => { + if (requiredNumericFields.includes(fieldName)) { + // Required numeric fields - initialize as empty string for table editing + row[fieldName] = '' + } else if (optionalNumericFields.includes(fieldName)) { + // Optional numeric fields - initialize as undefined + row[fieldName] = undefined + } else if (booleanFields.includes(fieldName)) { + // Optional boolean fields - initialize as undefined + row[fieldName] = undefined + } else { + // String fields - initialize as empty string + row[fieldName] = '' + } + }) + + return row as WellInventoryRow } // Parse a single row using the schema and return the errors From 45d18fd54b3590ded99621d6a7072434fca0ce46 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Mon, 24 Nov 2025 10:44:19 -0800 Subject: [PATCH 13/28] feat: remove add row button to keep as validation engine for csv import --- .../well-inventory-bulk-import/index.tsx | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 525ec0a8..586ced4d 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -18,12 +18,11 @@ import { useNotification, useDataProvider } from '@refinedev/core' import { useState, useMemo } from 'react' import FileUploadIcon from '@mui/icons-material/FileUpload' import InfoIcon from '@mui/icons-material/Info' -import AddIcon from '@mui/icons-material/Add' import DeleteIcon from '@mui/icons-material/Delete' import { DataGrid, type GridColDef, type GridRowModel } from '@mui/x-data-grid' import Papa from 'papaparse' import { parseCSV } from '@/utils/ParseCSV' -import { validateAllRows, allFieldNames, createEmptyRow } from './utils' +import { validateAllRows, allFieldNames } from './utils' import { wellInventoryRowSchema, type WellInventoryRow } from './schema' import { createGridColumns } from './grid-defs' @@ -119,14 +118,6 @@ export const WellInventoryBulkImport: React.FC = () => { event.target.value = '' } - const handleAddRow = () => { - const newRow: TableRow = { - id: Date.now(), - ...createEmptyRow(), - } - setRows([...rows, newRow]) - } - const handleDeleteRow = (id: number) => { setRows(rows.filter(row => row.id !== id)) const newErrors = new Map(validationErrors) @@ -362,7 +353,13 @@ export const WellInventoryBulkImport: React.FC = () => { <> - Fill out the table below or import a CSV file to bulk import well inventory data. + Import a CSV file to validate your well inventorydata and then submit in bulk. + + + Note: All fields are editable. Scroll horizontally to see additional columns. Data will not be saved until you submit, and it will be validated a second time on submission. + + + Note: Well screens and well attachments are not supported in this bulk import. @@ -384,14 +381,6 @@ export const WellInventoryBulkImport: React.FC = () => { Import CSV - {rows.length > 0 && ( Date: Mon, 24 Nov 2025 10:59:41 -0800 Subject: [PATCH 14/28] feat: change instructions and no row display message --- .../well-inventory-bulk-import/index.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 586ced4d..3222c68a 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -352,15 +352,16 @@ export const WellInventoryBulkImport: React.FC = () => { {!uploadResult && ( <> - - Import a CSV file to validate your well inventorydata and then submit in bulk. - - - Note: All fields are editable. Scroll horizontally to see additional columns. Data will not be saved until you submit, and it will be validated a second time on submission. - - - Note: Well screens and well attachments are not supported in this bulk import. + + Import a CSV file to validate your well inventory data and submit in bulk. + } sx={{ mb: 2 }}> + + All fields are editable. Scroll horizontally to see additional columns. Data will not be saved until you submit, and will be validated once more on submission. +
+ Well screens and well attachments are not supported in this bulk import. Submit button is below the table. +
+
@@ -440,6 +441,13 @@ export const WellInventoryBulkImport: React.FC = () => { return validationErrors.has(params.row.id) ? 'error-row' : '' }} disableRowSelectionOnClick + slots={{ + noRowsOverlay: () => ( + + No rows to display. Import a CSV file to get started. + + ) + }} sx={{ '& .error-row': { backgroundColor: 'rgba(211, 47, 47, 0.08)', From e7852779978f5d377d1d161b05a188c88ef52abb Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Mon, 24 Nov 2025 11:13:05 -0800 Subject: [PATCH 15/28] feat: add csv template button --- .../well-inventory-bulk-import/index.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 3222c68a..3c717232 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -10,15 +10,13 @@ import { ListItem, ListItemText, Divider, - IconButton, - Tooltip, + Link, } from '@mui/material' import { Create } from '@refinedev/mui' import { useNotification, useDataProvider } from '@refinedev/core' import { useState, useMemo } from 'react' import FileUploadIcon from '@mui/icons-material/FileUpload' import InfoIcon from '@mui/icons-material/Info' -import DeleteIcon from '@mui/icons-material/Delete' import { DataGrid, type GridColDef, type GridRowModel } from '@mui/x-data-grid' import Papa from 'papaparse' import { parseCSV } from '@/utils/ParseCSV' @@ -382,6 +380,19 @@ export const WellInventoryBulkImport: React.FC = () => { Import CSV + + + {rows.length > 0 && ( Date: Mon, 24 Nov 2025 11:18:12 -0800 Subject: [PATCH 16/28] feat: add a no validation error found success chip --- src/pages/ocotillo/well-inventory-bulk-import/index.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 3c717232..a2fff544 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -407,6 +407,13 @@ export const WellInventoryBulkImport: React.FC = () => { variant="outlined" /> )} + {rows.length > 0 && !hasValidationErrors && ( + + )} {hasValidationErrors && ( From 95596a5f9479668687479662ecc2f1f27093b90d Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Mon, 24 Nov 2025 11:30:00 -0800 Subject: [PATCH 17/28] fix: change the no validation error message in chip --- src/pages/ocotillo/well-inventory-bulk-import/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index a2fff544..cade9b64 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -409,7 +409,7 @@ export const WellInventoryBulkImport: React.FC = () => { )} {rows.length > 0 && !hasValidationErrors && ( From e8ef7229b16933211468f14f2981b13c13147d6d Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Mon, 24 Nov 2025 11:30:52 -0800 Subject: [PATCH 18/28] fix: move well inventory bulk import to top of apps --- src/resources/ocotillo.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/resources/ocotillo.tsx b/src/resources/ocotillo.tsx index 3f0514d6..d08795d2 100644 --- a/src/resources/ocotillo.tsx +++ b/src/resources/ocotillo.tsx @@ -320,6 +320,16 @@ let ocotillo = [ label: 'Apps', }, }, + { + name: 'well-inventory-bulk-import', + list: '/ocotillo/well-inventory-bulk-import', + meta: { + label: 'Well Inventory Bulk Import', + parent: 'ocotillo.apps', + nestedLevel: 2, + icon: , + }, + }, { name: 'hydrograph-corrector', list: '/ocotillo/hydrograph-corrector', @@ -342,16 +352,6 @@ let ocotillo = [ icon: , }, }, - { - name: 'well-inventory-bulk-import', - list: '/ocotillo/well-inventory-bulk-import', - meta: { - label: 'Well Inventory Bulk Import', - parent: 'ocotillo.apps', - nestedLevel: 2, - icon: , - }, - }, { name: 'forms', icon: , From 89189cd038e58b2d5a76df587f83fde3450bd0f4 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Mon, 24 Nov 2025 11:33:29 -0800 Subject: [PATCH 19/28] fix: change import to upload to avoid confusion in validation workflow --- src/pages/ocotillo/well-inventory-bulk-import/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index cade9b64..98483ac8 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -351,7 +351,7 @@ export const WellInventoryBulkImport: React.FC = () => { <> - Import a CSV file to validate your well inventory data and submit in bulk. + Upload a CSV file to validate your well inventory data and submit in bulk. } sx={{ mb: 2 }}> @@ -377,7 +377,7 @@ export const WellInventoryBulkImport: React.FC = () => { startIcon={} disabled={isSubmitting} > - Import CSV + Upload CSV Date: Tue, 25 Nov 2025 12:44:35 -0800 Subject: [PATCH 20/28] feat: update schemas to better match pydantic api schemas --- .../well-inventory-bulk-import/index.tsx | 41 +- .../well-inventory-bulk-import/schema.ts | 358 ++++++++++-------- .../well-inventory-bulk-import/utils.ts | 23 +- 3 files changed, 230 insertions(+), 192 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 98483ac8..03181cb3 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -44,7 +44,7 @@ export interface TableRow extends Omit { openNotification({ message: 'CSV data added to table', - description: `Added ${newRows.length} row(s) to the table from the CSV file. Please review and fix any validation errors.`, + description: `Added ${newRows.length} row(s) to the table from the CSV file. Please review and fix any errors before submitting.`, type: 'success', }) } catch (error: any) { @@ -264,12 +264,9 @@ export const WellInventoryBulkImport: React.FC = () => { } setValidationErrors(errorMap) - setFieldErrors(fieldErrorMap) - - // DON'T set uploadResult - keep table visible - + setFieldErrors(fieldErrorMap) openNotification({ - message: 'Upload failed - Validation Errors', + message: 'Import failed - Validation Errors', description: `${errorCount} validation error(s) found. Please fix the errors in the table and try again.`, type: 'error', }) @@ -277,11 +274,10 @@ export const WellInventoryBulkImport: React.FC = () => { const errorMessage = error.message || 'An error occurred while uploading the file.' openNotification({ - message: 'Upload failed', + message: 'Import failed', description: errorMessage, type: 'error', }) - // Don't set uploadResult on other errors either - keep table visible } } finally { setIsSubmitting(false) @@ -299,19 +295,14 @@ export const WellInventoryBulkImport: React.FC = () => { } const convertRowsToCSV = (rows: TableRow[]): string => { - // Convert TableRow to WellInventoryRow (remove id and _errors, handle utm_zone) + // Convert TableRow to WellInventoryRow (remove id and _errors) const csvData = rows.map((row) => { const csvRow: any = {} allFieldNames.forEach((fieldName) => { let value = row[fieldName as keyof WellInventoryRow] - // Append 'N' to utm_zone - if (fieldName === 'utm_zone' && value) { - value = `${value}N` as any - } - - // Convert undefined/null to empty string - csvRow[fieldName] = value === undefined || value === null ? '' : String(value) + // Convert undefined/null to empty string (Papa.unparse would output "undefined"/"null" otherwise) + csvRow[fieldName] = value == null ? '' : String(value) }) return csvRow }) @@ -355,9 +346,13 @@ export const WellInventoryBulkImport: React.FC = () => { } sx={{ mb: 2 }}> - All fields are editable. Scroll horizontally to see additional columns. Data will not be saved until you submit, and will be validated once more on submission. + All fields are editable. Scroll horizontally to see additional columns. Data will not be saved until you submit. +
+ If validation fails on submission, the table will be updated to show the errors and you will be able to fix the errors and submit again. +
+ Well screens and well attachments are not supported in this bulk import.
- Well screens and well attachments are not supported in this bulk import. Submit button is below the table. + The submit button is below the table.
@@ -409,7 +404,7 @@ export const WellInventoryBulkImport: React.FC = () => { )} {rows.length > 0 && !hasValidationErrors && ( @@ -501,16 +496,16 @@ export const WellInventoryBulkImport: React.FC = () => { {uploadResult.summary.total_rows_imported > 0 ? ( <> - Upload Completed Successfully! + Import Completed Successfully! - The well inventory file has been imported successfully. + The well inventory data has been imported successfully. ) : ( <> - Upload Failed - Validation Errors + Import Failed - Validation Errors No wells were imported due to validation errors. Please review the errors below and fix your CSV file. diff --git a/src/pages/ocotillo/well-inventory-bulk-import/schema.ts b/src/pages/ocotillo/well-inventory-bulk-import/schema.ts index 2a15b318..f7216e4d 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/schema.ts +++ b/src/pages/ocotillo/well-inventory-bulk-import/schema.ts @@ -6,196 +6,236 @@ const optionalNumber = z.preprocess( const num = Number(val) return isNaN(num) ? undefined : num }, - z.number().positive().optional() + z.number().optional() ) const optionalString = z.string().optional().or(z.literal('')) -const iso8601Date = z - .string() - .min(1, 'Date/Time is required') - .refine( +// Required number field (rejects empty strings) +const requiredNumber = (message: string) => + z.preprocess( (val) => { - if (!val || val === '') return false - // ISO 8601 with timezone offset: 2025-02-15T10:30:00-08:00 - const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/ - return iso8601Regex.test(val) || !isNaN(Date.parse(val)) + if (val === '' || val === null || val === undefined) return NaN + return val }, - { message: 'Date must be a valid ISO 8601 timestamp with timezone offset (e.g., 2025-02-15T10:30:00-08:00)' } + z.coerce.number().refine((val) => !isNaN(val), { message }) ) +// Optional boolean field (allows empty strings) +const optionalBoolean = z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return undefined + if (typeof val === 'boolean') return val + const str = String(val).toLowerCase() + return str === 'true' || str === '1' || str === 'yes' + }, + z.boolean().optional() +) + export const wellInventoryRowSchema = z.object({ // Required fields project: z.string().min(1, 'Project is required'), well_name_point_id: z.string().min(1, 'Well name/point ID is required'), site_name: z.string().min(1, 'Site name is required'), - date_time: iso8601Date, + date_time: z.string().min(1, 'Date/Time is required'), field_staff: z.string().min(1, 'Field staff is required'), - utm_easting: z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return NaN - return Number(val) - }, - z.number('UTM Easting is required and must be a valid number').refine( - (val) => !isNaN(val), - { message: 'UTM Easting is required' } - ) - ), - utm_northing: z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return NaN - return Number(val) - }, - z.number('UTM Northing is required and must be a valid number').refine( - (val) => !isNaN(val), - { message: 'UTM Northing is required' } - ) - ), - utm_zone: z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return NaN - return Number(val) - }, - z.number().int().min(1).max(60, 'UTM Zone must be between 1 and 60').refine( - (val) => !isNaN(val), - { message: 'UTM Zone is required' } - ) - ), - elevation_ft: z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return NaN - return Number(val) - }, - z.number('Elevation (ft) is required and must be a valid number').refine( - (val) => !isNaN(val), - { message: 'Elevation (ft) is required' } - ) - ), + utm_easting: requiredNumber('UTM Easting is required and must be a number'), + utm_northing: requiredNumber('UTM Northing is required and must be a number'), + utm_zone: z.string().min(1, 'UTM Zone is required'), + elevation_ft: requiredNumber('Elevation (ft) is required and must be a number'), elevation_method: z.string().min(1, 'Elevation method is required'), - measuring_point_height_ft: z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return NaN - return Number(val) - }, - z.number('Measuring point height (ft) is required and must be a valid number').refine( - (val) => !isNaN(val), - { message: 'Measuring point height (ft) is required' } - ) - ), + measuring_point_height_ft: requiredNumber('Measuring point height (ft) is required and must be a number'), // Optional field staff - field_staff_2: optionalString, - field_staff_3: optionalString, + field_staff_2: z.string().optional(), + field_staff_3: z.string().optional(), // Contact 1 fields - contact_1_name: optionalString, - contact_1_organization: optionalString, - contact_1_role: optionalString, - contact_1_type: optionalString, - contact_1_phone_1: optionalString, - contact_1_phone_1_type: optionalString, - contact_1_phone_2: optionalString, - contact_1_phone_2_type: optionalString, - contact_1_email_1: optionalString, - contact_1_email_1_type: optionalString, - contact_1_email_2: optionalString, - contact_1_email_2_type: optionalString, - contact_1_address_1_line_1: optionalString, - contact_1_address_1_line_2: optionalString, - contact_1_address_1_type: optionalString, - contact_1_address_1_state: optionalString, - contact_1_address_1_city: optionalString, - contact_1_address_1_postal_code: optionalString, - contact_1_address_2_line_1: optionalString, - contact_1_address_2_line_2: optionalString, - contact_1_address_2_type: optionalString, - contact_1_address_2_state: optionalString, - contact_1_address_2_city: optionalString, - contact_1_address_2_postal_code: optionalString, + contact_1_name: z.string().optional(), + contact_1_organization: z.string().optional(), + contact_1_role: z.string().optional(), + contact_1_type: z.string().optional(), + contact_1_phone_1: z.string().optional(), + contact_1_phone_1_type: z.string().optional(), + contact_1_phone_2: z.string().optional(), + contact_1_phone_2_type: z.string().optional(), + contact_1_email_1: z.string().optional(), + contact_1_email_1_type: z.string().optional(), + contact_1_email_2: z.string().optional(), + contact_1_email_2_type: z.string().optional(), + contact_1_address_1_line_1: z.string().optional(), + contact_1_address_1_line_2: z.string().optional(), + contact_1_address_1_type: z.string().optional(), + contact_1_address_1_state: z.string().optional(), + contact_1_address_1_city: z.string().optional(), + contact_1_address_1_postal_code: z.string().optional(), + contact_1_address_2_line_1: z.string().optional(), + contact_1_address_2_line_2: z.string().optional(), + contact_1_address_2_type: z.string().optional(), + contact_1_address_2_state: z.string().optional(), + contact_1_address_2_city: z.string().optional(), + contact_1_address_2_postal_code: z.string().optional(), // Contact 2 fields - contact_2_name: optionalString, - contact_2_organization: optionalString, - contact_2_role: optionalString, - contact_2_type: optionalString, - contact_2_phone_1: optionalString, - contact_2_phone_1_type: optionalString, - contact_2_phone_2: optionalString, - contact_2_phone_2_type: optionalString, - contact_2_email_1: optionalString, - contact_2_email_1_type: optionalString, - contact_2_email_2: optionalString, - contact_2_email_2_type: optionalString, - contact_2_address_1_line_1: optionalString, - contact_2_address_1_line_2: optionalString, - contact_2_address_1_type: optionalString, - contact_2_address_1_state: optionalString, - contact_2_address_1_city: optionalString, - contact_2_address_1_postal_code: optionalString, - contact_2_address_2_line_1: optionalString, - contact_2_address_2_line_2: optionalString, - contact_2_address_2_type: optionalString, - contact_2_address_2_state: optionalString, - contact_2_address_2_city: optionalString, - contact_2_address_2_postal_code: optionalString, + contact_2_name: z.string().optional(), + contact_2_organization: z.string().optional(), + contact_2_role: z.string().optional(), + contact_2_type: z.string().optional(), + contact_2_phone_1: z.string().optional(), + contact_2_phone_1_type: z.string().optional(), + contact_2_phone_2: z.string().optional(), + contact_2_phone_2_type: z.string().optional(), + contact_2_email_1: z.string().optional(), + contact_2_email_1_type: z.string().optional(), + contact_2_email_2: z.string().optional(), + contact_2_email_2_type: z.string().optional(), + contact_2_address_1_line_1: z.string().optional(), + contact_2_address_1_line_2: z.string().optional(), + contact_2_address_1_type: z.string().optional(), + contact_2_address_1_state: z.string().optional(), + contact_2_address_1_city: z.string().optional(), + contact_2_address_1_postal_code: z.string().optional(), + contact_2_address_2_line_1: z.string().optional(), + contact_2_address_2_line_2: z.string().optional(), + contact_2_address_2_type: z.string().optional(), + contact_2_address_2_state: z.string().optional(), + contact_2_address_2_city: z.string().optional(), + contact_2_address_2_postal_code: z.string().optional(), // Site information - directions_to_site: optionalString, - specific_location_of_well: optionalString, - repeat_measurement_permission: optionalString, - sampling_permission: optionalString, - datalogger_installation_permission: optionalString, - public_availability_acknowledgement: optionalString, - result_communication_preference: optionalString, - contact_special_requests_notes: optionalString, + directions_to_site: z.string().optional(), + specific_location_of_well: z.string().optional(), + repeat_measurement_permission: optionalBoolean, + sampling_permission: optionalBoolean, + datalogger_installation_permission: optionalBoolean, + public_availability_acknowledgement: optionalBoolean, + special_requests: z.string().optional(), + result_communication_preference: z.string().optional(), + contact_special_requests_notes: z.string().optional(), // Well details - ose_well_record_id: optionalString, - date_drilled: optionalString, - completion_source: optionalString, + ose_well_record_id: z.string().optional(), + date_drilled: z.string().optional(), + completion_source: z.string().optional(), total_well_depth_ft: optionalNumber, historic_depth_to_water_ft: optionalNumber, - depth_source: optionalString, - well_pump_type: optionalString, + depth_source: z.string().optional(), + well_pump_type: z.string().optional(), well_pump_depth_ft: optionalNumber, - is_open: z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return undefined - if (typeof val === 'boolean') return val - const str = String(val).toLowerCase() - return str === 'true' || str === '1' || str === 'yes' - }, - z.boolean().optional() - ), - datalogger_possible: z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return undefined - if (typeof val === 'boolean') return val - const str = String(val).toLowerCase() - return str === 'true' || str === '1' || str === 'yes' - }, - z.boolean().optional() - ), + is_open: optionalBoolean, + datalogger_possible: optionalBoolean, casing_diameter_ft: optionalNumber, - measuring_point_description: optionalString, - well_purpose: optionalString, - well_purpose_2: optionalString, - well_hole_status: optionalString, - monitoring_frequency: optionalString, - sampling_scenario_notes: optionalString, - well_measuring_notes: optionalString, - sample_possible: z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return undefined - if (typeof val === 'boolean') return val - const str = String(val).toLowerCase() - return str === 'true' || str === '1' || str === 'yes' - }, - z.boolean().optional() - ), + measuring_point_description: z.string().optional(), + well_purpose: z.string().optional(), + well_purpose_2: z.string().optional(), + well_hole_status: z.string().optional(), + monitoring_frequency: z.string().optional(), + sampling_scenario_notes: z.string().optional(), + well_measuring_notes: z.string().optional(), + sample_possible: optionalBoolean, +}).superRefine((data, ctx) => { + // Contact validation: if name exists, role and type must exist + for (const contactNum of [1, 2]) { + const name = data[`contact_${contactNum}_name` as keyof typeof data] as string | undefined + const role = data[`contact_${contactNum}_role` as keyof typeof data] as string | undefined + const type = data[`contact_${contactNum}_type` as keyof typeof data] as string | undefined + + if (name) { + if (!role) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_role must be provided if name is provided`, + path: [`contact_${contactNum}_role`] + }) + } + if (!type) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_type must be provided if name is provided`, + path: [`contact_${contactNum}_type`] + }) + } + } + + // Phone validation: if phone exists, phone_type must exist + for (const phoneNum of [1, 2]) { + const phone = data[`contact_${contactNum}_phone_${phoneNum}` as keyof typeof data] as string | undefined + const phoneType = data[`contact_${contactNum}_phone_${phoneNum}_type` as keyof typeof data] as string | undefined + if (phone && !phoneType) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_phone_${phoneNum}_type must be provided if phone number is provided`, + path: [`contact_${contactNum}_phone_${phoneNum}_type`] + }) + } + } + + // Email validation: if email exists, email_type must exist + for (const emailNum of [1, 2]) { + const email = data[`contact_${contactNum}_email_${emailNum}` as keyof typeof data] as string | undefined + const emailType = data[`contact_${contactNum}_email_${emailNum}_type` as keyof typeof data] as string | undefined + if (email && !emailType) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_email_${emailNum}_type must be provided if email is provided`, + path: [`contact_${contactNum}_email_${emailNum}_type`] + }) + } + } + + // Address validation: if any address field exists, all required fields must exist + for (const addrNum of [1, 2]) { + const line1 = data[`contact_${contactNum}_address_${addrNum}_line_1` as keyof typeof data] as string | undefined + const type = data[`contact_${contactNum}_address_${addrNum}_type` as keyof typeof data] as string | undefined + const state = data[`contact_${contactNum}_address_${addrNum}_state` as keyof typeof data] as string | undefined + const city = data[`contact_${contactNum}_address_${addrNum}_city` as keyof typeof data] as string | undefined + const postalCode = data[`contact_${contactNum}_address_${addrNum}_postal_code` as keyof typeof data] as string | undefined + const line2 = data[`contact_${contactNum}_address_${addrNum}_line_2` as keyof typeof data] as string | undefined + + // Check if any address field is filled + const hasAnyAddressField = line1 || type || state || city || postalCode || line2 + + if (hasAnyAddressField) { + if (!line1) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_address_${addrNum}_line_1 is required when address fields are provided`, + path: [`contact_${contactNum}_address_${addrNum}_line_1`] + }) + } + if (!type) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_address_${addrNum}_type is required when address fields are provided`, + path: [`contact_${contactNum}_address_${addrNum}_type`] + }) + } + if (!state) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_address_${addrNum}_state is required when address fields are provided`, + path: [`contact_${contactNum}_address_${addrNum}_state`] + }) + } + if (!city) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_address_${addrNum}_city is required when address fields are provided`, + path: [`contact_${contactNum}_address_${addrNum}_city`] + }) + } + if (!postalCode) { + ctx.addIssue({ + code: 'custom', + message: `contact_${contactNum}_address_${addrNum}_postal_code is required when address fields are provided`, + path: [`contact_${contactNum}_address_${addrNum}_postal_code`] + }) + } + } + } + } }) export type WellInventoryRow = z.infer export const wellInventoryRowsSchema = z.array(wellInventoryRowSchema) - diff --git a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts index ab413802..aa211ef0 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts +++ b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts @@ -7,18 +7,19 @@ export type { WellInventoryRow } export const allFieldNames: string[] = Object.keys(wellInventoryRowSchema.shape) // Fields that should be initialized as empty strings (for editable table cells) -export const requiredNumericFields = ['utm_easting', 'utm_northing', 'utm_zone', 'elevation_ft', 'measuring_point_height_ft'] +export const requiredNumericFields = ['utm_easting', 'utm_northing', 'elevation_ft', 'measuring_point_height_ft'] +export const requiredStringFields = ['utm_zone'] export const optionalNumericFields = ['total_well_depth_ft', 'historic_depth_to_water_ft', 'well_pump_depth_ft', 'casing_diameter_ft'] export const booleanFields = ['is_open', 'datalogger_possible', 'sample_possible'] -// All numeric fields (required + optional) +// All numeric fields export const numericFields = [...requiredNumericFields, ...optionalNumericFields] -// Required string fields (from schema - fields that are not optional and not numeric/boolean) -const requiredStringFields = ['project', 'well_name_point_id', 'site_name', 'date_time', 'field_staff', 'elevation_method'] +// Required string fields +const otherRequiredStringFields = ['project', 'well_name_point_id', 'site_name', 'date_time', 'field_staff', 'elevation_method'] // All required fields -export const requiredFields = [...requiredStringFields, ...requiredNumericFields] +export const requiredFields = [...otherRequiredStringFields, ...requiredStringFields, ...requiredNumericFields] // Create an empty row with all fields initialized export function createEmptyRow(): WellInventoryRow { @@ -26,16 +27,19 @@ export function createEmptyRow(): WellInventoryRow { allFieldNames.forEach((fieldName) => { if (requiredNumericFields.includes(fieldName)) { - // Required numeric fields - initialize as empty string for table editing + // Required numeric fields + row[fieldName] = '' + } else if (requiredStringFields.includes(fieldName)) { + // Required string fields row[fieldName] = '' } else if (optionalNumericFields.includes(fieldName)) { - // Optional numeric fields - initialize as undefined + // Optional numeric fields row[fieldName] = undefined } else if (booleanFields.includes(fieldName)) { - // Optional boolean fields - initialize as undefined + // Optional boolean fields row[fieldName] = undefined } else { - // String fields - initialize as empty string + // Other string fields row[fieldName] = '' } }) @@ -62,7 +66,6 @@ export function validateRow(row: any, rowIndex: number): { isValid: boolean; err // Validate all rows and return the errors export function validateAllRows(rows: any[]): Array<{ rowIndex: number; errors: string[] }> { const validationErrors: Array<{ rowIndex: number; errors: string[] }> = [] - const wellNamePointIds = new Set() rows.forEach((row, index) => { const validation = validateRow(row, index) From d322c7d7b6c7dc122dd1f238fd43caed5974e315 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Tue, 25 Nov 2025 13:58:24 -0800 Subject: [PATCH 21/28] feat: move submit button above table --- .../ocotillo/well-inventory-bulk-import/index.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 03181cb3..5d59526d 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -335,6 +335,7 @@ export const WellInventoryBulkImport: React.FC = () => { onClick: handleSubmit, disabled: rows.length === 0 || isSubmitting || hasValidationErrors, loading: isSubmitting, + sx: { display: 'none'} }} > @@ -350,9 +351,7 @@ export const WellInventoryBulkImport: React.FC = () => {
If validation fails on submission, the table will be updated to show the errors and you will be able to fix the errors and submit again.
- Well screens and well attachments are not supported in this bulk import. -
- The submit button is below the table. + Well screens and well attachments are not supported in this bulk import.
@@ -388,6 +387,14 @@ export const WellInventoryBulkImport: React.FC = () => { CSV Template + {rows.length > 0 && ( Date: Wed, 26 Nov 2025 10:38:24 -0800 Subject: [PATCH 22/28] refactor: move error and validation row mapping into utils --- .../well-inventory-bulk-import/index.tsx | 272 +++++++----------- .../well-inventory-bulk-import/utils.ts | 117 +++++--- 2 files changed, 188 insertions(+), 201 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 5d59526d..789d0787 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -14,23 +14,19 @@ import { } from '@mui/material' import { Create } from '@refinedev/mui' import { useNotification, useDataProvider } from '@refinedev/core' -import { useState, useMemo } from 'react' +import { useState, useMemo, useCallback } from 'react' import FileUploadIcon from '@mui/icons-material/FileUpload' import InfoIcon from '@mui/icons-material/Info' -import { DataGrid, type GridColDef, type GridRowModel } from '@mui/x-data-grid' +import { DataGrid, type GridRowModel } from '@mui/x-data-grid' import Papa from 'papaparse' import { parseCSV } from '@/utils/ParseCSV' -import { validateAllRows, allFieldNames } from './utils' +import { validateAllRows, allFieldNames, mapValidationErrors, mapApiErrors, type ErrorMap, type FieldErrorMap, type ApiValidationError } from './utils' import { wellInventoryRowSchema, type WellInventoryRow } from './schema' import { createGridColumns } from './grid-defs' +import type { FetchValidationError } from '@/interfaces/FetchValidationError' interface UploadResult { - validation_errors: Array<{ - row: number - field: string - error: string - value?: string - }> + validation_errors: ApiValidationError[] summary: { total_rows_processed: number total_rows_imported: number @@ -53,12 +49,28 @@ export const WellInventoryBulkImport: React.FC = () => { const [rows, setRows] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) const [uploadResult, setUploadResult] = useState(null) - const [validationErrors, setValidationErrors] = useState>(new Map()) - const [fieldErrors, setFieldErrors] = useState>>(new Map()) // Map of "rowId-fieldName" to error messages + const [validationErrors, setValidationErrors] = useState(new Map()) + const [fieldErrors, setFieldErrors] = useState(new Map()) const { open: openNotification } = useNotification() const dataProvider = useDataProvider() const provider = dataProvider('ocotillo') + // Clear errors for a specific row + const clearRowErrors = useCallback((rowId: number) => { + setValidationErrors(prev => { + const next = new Map(prev) + next.delete(rowId) + return next + }) + setFieldErrors(prev => { + const next = new Map(prev) + Array.from(next.keys()) + .filter(key => key.startsWith(`${rowId}-`)) + .forEach(key => next.delete(key)) + return next + }) + }, []) + const handleCSVImport = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return @@ -71,31 +83,8 @@ export const WellInventoryBulkImport: React.FC = () => { })) setRows(newRows) - // Validate imported rows const errors = validateAllRows(newRows) - const errorMap = new Map() - const fieldErrorMap = new Map>() - - errors.forEach(({ rowIndex, errors }) => { - const tableRow = newRows[rowIndex - 1] - if (tableRow) { - errorMap.set(tableRow.id, errors) - - // Extract field-level errors - errors.forEach(error => { - const match = error.match(/^([^:]+):\s*(.+)$/) - if (match) { - const fieldName = match[1].trim() - const errorMessage = match[2].trim() - const key = `${tableRow.id}-${fieldName}` - if (!fieldErrorMap.has(key)) { - fieldErrorMap.set(key, new Set()) - } - fieldErrorMap.get(key)!.add(errorMessage) - } - }) - } - }) + const [errorMap, fieldErrorMap] = mapValidationErrors(errors, newRows) setValidationErrors(errorMap) setFieldErrors(fieldErrorMap) @@ -104,77 +93,65 @@ export const WellInventoryBulkImport: React.FC = () => { description: `Added ${newRows.length} row(s) to the table from the CSV file. Please review and fix any errors before submitting.`, type: 'success', }) - } catch (error: any) { + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse CSV file' openNotification({ - message: 'CSV data add to table failed', - description: error.message || 'Failed to parse CSV file', + message: 'CSV import failed', + description: message, type: 'error', }) + } finally { + event.target.value = '' } - - // Reset file input - event.target.value = '' } - const handleDeleteRow = (id: number) => { - setRows(rows.filter(row => row.id !== id)) - const newErrors = new Map(validationErrors) - newErrors.delete(id) - setValidationErrors(newErrors) - - // Clear field errors for this row - const newFieldErrors = new Map(fieldErrors) - Array.from(newFieldErrors.keys()) - .filter(key => key.startsWith(`${id}-`)) - .forEach(key => newFieldErrors.delete(key)) - setFieldErrors(newFieldErrors) - } + const handleDeleteRow = useCallback((id: number) => { + setRows(prev => prev.filter(row => row.id !== id)) + clearRowErrors(id) + }, [clearRowErrors]) - const processRowUpdate = (newRow: GridRowModel): GridRowModel => { - const updatedRows = rows.map((row) => (row.id === newRow.id ? (newRow as TableRow) : row)) - setRows(updatedRows) + const processRowUpdate = useCallback((newRow: GridRowModel): GridRowModel => { + setRows(prev => prev.map(row => row.id === newRow.id ? (newRow as TableRow) : row)) - // Validate the updated row const validation = wellInventoryRowSchema.safeParse(newRow) - const errorMap = new Map(validationErrors) - const fieldErrorMap = new Map(fieldErrors) - - // Clear existing errors for this row - Array.from(fieldErrorMap.keys()) - .filter(key => key.startsWith(`${newRow.id}-`)) - .forEach(key => fieldErrorMap.delete(key)) - - const errors: string[] = [] if (!validation.success) { + const errors: string[] = [] + const fieldErrorMap = new Map>() + validation.error.issues.forEach(err => { const field = err.path.join('.') - const errorMsg = `${field}: ${err.message}` - errors.push(errorMsg) + errors.push(`${field}: ${err.message}`) - // Track field-level error const key = `${newRow.id}-${field}` if (!fieldErrorMap.has(key)) { fieldErrorMap.set(key, new Set()) } fieldErrorMap.get(key)!.add(err.message) }) - } - - if (errors.length > 0) { - errorMap.set(newRow.id, errors) + + setValidationErrors(prev => { + const next = new Map(prev) + next.set(newRow.id, errors) + return next + }) + setFieldErrors(prev => { + const next = new Map(prev) + // Clear existing errors for this row + Array.from(next.keys()) + .filter(key => key.startsWith(`${newRow.id}-`)) + .forEach(key => next.delete(key)) + // Add new errors + fieldErrorMap.forEach((value, key) => next.set(key, value)) + return next + }) } else { - errorMap.delete(newRow.id) + clearRowErrors(newRow.id) } - setValidationErrors(errorMap) - setFieldErrors(fieldErrorMap) return newRow - } + }, [clearRowErrors]) - /* - * Handle submit to return rows to csv and upload to the API - */ const handleSubmit = async () => { if (rows.length === 0) { openNotification({ @@ -185,18 +162,10 @@ export const WellInventoryBulkImport: React.FC = () => { return } - // Validate all rows before submission const errors = validateAllRows(rows) if (errors.length > 0) { - const errorMap = new Map() - errors.forEach(({ rowIndex, errors }) => { - const tableRow = rows[rowIndex - 1] - if (tableRow) { - errorMap.set(tableRow.id, errors) - } - }) + const [errorMap] = mapValidationErrors(errors, rows) setValidationErrors(errorMap) - openNotification({ message: 'Validation errors found', description: `Please fix ${errors.length} validation error(s) before submitting.`, @@ -208,80 +177,57 @@ export const WellInventoryBulkImport: React.FC = () => { setIsSubmitting(true) try { - // Convert rows to CSV format - const csvContent = convertRowsToCSV(rows) - const blob = new Blob([csvContent], { type: 'text/csv' }) - const file = new File([blob], 'well-inventory.csv', { type: 'text/csv' }) - - const formData = new FormData() - formData.append('file', file) - - const result = await provider.custom({ - url: 'well-inventory-csv', - method: 'post', - payload: formData, - headers: {}, - }) - - if (result?.data) { - setUploadResult(result.data as UploadResult) - } - - openNotification({ - message: 'Upload successful', - description: 'The well inventory file has been imported successfully.', - type: 'success', - }) - } catch (error: any) { - console.error('Error uploading file:', error) + const csvContent = convertRowsToCSV(rows) + const blob = new Blob([csvContent], { type: 'text/csv' }) + const file = new File([blob], 'well-inventory.csv', { type: 'text/csv' }) + const formData = new FormData() + formData.append('file', file) + + const result = await provider.custom({ + url: 'well-inventory-csv', + method: 'post', + payload: formData, + headers: {}, + }) + + if (result?.data) { + setUploadResult(result.data as UploadResult) + } + + openNotification({ + message: 'Upload successful', + description: 'The well inventory file has been imported successfully.', + type: 'success', + }) + } catch (error) { + const fetchError = error as FetchValidationError & { data?: UploadResult } + + if (fetchError.status === 422 && fetchError.data && 'validation_errors' in fetchError.data) { + const apiErrors = fetchError.data + const errorCount = apiErrors.summary?.validation_errors_or_warnings ?? 0 - // Handle 422 validation errors from bulk import - if (error.status === 422 && error.data) { - const apiErrors = error.data as UploadResult - const errorCount = apiErrors.summary?.validation_errors_or_warnings || 0 - - // Map API validation errors back to table rows - const errorMap = new Map() - const fieldErrorMap = new Map>() - - if (apiErrors.validation_errors) { - apiErrors.validation_errors.forEach((apiError) => { - // API errors have row numbers (1-based), match to table rows - const tableRow = rows[apiError.row - 1] // Convert to 0-based index - if (tableRow) { - const existingErrors = errorMap.get(tableRow.id) || [] - const errorMsg = `${apiError.field}: ${apiError.error}` - errorMap.set(tableRow.id, [...existingErrors, errorMsg]) - - // Track field-level error - const key = `${tableRow.id}-${apiError.field}` - if (!fieldErrorMap.has(key)) { - fieldErrorMap.set(key, new Set()) - } - fieldErrorMap.get(key)!.add(apiError.error) - } - }) - } - + if (apiErrors.validation_errors) { + const [errorMap, fieldErrorMap] = mapApiErrors(apiErrors.validation_errors, rows) setValidationErrors(errorMap) - setFieldErrors(fieldErrorMap) - openNotification({ - message: 'Import failed - Validation Errors', - description: `${errorCount} validation error(s) found. Please fix the errors in the table and try again.`, - type: 'error', - }) - } else { - const errorMessage = error.message || 'An error occurred while uploading the file.' - - openNotification({ - message: 'Import failed', - description: errorMessage, - type: 'error', - }) + setFieldErrors(fieldErrorMap) } - } finally { - setIsSubmitting(false) + + openNotification({ + message: 'Import failed - Validation Errors', + description: `${errorCount} validation error(s) found. Please fix the errors in the table and try again.`, + type: 'error', + }) + } else { + const message = fetchError.message || 'An error occurred while uploading the file.' + openNotification({ + message: 'Import failed', + description: message, + type: 'error', + }) } + } finally { + setIsSubmitting(false) + } } const handleReset = () => { @@ -455,7 +401,11 @@ export const WellInventoryBulkImport: React.FC = () => { columns={columns} processRowUpdate={processRowUpdate} onProcessRowUpdateError={(error) => { - console.error('Row update error:', error) + openNotification({ + message: 'Row update failed', + description: error instanceof Error ? error.message : 'Failed to update row', + type: 'error', + }) }} getRowClassName={(params) => { return validationErrors.has(params.row.id) ? 'error-row' : '' diff --git a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts index aa211ef0..1d438dc3 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/utils.ts +++ b/src/pages/ocotillo/well-inventory-bulk-import/utils.ts @@ -3,50 +3,19 @@ import type { WellInventoryRow } from './schema' export type { WellInventoryRow } -// Derive field names from the zod schema - single source of truth +// Derive field names from the zod schema export const allFieldNames: string[] = Object.keys(wellInventoryRowSchema.shape) -// Fields that should be initialized as empty strings (for editable table cells) -export const requiredNumericFields = ['utm_easting', 'utm_northing', 'elevation_ft', 'measuring_point_height_ft'] -export const requiredStringFields = ['utm_zone'] -export const optionalNumericFields = ['total_well_depth_ft', 'historic_depth_to_water_ft', 'well_pump_depth_ft', 'casing_diameter_ft'] -export const booleanFields = ['is_open', 'datalogger_possible', 'sample_possible'] - -// All numeric fields -export const numericFields = [...requiredNumericFields, ...optionalNumericFields] - -// Required string fields +// Field type definitions (used for grid column configuration) +const requiredNumericFields = ['utm_easting', 'utm_northing', 'elevation_ft', 'measuring_point_height_ft'] +const requiredStringFields = ['utm_zone'] +const optionalNumericFields = ['total_well_depth_ft', 'historic_depth_to_water_ft', 'well_pump_depth_ft', 'casing_diameter_ft'] const otherRequiredStringFields = ['project', 'well_name_point_id', 'site_name', 'date_time', 'field_staff', 'elevation_method'] -// All required fields +export const numericFields = [...requiredNumericFields, ...optionalNumericFields] +export const booleanFields = ['is_open', 'datalogger_possible', 'sample_possible'] export const requiredFields = [...otherRequiredStringFields, ...requiredStringFields, ...requiredNumericFields] -// Create an empty row with all fields initialized -export function createEmptyRow(): WellInventoryRow { - const row: any = {} - - allFieldNames.forEach((fieldName) => { - if (requiredNumericFields.includes(fieldName)) { - // Required numeric fields - row[fieldName] = '' - } else if (requiredStringFields.includes(fieldName)) { - // Required string fields - row[fieldName] = '' - } else if (optionalNumericFields.includes(fieldName)) { - // Optional numeric fields - row[fieldName] = undefined - } else if (booleanFields.includes(fieldName)) { - // Optional boolean fields - row[fieldName] = undefined - } else { - // Other string fields - row[fieldName] = '' - } - }) - - return row as WellInventoryRow -} - // Parse a single row using the schema and return the errors export function validateRow(row: any, rowIndex: number): { isValid: boolean; errors: string[] } { const result = wellInventoryRowSchema.safeParse(row) @@ -66,7 +35,7 @@ export function validateRow(row: any, rowIndex: number): { isValid: boolean; err // Validate all rows and return the errors export function validateAllRows(rows: any[]): Array<{ rowIndex: number; errors: string[] }> { const validationErrors: Array<{ rowIndex: number; errors: string[] }> = [] - + rows.forEach((row, index) => { const validation = validateRow(row, index) const errors = [...validation.errors] @@ -78,7 +47,75 @@ export function validateAllRows(rows: any[]): Array<{ rowIndex: number; errors: }) } }) - + return validationErrors } +// Error mapping types +export type ErrorMap = Map +export type FieldErrorMap = Map> + +// API error types +export interface ApiValidationError { + row: number + field: string + error: string + value?: string +} + +// Map validation errors to error maps +export function mapValidationErrors( + errors: Array<{ rowIndex: number; errors: string[] }>, + rows: T[] +): [ErrorMap, FieldErrorMap] { + const errorMap = new Map() + const fieldErrorMap = new Map>() + + errors.forEach(({ rowIndex, errors }) => { + const tableRow = rows[rowIndex - 1] + if (!tableRow) return + + errorMap.set(tableRow.id, errors) + + errors.forEach(error => { + const match = error.match(/^([^:]+):\s*(.+)$/) + if (match) { + const [, fieldName, errorMessage] = match + const key = `${tableRow.id}-${fieldName.trim()}` + if (!fieldErrorMap.has(key)) { + fieldErrorMap.set(key, new Set()) + } + fieldErrorMap.get(key)!.add(errorMessage.trim()) + } + }) + }) + + return [errorMap, fieldErrorMap] +} + +// Map API validation errors to error maps +export function mapApiErrors( + apiErrors: ApiValidationError[], + rows: T[] +): [ErrorMap, FieldErrorMap] { + const errorMap = new Map() + const fieldErrorMap = new Map>() + + apiErrors.forEach(apiError => { + const tableRow = rows[apiError.row - 1] + if (!tableRow) return + + const errorMsg = `${apiError.field}: ${apiError.error}` + const existingErrors = errorMap.get(tableRow.id) || [] + errorMap.set(tableRow.id, [...existingErrors, errorMsg]) + + const key = `${tableRow.id}-${apiError.field}` + if (!fieldErrorMap.has(key)) { + fieldErrorMap.set(key, new Set()) + } + fieldErrorMap.get(key)!.add(apiError.error) + }) + + return [errorMap, fieldErrorMap] +} + From 7a27a0e3151484bc05dba12d4efcbc41b24b0b63 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Tue, 2 Dec 2025 10:08:16 -0800 Subject: [PATCH 23/28] fix: removes field name parsing helper to keep the field name consistent with csv file --- .../ocotillo/well-inventory-bulk-import/grid-defs.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/grid-defs.tsx b/src/pages/ocotillo/well-inventory-bulk-import/grid-defs.tsx index 9e1c97da..4b6622f4 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/grid-defs.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/grid-defs.tsx @@ -4,15 +4,6 @@ import DeleteIcon from '@mui/icons-material/Delete' import { allFieldNames, requiredFields, numericFields, booleanFields } from './utils' import type { TableRow } from './index' -// Helper to convert field name to display name -// Converts snake_case to Title Case (e.g., "well_name_point_id" -> "Well Name Point Id") -const getDisplayName = (fieldName: string): string => { - return fieldName - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} - export function createGridColumns( getCellError: (rowId: number, fieldName: string) => boolean, handleDeleteRow: (id: number) => void @@ -25,7 +16,7 @@ export function createGridColumns( const baseColumn: GridColDef = { field: fieldName, - headerName: getDisplayName(fieldName), + headerName: fieldName, width: 150, editable: true, cellClassName: (params) => { From e7c3529bbcecfec984584805e91040fde123d304 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Tue, 2 Dec 2025 10:46:16 -0800 Subject: [PATCH 24/28] fix: adds return statement and notification if user is able to select multiple files somehow. --- .../ocotillo/well-inventory-bulk-import/index.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 789d0787..064797d3 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -72,9 +72,20 @@ export const WellInventoryBulkImport: React.FC = () => { }, []) const handleCSVImport = async (event: React.ChangeEvent) => { + //If multiple file upload is attempted (should not be possible), notify the user + if (event.target.files?.length && event.target.files.length > 1) { + openNotification({ + message: 'Only one file can be uploaded at a time', + description: 'Please upload only one file at a time.', + type: 'error', + }) + return + } + //get file const file = event.target.files?.[0] if (!file) return + //parse the file try { const parsedRows = await parseCSV(file, allFieldNames) const newRows: TableRow[] = parsedRows.map((row, index) => ({ @@ -308,6 +319,7 @@ export const WellInventoryBulkImport: React.FC = () => { id="csv-input" type="file" accept=".csv" + multiple={false} style={{ display: 'none' }} onChange={handleCSVImport} /> From ecc11cb7551e713c75ba440a5ff34ee0589b3e9a Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Tue, 2 Dec 2025 10:52:32 -0800 Subject: [PATCH 25/28] fix: removes leftover TODO note --- src/providers/ocotillo-data-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/ocotillo-data-provider.ts b/src/providers/ocotillo-data-provider.ts index 9a8b2ae6..fedfda0b 100644 --- a/src/providers/ocotillo-data-provider.ts +++ b/src/providers/ocotillo-data-provider.ts @@ -236,7 +236,7 @@ export const ocotilloDataProvider: DataProvider = { return { data: response.data } } catch (error: any) { /** - * TODO: Add better error handling for bulk import based on API Pydantic validation errors + * 422 Error handling for bulk well inventory import based on API Pydantic validation errors */ if (error.response?.status === 422 && error.response?.data?.validation_errors) { const transformedError = new Error('Validation errors occurred during import') From 1e7f0c05f669dd1ae5b0c5f0aad601e8bb7593f3 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Tue, 2 Dec 2025 10:59:39 -0800 Subject: [PATCH 26/28] fix: move zod helpers into utils/zod folder --- .../well-inventory-bulk-import/schema.ts | 35 ++----------------- src/utils/zod/optionalBoolean.ts | 11 ++++++ src/utils/zod/optionalNumber.ts | 10 ++++++ src/utils/zod/requiredNumber.ts | 10 ++++++ 4 files changed, 34 insertions(+), 32 deletions(-) create mode 100644 src/utils/zod/optionalBoolean.ts create mode 100644 src/utils/zod/optionalNumber.ts create mode 100644 src/utils/zod/requiredNumber.ts diff --git a/src/pages/ocotillo/well-inventory-bulk-import/schema.ts b/src/pages/ocotillo/well-inventory-bulk-import/schema.ts index f7216e4d..90394a5e 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/schema.ts +++ b/src/pages/ocotillo/well-inventory-bulk-import/schema.ts @@ -1,36 +1,7 @@ import { z } from 'zod' - -const optionalNumber = z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return undefined - const num = Number(val) - return isNaN(num) ? undefined : num - }, - z.number().optional() -) - -const optionalString = z.string().optional().or(z.literal('')) - -// Required number field (rejects empty strings) -const requiredNumber = (message: string) => - z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return NaN - return val - }, - z.coerce.number().refine((val) => !isNaN(val), { message }) - ) - -// Optional boolean field (allows empty strings) -const optionalBoolean = z.preprocess( - (val) => { - if (val === '' || val === null || val === undefined) return undefined - if (typeof val === 'boolean') return val - const str = String(val).toLowerCase() - return str === 'true' || str === '1' || str === 'yes' - }, - z.boolean().optional() -) +import { optionalNumber } from '@/utils/zod/optionalNumber' +import { requiredNumber } from '@/utils/zod/requiredNumber' +import { optionalBoolean } from '@/utils/zod/optionalBoolean' export const wellInventoryRowSchema = z.object({ // Required fields diff --git a/src/utils/zod/optionalBoolean.ts b/src/utils/zod/optionalBoolean.ts new file mode 100644 index 00000000..b665adc7 --- /dev/null +++ b/src/utils/zod/optionalBoolean.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const optionalBoolean = z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return undefined + if (typeof val === 'boolean') return val + const str = String(val).toLowerCase() + return str === 'true' || str === '1' || str === 'yes' + }, + z.boolean().optional() + ) \ No newline at end of file diff --git a/src/utils/zod/optionalNumber.ts b/src/utils/zod/optionalNumber.ts new file mode 100644 index 00000000..81e8a8b8 --- /dev/null +++ b/src/utils/zod/optionalNumber.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const optionalNumber = z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return undefined + const num = Number(val) + return isNaN(num) ? undefined : num + }, + z.number().optional() + ) \ No newline at end of file diff --git a/src/utils/zod/requiredNumber.ts b/src/utils/zod/requiredNumber.ts new file mode 100644 index 00000000..761d835b --- /dev/null +++ b/src/utils/zod/requiredNumber.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const requiredNumber = (message: string) => + z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return NaN + return val + }, + z.coerce.number().refine((val) => !isNaN(val), { message }) + ) \ No newline at end of file From c2e40d1bef3880adf27a7932727fb6a31911dacb Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Tue, 2 Dec 2025 11:47:53 -0800 Subject: [PATCH 27/28] fix: add returned type to handle csv import --- src/pages/ocotillo/well-inventory-bulk-import/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx index 064797d3..8251ced1 100644 --- a/src/pages/ocotillo/well-inventory-bulk-import/index.tsx +++ b/src/pages/ocotillo/well-inventory-bulk-import/index.tsx @@ -87,7 +87,7 @@ export const WellInventoryBulkImport: React.FC = () => { //parse the file try { - const parsedRows = await parseCSV(file, allFieldNames) + const parsedRows: Awaited = await parseCSV(file, allFieldNames) const newRows: TableRow[] = parsedRows.map((row, index) => ({ ...row, id: Date.now() + index, From 3dedcef17be874dae88835a4db31c732ff0b6050 Mon Sep 17 00:00:00 2001 From: Chase Martin Date: Tue, 2 Dec 2025 15:42:03 -0800 Subject: [PATCH 28/28] feat: add simple cypress test for happy path well inventory csv upload --- .../e2e/ocotillo/well-inventory-bulk.cy.ts | 51 +++++++++++++++++++ cypress/fixtures/well-inventory.csv | 2 + 2 files changed, 53 insertions(+) create mode 100644 cypress/e2e/ocotillo/well-inventory-bulk.cy.ts create mode 100644 cypress/fixtures/well-inventory.csv diff --git a/cypress/e2e/ocotillo/well-inventory-bulk.cy.ts b/cypress/e2e/ocotillo/well-inventory-bulk.cy.ts new file mode 100644 index 00000000..b38cc659 --- /dev/null +++ b/cypress/e2e/ocotillo/well-inventory-bulk.cy.ts @@ -0,0 +1,51 @@ +/// + +describe('Well Inventory Bulk Import Page', () => { + beforeEach(() => { + cy.login() + cy.visit('/ocotillo/well-inventory-bulk-import') + }) + + it('should render the well inventory bulk import page UI without errors', () => { + cy.get('input').should('have.length.at.least', 1) + cy.contains('Well Inventory Bulk Import').should('exist') + cy.get('label').contains(/upload csv/i).should('exist') + cy.get('button').contains(/submit/i).should('exist') + }) + + it('should preserve CSV data through parse and unparse cycle', () => { + cy.readFile('cypress/fixtures/well-inventory.csv').then((originalCsv) => { + cy.intercept('POST', '**/well-inventory-csv', (req) => { + // For data sent as multipart string, check body contains CSV content + const body = req.body as string + const originalLines = originalCsv.split('\n') + + // Check that key data from original CSV is in the submitted body + originalLines.forEach(line => { + if (line.trim()) { + //check all original csv fields for exact match in body + const fields = line.split(',') + fields.forEach(field => { + console.log("checking field: ", field.trim()) + expect(body).to.include(field.trim()) + }) + } + }) + + req.reply({ + statusCode: 200, + body: { + summary: { total_rows_processed: 1, total_rows_imported: 1, validation_errors_or_warnings: 0 }, + wells: [], + validation_errors: [] + } + }) + }).as('submitCSV') + + cy.get('input[type="file"]').selectFile('cypress/fixtures/well-inventory.csv', { force: true }) + cy.wait(1500) + cy.get('button').contains(/submit/i).click() + cy.wait('@submitCSV') + }) + }) +}) \ No newline at end of file diff --git a/cypress/fixtures/well-inventory.csv b/cypress/fixtures/well-inventory.csv new file mode 100644 index 00000000..d39ac291 --- /dev/null +++ b/cypress/fixtures/well-inventory.csv @@ -0,0 +1,2 @@ +project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,measuring_point_description +Test Project_2,WELL-002,SITE-001,2024-01-15 10:30:00,John Smith,352342,4040485,13N,5500.5,Survey-grade GPS,2.5,Top of casing \ No newline at end of file