From adac3355b281240bf4079a64fa3a80756cdf6450 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 20 Jul 2026 14:35:48 -0500 Subject: [PATCH 001/144] fix(config/auth): add util to prevent double slashes --- .gitignore | 3 ++- src/components/Auth.tsx | 7 ++----- src/config/auth.ts | 5 +++++ src/providers/authentik-provider.ts | 6 +++--- src/test/config/auth.test.ts | 16 ++++++++++++++++ 5 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/test/config/auth.test.ts diff --git a/.gitignore b/.gitignore index 8768adc0..d503cc5b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,9 @@ yarn-error.log* # But keep .env.example and .env.*.example !.env.example !.env.*.example +mise.toml cypress/e2e/1-getting-started/* cypress/e2e/2-advanced-examples/* cypress/screenshots/* -cypress/downloads/* \ No newline at end of file +cypress/downloads/* diff --git a/src/components/Auth.tsx b/src/components/Auth.tsx index 0e761d00..ab8e714f 100644 --- a/src/components/Auth.tsx +++ b/src/components/Auth.tsx @@ -18,7 +18,7 @@ import { Stack, Typography, } from '@mui/material' -import { AUTHENTIK_URL, CLIENT_ID, REDIRECT_URI } from '@/config' +import { buildAuthentikUrl, CLIENT_ID, REDIRECT_URI } from '@/config' type TokenResponse = { access_token: string @@ -60,10 +60,7 @@ export const Callback = () => { throw new Error('Invalid state parameter') } - const tokenUrl = new URL( - 'token/', - `${AUTHENTIK_URL.replace(/\/+$/, '')}/` - ) + const tokenUrl = buildAuthentikUrl('token/') const resp = await fetch(tokenUrl.toString(), { method: 'POST', diff --git a/src/config/auth.ts b/src/config/auth.ts index 0bbaabe7..a912c516 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -1,6 +1,11 @@ export const AUTHENTIK_URL = import.meta.env.VITE_AUTHENTIK_URL || 'http://localhost:8000/' +export const buildAuthentikUrl = ( + path: string, + baseUrl = AUTHENTIK_URL +): URL => new URL(path.replace(/^\/+/, ''), `${baseUrl.replace(/\/+$/, '')}/`) + export const CLIENT_ID = import.meta.env.VITE_AUTHENTIK_CLIENT_ID || 'authentik' const envRedirect = import.meta.env.VITE_AUTHENTIK_REDIRECT_URI diff --git a/src/providers/authentik-provider.ts b/src/providers/authentik-provider.ts index 5f4f44f0..d885e02f 100644 --- a/src/providers/authentik-provider.ts +++ b/src/providers/authentik-provider.ts @@ -15,7 +15,7 @@ import { getStatusCode, hasError } from '@/utils/Http' import { isJwtExpired } from '@/utils/Jwt' import { HttpStatus } from '@/enums' import { - AUTHENTIK_URL, + buildAuthentikUrl, CLIENT_ID, REDIRECT_URI, STORAGE_KEYS, @@ -136,7 +136,7 @@ export const getAccessToken = async ({ const refreshToken = localStorage.getItem(STORAGE_KEYS.refreshToken) if (!refreshToken) return null - const url = new URL(`${AUTHENTIK_URL}/token/`) + const url = buildAuthentikUrl('token/') const response = await fetch(url.toString(), { method: 'POST', @@ -204,7 +204,7 @@ export const authentikAuthProvider: AuthProvider = { const RESPONSE_TYPE = 'code' const SCOPE = 'openid profile email offline_access permissions' - const authUrl = new URL(`${AUTHENTIK_URL}/authorize/`) + const authUrl = buildAuthentikUrl('authorize/') authUrl.searchParams.set('client_id', CLIENT_ID) authUrl.searchParams.set('redirect_uri', REDIRECT_URI) authUrl.searchParams.set('response_type', RESPONSE_TYPE) diff --git a/src/test/config/auth.test.ts b/src/test/config/auth.test.ts new file mode 100644 index 00000000..2f424f30 --- /dev/null +++ b/src/test/config/auth.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { buildAuthentikUrl } from '@/config/auth' + +describe('buildAuthentikUrl', () => { + it('does not duplicate slashes when the base URL ends with a slash', () => { + expect( + buildAuthentikUrl('authorize/', 'http://localhost:8000/').toString() + ).toBe('http://localhost:8000/authorize/') + }) + + it('does not duplicate slashes when the path starts with a slash', () => { + expect( + buildAuthentikUrl('/authorize/', 'http://localhost:8000').toString() + ).toBe('http://localhost:8000/authorize/') + }) +}) From 5d46d28fa5dcc98ce8e2d60f281b36ef5d6eaddd Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 20 Jul 2026 16:04:18 -0500 Subject: [PATCH 002/144] test(cypress): add new tests & remove old tests --- cypress/e2e/ocotillo/list-pages.cy.ts | 85 ++++++ cypress/e2e/ocotillo/show-pages.cy.ts | 65 ++++ cypress/e2e/ocotillo/well-show.cy.ts | 40 ++- cypress/support/ocotillo-fixtures.ts | 288 ++++++++++++++++++ src/components/ListPage.tsx | 10 +- src/pages/ocotillo/contact/list.tsx | 1 + src/pages/ocotillo/thing/list.tsx | 32 +- .../ocotillo/thing/well-projects/list.tsx | 4 +- 8 files changed, 503 insertions(+), 22 deletions(-) create mode 100644 cypress/e2e/ocotillo/list-pages.cy.ts create mode 100644 cypress/e2e/ocotillo/show-pages.cy.ts create mode 100644 cypress/support/ocotillo-fixtures.ts diff --git a/cypress/e2e/ocotillo/list-pages.cy.ts b/cypress/e2e/ocotillo/list-pages.cy.ts new file mode 100644 index 00000000..260974b4 --- /dev/null +++ b/cypress/e2e/ocotillo/list-pages.cy.ts @@ -0,0 +1,85 @@ +/// + +import { + interceptOcotilloListFixtures, + projectAlpha, + projectBeta, + wellOne, + wellTwo, +} from '../../support/ocotillo-fixtures' + +describe('Ocotillo List Pages', () => { + beforeEach(() => { + cy.viewport(1600, 900) + interceptOcotilloListFixtures() + cy.login() + }) + + it('renders the wells list with search, actions, columns, and rows', () => { + cy.visit('/ocotillo/well') + cy.wait('@getWells') + + cy.contains('h3', /^Wells$/).should('be.visible') + cy.get('input[aria-label="Search wells by well name"]').should( + 'have.attr', + 'placeholder', + 'Search by well name' + ) + cy.get('button') + .contains(/batch field sheets/i) + .should('be.visible') + cy.get('button') + .contains(/export/i) + .should('be.visible') + + cy.contains('[role="columnheader"]', 'Name').should('be.visible') + cy.contains('[role="columnheader"]', 'Site name').should('be.visible') + cy.contains('[role="columnheader"]', 'Monitoring').should('be.visible') + cy.contains('[role="columnheader"]', 'Well Status').should('be.visible') + + cy.contains('[role="row"]', wellOne.name).should('be.visible') + cy.contains('[role="row"]', wellTwo.name).should('be.visible') + cy.contains(projectAlpha.name).should('be.visible') + }) + + it('renders the project list with project rows and navigation targets', () => { + cy.visit('/ocotillo/well/projects') + cy.wait('@getProjects') + + cy.contains('h3', /^Projects$/).should('be.visible') + cy.get('input[aria-label="Filter rows on this page"]').should( + 'have.attr', + 'placeholder', + 'Filter this page...' + ) + + cy.contains('[role="columnheader"]', 'Name').should('be.visible') + cy.contains('[role="columnheader"]', 'Description').should('be.visible') + cy.contains('[role="columnheader"]', 'Release Status').should('be.visible') + cy.contains('[role="columnheader"]', 'Type').should('be.visible') + + cy.contains('[role="row"]', projectAlpha.name).should('be.visible') + cy.contains('[role="row"]', projectBeta.name).should('be.visible') + cy.contains(projectAlpha.description).should('be.visible') + }) + + it('renders the contacts list with contact method and associated site columns', () => { + cy.visit('/ocotillo/contact') + cy.wait('@getContacts') + + cy.contains('h3', /contacts & owners/i).should('be.visible') + cy.get('input[aria-label="Filter rows on this page"]').should('be.visible') + + cy.contains('[role="columnheader"]', 'Name').should('be.visible') + cy.contains('[role="columnheader"]', 'Organization').should('be.visible') + cy.contains('[role="columnheader"]', 'Role').should('be.visible') + cy.contains('[role="columnheader"]', 'Contact Type').should('be.visible') + cy.contains('[role="columnheader"]', 'Associated Sites').should( + 'be.visible' + ) + + cy.contains('[role="row"]', 'Alex Contact').should('be.visible') + cy.contains('[role="row"]', 'Jordan Manager').should('be.visible') + cy.contains(wellOne.name).should('be.visible') + }) +}) diff --git a/cypress/e2e/ocotillo/show-pages.cy.ts b/cypress/e2e/ocotillo/show-pages.cy.ts new file mode 100644 index 00000000..baa20c92 --- /dev/null +++ b/cypress/e2e/ocotillo/show-pages.cy.ts @@ -0,0 +1,65 @@ +/// + +import { + contactOne, + interceptContactShowFixtures, + interceptProjectShowFixtures, + projectAlpha, + wellOne, + wellTwo, +} from '../../support/ocotillo-fixtures' + +describe('Ocotillo Show Pages', () => { + it('renders the project show page with details, map, and associated wells', () => { + interceptProjectShowFixtures() + cy.login() + cy.visit('/ocotillo/well/projects/show/10') + cy.wait('@getProject') + + cy.contains('h3', projectAlpha.name).should('be.visible') + cy.contains(projectAlpha.group_type).should('be.visible') + cy.contains('Project Details').should('be.visible') + cy.contains('Project Details') + .closest('[class*="MuiPaper-root"]') + .within(() => { + cy.contains('Release Status').should('be.visible') + cy.contains(projectAlpha.release_status).should('be.visible') + }) + cy.contains(projectAlpha.description).should('be.visible') + cy.contains('Created By').should('be.visible') + cy.contains(projectAlpha.created_by_name).should('be.visible') + + cy.contains('Project Map').should('be.visible') + cy.get('[data-testid="ocotillo-map-container"]', { + timeout: 20000, + }).should('exist') + cy.contains('Associated Wells').scrollIntoView().should('be.visible') + cy.get('a') + .contains('View all 2 wells') + .should('have.attr', 'href') + .and('include', '/ocotillo/well?projectId=10') + cy.contains('[role="row"]', wellOne.name).should('be.visible') + cy.contains('[role="row"]', wellTwo.name).should('be.visible') + }) + + it('renders the contact show page with details and associated sites', () => { + interceptContactShowFixtures() + cy.login() + cy.visit('/ocotillo/contact/show/1') + cy.wait('@getContact') + + cy.contains('h3', contactOne.name).should('be.visible') + cy.contains(contactOne.role).should('be.visible') + cy.contains(contactOne.organization).should('be.visible') + cy.contains('Contact Details').should('be.visible') + cy.contains(contactOne.emails[0].email).should('be.visible') + cy.contains('(505) 555-1212').should('be.visible') + cy.contains('801 Leroy Place').should('be.visible') + + cy.contains('Associated Sites').should('be.visible') + cy.contains(wellOne.name).should('be.visible') + cy.contains('Depth to water').should('be.visible') + cy.contains('42.5 ft bgs').should('be.visible') + cy.contains('Associated Sites Map').should('be.visible') + }) +}) diff --git a/cypress/e2e/ocotillo/well-show.cy.ts b/cypress/e2e/ocotillo/well-show.cy.ts index a04c6bd8..6579b0d8 100644 --- a/cypress/e2e/ocotillo/well-show.cy.ts +++ b/cypress/e2e/ocotillo/well-show.cy.ts @@ -1,17 +1,43 @@ /// +import { + contactOne, + interceptWellShowFixtures, + projectAlpha, + wellOne, +} from '../../support/ocotillo-fixtures' + describe('Thing Well Show Page', () => { beforeEach(() => { + interceptWellShowFixtures() cy.login() - - cy.intercept('GET', '**/thing/*').as('getWell') cy.visit('/ocotillo/well/show/1') - cy.wait('@getWell') + cy.wait('@getWellDetails') }) - it('should render the well show page UI without errors', () => { - cy.get('[data-testid="ocotillo-map-container"]', { timeout: 20000 }).should( - 'exist' - ) + it('renders the current well detail UI with core cards and related data', () => { + cy.contains('h3', wellOne.name).should('be.visible') + cy.contains(wellOne.site_name).should('exist') + cy.contains(wellOne.monitoring_status).should('exist') + cy.contains(wellOne.well_status).should('exist') + + cy.contains('Hole Depth').should('be.visible') + cy.contains('210 ft').should('be.visible') + cy.contains('Well Depth').should('be.visible') + cy.contains('198 ft').should('be.visible') + cy.contains('Measuring Point').should('be.visible') + cy.contains('Top of casing | 2.5 ft').should('be.visible') + + cy.get('[data-testid="ocotillo-map-container"]', { + timeout: 20000, + }).should('exist') + cy.contains('Hydrograph').should('exist') + cy.contains('Recent Water Level Observations').should('exist') + cy.contains('Alternate IDs').should('exist') + + cy.contains(contactOne.name).should('exist') + cy.contains(contactOne.organization).should('exist') + cy.contains(contactOne.emails[0].email).should('exist') + cy.contains(projectAlpha.name.toUpperCase()).should('exist') }) }) diff --git a/cypress/support/ocotillo-fixtures.ts b/cypress/support/ocotillo-fixtures.ts new file mode 100644 index 00000000..c30408f3 --- /dev/null +++ b/cypress/support/ocotillo-fixtures.ts @@ -0,0 +1,288 @@ +/// + +export const projectAlpha = { + id: 10, + name: 'Rio Grande Monitoring', + description: 'Long-term water level monitoring in the middle Rio Grande.', + group_type: 'Project', + release_status: 'public', + parent_group_id: null, + project_area: null, + well_count: 2, + created_at: '2026-01-05T12:00:00Z', + created_by_name: 'Data Team', +} + +export const projectBeta = { + id: 11, + name: 'Chuska Reconnaissance', + description: 'Reconnaissance wells near Chuska.', + group_type: 'Project', + release_status: 'draft', + parent_group_id: null, + project_area: null, + well_count: 1, + created_at: '2026-02-10T12:00:00Z', + created_by_name: 'Field Team', +} + +export const wellOne = { + id: 1, + name: 'RG-001', + site_name: 'Rio Grande Site 1', + created_at: '2026-01-15T12:00:00Z', + release_status: 'public', + thing_type: 'water well', + location_id: 101, + monitoring_status: 'Active', + well_status: 'In use', + hole_depth: 210, + hole_depth_unit: 'ft', + well_depth: 198, + well_depth_unit: 'ft', + well_completion_date: '2025-12-10', + well_driller_name: 'Mesa Drilling', + measuring_point_description: 'Top of casing', + measuring_point_height: 2.5, + measuring_point_height_unit: 'ft', + first_visit_date: '2026-01-20', + groups: [projectAlpha], + contacts: [ + { + id: 1, + name: 'Alex Contact', + organization: 'NMBGMR', + role: 'Owner', + contact_type: 'Primary', + release_status: 'public', + }, + ], + aquifers: [ + { aquifer_system: 'Santa Fe Group', aquifer_types: ['basin fill'] }, + ], + alternate_ids: [ + { + id: 1001, + alternate_organization: 'USGS', + alternate_id: '08300000', + relation: 'site id', + }, + ], + current_location: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-106.65, 35.08, 5000], + }, + properties: { + elevation: 5000, + elevation_unit: 'ft', + }, + }, +} + +export const wellTwo = { + id: 2, + name: 'RG-002', + site_name: 'Rio Grande Site 2', + created_at: '2026-01-20T12:00:00Z', + release_status: 'public', + thing_type: 'water well', + location_id: 102, + monitoring_status: 'Inactive', + well_status: 'Plugged', + hole_depth: 150, + hole_depth_unit: 'ft', + well_depth: 140, + well_depth_unit: 'ft', + groups: [projectAlpha, projectBeta], + contacts: [], + current_location: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-106.7, 35.12, 5020], + }, + properties: { + elevation: 5020, + elevation_unit: 'ft', + }, + }, +} + +export const contactOne = { + id: 1, + name: 'Alex Contact', + organization: 'NMBGMR', + role: 'Owner', + contact_type: 'Primary', + release_status: 'public', + created_at: '2026-01-01T00:00:00Z', + things: [wellOne], + phones: [ + { + id: 1, + contact_id: 1, + phone_type: 'Primary', + phone_number: '5055551212', + release_status: 'public', + created_at: '2026-01-01T00:00:00Z', + }, + ], + emails: [ + { + id: 1, + contact_id: 1, + email_type: 'Primary', + email: 'alex@example.org', + release_status: 'public', + created_at: '2026-01-01T00:00:00Z', + }, + ], + addresses: [ + { + id: 1, + contact_id: 1, + address_type: 'Mailing', + address_line_1: '801 Leroy Place', + city: 'Socorro', + state: 'NM', + postal_code: '87801', + country: 'USA', + release_status: 'public', + created_at: '2026-01-01T00:00:00Z', + }, + ], +} + +export const contactTwo = { + id: 2, + name: 'Jordan Manager', + organization: 'Bureau of Geology', + role: 'Manager', + contact_type: 'Secondary', + release_status: 'public', + created_at: '2026-02-01T00:00:00Z', + things: [wellTwo], + phones: [], + emails: [], + addresses: [], +} + +const listResponse = (items: unknown[]) => ({ + items, + total: items.length, +}) + +export const interceptOcotilloListFixtures = () => { + cy.intercept('GET', 'http://localhost:8000/thing/water-well*', (req) => { + const query = String(req.query.name_contains ?? '').toLowerCase() + const groupFilter = ([] as string[]).concat(req.query.filter ?? []) + const wantsProjectAlpha = groupFilter.some( + (filter) => filter.includes('"groups"') && filter.includes('"10"') + ) + + let wells = [wellOne, wellTwo] + if (wantsProjectAlpha) + wells = wells.filter((well) => well.groups.some((g) => g.id === 10)) + if (query) + wells = wells.filter((well) => well.name.toLowerCase().includes(query)) + + req.reply({ statusCode: 200, body: listResponse(wells) }) + }).as('getWells') + + cy.intercept('GET', 'http://localhost:8000/group*', { + statusCode: 200, + body: listResponse([projectAlpha, projectBeta]), + }).as('getProjects') + + cy.intercept('GET', 'http://localhost:8000/contact*', { + statusCode: 200, + body: listResponse([contactOne, contactTwo]), + }).as('getContacts') +} + +export const interceptWellShowFixtures = () => { + cy.intercept('GET', 'http://localhost:8000/thing/water-well/1/details**', { + statusCode: 200, + body: { + well: wellOne, + contacts: [contactOne], + sensors: [], + deployments: [], + well_screens: [], + field_events: [], + first_field_event: null, + }, + }).as('getWellDetails') + + cy.intercept('GET', 'http://localhost:8000/asset*', { + statusCode: 200, + body: listResponse([]), + }).as('getWellAssets') + + cy.intercept('GET', 'http://localhost:8000/thing/1/id-link*', { + statusCode: 200, + body: listResponse(wellOne.alternate_ids), + }).as('getWellIdLinks') + + cy.intercept('GET', 'http://localhost:8000/observation/groundwater-level*', { + statusCode: 200, + body: listResponse([]), + }).as('getGroundwaterLevels') + + cy.intercept( + 'GET', + 'http://localhost:8000/observation/transducer-groundwater-level*', + { + statusCode: 200, + body: listResponse([]), + } + ).as('getTransducerGroundwaterLevels') +} + +export const interceptProjectShowFixtures = () => { + cy.intercept('GET', 'http://localhost:8000/group/10**', { + statusCode: 200, + body: projectAlpha, + }).as('getProject') + + cy.intercept('GET', 'http://localhost:8000/thing/water-well*', { + statusCode: 200, + body: listResponse([wellOne, wellTwo]), + }).as('getProjectWells') +} + +export const interceptContactShowFixtures = () => { + cy.intercept('GET', 'http://localhost:8000/contact/1**', { + statusCode: 200, + body: contactOne, + }).as('getContact') + + cy.intercept('GET', 'http://localhost:8000/thing/1**', { + statusCode: 200, + body: wellOne, + }).as('getAssociatedWell') + + cy.intercept('GET', 'http://localhost:8000/observation/groundwater-level*', { + statusCode: 200, + body: listResponse([ + { + id: 501, + sample_id: 701, + observation_datetime: '2026-03-01T10:00:00Z', + depth_to_water_bgs: 42.5, + }, + ]), + }).as('getContactWellObservations') + + cy.intercept('GET', 'http://localhost:8000/sample/701**', { + statusCode: 200, + body: { + id: 701, + sample_date: '2026-03-01T10:00:00Z', + sampler_name: 'Field Staff', + contact: contactOne, + }, + }).as('getContactWellSample') +} diff --git a/src/components/ListPage.tsx b/src/components/ListPage.tsx index 3e7e3633..8e3cb7d0 100644 --- a/src/components/ListPage.tsx +++ b/src/components/ListPage.tsx @@ -182,6 +182,8 @@ type ListPageProps = { hideBreadcrumb?: boolean /** Hide create/edit header buttons and default export */ hideHeaderButtons?: boolean + /** Explicit access-control resource when Refine cannot infer it from custom routes. */ + accessResource?: string } export const ListPage: React.FC = ({ @@ -207,6 +209,7 @@ export const ListPage: React.FC = ({ hideBreadcrumb = false, hideHeaderButtons = false, getRowHref, + accessResource, }) => { if (!exportProps) { exportProps = { pageSize: 1000 } @@ -219,6 +222,7 @@ export const ListPage: React.FC = ({ const { show } = useNavigation() const { resource } = useResourceParams() + const canAccessResource = accessResource ?? resource?.name const handleSelectionChangeWrapper = (selectionModel: any) => { if (onSelectionChange) { @@ -234,7 +238,9 @@ export const ListPage: React.FC = ({ }) => { return ( <> - {defaultButtons} + + {defaultButtons} + = ({ getRowHref || (!disableRowClick && resource) ? 'pointer' : 'default' return ( - + null : headerButtons || defaultHeaderButtons diff --git a/src/pages/ocotillo/contact/list.tsx b/src/pages/ocotillo/contact/list.tsx index 900853ba..72f071dc 100644 --- a/src/pages/ocotillo/contact/list.tsx +++ b/src/pages/ocotillo/contact/list.tsx @@ -204,6 +204,7 @@ export const ContactList: React.FC = () => { onSelectionChange={(params) => setSelectedContactId(params.length > 0 ? (params[0] as number) : null) } + accessResource="ocotillo.contact" /> {selectedContactId && ( <> diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index 4383847f..1e24fe08 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -1,11 +1,19 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router' -import { useExport, useGo, useLink, useOne, type CrudFilter } from '@refinedev/core' +import { + useExport, + useGo, + useLink, + useOne, + type CrudFilter, +} from '@refinedev/core' import { useDataGrid } from '@refinedev/mui' +import { GridColDef } from '@mui/x-data-grid' import { - GridColDef, -} from '@mui/x-data-grid' -import { captureEvent, consumeWellsProjectFilterSource, setWellsProjectFilterSource } from '@/analytics/posthog' + captureEvent, + consumeWellsProjectFilterSource, + setWellsProjectFilterSource, +} from '@/analytics/posthog' import { Download, FileText, Loader2, X } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -84,9 +92,7 @@ export const WellList: React.FC = () => { const projectFilters = useMemo( () => - projectId - ? [{ field: 'groups', operator: 'eq', value: projectId }] - : [], + projectId ? [{ field: 'groups', operator: 'eq', value: projectId }] : [], [projectId] ) @@ -477,14 +483,16 @@ export const WellList: React.FC = () => { onRowClick={(params) => captureEvent('wells_row_clicked', { well_id: params.id }) } + accessResource="ocotillo.thing-well" > {projectId ? (
- -
+ +
Project: {projectName ?? projectId} - +
-
@@ -689,30 +573,14 @@ export function DataGridPage() { open={panelOpen} panel={ setPanelOpen(false)} />} > -
- {isLoading || size.width === 0 ? ( -
- Loading wells… -
- ) : ( - - )} -
+ setBulkOpen(false)} /> From 53fc863a4df3244ab179cf680dcf66af4e03dc3c Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 21 Jul 2026 14:57:52 -0700 Subject: [PATCH 006/144] BDMS-878: geothermal records data-entry grid (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an inline-editable spreadsheet of the records belonging to a well, built on the reusable EditableDataGrid from Phase 1. - GeoThermalRecordsGrid (pages/geothermal/wells/records-grid.tsx): reads wells/{id}/records through the geothermal provider, renders an editable grid, and accumulates cell edits in local state with a dirty-row count. No write-back yet — a disabled "Save changes" button marks where the Phase 3 batch save lands. - Admin-gated via canManageGeothermal (useAccessCapabilities), per the BDMS-878 decision that both editing and entering rows require Geothermal.Admin. Query is disabled until the check passes. - Route wells/records-grid/:id mounted in GeothermalRoutes; reachable from the well show page via a gated "Open data-entry grid" button. Columns are PROVISIONAL — they mirror the current IWellRecord shape (11 string fields) since the geothermal API contract is not finalized. The column list is a single localized array to swap once the real field set and types are pinned. Co-Authored-By: Claude Opus 4.8 --- src/pages/geothermal/wells/index.ts | 1 + src/pages/geothermal/wells/records-grid.tsx | 146 ++++++++++++++++++++ src/pages/geothermal/wells/show.tsx | 19 ++- src/routes/geothermal.tsx | 2 + 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/pages/geothermal/wells/records-grid.tsx diff --git a/src/pages/geothermal/wells/index.ts b/src/pages/geothermal/wells/index.ts index 36a92846..1e47e353 100644 --- a/src/pages/geothermal/wells/index.ts +++ b/src/pages/geothermal/wells/index.ts @@ -1,2 +1,3 @@ export * from "./list"; export * from "./show"; +export * from "./records-grid"; diff --git a/src/pages/geothermal/wells/records-grid.tsx b/src/pages/geothermal/wells/records-grid.tsx new file mode 100644 index 00000000..bd02d258 --- /dev/null +++ b/src/pages/geothermal/wells/records-grid.tsx @@ -0,0 +1,146 @@ +import { useEffect, useMemo, useState } from 'react' +import { useList, useParsed } from '@refinedev/core' +import type { IWellRecord } from '@/interfaces/geothermal' +import { useAccessCapabilities } from '@/hooks' +import { + EditableDataGrid, + type CellValue, + type GridColumnSpec, +} from '@/components/grid' +import { Button } from '@/components/ui/button' + +/** + * Editable text column for a well-record field. + * + * PROVISIONAL — the geothermal API contract is not finalized. These columns + * mirror the current IWellRecord shape (11 string fields) so the grid reads and + * edits real data today; swap this list for the real field set / types once the + * contract lands. Column changes are localized to this array. + */ +function textCol( + id: keyof IWellRecord, + title: string, + width = 150 +): GridColumnSpec { + return { + id, + title, + width, + editable: true, + getValue: (r) => r[id] ?? '', + setValue: (r, v) => ({ ...r, [id]: v ?? '' }), + } +} + +const RECORD_COLUMNS: GridColumnSpec[] = [ + { + id: 'OBJECTID', + title: 'ID', + width: 90, + kind: 'number', + // Server-assigned key — read-only. + getValue: (r) => r.OBJECTID, + }, + textCol('WellDataID', 'WellDataID', 130), + textCol('WellName', 'WellName', 180), + textCol('WellNumber', 'WellNumber', 130), + textCol('API_suffix', 'API_suffix', 120), + textCol('ActionDate', 'ActionDate', 130), + textCol('EntryDate', 'EntryDate', 130), + textCol('EnteredBy', 'EnteredBy', 140), + textCol('RecrdSetID', 'RecrdSetID', 120), + textCol('SourceID', 'SourceID', 120), + textCol('Comments', 'Comments', 280), +] + +function rowKey(r: IWellRecord): string { + return String(r.OBJECTID) +} + +/** + * Phase 2 — inline-editable spreadsheet of the records belonging to one well. + * + * Reads `wells/{id}/records` through the geothermal provider and lets an admin + * edit cells in place. Edits accumulate in local state and mark rows dirty; the + * grid does NOT write back yet — an explicit batch save lands in Phase 3. + * Admin-gated per BDMS-878 (`canManageGeothermal`). + */ +export const GeoThermalRecordsGrid = () => { + const { id } = useParsed() + const { canManageGeothermal, isLoading: permLoading } = + useAccessCapabilities() + + const { query } = useList({ + resource: `wells/${id}/records`, + dataProviderName: 'geothermal', + pagination: { pageSize: 500, mode: 'server' }, + queryOptions: { enabled: canManageGeothermal && id != null }, + }) + + // Local, editable copy. `original` is the pristine snapshot used to compute + // which rows are dirty (keyed by server id). + const [records, setRecords] = useState([]) + const [original, setOriginal] = useState>(new Map()) + + useEffect(() => { + const data = query.data?.data + if (!data) return + setRecords(data) + setOriginal(new Map(data.map((r) => [rowKey(r), JSON.stringify(r)]))) + }, [query.data]) + + const dirtyCount = useMemo(() => { + let n = 0 + for (const r of records) { + const snap = original.get(rowKey(r)) + if (snap !== undefined && snap !== JSON.stringify(r)) n++ + } + return n + }, [records, original]) + + if (permLoading) { + return ( +
+ Checking access… +
+ ) + } + + if (!canManageGeothermal) { + return ( +
+ You need the Geothermal Admin role to enter well data. +
+ ) + } + + return ( +
+ {/* Toolbar */} +
+ Well records + {id != null && ( + Well {String(id)} + )} +
+ + {dirtyCount} unsaved {dirtyCount === 1 ? 'change' : 'changes'} + + {/* Save is wired in Phase 3 (explicit batch write-back). */} + +
+
+ + +
+ ) +} diff --git a/src/pages/geothermal/wells/show.tsx b/src/pages/geothermal/wells/show.tsx index b335a820..fbed4f63 100644 --- a/src/pages/geothermal/wells/show.tsx +++ b/src/pages/geothermal/wells/show.tsx @@ -1,5 +1,6 @@ -import {Stack, Typography} from "@mui/material"; -import {useList, useOne, useParsed, useShow} from "@refinedev/core"; +import {Button, Stack, Typography} from "@mui/material"; +import {useGo, useList, useOne, useParsed, useShow} from "@refinedev/core"; +import {useAccessCapabilities} from "@/hooks"; import { DateField, MarkdownField, @@ -14,6 +15,8 @@ import type {IWell, IWellRecord} from "@/interfaces/geothermal"; export const GeoThermalWellShow = () => { const {id} = useParsed(); + const go = useGo(); + const {canManageGeothermal} = useAccessCapabilities(); const {query, result: record} = useShow({ resource: "wells", @@ -130,6 +133,18 @@ export const GeoThermalWellShow = () => { + {canManageGeothermal && id != null && ( + + )} { @@ -13,6 +14,7 @@ export const GeothermalRoutes = () => { } /> } /> + } /> } /> From 2726271f944ec843a552ff463e19e0bc6f54c0ae Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 21 Jul 2026 15:01:56 -0700 Subject: [PATCH 007/144] BDMS-878: explicit batch save for records grid (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire write-back for the geothermal records grid. No autosave — edits accumulate and a "Save changes" action flushes every dirty row. Provider: - geothermal create/update now map FastAPI/Pydantic 422/409 payloads ({ detail: [{ loc, msg }] }) to Refine fieldErrors, mirroring the ocotillo provider. Shared throwOnWriteError + buildFieldErrors helpers; strips the leading body. segment from field paths. EditableDataGrid: - new cellErrors(rowIndex) => { colId: msg } prop; errored cells render with an error-tinted background so rejected fields surface inline. Records grid: - Save flushes dirty rows via provider.update through Promise.allSettled (no server bulk endpoint → one request per row). Per-row tracking: saved rows update their snapshot and clear dirty; failed rows stay dirty for retry and tint their rejected cells from fieldErrors. Toolbar shows an "n saved, m failed" summary and disables Save while in flight or when nothing is dirty. New-row creation (create path) is Phase 4; this only saves edits to existing rows. Co-Authored-By: Claude Opus 4.8 --- src/components/grid/EditableDataGrid.tsx | 17 ++- src/pages/geothermal/wells/records-grid.tsx | 146 +++++++++++++++++--- src/providers/geothermal-data-provider.ts | 55 +++++++- 3 files changed, 193 insertions(+), 25 deletions(-) diff --git a/src/components/grid/EditableDataGrid.tsx b/src/components/grid/EditableDataGrid.tsx index 3ed0bed9..1805ccfa 100644 --- a/src/components/grid/EditableDataGrid.tsx +++ b/src/components/grid/EditableDataGrid.tsx @@ -75,11 +75,20 @@ export interface EditableDataGridProps * rows for an explicit batch save. */ onRowsChange?: (rows: T[]) => void + /** + * Per-row validation errors keyed by column id. Return `undefined` for a row + * with no errors. Errored cells render with an error-tinted background so + * failures (e.g. a rejected batch save) surface inline. + */ + cellErrors?: (rowIndex: number) => Record | undefined /** Show a centered loading message instead of the grid. */ isLoading?: boolean loadingMessage?: string } +// Background tint applied to a cell that has a validation error. +const ERROR_CELL_THEME = { bgCell: '#fee2e2', bgCellMedium: '#fee2e2' } + function toDisplayString(value: CellValue): string { return value != null ? String(value) : '' } @@ -95,6 +104,7 @@ export function EditableDataGrid({ columns, rows, onRowsChange, + cellErrors, isLoading = false, loadingMessage = 'Loading…', freezeColumns, @@ -125,6 +135,8 @@ export function EditableDataGrid({ const value = colDef.getValue(rowData) const display = toDisplayString(value) const editable = colDef.editable === true && colDef.setValue !== undefined + const error = cellErrors?.(row)?.[colDef.id] + const errorTheme = error ? { themeOverride: ERROR_CELL_THEME } : {} if (colDef.kind === 'uri') { return { @@ -133,6 +145,7 @@ export function EditableDataGrid({ allowOverlay: false, readonly: true, hoverEffect: colDef.onClick !== undefined, + ...errorTheme, } } @@ -143,6 +156,7 @@ export function EditableDataGrid({ displayData: display, allowOverlay: editable, readonly: !editable, + ...errorTheme, } } @@ -152,9 +166,10 @@ export function EditableDataGrid({ displayData: display, allowOverlay: editable, readonly: !editable, + ...errorTheme, } }, - [columns, rows] + [columns, rows, cellErrors] ) const onCellEdited = useCallback( diff --git a/src/pages/geothermal/wells/records-grid.tsx b/src/pages/geothermal/wells/records-grid.tsx index bd02d258..2192eb38 100644 --- a/src/pages/geothermal/wells/records-grid.tsx +++ b/src/pages/geothermal/wells/records-grid.tsx @@ -1,12 +1,8 @@ -import { useEffect, useMemo, useState } from 'react' -import { useList, useParsed } from '@refinedev/core' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useDataProvider, useList, useParsed } from '@refinedev/core' import type { IWellRecord } from '@/interfaces/geothermal' import { useAccessCapabilities } from '@/hooks' -import { - EditableDataGrid, - type CellValue, - type GridColumnSpec, -} from '@/components/grid' +import { EditableDataGrid, type GridColumnSpec } from '@/components/grid' import { Button } from '@/components/ui/button' /** @@ -57,18 +53,40 @@ function rowKey(r: IWellRecord): string { return String(r.OBJECTID) } +/** Refine's `fieldErrors` ({ field: [msg] }) flattened to { field: joinedMsg }. */ +type FieldErrors = Record + +function flattenFieldErrors(raw: unknown): FieldErrors | undefined { + if (!raw || typeof raw !== 'object') return undefined + const out: FieldErrors = {} + for (const [field, msgs] of Object.entries(raw as Record)) { + out[field] = Array.isArray(msgs) ? msgs.join(' ') : String(msgs) + } + return Object.keys(out).length > 0 ? out : undefined +} + +interface SaveSummary { + saved: number + failed: number +} + /** - * Phase 2 — inline-editable spreadsheet of the records belonging to one well. + * Phase 2/3 — inline-editable spreadsheet of the records belonging to one well, + * with an explicit batch save. * * Reads `wells/{id}/records` through the geothermal provider and lets an admin - * edit cells in place. Edits accumulate in local state and mark rows dirty; the - * grid does NOT write back yet — an explicit batch save lands in Phase 3. + * edit cells in place. Edits accumulate in local state and mark rows dirty. A + * "Save changes" action flushes every dirty row through the provider `update` + * (there is no server bulk endpoint), tracking success/failure per row: + * saved rows clear their dirty flag; failed rows stay dirty for retry and have + * their rejected cells tinted via the provider's Pydantic `fieldErrors`. * Admin-gated per BDMS-878 (`canManageGeothermal`). */ export const GeoThermalRecordsGrid = () => { const { id } = useParsed() const { canManageGeothermal, isLoading: permLoading } = useAccessCapabilities() + const dataProvider = useDataProvider() const { query } = useList({ resource: `wells/${id}/records`, @@ -81,22 +99,86 @@ export const GeoThermalRecordsGrid = () => { // which rows are dirty (keyed by server id). const [records, setRecords] = useState([]) const [original, setOriginal] = useState>(new Map()) + const [saving, setSaving] = useState(false) + const [saveErrors, setSaveErrors] = useState>( + new Map() + ) + const [summary, setSummary] = useState(null) useEffect(() => { const data = query.data?.data if (!data) return setRecords(data) setOriginal(new Map(data.map((r) => [rowKey(r), JSON.stringify(r)]))) + setSaveErrors(new Map()) + setSummary(null) }, [query.data]) - const dirtyCount = useMemo(() => { - let n = 0 - for (const r of records) { - const snap = original.get(rowKey(r)) - if (snap !== undefined && snap !== JSON.stringify(r)) n++ - } - return n - }, [records, original]) + // Rows whose current value differs from the last-saved snapshot. + const dirtyRows = useMemo( + () => + records + .map((r, index) => ({ r, index })) + .filter(({ r }) => { + const snap = original.get(rowKey(r)) + return snap !== undefined && snap !== JSON.stringify(r) + }), + [records, original] + ) + + const cellErrors = useCallback( + (rowIndex: number): FieldErrors | undefined => { + const row = records[rowIndex] + return row ? saveErrors.get(rowKey(row)) : undefined + }, + [records, saveErrors] + ) + + const handleSave = useCallback(async () => { + if (dirtyRows.length === 0) return + setSaving(true) + setSummary(null) + + const resource = `wells/${id}/records` + const provider = dataProvider('geothermal') + + const results = await Promise.allSettled( + dirtyRows.map(({ r }) => + provider.update({ resource, id: r.OBJECTID, variables: r }) + ) + ) + + setOriginal((prev) => { + const next = new Map(prev) + results.forEach((res, i) => { + if (res.status === 'fulfilled') { + const row = dirtyRows[i].r + next.set(rowKey(row), JSON.stringify(row)) + } + }) + return next + }) + + const nextErrors = new Map() + let saved = 0 + let failed = 0 + results.forEach((res, i) => { + const key = rowKey(dirtyRows[i].r) + if (res.status === 'fulfilled') { + saved++ + } else { + failed++ + const fe = flattenFieldErrors( + (res.reason as { fieldErrors?: unknown })?.fieldErrors + ) + if (fe) nextErrors.set(key, fe) + } + }) + + setSaveErrors(nextErrors) + setSummary({ saved, failed }) + setSaving(false) + }, [dirtyRows, id, dataProvider]) if (permLoading) { return ( @@ -114,21 +196,40 @@ export const GeoThermalRecordsGrid = () => { ) } + const dirtyCount = dirtyRows.length + return (
{/* Toolbar */}
Well records {id != null && ( - Well {String(id)} + + Well {String(id)} + )}
+ {summary && ( + 0 + ? 'text-sm text-destructive' + : 'text-sm text-muted-foreground' + } + > + {summary.saved} saved + {summary.failed > 0 ? `, ${summary.failed} failed` : ''} + + )} {dirtyCount} unsaved {dirtyCount === 1 ? 'change' : 'changes'} - {/* Save is wired in Phase 3 (explicit batch write-back). */} -
@@ -137,6 +238,7 @@ export const GeoThermalRecordsGrid = () => { columns={RECORD_COLUMNS} rows={records} onRowsChange={setRecords} + cellErrors={cellErrors} isLoading={query.isLoading} loadingMessage="Loading records…" freezeColumns={1} diff --git a/src/providers/geothermal-data-provider.ts b/src/providers/geothermal-data-provider.ts index e963d228..43a505be 100644 --- a/src/providers/geothermal-data-provider.ts +++ b/src/providers/geothermal-data-provider.ts @@ -23,6 +23,57 @@ export const fetcher = async (url: string, options?: RequestInit) => { return response; }; +/** + * Map a FastAPI/Pydantic validation payload (`{ detail: [{ loc, msg }] }`) to + * Refine's `fieldErrors` shape (`{ field: [msg] }`). Mirrors the ocotillo + * provider so failed cells can surface inline. Strips the leading `body.` + * segment Pydantic prepends to request-body fields. + */ +const buildFieldErrors = ( + detail: Array<{ loc?: (string | number)[]; msg?: string }>, +): Record => { + const refined: Record = {}; + detail.forEach((issue) => { + const path = (issue.loc ?? []).join("."); + const field = path.startsWith("body.") ? path.substring(5) : path; + if (!field) return; + (refined[field] ??= []).push(issue.msg ?? "Invalid value"); + }); + return refined; +}; + +/** + * Throw on a non-2xx write response. For 422/409 with a Pydantic `detail` + * array, throw a transformed Error carrying `fieldErrors`/`errors` so callers + * can attach messages to specific cells; otherwise throw the raw Response. + */ +const throwOnWriteError = async (response: Response): Promise => { + if (response.status >= 200 && response.status <= 299) return; + + if (response.status === 422 || response.status === 409) { + let body: { detail?: unknown } | undefined; + try { + body = await response.json(); + } catch { + throw response; + } + if (body?.detail && Array.isArray(body.detail)) { + const fieldErrors = buildFieldErrors(body.detail); + const error = new Error("Validation Error") as Error & { + status?: number; + errors?: Record; + fieldErrors?: Record; + }; + error.status = response.status; + error.errors = fieldErrors; + error.fieldErrors = fieldErrors; + throw error; + } + } + + throw response; +}; + export const geothermalDataProvider: DataProvider = { getList: async ({ resource, pagination, filters, sorters, meta }) => { const params = new URLSearchParams(); @@ -91,7 +142,7 @@ export const geothermalDataProvider: DataProvider = { }, }); - if (response.status < 200 || response.status > 299) throw response; + await throwOnWriteError(response); const data = await response.json(); @@ -106,7 +157,7 @@ export const geothermalDataProvider: DataProvider = { }, }); - if (response.status < 200 || response.status > 299) throw response; + await throwOnWriteError(response); const data = await response.json(); From c38c681c772347f0d75b1f4d08228bbc66946de3 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 21 Jul 2026 15:05:46 -0700 Subject: [PATCH 008/144] BDMS-878: enter new records inline in the grid (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new-record entry to the geothermal records grid, flushing through the Phase 3 batch save via the provider create path. - "Add N rows" appends blank rows to the same grid. New rows carry a client-only temp id (new:N) in OBJECTID until the server assigns a real one, so their ID cell stays blank and create is distinguished from update. No separate bulk-add modal / upload step — entry happens inline in the same spreadsheet, so Glide's copy/paste-from-Excel works for new rows too. - Save now builds a pending-op list: changed existing rows -> update, non-blank new rows -> create (blank appended rows are ignored). Runs all through Promise.allSettled with per-row tracking. A created row adopts the server-returned record (real OBJECTID) and stops being new; failed rows stay pending for retry with their cells tinted from fieldErrors. Unsaved count and summary now include creates. Assumes create returns the created record; reconciliation of the temp row relies on that response. The demo BulkAddModal stays as its own reference and is unaffected. Co-Authored-By: Claude Opus 4.8 --- src/pages/geothermal/wells/records-grid.tsx | 178 +++++++++++++++----- 1 file changed, 137 insertions(+), 41 deletions(-) diff --git a/src/pages/geothermal/wells/records-grid.tsx b/src/pages/geothermal/wells/records-grid.tsx index 2192eb38..13cefc2c 100644 --- a/src/pages/geothermal/wells/records-grid.tsx +++ b/src/pages/geothermal/wells/records-grid.tsx @@ -28,13 +28,27 @@ function textCol( } } +// Editable field keys (everything except the server-assigned OBJECTID). +const EDITABLE_KEYS: (keyof IWellRecord)[] = [ + 'WellDataID', + 'WellName', + 'WellNumber', + 'API_suffix', + 'ActionDate', + 'EntryDate', + 'EnteredBy', + 'RecrdSetID', + 'SourceID', + 'Comments', +] + const RECORD_COLUMNS: GridColumnSpec[] = [ { id: 'OBJECTID', title: 'ID', width: 90, kind: 'number', - // Server-assigned key — read-only. + // Server-assigned key — read-only. Blank for not-yet-saved new rows. getValue: (r) => r.OBJECTID, }, textCol('WellDataID', 'WellDataID', 130), @@ -49,10 +63,39 @@ const RECORD_COLUMNS: GridColumnSpec[] = [ textCol('Comments', 'Comments', 280), ] +// New rows carry a client-only temp id (`new:N`) in OBJECTID until the server +// assigns a real one on create. The prefix distinguishes create from update. +const NEW_PREFIX = 'new:' +const ADD_ROW_COUNT = 10 + function rowKey(r: IWellRecord): string { return String(r.OBJECTID) } +function isNewRow(r: IWellRecord): boolean { + return rowKey(r).startsWith(NEW_PREFIX) +} + +function isBlankNew(r: IWellRecord): boolean { + return EDITABLE_KEYS.every((k) => !r[k]) +} + +function makeBlankRecord(tempId: string): IWellRecord { + return { + OBJECTID: tempId, + WellDataID: '', + WellName: '', + WellNumber: '', + API_suffix: '', + ActionDate: '', + EntryDate: '', + EnteredBy: '', + RecrdSetID: '', + SourceID: '', + Comments: '', + } +} + /** Refine's `fieldErrors` ({ field: [msg] }) flattened to { field: joinedMsg }. */ type FieldErrors = Record @@ -65,22 +108,31 @@ function flattenFieldErrors(raw: unknown): FieldErrors | undefined { return Object.keys(out).length > 0 ? out : undefined } +type PendingOp = { + kind: 'create' | 'update' + index: number + key: string + row: IWellRecord +} + interface SaveSummary { saved: number failed: number } /** - * Phase 2/3 — inline-editable spreadsheet of the records belonging to one well, - * with an explicit batch save. + * Phase 2/3/4 — inline-editable spreadsheet of the records belonging to one + * well, with explicit batch save and inline new-record entry. * * Reads `wells/{id}/records` through the geothermal provider and lets an admin - * edit cells in place. Edits accumulate in local state and mark rows dirty. A - * "Save changes" action flushes every dirty row through the provider `update` - * (there is no server bulk endpoint), tracking success/failure per row: - * saved rows clear their dirty flag; failed rows stay dirty for retry and have - * their rejected cells tinted via the provider's Pydantic `fieldErrors`. - * Admin-gated per BDMS-878 (`canManageGeothermal`). + * edit cells in place and append blank rows for new records — no separate + * upload step. Edits and new rows accumulate in local state; "Save changes" + * flushes each pending row through the provider (there is no server bulk + * endpoint): existing dirty rows via `update`, new rows via `create`. Per-row + * tracking — saved rows clear their pending flag (a created row adopts the + * server-returned record, gaining its real id); failed rows stay pending for + * retry and have their rejected cells tinted from the provider's Pydantic + * `fieldErrors`. Admin-gated per BDMS-878 (`canManageGeothermal`). */ export const GeoThermalRecordsGrid = () => { const { id } = useParsed() @@ -96,9 +148,10 @@ export const GeoThermalRecordsGrid = () => { }) // Local, editable copy. `original` is the pristine snapshot used to compute - // which rows are dirty (keyed by server id). + // which existing rows are dirty (keyed by server id). const [records, setRecords] = useState([]) const [original, setOriginal] = useState>(new Map()) + const [newCounter, setNewCounter] = useState(0) const [saving, setSaving] = useState(false) const [saveErrors, setSaveErrors] = useState>( new Map() @@ -114,17 +167,23 @@ export const GeoThermalRecordsGrid = () => { setSummary(null) }, [query.data]) - // Rows whose current value differs from the last-saved snapshot. - const dirtyRows = useMemo( - () => - records - .map((r, index) => ({ r, index })) - .filter(({ r }) => { - const snap = original.get(rowKey(r)) - return snap !== undefined && snap !== JSON.stringify(r) - }), - [records, original] - ) + // Pending write operations: changed existing rows (update) + non-blank new + // rows (create). Blank appended rows are ignored until the user fills them. + const pending = useMemo(() => { + const ops: PendingOp[] = [] + records.forEach((r, index) => { + const key = rowKey(r) + if (isNewRow(r)) { + if (!isBlankNew(r)) ops.push({ kind: 'create', index, key, row: r }) + } else { + const snap = original.get(key) + if (snap !== undefined && snap !== JSON.stringify(r)) { + ops.push({ kind: 'update', index, key, row: r }) + } + } + }) + return ops + }, [records, original]) const cellErrors = useCallback( (rowIndex: number): FieldErrors | undefined => { @@ -134,8 +193,18 @@ export const GeoThermalRecordsGrid = () => { [records, saveErrors] ) + const handleAddRows = useCallback(() => { + setRecords((prev) => { + const blanks = Array.from({ length: ADD_ROW_COUNT }, (_, i) => + makeBlankRecord(`${NEW_PREFIX}${newCounter + i}`) + ) + return [...prev, ...blanks] + }) + setNewCounter((n) => n + ADD_ROW_COUNT) + }, [newCounter]) + const handleSave = useCallback(async () => { - if (dirtyRows.length === 0) return + if (pending.length === 0) return setSaving(true) setSummary(null) @@ -143,42 +212,61 @@ export const GeoThermalRecordsGrid = () => { const provider = dataProvider('geothermal') const results = await Promise.allSettled( - dirtyRows.map(({ r }) => - provider.update({ resource, id: r.OBJECTID, variables: r }) - ) - ) - - setOriginal((prev) => { - const next = new Map(prev) - results.forEach((res, i) => { - if (res.status === 'fulfilled') { - const row = dirtyRows[i].r - next.set(rowKey(row), JSON.stringify(row)) + pending.map((op) => { + if (op.kind === 'create') { + // Server assigns OBJECTID — omit the temp id from the payload. + const { OBJECTID: _tempId, ...variables } = op.row + return provider.create({ resource, variables }) } + return provider.update({ + resource, + id: op.row.OBJECTID, + variables: op.row, + }) }) - return next - }) + ) + const createdByIndex = new Map() + const nextSnaps: Array<[string, string]> = [] const nextErrors = new Map() let saved = 0 let failed = 0 + results.forEach((res, i) => { - const key = rowKey(dirtyRows[i].r) + const op = pending[i] if (res.status === 'fulfilled') { saved++ + if (op.kind === 'create') { + // Adopt the server record (real OBJECTID) so the row stops being new. + const created = (res.value?.data as IWellRecord) ?? op.row + createdByIndex.set(op.index, created) + nextSnaps.push([rowKey(created), JSON.stringify(created)]) + } else { + nextSnaps.push([op.key, JSON.stringify(op.row)]) + } } else { failed++ const fe = flattenFieldErrors( (res.reason as { fieldErrors?: unknown })?.fieldErrors ) - if (fe) nextErrors.set(key, fe) + if (fe) nextErrors.set(op.key, fe) } }) + if (createdByIndex.size > 0) { + setRecords((prev) => + prev.map((r, i) => createdByIndex.get(i) ?? r) + ) + } + setOriginal((prev) => { + const next = new Map(prev) + nextSnaps.forEach(([k, v]) => next.set(k, v)) + return next + }) setSaveErrors(nextErrors) setSummary({ saved, failed }) setSaving(false) - }, [dirtyRows, id, dataProvider]) + }, [pending, id, dataProvider]) if (permLoading) { return ( @@ -196,7 +284,7 @@ export const GeoThermalRecordsGrid = () => { ) } - const dirtyCount = dirtyRows.length + const pendingCount = pending.length return (
@@ -222,12 +310,20 @@ export const GeoThermalRecordsGrid = () => { )} - {dirtyCount} unsaved {dirtyCount === 1 ? 'change' : 'changes'} + {pendingCount} unsaved {pendingCount === 1 ? 'change' : 'changes'} + From ed8da3be4ef73ea1d9c7d881d9fcb23abccb2588 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 21 Jul 2026 15:09:18 -0700 Subject: [PATCH 009/144] BDMS-878: tests for records-grid logic + geothermal write errors Extract the pure save logic out of the records-grid page into recordsGridLogic.ts (rowKey, isNewRow, isBlankNew, makeBlankRecord, flattenFieldErrors, computePendingOps) so it is unit-testable without mounting the Glide canvas. The page imports from it; behavior unchanged. Tests: - recordsGridLogic.test.ts (9): computePendingOps splits changed existing rows -> update and non-blank new rows -> create, skips blank appended rows and unchanged rows, preserves row indices for mixed batches; makeBlankRecord / isNewRow / isBlankNew / flattenFieldErrors. - geothermal-data-provider.test.ts (5): create/update map 422 and 409 Pydantic payloads to fieldErrors (stripping the body. prefix, mirrored onto .errors), throw the raw Response for non-validation statuses and for a 422 without a detail array, and return { data } on success. fetch + getAccessToken + settings are mocked. Co-Authored-By: Claude Opus 4.8 --- src/pages/geothermal/wells/records-grid.tsx | 91 ++------------- .../geothermal/wells/recordsGridLogic.ts | 90 +++++++++++++++ src/test/pages/recordsGridLogic.test.ts | 107 ++++++++++++++++++ .../geothermal-data-provider.test.ts | 102 +++++++++++++++++ 4 files changed, 311 insertions(+), 79 deletions(-) create mode 100644 src/pages/geothermal/wells/recordsGridLogic.ts create mode 100644 src/test/pages/recordsGridLogic.test.ts create mode 100644 src/test/providers/geothermal-data-provider.test.ts diff --git a/src/pages/geothermal/wells/records-grid.tsx b/src/pages/geothermal/wells/records-grid.tsx index 13cefc2c..1cd7c742 100644 --- a/src/pages/geothermal/wells/records-grid.tsx +++ b/src/pages/geothermal/wells/records-grid.tsx @@ -4,6 +4,14 @@ import type { IWellRecord } from '@/interfaces/geothermal' import { useAccessCapabilities } from '@/hooks' import { EditableDataGrid, type GridColumnSpec } from '@/components/grid' import { Button } from '@/components/ui/button' +import { + computePendingOps, + flattenFieldErrors, + makeBlankRecord, + NEW_PREFIX, + rowKey, + type FieldErrors, +} from './recordsGridLogic' /** * Editable text column for a well-record field. @@ -28,20 +36,6 @@ function textCol( } } -// Editable field keys (everything except the server-assigned OBJECTID). -const EDITABLE_KEYS: (keyof IWellRecord)[] = [ - 'WellDataID', - 'WellName', - 'WellNumber', - 'API_suffix', - 'ActionDate', - 'EntryDate', - 'EnteredBy', - 'RecrdSetID', - 'SourceID', - 'Comments', -] - const RECORD_COLUMNS: GridColumnSpec[] = [ { id: 'OBJECTID', @@ -63,58 +57,8 @@ const RECORD_COLUMNS: GridColumnSpec[] = [ textCol('Comments', 'Comments', 280), ] -// New rows carry a client-only temp id (`new:N`) in OBJECTID until the server -// assigns a real one on create. The prefix distinguishes create from update. -const NEW_PREFIX = 'new:' const ADD_ROW_COUNT = 10 -function rowKey(r: IWellRecord): string { - return String(r.OBJECTID) -} - -function isNewRow(r: IWellRecord): boolean { - return rowKey(r).startsWith(NEW_PREFIX) -} - -function isBlankNew(r: IWellRecord): boolean { - return EDITABLE_KEYS.every((k) => !r[k]) -} - -function makeBlankRecord(tempId: string): IWellRecord { - return { - OBJECTID: tempId, - WellDataID: '', - WellName: '', - WellNumber: '', - API_suffix: '', - ActionDate: '', - EntryDate: '', - EnteredBy: '', - RecrdSetID: '', - SourceID: '', - Comments: '', - } -} - -/** Refine's `fieldErrors` ({ field: [msg] }) flattened to { field: joinedMsg }. */ -type FieldErrors = Record - -function flattenFieldErrors(raw: unknown): FieldErrors | undefined { - if (!raw || typeof raw !== 'object') return undefined - const out: FieldErrors = {} - for (const [field, msgs] of Object.entries(raw as Record)) { - out[field] = Array.isArray(msgs) ? msgs.join(' ') : String(msgs) - } - return Object.keys(out).length > 0 ? out : undefined -} - -type PendingOp = { - kind: 'create' | 'update' - index: number - key: string - row: IWellRecord -} - interface SaveSummary { saved: number failed: number @@ -169,21 +113,10 @@ export const GeoThermalRecordsGrid = () => { // Pending write operations: changed existing rows (update) + non-blank new // rows (create). Blank appended rows are ignored until the user fills them. - const pending = useMemo(() => { - const ops: PendingOp[] = [] - records.forEach((r, index) => { - const key = rowKey(r) - if (isNewRow(r)) { - if (!isBlankNew(r)) ops.push({ kind: 'create', index, key, row: r }) - } else { - const snap = original.get(key) - if (snap !== undefined && snap !== JSON.stringify(r)) { - ops.push({ kind: 'update', index, key, row: r }) - } - } - }) - return ops - }, [records, original]) + const pending = useMemo( + () => computePendingOps(records, original), + [records, original] + ) const cellErrors = useCallback( (rowIndex: number): FieldErrors | undefined => { diff --git a/src/pages/geothermal/wells/recordsGridLogic.ts b/src/pages/geothermal/wells/recordsGridLogic.ts new file mode 100644 index 00000000..ca8de4bd --- /dev/null +++ b/src/pages/geothermal/wells/recordsGridLogic.ts @@ -0,0 +1,90 @@ +import type { IWellRecord } from '@/interfaces/geothermal' + +// Editable field keys (everything except the server-assigned OBJECTID). +export const EDITABLE_KEYS: (keyof IWellRecord)[] = [ + 'WellDataID', + 'WellName', + 'WellNumber', + 'API_suffix', + 'ActionDate', + 'EntryDate', + 'EnteredBy', + 'RecrdSetID', + 'SourceID', + 'Comments', +] + +// New rows carry a client-only temp id (`new:N`) in OBJECTID until the server +// assigns a real one on create. The prefix distinguishes create from update. +export const NEW_PREFIX = 'new:' + +export function rowKey(r: IWellRecord): string { + return String(r.OBJECTID) +} + +export function isNewRow(r: IWellRecord): boolean { + return rowKey(r).startsWith(NEW_PREFIX) +} + +export function isBlankNew(r: IWellRecord): boolean { + return EDITABLE_KEYS.every((k) => !r[k]) +} + +export function makeBlankRecord(tempId: string): IWellRecord { + return { + OBJECTID: tempId, + WellDataID: '', + WellName: '', + WellNumber: '', + API_suffix: '', + ActionDate: '', + EntryDate: '', + EnteredBy: '', + RecrdSetID: '', + SourceID: '', + Comments: '', + } +} + +/** Refine's `fieldErrors` ({ field: [msg] }) flattened to { field: joinedMsg }. */ +export type FieldErrors = Record + +export function flattenFieldErrors(raw: unknown): FieldErrors | undefined { + if (!raw || typeof raw !== 'object') return undefined + const out: FieldErrors = {} + for (const [field, msgs] of Object.entries(raw as Record)) { + out[field] = Array.isArray(msgs) ? msgs.join(' ') : String(msgs) + } + return Object.keys(out).length > 0 ? out : undefined +} + +export type PendingOp = { + kind: 'create' | 'update' + index: number + key: string + row: IWellRecord +} + +/** + * Split the current rows into pending write operations against the last-saved + * snapshot: changed existing rows → `update`, non-blank new rows → `create`. + * Blank appended rows and unchanged existing rows produce no op. + */ +export function computePendingOps( + records: IWellRecord[], + original: Map +): PendingOp[] { + const ops: PendingOp[] = [] + records.forEach((r, index) => { + const key = rowKey(r) + if (isNewRow(r)) { + if (!isBlankNew(r)) ops.push({ kind: 'create', index, key, row: r }) + } else { + const snap = original.get(key) + if (snap !== undefined && snap !== JSON.stringify(r)) { + ops.push({ kind: 'update', index, key, row: r }) + } + } + }) + return ops +} diff --git a/src/test/pages/recordsGridLogic.test.ts b/src/test/pages/recordsGridLogic.test.ts new file mode 100644 index 00000000..c0fe5756 --- /dev/null +++ b/src/test/pages/recordsGridLogic.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import type { IWellRecord } from '@/interfaces/geothermal' +import { + computePendingOps, + flattenFieldErrors, + isBlankNew, + isNewRow, + makeBlankRecord, + NEW_PREFIX, + rowKey, +} from '@/pages/geothermal/wells/recordsGridLogic' + +function existing(objectId: string, over: Partial = {}): IWellRecord { + return { ...makeBlankRecord(objectId), ...over, OBJECTID: objectId } +} + +function snapshotOf(records: IWellRecord[]): Map { + return new Map(records.map((r) => [rowKey(r), JSON.stringify(r)])) +} + +describe('makeBlankRecord', () => { + it('carries the temp id and leaves every editable field empty', () => { + const r = makeBlankRecord(`${NEW_PREFIX}3`) + expect(r.OBJECTID).toBe('new:3') + expect(isBlankNew(r)).toBe(true) + expect(isNewRow(r)).toBe(true) + }) +}) + +describe('isNewRow', () => { + it('is true only for temp-prefixed ids', () => { + expect(isNewRow(existing('new:0'))).toBe(true) + expect(isNewRow(existing('1024'))).toBe(false) + }) +}) + +describe('isBlankNew', () => { + it('is false once any editable field is filled', () => { + expect(isBlankNew(makeBlankRecord('new:1'))).toBe(true) + expect(isBlankNew(existing('new:1', { WellName: 'A' }))).toBe(false) + }) +}) + +describe('computePendingOps', () => { + it('emits nothing when no row changed', () => { + const rows = [existing('1'), existing('2')] + expect(computePendingOps(rows, snapshotOf(rows))).toEqual([]) + }) + + it('emits an update for a changed existing row only', () => { + const rows = [existing('1'), existing('2')] + const snap = snapshotOf(rows) + const edited = [{ ...rows[0], WellName: 'changed' }, rows[1]] + + const ops = computePendingOps(edited, snap) + + expect(ops).toHaveLength(1) + expect(ops[0]).toMatchObject({ kind: 'update', index: 0, key: '1' }) + }) + + it('emits a create for a filled new row and skips blank new rows', () => { + const existingRow = existing('1') + const snap = snapshotOf([existingRow]) + const rows = [ + existingRow, + makeBlankRecord('new:0'), // blank -> skipped + existing('new:1', { WellName: 'Fresh' }), // filled -> create + ] + + const ops = computePendingOps(rows, snap) + + expect(ops).toHaveLength(1) + expect(ops[0]).toMatchObject({ kind: 'create', index: 2, key: 'new:1' }) + }) + + it('preserves row indices for mixed create/update', () => { + const a = existing('1') + const b = existing('2') + const snap = snapshotOf([a, b]) + const rows = [ + { ...a, Comments: 'edit' }, // update, index 0 + b, // unchanged + existing('new:0', { WellName: 'New' }), // create, index 2 + ] + + const ops = computePendingOps(rows, snap) + + expect(ops.map((o) => ({ kind: o.kind, index: o.index }))).toEqual([ + { kind: 'update', index: 0 }, + { kind: 'create', index: 2 }, + ]) + }) +}) + +describe('flattenFieldErrors', () => { + it('joins each field message array into a single string', () => { + expect( + flattenFieldErrors({ WellName: ['required'], WellNumber: ['a', 'b'] }) + ).toEqual({ WellName: 'required', WellNumber: 'a b' }) + }) + + it('returns undefined for empty or non-object input', () => { + expect(flattenFieldErrors(undefined)).toBeUndefined() + expect(flattenFieldErrors({})).toBeUndefined() + expect(flattenFieldErrors(null)).toBeUndefined() + }) +}) diff --git a/src/test/providers/geothermal-data-provider.test.ts b/src/test/providers/geothermal-data-provider.test.ts new file mode 100644 index 00000000..9bc10e29 --- /dev/null +++ b/src/test/providers/geothermal-data-provider.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/settings', () => ({ + settings: { nmbgmr_geothermal_api_url: 'https://geo.test' }, +})) +vi.mock('@/providers/authentik-provider', () => ({ + getAccessToken: vi.fn(async () => 'test-token'), +})) + +import { geothermalDataProvider } from '@/providers/geothermal-data-provider' + +// Minimal Response stand-in — the provider only reads status + json(). +const resp = (status: number, body: unknown) => + ({ status, json: async () => body }) as unknown as Response + +const stubFetch = (r: Response) => { + const fn = vi.fn(async () => r) + vi.stubGlobal('fetch', fn) + return fn +} + +const validation422 = { + detail: [ + { loc: ['body', 'WellName'], msg: 'field required' }, + { loc: ['body', 'WellNumber'], msg: 'value is not a valid integer' }, + ], +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('geothermal provider write error mapping', () => { + it('update maps a 422 Pydantic payload to fieldErrors (body. stripped)', async () => { + stubFetch(resp(422, validation422)) + + const err = await geothermalDataProvider + .update!({ resource: 'wells/1/records', id: 5, variables: {} }) + .then(() => null) + .catch((e) => e) + + expect(err).toBeInstanceOf(Error) + expect(err.message).toBe('Validation Error') + expect(err.status).toBe(422) + expect(err.fieldErrors).toEqual({ + WellName: ['field required'], + WellNumber: ['value is not a valid integer'], + }) + // Refine also reads `.errors`. + expect(err.errors).toEqual(err.fieldErrors) + }) + + it('create maps a 409 conflict payload to fieldErrors', async () => { + stubFetch(resp(409, { detail: [{ loc: ['body', 'WellDataID'], msg: 'exists' }] })) + + const err = await geothermalDataProvider + .create!({ resource: 'wells/1/records', variables: { WellDataID: 'x' } }) + .then(() => null) + .catch((e) => e) + + expect(err.status).toBe(409) + expect(err.fieldErrors).toEqual({ WellDataID: ['exists'] }) + }) + + it('throws the raw response for a non-validation error status', async () => { + const r = resp(500, { detail: 'boom' }) + stubFetch(r) + + const err = await geothermalDataProvider + .update!({ resource: 'wells/1/records', id: 5, variables: {} }) + .then(() => null) + .catch((e) => e) + + expect(err).toBe(r) + expect(err).not.toBeInstanceOf(Error) + }) + + it('throws the raw response for a 422 without a detail array', async () => { + const r = resp(422, { message: 'nope' }) + stubFetch(r) + + const err = await geothermalDataProvider + .create!({ resource: 'wells/1/records', variables: {} }) + .then(() => null) + .catch((e) => e) + + expect(err).toBe(r) + }) + + it('returns { data } on a successful create', async () => { + const created = { OBJECTID: 99, WellName: 'New' } + stubFetch(resp(201, created)) + + const result = await geothermalDataProvider.create!({ + resource: 'wells/1/records', + variables: { WellName: 'New' }, + }) + + expect(result).toEqual({ data: created }) + }) +}) From c67c0796686635651a1ea3ebbbcbb0c45908f2b1 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 21 Jul 2026 15:13:18 -0700 Subject: [PATCH 010/144] Show Example nav so the Data Grid (glide editor) is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip SHOW_EXAMPLE_NAV to true. The sandbox "Example" section already carries a "Data Grid" item linking to /example/data-grid (the Glide Data Grid specimen); it was just gated off. No new plumbing — the item, route (App.tsx) and page (pages/example/DataGridPage.tsx) all exist. Co-Authored-By: Claude Opus 4.8 --- src/config/navigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 3aa6c797..4af538de 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -56,7 +56,7 @@ export type NavItem = { } /** Toggle Example nav (typography, data grid demos). Set true to restore. */ -export const SHOW_EXAMPLE_NAV = false +export const SHOW_EXAMPLE_NAV = true /** * Top bar: views and tools. From 584e74dba18040192276916669ecdd7b54961f78 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 21 Jul 2026 15:20:08 -0700 Subject: [PATCH 011/144] Rename sidebar "Example" nav section to "Sandbox" Match the label users refer to. Only the visible label + tooltip change; the /example/* routes and specimen pages are untouched. Also add .claude/launch.json (npm run dev on :5173) for local preview. Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 11 +++++++++++ src/components/AppShell.tsx | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..8b30f3bf --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 2c84b52b..dc464995 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -486,9 +486,9 @@ function ExampleNavItem() { - + - Example + Sandbox From bbe07d03c3339b0d5a63c2e5c99ed83ef28dbb73 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 21 Jul 2026 15:42:41 -0700 Subject: [PATCH 012/144] BDMS-878: add Geothermal Records grid to the Sandbox nav The Sandbox "Data Grid" item is the ocotillo-wells design specimen, not the geothermal grid. Add a distinct "Geothermal Records" entry that opens the real geothermal data-entry grid. - GeoThermalRecordsGridPicker: the geothermal grid is scoped to one well (records-grid/:id), so the Sandbox entry lands on a picker that lists geothermal wells and navigates into the chosen well's grid. Admin-gated (canManageGeothermal), same as the grid. - Route wells/records-grid (index) -> picker, alongside the existing wells/records-grid/:id -> grid. - Sandbox nav gains a "Geothermal Records" sub-item; the section's open/active detection now also matches the geothermal grid path. Co-Authored-By: Claude Opus 4.8 --- src/components/AppShell.tsx | 21 ++++- src/pages/geothermal/wells/index.ts | 1 + .../geothermal/wells/records-grid-picker.tsx | 93 +++++++++++++++++++ src/routes/geothermal.tsx | 2 + 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/pages/geothermal/wells/records-grid-picker.tsx diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index dc464995..ce65a149 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -468,13 +468,22 @@ function AppSidebar() { ) } +const SANDBOX_GEOTHERMAL_GRID = '/geothermal/wells/records-grid' + +function isSandboxPath(pathname: string): boolean { + return ( + pathname.startsWith('/example') || + pathname.startsWith(SANDBOX_GEOTHERMAL_GRID) + ) +} + function ExampleNavItem() { const location = useLocation() const navigate = useNavigate() - const [open, setOpen] = useState(location.pathname.startsWith('/example')) + const [open, setOpen] = useState(isSandboxPath(location.pathname)) useEffect(() => { - if (!location.pathname.startsWith('/example')) setOpen(false) + if (!isSandboxPath(location.pathname)) setOpen(false) }, [location.pathname]) const handleClick = () => { @@ -510,6 +519,14 @@ function ExampleNavItem() { Data Grid + + + Geothermal Records + + diff --git a/src/pages/geothermal/wells/index.ts b/src/pages/geothermal/wells/index.ts index 1e47e353..ca6eee7d 100644 --- a/src/pages/geothermal/wells/index.ts +++ b/src/pages/geothermal/wells/index.ts @@ -1,3 +1,4 @@ export * from "./list"; export * from "./show"; export * from "./records-grid"; +export * from "./records-grid-picker"; diff --git a/src/pages/geothermal/wells/records-grid-picker.tsx b/src/pages/geothermal/wells/records-grid-picker.tsx new file mode 100644 index 00000000..78c29455 --- /dev/null +++ b/src/pages/geothermal/wells/records-grid-picker.tsx @@ -0,0 +1,93 @@ +import { useList, useGo } from '@refinedev/core' +import type { IWell } from '@/interfaces/geothermal' +import { useAccessCapabilities } from '@/hooks' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +// The geothermal wells endpoint returns more than IWell currently types; read +// the label fields loosely until the contract/interface is finalized. +type WellRow = IWell & { WellDataID?: string; County?: string } + +function wellLabel(w: WellRow): string { + const parts = [w.WellDataID, w.County].filter(Boolean) + const suffix = parts.length ? ` — ${parts.join(', ')}` : '' + return `${w.OBJECTID}${suffix}` +} + +/** + * Sandbox entry point for the geothermal records grid. The grid itself is + * scoped to one well (`records-grid/:id`); this page lets an admin pick a well + * and navigates into its grid. Admin-gated per BDMS-878 (`canManageGeothermal`). + */ +export const GeoThermalRecordsGridPicker = () => { + const go = useGo() + const { canManageGeothermal, isLoading: permLoading } = + useAccessCapabilities() + + const { query } = useList({ + resource: 'wells', + dataProviderName: 'geothermal', + pagination: { pageSize: 500, mode: 'server' }, + queryOptions: { enabled: canManageGeothermal }, + }) + + if (permLoading) { + return ( +
+ Checking access… +
+ ) + } + + if (!canManageGeothermal) { + return ( +
+ You need the Geothermal Admin role to enter well data. +
+ ) + } + + const wells = query.data?.data ?? [] + + return ( +
+
+

Geothermal records

+

+ Pick a well to open its records data-entry grid. +

+
+ + +
+ ) +} diff --git a/src/routes/geothermal.tsx b/src/routes/geothermal.tsx index dde07b64..6d3b3b0b 100644 --- a/src/routes/geothermal.tsx +++ b/src/routes/geothermal.tsx @@ -5,6 +5,7 @@ import { GeoThermalWellList, GeoThermalWellShow, GeoThermalRecordsGrid, + GeoThermalRecordsGridPicker, } from '@/pages/geothermal' export const GeothermalRoutes = () => { @@ -14,6 +15,7 @@ export const GeothermalRoutes = () => { } /> } /> + } /> } /> } /> From 0b8841b7fdf6546b86b7f3ffd7f55da469f6a792 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 21 Jul 2026 15:49:27 -0700 Subject: [PATCH 013/144] BDMS-878: bypass Geothermal.Admin gate in local dev Add canEnterGeothermalData(canManageGeothermal) = canManageGeothermal || import.meta.env.DEV. Under `vite` dev (and vitest) DEV is true, so the records grid, its well picker, and the well-show entry button skip the "You need the Geothermal Admin role" block for local testing. Production builds (`vite build`) set DEV=false, so the gate stays fully enforced when deployed. Single greppable helper (BYPASS_GEOTHERMAL_GATE) to remove if the bypass is ever unwanted. Co-Authored-By: Claude Opus 4.8 --- src/pages/geothermal/wells/records-grid-picker.tsx | 5 +++-- src/pages/geothermal/wells/records-grid.tsx | 7 +++++-- src/pages/geothermal/wells/recordsGridLogic.ts | 12 ++++++++++++ src/pages/geothermal/wells/show.tsx | 3 ++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/pages/geothermal/wells/records-grid-picker.tsx b/src/pages/geothermal/wells/records-grid-picker.tsx index 78c29455..0f2afe29 100644 --- a/src/pages/geothermal/wells/records-grid-picker.tsx +++ b/src/pages/geothermal/wells/records-grid-picker.tsx @@ -1,6 +1,7 @@ import { useList, useGo } from '@refinedev/core' import type { IWell } from '@/interfaces/geothermal' import { useAccessCapabilities } from '@/hooks' +import { canEnterGeothermalData } from './recordsGridLogic' import { Select, SelectContent, @@ -33,7 +34,7 @@ export const GeoThermalRecordsGridPicker = () => { resource: 'wells', dataProviderName: 'geothermal', pagination: { pageSize: 500, mode: 'server' }, - queryOptions: { enabled: canManageGeothermal }, + queryOptions: { enabled: canEnterGeothermalData(canManageGeothermal) }, }) if (permLoading) { @@ -44,7 +45,7 @@ export const GeoThermalRecordsGridPicker = () => { ) } - if (!canManageGeothermal) { + if (!canEnterGeothermalData(canManageGeothermal)) { return (
You need the Geothermal Admin role to enter well data. diff --git a/src/pages/geothermal/wells/records-grid.tsx b/src/pages/geothermal/wells/records-grid.tsx index 1cd7c742..45a1dee1 100644 --- a/src/pages/geothermal/wells/records-grid.tsx +++ b/src/pages/geothermal/wells/records-grid.tsx @@ -5,6 +5,7 @@ import { useAccessCapabilities } from '@/hooks' import { EditableDataGrid, type GridColumnSpec } from '@/components/grid' import { Button } from '@/components/ui/button' import { + canEnterGeothermalData, computePendingOps, flattenFieldErrors, makeBlankRecord, @@ -88,7 +89,9 @@ export const GeoThermalRecordsGrid = () => { resource: `wells/${id}/records`, dataProviderName: 'geothermal', pagination: { pageSize: 500, mode: 'server' }, - queryOptions: { enabled: canManageGeothermal && id != null }, + queryOptions: { + enabled: canEnterGeothermalData(canManageGeothermal) && id != null, + }, }) // Local, editable copy. `original` is the pristine snapshot used to compute @@ -209,7 +212,7 @@ export const GeoThermalRecordsGrid = () => { ) } - if (!canManageGeothermal) { + if (!canEnterGeothermalData(canManageGeothermal)) { return (
You need the Geothermal Admin role to enter well data. diff --git a/src/pages/geothermal/wells/recordsGridLogic.ts b/src/pages/geothermal/wells/recordsGridLogic.ts index ca8de4bd..5e8eefac 100644 --- a/src/pages/geothermal/wells/recordsGridLogic.ts +++ b/src/pages/geothermal/wells/recordsGridLogic.ts @@ -1,5 +1,17 @@ import type { IWellRecord } from '@/interfaces/geothermal' +/** + * Whether to bypass the Geothermal.Admin gate. True only in local dev + * (`vite` dev server / vitest); production builds (`vite build`) set + * `import.meta.env.DEV` to false, so the gate stays enforced when deployed. + */ +export const BYPASS_GEOTHERMAL_GATE = import.meta.env.DEV === true + +/** Admins can always enter data; locally the gate is bypassed for testing. */ +export function canEnterGeothermalData(canManageGeothermal: boolean): boolean { + return canManageGeothermal || BYPASS_GEOTHERMAL_GATE +} + // Editable field keys (everything except the server-assigned OBJECTID). export const EDITABLE_KEYS: (keyof IWellRecord)[] = [ 'WellDataID', diff --git a/src/pages/geothermal/wells/show.tsx b/src/pages/geothermal/wells/show.tsx index fbed4f63..ed3eda86 100644 --- a/src/pages/geothermal/wells/show.tsx +++ b/src/pages/geothermal/wells/show.tsx @@ -1,6 +1,7 @@ import {Button, Stack, Typography} from "@mui/material"; import {useGo, useList, useOne, useParsed, useShow} from "@refinedev/core"; import {useAccessCapabilities} from "@/hooks"; +import {canEnterGeothermalData} from "./recordsGridLogic"; import { DateField, MarkdownField, @@ -133,7 +134,7 @@ export const GeoThermalWellShow = () => { - {canManageGeothermal && id != null && ( + {canEnterGeothermalData(canManageGeothermal) && id != null && ( + {/* Batch create lands in P2; CSV upload in P3. */} + +
+
+ + +
+ ) +} diff --git a/src/routes/geothermal.tsx b/src/routes/geothermal.tsx index 6d3b3b0b..882e5a08 100644 --- a/src/routes/geothermal.tsx +++ b/src/routes/geothermal.tsx @@ -6,6 +6,7 @@ import { GeoThermalWellShow, GeoThermalRecordsGrid, GeoThermalRecordsGridPicker, + GeoThermalWellInventory, } from '@/pages/geothermal' export const GeothermalRoutes = () => { @@ -17,6 +18,7 @@ export const GeothermalRoutes = () => { } /> } /> } /> + } /> } /> From 9e855dc12df0c6140021fd758b59d56392420de5 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 25 Jul 2026 09:47:42 -0700 Subject: [PATCH 020/144] Fix CI access-control matrix + address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accessControl.test.ts: geothermal resources (geothermal.dashboard, geothermal.geothermal_wells) are now registered, so the decision- complete matrix must account for them. They belong to the geothermal portal — granted to Geothermal roles (list/show), not AMP. Updated the expected routable set and per-scenario access accordingly. Fixes the Vitest Test Suite failure. - navigation.ts: gate SHOW_EXAMPLE_NAV on import.meta.env.DEV so the WIP Sandbox section doesn't ship to production (Copilot review). - records-grid.tsx: the ID column held a string OBJECTID (and "new:N" temp ids) in a number cell; switch to a read-only text cell, blank for new rows (Copilot review). Co-Authored-By: Claude Opus 4.8 --- src/config/navigation.ts | 8 +++++-- src/pages/geothermal/wells/records-grid.tsx | 7 +++--- src/test/utils/accessControl.test.ts | 25 +++++++++++++++++---- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 4af538de..0c9f3daf 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -55,8 +55,12 @@ export type NavItem = { children?: NavItem[] } -/** Toggle Example nav (typography, data grid demos). Set true to restore. */ -export const SHOW_EXAMPLE_NAV = true +/** + * Show the Sandbox nav (typography + data-grid demos, WIP geothermal grids). + * Dev-only: true under `vite` dev, false in production builds, so the WIP + * section doesn't ship. Force true here to preview it in a prod build. + */ +export const SHOW_EXAMPLE_NAV = import.meta.env.DEV /** * Top bar: views and tools. diff --git a/src/pages/geothermal/wells/records-grid.tsx b/src/pages/geothermal/wells/records-grid.tsx index a83e589c..09b054d0 100644 --- a/src/pages/geothermal/wells/records-grid.tsx +++ b/src/pages/geothermal/wells/records-grid.tsx @@ -8,6 +8,7 @@ import { canEnterGeothermalData, computePendingOps, flattenFieldErrors, + isNewRow, makeBlankRecord, NEW_PREFIX, rowKey, @@ -42,9 +43,9 @@ const RECORD_COLUMNS: GridColumnSpec[] = [ id: 'OBJECTID', title: 'ID', width: 90, - kind: 'number', - // Server-assigned key — read-only. Blank for not-yet-saved new rows. - getValue: (r) => r.OBJECTID, + // Server-assigned string key — read-only text. Blank for new rows (their + // OBJECTID holds a client temp id like "new:1" until saved). + getValue: (r) => (isNewRow(r) ? '' : r.OBJECTID), }, textCol('WellDataID', 'WellDataID', 130), textCol('WellName', 'WellName', 180), diff --git a/src/test/utils/accessControl.test.ts b/src/test/utils/accessControl.test.ts index db9a04af..d2581892 100644 --- a/src/test/utils/accessControl.test.ts +++ b/src/test/utils/accessControl.test.ts @@ -35,6 +35,8 @@ const routableResourceNames = routableResources .map((resource) => resource.name) .sort() const expectedRegisteredRoutableResources = [ + 'geothermal.dashboard', + 'geothermal.geothermal_wells', 'ocotillo.asset-unassociated', 'ocotillo.collections', 'ocotillo.contact', @@ -48,6 +50,13 @@ const expectedRegisteredRoutableResources = [ 'ocotillo.thing-well-projects', ].sort() +// Geothermal resources are a separate portal — accessible to Geothermal roles +// (list/show), not AMP roles. +const GEOTHERMAL_ROUTABLE = [ + 'geothermal.dashboard', + 'geothermal.geothermal_wells', +] + const expectedAccessByScenario: Scenario[] = [ { name: 'anonymous', @@ -81,22 +90,29 @@ const expectedAccessByScenario: Scenario[] = [ { name: 'AMP.Admin', groups: ['AMP.Admin'], - allowedResources: routableResources.map((resource) => resource.name), + // AMP.Admin owns the water portal, not geothermal. + allowedResources: routableResources + .map((resource) => resource.name) + .filter((name) => !name.startsWith('geothermal.')), }, { name: 'Geothermal.Viewer', groups: ['Geothermal.Viewer'], - allowedResources: [], + allowedResources: [...GEOTHERMAL_ROUTABLE], }, { name: 'Geothermal.Editor', groups: ['Geothermal.Editor'], - allowedResources: [], + allowedResources: [...GEOTHERMAL_ROUTABLE], }, { name: 'Geothermal.Admin', groups: ['Geothermal.Admin'], - allowedResources: ['water.locations', 'ocotillo.location'], + allowedResources: [ + 'water.locations', + 'ocotillo.location', + ...GEOTHERMAL_ROUTABLE, + ], }, { name: 'AMP.Viewer + Geothermal.Editor', @@ -108,6 +124,7 @@ const expectedAccessByScenario: Scenario[] = [ 'ocotillo.contact', 'ocotillo.thing-well-batch-export', 'ocotillo.thing-well-projects', + ...GEOTHERMAL_ROUTABLE, ], }, ] From 53bad9048be10805991711a7944b94d098691b1a Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 25 Jul 2026 09:59:07 -0700 Subject: [PATCH 021/144] BDMS-878: batch-create wells from the inventory grid (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the inventory "Create wells" action. Every non-blank draft POSTs to thing/geothermal-well via the geothermal provider (one request per row — no server bulk endpoint), through Promise.allSettled with per-row tracking: - created wells drop out of the grid; a "N created" summary reports them. - failed rows stay for retry with their rejected cells tinted from the provider's Pydantic fieldErrors (422/409), and remapped to the surviving rows' new indices. - cleanDraft strips empty fields so the payload carries only entered data. - Create button enables only with non-blank rows and shows "Creating…" while in flight. Verified live: a filled row POSTs, the failure is tracked per row (the backend create endpoint isn't implemented yet — GET-only), the row is retained, and the summary shows "0 created, 1 failed". Success path (drop + count) mirrors the tested records-grid save. CSV load is P3; dropdown/date/boolean cells + client validation P4. Co-Authored-By: Claude Opus 4.8 --- src/pages/geothermal/wells/inventory.tsx | 132 +++++++++++++++++++++-- 1 file changed, 121 insertions(+), 11 deletions(-) diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index 4c2c573c..4fe44793 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useDataProvider } from '@refinedev/core' import type { IWell } from '@/interfaces/geothermal' import { useAccessCapabilities } from '@/hooks' import { @@ -7,7 +8,11 @@ import { type GridColumnSpec, } from '@/components/grid' import { Button } from '@/components/ui/button' -import { canEnterGeothermalData } from './recordsGridLogic' +import { + canEnterGeothermalData, + flattenFieldErrors, + type FieldErrors, +} from './recordsGridLogic' // A not-yet-saved well being inventoried. well_data_id / thing_id are // server-assigned, so drafts hold only the user-entered fields. @@ -91,31 +96,114 @@ function isBlankDraft(r: WellDraft): boolean { }) } +// Drop empty fields so the create payload carries only what the user entered. +function cleanDraft(r: WellDraft): Record { + const out: Record = {} + for (const [k, v] of Object.entries(r)) { + if (v !== null && v !== '' && v !== undefined) out[k] = v + } + return out +} + +interface CreateSummary { + created: number + failed: number +} + const INITIAL_ROWS = 10 const ADD_ROW_COUNT = 10 /** - * P1 — Geothermal well inventory (direct grid entry). + * P1/P2 — Geothermal well inventory (direct grid entry + batch create). * * An editable spreadsheet for inventorying new geothermal wells. Rows are blank - * well drafts the user types or pastes into; "Add rows" appends more. Entries - * live in local state only — batch create lands in P2, CSV load in P3, and - * dropdown/date/boolean editors + validation in P4. Admin-gated per BDMS-878 - * (`canEnterGeothermalData`, bypassed in local dev). + * well drafts the user types or pastes into; "Add rows" appends more. "Create + * wells" POSTs every non-blank draft through the geothermal provider + * (one request per row — no server bulk endpoint), tracking success/failure per + * row: created wells drop out of the grid, failed rows stay with their rejected + * cells tinted from the provider's Pydantic `fieldErrors`. CSV load lands in P3; + * dropdown/date/boolean editors + client validation in P4. Admin-gated per + * BDMS-878 (`canEnterGeothermalData`, bypassed in local dev). */ export const GeoThermalWellInventory = () => { const { canManageGeothermal, isLoading: permLoading } = useAccessCapabilities() + const dataProvider = useDataProvider() const [rows, setRows] = useState(() => Array.from({ length: INITIAL_ROWS }, () => ({})) ) + const [saving, setSaving] = useState(false) + // Validation errors per current row index (from a rejected create). + const [saveErrors, setSaveErrors] = useState>( + new Map() + ) + const [summary, setSummary] = useState(null) const filledCount = useMemo( () => rows.filter((r) => !isBlankDraft(r)).length, [rows] ) + const cellErrors = useCallback( + (rowIndex: number) => saveErrors.get(rowIndex), + [saveErrors] + ) + + const handleCreate = useCallback(async () => { + const pending = rows + .map((r, index) => ({ r, index })) + .filter(({ r }) => !isBlankDraft(r)) + if (pending.length === 0) return + + setSaving(true) + setSummary(null) + + const provider = dataProvider('geothermal') + const resource = 'thing/geothermal-well' + const results = await Promise.allSettled( + pending.map(({ r }) => provider.create({ resource, variables: cleanDraft(r) })) + ) + + const succeeded = new Set() + const errorsByIndex = new Map() + let created = 0 + let failed = 0 + results.forEach((res, k) => { + const origIndex = pending[k].index + if (res.status === 'fulfilled') { + created++ + succeeded.add(origIndex) + } else { + failed++ + const fe = flattenFieldErrors( + (res.reason as { fieldErrors?: unknown })?.fieldErrors + ) + if (fe) errorsByIndex.set(origIndex, fe) + } + }) + + // Drop created rows; keep blanks + failed rows, remapping errors to the + // surviving rows' new indices. + const nextRows: WellDraft[] = [] + const nextErrors = new Map() + rows.forEach((r, i) => { + if (succeeded.has(i)) return + const fe = errorsByIndex.get(i) + if (fe) nextErrors.set(nextRows.length, fe) + nextRows.push(r) + }) + + setRows( + nextRows.length > 0 + ? nextRows + : Array.from({ length: INITIAL_ROWS }, () => ({})) + ) + setSaveErrors(nextErrors) + setSummary({ created, failed }) + setSaving(false) + }, [rows, dataProvider]) + if (permLoading) { return (
@@ -147,15 +235,36 @@ export const GeoThermalWellInventory = () => { Enter new geothermal wells
+ {summary && ( + 0 + ? 'text-sm text-destructive' + : 'text-sm text-muted-foreground' + } + > + {summary.created} created + {summary.failed > 0 ? `, ${summary.failed} failed` : ''} + + )} {filledCount} {filledCount === 1 ? 'well' : 'wells'} to add - - {/* Batch create lands in P2; CSV upload in P3. */} -
@@ -164,6 +273,7 @@ export const GeoThermalWellInventory = () => { columns={COLUMNS} rows={rows} onRowsChange={setRows} + cellErrors={cellErrors} freezeColumns={1} />
From 93953442ea982e816d805fa28cd2d0b26f96e497 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 25 Jul 2026 10:18:59 -0700 Subject: [PATCH 022/144] BDMS-878: CSV load for the well inventory grid (P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CSV upload + a matching downloadable template to the inventory grid. - inventoryFields.ts: extract the well-draft model (field lists, headers, isBlankDraft, cleanDraft) so the grid, CSV, and tests share one source. - inventoryCsv.ts (papaparse): parseCsvFile loads a File into well drafts; mapRecordsToDrafts (pure) matches headers to fields case-insensitively and trimmed, coerces number fields (dropping unparseable), collects unknown headers, and skips empty rows. buildTemplateCsv emits a header row of the canonical field names. - inventory.tsx: "Upload CSV" (hidden file input) loads parsed rows into the grid with a status line ("Loaded N rows · ignored columns: … · M malformed rows skipped"); "Download template" saves the header CSV. - Tests for the mapper (coercion, case-insensitive match, unknown-header collection, empty-row skip) and the template header row. Verified live: uploading a CSV populates the grid, maps recognized columns, and reports an ignored unknown column. Dropdown/date/boolean cells + client validation land in P4. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 21 +- package.json | 2 + src/pages/geothermal/wells/inventory.tsx | 199 ++++++++++-------- src/pages/geothermal/wells/inventoryCsv.ts | 86 ++++++++ src/pages/geothermal/wells/inventoryFields.ts | 65 ++++++ src/test/pages/inventoryCsv.test.ts | 54 +++++ 6 files changed, 330 insertions(+), 97 deletions(-) create mode 100644 src/pages/geothermal/wells/inventoryCsv.ts create mode 100644 src/pages/geothermal/wells/inventoryFields.ts create mode 100644 src/test/pages/inventoryCsv.test.ts diff --git a/package-lock.json b/package-lock.json index b6cf175f..88b85397 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ocotillo-ui", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ocotillo-ui", - "version": "1.0.1", + "version": "1.1.0", "dependencies": { "@base-ui-components/react": "^1.0.0-alpha.6", "@casl/ability": "^6.7.3", @@ -41,6 +41,7 @@ "@tiptap/react": "^2.9.1", "@tiptap/starter-kit": "^2.9.1", "@turf/turf": "^7.2.0", + "@types/papaparse": "^5.5.2", "axios-auth-refresh": "^3.3.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -57,6 +58,7 @@ "mapbox-gl-style-switcher": "^1.0.11", "marked": "^4.3.0", "pako": "^2.1.0", + "papaparse": "^5.5.4", "posthog-js": "^1.402.2", "proj4": "^2.15.0", "radix-ui": "^1.4.3", @@ -11363,6 +11365,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "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", @@ -24379,9 +24390,9 @@ "license": "(MIT AND Zlib)" }, "node_modules/papaparse": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", - "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", "license": "MIT" }, "node_modules/parent-module": { diff --git a/package.json b/package.json index e503f3a1..80cd90c0 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@tiptap/react": "^2.9.1", "@tiptap/starter-kit": "^2.9.1", "@turf/turf": "^7.2.0", + "@types/papaparse": "^5.5.2", "axios-auth-refresh": "^3.3.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -83,6 +84,7 @@ "mapbox-gl-style-switcher": "^1.0.11", "marked": "^4.3.0", "pako": "^2.1.0", + "papaparse": "^5.5.4", "posthog-js": "^1.402.2", "proj4": "^2.15.0", "radix-ui": "^1.4.3", diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index 4fe44793..8db83005 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -1,6 +1,5 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useDataProvider } from '@refinedev/core' -import type { IWell } from '@/interfaces/geothermal' import { useAccessCapabilities } from '@/hooks' import { EditableDataGrid, @@ -13,56 +12,21 @@ import { flattenFieldErrors, type FieldErrors, } from './recordsGridLogic' - -// A not-yet-saved well being inventoried. well_data_id / thing_id are -// server-assigned, so drafts hold only the user-entered fields. -type WellDraft = Partial> - -// Fields the user fills when inventorying a new well (everything except the -// server-assigned id). Enum fields (well_class/well_type/status) are plain text -// in P1 — dropdowns land in P4. Boolean/date editors also come in P4. -const TEXT_FIELDS: (keyof WellDraft)[] = [ - 'name', - 'api', - 'well_number', - 'well_class', - 'well_type', - 'status', - 'operator', - 'owner', - 'completion_date', - 'has_geothermal_data', - 'county', - 'state', -] -const NUMBER_FIELDS: (keyof WellDraft)[] = [ - 'total_depth', - 'latitude', - 'longitude', -] - -const HEADERS: Partial> = { - name: 'Name', - api: 'API', - well_number: 'Well #', - well_class: 'Class', - well_type: 'Type', - status: 'Status', - operator: 'Operator', - owner: 'Owner', - completion_date: 'Completion', - has_geothermal_data: 'Geo data?', - county: 'County', - state: 'State', - total_depth: 'Total depth', - latitude: 'Latitude', - longitude: 'Longitude', -} +import { + ALL_FIELDS, + HEADERS, + NUMBER_FIELDS, + TEXT_FIELDS, + cleanDraft, + isBlankDraft, + type WellDraft, +} from './inventoryFields' +import { buildTemplateCsv, parseCsvFile } from './inventoryCsv' function textCol(id: keyof WellDraft): GridColumnSpec { return { id, - title: HEADERS[id] ?? id, + title: HEADERS[id], width: 150, editable: true, getValue: (r) => (r[id] as CellValue) ?? '', @@ -73,7 +37,7 @@ function textCol(id: keyof WellDraft): GridColumnSpec { function numberCol(id: keyof WellDraft): GridColumnSpec { return { id, - title: HEADERS[id] ?? id, + title: HEADERS[id], width: 130, kind: 'number', editable: true, @@ -87,24 +51,6 @@ const COLUMNS: GridColumnSpec[] = [ ...NUMBER_FIELDS.map(numberCol), ] -const ALL_FIELDS: (keyof WellDraft)[] = [...TEXT_FIELDS, ...NUMBER_FIELDS] - -function isBlankDraft(r: WellDraft): boolean { - return ALL_FIELDS.every((k) => { - const v = r[k] - return v == null || v === '' - }) -} - -// Drop empty fields so the create payload carries only what the user entered. -function cleanDraft(r: WellDraft): Record { - const out: Record = {} - for (const [k, v] of Object.entries(r)) { - if (v !== null && v !== '' && v !== undefined) out[k] = v - } - return out -} - interface CreateSummary { created: number failed: number @@ -112,33 +58,40 @@ interface CreateSummary { const INITIAL_ROWS = 10 const ADD_ROW_COUNT = 10 +const TEMPLATE_FILENAME = 'geothermal-well-inventory-template.csv' + +function blankRows(n: number): WellDraft[] { + return Array.from({ length: n }, () => ({})) +} /** - * P1/P2 — Geothermal well inventory (direct grid entry + batch create). + * P1/P2/P3 — Geothermal well inventory (direct grid entry + CSV load + batch + * create). * - * An editable spreadsheet for inventorying new geothermal wells. Rows are blank - * well drafts the user types or pastes into; "Add rows" appends more. "Create - * wells" POSTs every non-blank draft through the geothermal provider - * (one request per row — no server bulk endpoint), tracking success/failure per - * row: created wells drop out of the grid, failed rows stay with their rejected - * cells tinted from the provider's Pydantic `fieldErrors`. CSV load lands in P3; - * dropdown/date/boolean editors + client validation in P4. Admin-gated per - * BDMS-878 (`canEnterGeothermalData`, bypassed in local dev). + * An editable spreadsheet for inventorying new geothermal wells. Rows come from + * typing/pasting into blank rows ("Add rows"), or from an uploaded CSV + * ("Upload CSV", with a matching "Download template"). "Create wells" POSTs + * every non-blank draft through the geothermal provider (one request per row — + * no server bulk endpoint), tracking success/failure per row: created wells + * drop out of the grid, failed rows stay with their rejected cells tinted from + * the provider's Pydantic `fieldErrors`. Dropdown/date/boolean editors + + * client validation land in P4. Admin-gated per BDMS-878 + * (`canEnterGeothermalData`, bypassed in local dev). */ export const GeoThermalWellInventory = () => { const { canManageGeothermal, isLoading: permLoading } = useAccessCapabilities() const dataProvider = useDataProvider() + const fileInputRef = useRef(null) - const [rows, setRows] = useState(() => - Array.from({ length: INITIAL_ROWS }, () => ({})) - ) + const [rows, setRows] = useState(() => blankRows(INITIAL_ROWS)) const [saving, setSaving] = useState(false) // Validation errors per current row index (from a rejected create). const [saveErrors, setSaveErrors] = useState>( new Map() ) const [summary, setSummary] = useState(null) + const [csvStatus, setCsvStatus] = useState(null) const filledCount = useMemo( () => rows.filter((r) => !isBlankDraft(r)).length, @@ -150,6 +103,50 @@ export const GeoThermalWellInventory = () => { [saveErrors] ) + const handleAddRows = useCallback( + () => setRows((prev) => [...prev, ...blankRows(ADD_ROW_COUNT)]), + [] + ) + + const handleDownloadTemplate = useCallback(() => { + const blob = new Blob([buildTemplateCsv()], { + type: 'text/csv;charset=utf-8', + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = TEMPLATE_FILENAME + a.click() + URL.revokeObjectURL(url) + }, []) + + const handleUploadCsv = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + // Reset so re-selecting the same file fires change again. + event.target.value = '' + if (!file) return + + setSummary(null) + setSaveErrors(new Map()) + try { + const { rows: parsed, unknownHeaders, errorCount } = + await parseCsvFile(file) + // Loaded rows plus a few blanks for further manual entry. + setRows([...parsed, ...blankRows(3)]) + const notes: string[] = [`Loaded ${parsed.length} rows`] + if (unknownHeaders.length > 0) { + notes.push(`ignored columns: ${unknownHeaders.join(', ')}`) + } + if (errorCount > 0) notes.push(`${errorCount} malformed rows skipped`) + setCsvStatus(notes.join(' · ')) + } catch { + setCsvStatus('Could not parse that CSV file.') + } + }, + [] + ) + const handleCreate = useCallback(async () => { const pending = rows .map((r, index) => ({ r, index })) @@ -158,11 +155,14 @@ export const GeoThermalWellInventory = () => { setSaving(true) setSummary(null) + setCsvStatus(null) const provider = dataProvider('geothermal') const resource = 'thing/geothermal-well' const results = await Promise.allSettled( - pending.map(({ r }) => provider.create({ resource, variables: cleanDraft(r) })) + pending.map(({ r }) => + provider.create({ resource, variables: cleanDraft(r) }) + ) ) const succeeded = new Set() @@ -194,11 +194,7 @@ export const GeoThermalWellInventory = () => { nextRows.push(r) }) - setRows( - nextRows.length > 0 - ? nextRows - : Array.from({ length: INITIAL_ROWS }, () => ({})) - ) + setRows(nextRows.length > 0 ? nextRows : blankRows(INITIAL_ROWS)) setSaveErrors(nextErrors) setSummary({ created, failed }) setSaving(false) @@ -220,12 +216,6 @@ export const GeoThermalWellInventory = () => { ) } - const handleAddRows = () => - setRows((prev) => [ - ...prev, - ...Array.from({ length: ADD_ROW_COUNT }, () => ({})), - ]) - return (
{/* Toolbar */} @@ -235,6 +225,9 @@ export const GeoThermalWellInventory = () => { Enter new geothermal wells
+ {csvStatus && ( + {csvStatus} + )} {summary && ( { {filledCount} {filledCount === 1 ? 'well' : 'wells'} to add + + + - {/* CSV upload lands in P3. */} diff --git a/src/pages/geothermal/wells/inventoryFields.ts b/src/pages/geothermal/wells/inventoryFields.ts index 3333484d..6340e9f7 100644 --- a/src/pages/geothermal/wells/inventoryFields.ts +++ b/src/pages/geothermal/wells/inventoryFields.ts @@ -48,6 +48,38 @@ export const HEADERS: Record = { longitude: 'Longitude', } +// Allowed values for the enum (dropdown) fields. +// PROVISIONAL — observed from the live data (8 wells); confirm the full lists +// with the backend once the contract lands. +export const ENUM_OPTIONS: Partial> = { + well_type: ['Wildcat', 'Production', 'Exploration'], + well_class: ['Oil & Gas'], + status: ['Active', 'Abandoned', 'Plugged'], +} + +// Fields required to create a well. +// PROVISIONAL — the create schema isn't in the (stripped) OpenAPI; confirm the +// real required set with the backend. +export const REQUIRED_FIELDS: (keyof WellDraft)[] = [ + 'name', + 'api', + 'well_type', + 'county', + 'state', + 'latitude', + 'longitude', +] + +/** Required fields that are empty on a draft, keyed field → message. */ +export function missingRequired(r: WellDraft): Record { + const errors: Record = {} + for (const f of REQUIRED_FIELDS) { + const v = r[f] + if (v == null || v === '') errors[f] = 'Required' + } + return errors +} + export function isBlankDraft(r: WellDraft): boolean { return ALL_FIELDS.every((k) => { const v = r[k] From facb306108d57839beddeeeffbd5ad61eddeb256 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 07:21:17 -0700 Subject: [PATCH 024/144] BDMS-878: county/state are server-derived from lat/lon (not required) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per decision, county/state are reverse-geocoded from latitude/longitude by the backend, auto-filling only when left blank. So the frontend does no geocoding and no longer requires county/state — latitude/longitude are the required location inputs; county/state stay optional (a user or CSV may still provide them, which the server keeps). - REQUIRED_FIELDS drops county/state (keeps name, api, well_type, latitude, longitude). - Spec §6/§6a document the server-side derivation contract. Co-Authored-By: Claude Opus 4.8 --- docs/geothermal-well-inventory.md | 22 +++++++++++++++---- src/pages/geothermal/wells/inventoryFields.ts | 7 +++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/geothermal-well-inventory.md b/docs/geothermal-well-inventory.md index b26cb57b..534fabf3 100644 --- a/docs/geothermal-well-inventory.md +++ b/docs/geothermal-well-inventory.md @@ -114,10 +114,24 @@ Reuse the batch-save pattern already built in `records-grid.tsx` - **Client (pre-save):** required fields must be non-empty; numeric/date/bool fields must parse. Invalid cells are tinted and block that row's save. - **Server:** 422/409 `fieldErrors` surface inline (existing mapping). -- **Proposed required fields:** `name`, `api`, `well_type`, `county`, `state`, - `latitude`, `longitude`. **TBD** — the API's create schema isn't in the - OpenAPI (stripped), so the real required set must be confirmed against the - backend. +- **Required fields:** `name`, `api`, `well_type`, `latitude`, `longitude`. + `county`/`state` are **not** required — see §6a. **TBD** — the API's create + schema isn't in the OpenAPI (stripped), so the real required set must be + confirmed against the backend. + +### 6a. county / state — server-derived from lat/lon + +Decision: `county` and `state` are reverse-geocoded from `latitude`/`longitude` +**by the backend**, **auto-filling only when left blank** (a user-entered value +is kept). Rationale: reverse-geocoding is authoritative and avoids bundling a +county-boundaries dataset in the client. + +- Frontend: `latitude`/`longitude` are the required location inputs; `county`/ + `state` are optional (the user may still type them, e.g. from CSV). No + client-side geocoding. +- Backend (to implement): on create, if `county`/`state` are absent, derive + them from the coordinates (NM county point-in-polygon; `state` defaults to + `NM`) before persisting. --- diff --git a/src/pages/geothermal/wells/inventoryFields.ts b/src/pages/geothermal/wells/inventoryFields.ts index 6340e9f7..f0f8aa5e 100644 --- a/src/pages/geothermal/wells/inventoryFields.ts +++ b/src/pages/geothermal/wells/inventoryFields.ts @@ -57,15 +57,16 @@ export const ENUM_OPTIONS: Partial> = { status: ['Active', 'Abandoned', 'Plugged'], } -// Fields required to create a well. +// Fields required to create a well. county/state are intentionally NOT +// required — the backend reverse-geocodes them from lat/lon when left blank +// (auto-fill empty only), so latitude/longitude are the required location +// inputs instead. // PROVISIONAL — the create schema isn't in the (stripped) OpenAPI; confirm the // real required set with the backend. export const REQUIRED_FIELDS: (keyof WellDraft)[] = [ 'name', 'api', 'well_type', - 'county', - 'state', 'latitude', 'longitude', ] From 03c241df3f76f729d38a2f024a17cf2f2a19154b Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 08:00:07 -0700 Subject: [PATCH 025/144] BDMS-878: "Save for later" caches the inventory grid locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Save for later action that persists the non-blank inventory rows to localStorage, so entered/imported data survives navigation and reloads. - loadDraft/saveDraft/hasDraft helpers (guarded for unavailable storage). - On mount, restore any saved draft (+ a few blank rows) and show a "Restored N saved rows" note; otherwise start with blank rows. - "Save for later" button writes the current non-blank rows and shows a "Saved N rows for later" note; saving an empty grid clears the cache. - After a successful create, keep an existing saved draft in sync with the surviving rows so created wells don't reappear on reload. Verified live: load rows → Save for later → reload restores them with the status note. Co-Authored-By: Claude Opus 4.8 --- src/pages/geothermal/wells/inventory.tsx | 65 +++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index eade0465..1299f675 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -87,11 +87,45 @@ interface CreateSummary { const INITIAL_ROWS = 10 const ADD_ROW_COUNT = 10 const TEMPLATE_FILENAME = 'geothermal-well-inventory-template.csv' +const DRAFT_KEY = 'geothermal:inventory:draft' function blankRows(n: number): WellDraft[] { return Array.from({ length: n }, () => ({})) } +// "Save for later" persistence — cache the non-blank rows in localStorage so +// entered/imported data survives navigation and reloads. +function loadDraft(): WellDraft[] | null { + try { + const raw = localStorage.getItem(DRAFT_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + if (Array.isArray(parsed) && parsed.length > 0) return parsed as WellDraft[] + } catch { + /* ignore malformed / unavailable storage */ + } + return null +} + +function saveDraft(rows: WellDraft[]): number { + const filled = rows.filter((r) => !isBlankDraft(r)) + try { + if (filled.length > 0) localStorage.setItem(DRAFT_KEY, JSON.stringify(filled)) + else localStorage.removeItem(DRAFT_KEY) + } catch { + /* ignore */ + } + return filled.length +} + +function hasDraft(): boolean { + try { + return localStorage.getItem(DRAFT_KEY) != null + } catch { + return false + } +} + /** * P1/P2/P3 — Geothermal well inventory (direct grid entry + CSV load + batch * create). @@ -112,7 +146,10 @@ export const GeoThermalWellInventory = () => { const dataProvider = useDataProvider() const fileInputRef = useRef(null) - const [rows, setRows] = useState(() => blankRows(INITIAL_ROWS)) + const [rows, setRows] = useState(() => { + const draft = loadDraft() + return draft ? [...draft, ...blankRows(3)] : blankRows(INITIAL_ROWS) + }) const [saving, setSaving] = useState(false) // Validation errors per current row index (from a rejected create). const [saveErrors, setSaveErrors] = useState>( @@ -120,6 +157,10 @@ export const GeoThermalWellInventory = () => { ) const [summary, setSummary] = useState(null) const [csvStatus, setCsvStatus] = useState(null) + const [draftStatus, setDraftStatus] = useState(() => { + const draft = loadDraft() + return draft ? `Restored ${draft.length} saved rows` : null + }) const filledCount = useMemo( () => rows.filter((r) => !isBlankDraft(r)).length, @@ -155,6 +196,15 @@ export const GeoThermalWellInventory = () => { [] ) + const handleSaveForLater = useCallback(() => { + const n = saveDraft(rows) + setCsvStatus(null) + setSummary(null) + setDraftStatus( + n > 0 ? `Saved ${n} ${n === 1 ? 'row' : 'rows'} for later` : 'Nothing to save' + ) + }, [rows]) + const handleDownloadTemplate = useCallback(() => { const blob = new Blob([buildTemplateCsv()], { type: 'text/csv;charset=utf-8', @@ -245,6 +295,8 @@ export const GeoThermalWellInventory = () => { setSaveErrors(nextErrors) setSummary({ created, failed }) setSaving(false) + // Keep a saved draft in sync with what still needs creating. + if (created > 0 && hasDraft()) saveDraft(nextRows) }, [rows, dataProvider]) if (permLoading) { @@ -272,6 +324,9 @@ export const GeoThermalWellInventory = () => { Enter new geothermal wells
+ {draftStatus && ( + {draftStatus} + )} {csvStatus && ( {csvStatus} )} @@ -328,6 +383,14 @@ export const GeoThermalWellInventory = () => { > Add {ADD_ROW_COUNT} rows +
From 79a09659ec586fe3b740648f5f56dd96e6a152f5 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 08:53:45 -0700 Subject: [PATCH 027/144] BDMS-878: map location picker + coord display for inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocationPickerModal: a Dialog with a react-map-gl satellite map; click to drop/move a pin, confirm to return lat/lon. Rendered per row (keyed) so it opens with that row's current coordinates. - Inventory: a read-only "Location" column whose cell opens the picker for its row ("📍 Pin on map", or the current coords); confirming writes latitude/longitude back to that row. - EditableDataGrid: onClick now receives the row index (needed to target the right row); add an optional per-column `format` that overrides only the displayed string, not the stored edit value. - Coordinates display rounded to 7 decimal places (formatCoord) in the lat/lon cells, the Location column, and the picker readout — full precision is kept for save. - Header: "Total depth" → "Total Depth (ft)". Co-Authored-By: Claude Opus 4.8 --- src/components/grid/EditableDataGrid.tsx | 14 ++- .../geothermal/wells/LocationPickerModal.tsx | 93 +++++++++++++++++++ src/pages/geothermal/wells/inventory.tsx | 54 ++++++++++- src/pages/geothermal/wells/inventoryFields.ts | 7 +- 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 src/pages/geothermal/wells/LocationPickerModal.tsx diff --git a/src/components/grid/EditableDataGrid.tsx b/src/components/grid/EditableDataGrid.tsx index 4e5fa647..b6b0ad88 100644 --- a/src/components/grid/EditableDataGrid.tsx +++ b/src/components/grid/EditableDataGrid.tsx @@ -45,6 +45,12 @@ export interface GridColumnSpec { editable?: boolean /** Read the display value for a row. */ getValue: (row: T) => CellValue + /** + * Optional display formatter. Overrides the default string rendering for the + * cell without changing the underlying edit value (e.g. round a coordinate + * for display while keeping full precision for save). + */ + format?: (value: CellValue) => string /** * Produce an updated row given a new cell value. Required for editable * columns; ignored otherwise. @@ -57,7 +63,7 @@ export interface GridColumnSpec { */ validate?: (value: CellValue, row: T) => string | undefined /** Called when a cell in this column is clicked (e.g. a URI link). */ - onClick?: (row: T) => void + onClick?: (row: T, rowIndex: number) => void } export interface EditableDataGridProps @@ -137,7 +143,9 @@ export function EditableDataGrid({ } const colDef = columns[col] const value = colDef.getValue(rowData) - const display = toDisplayString(value) + const display = colDef.format + ? colDef.format(value) + : toDisplayString(value) const editable = colDef.editable === true && colDef.setValue !== undefined const error = cellErrors?.(row)?.[colDef.id] const errorTheme = error ? { themeOverride: ERROR_CELL_THEME } : {} @@ -234,7 +242,7 @@ export function EditableDataGrid({ ([col, row]: Item) => { const colDef = columns[col] const rowData = rows[row] - if (colDef?.onClick && rowData !== undefined) colDef.onClick(rowData) + if (colDef?.onClick && rowData !== undefined) colDef.onClick(rowData, row) }, [columns, rows] ) diff --git a/src/pages/geothermal/wells/LocationPickerModal.tsx b/src/pages/geothermal/wells/LocationPickerModal.tsx new file mode 100644 index 00000000..e69d2686 --- /dev/null +++ b/src/pages/geothermal/wells/LocationPickerModal.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react' +import { Map, Marker, type MapLayerMouseEvent } from 'react-map-gl' +import 'mapbox-gl/dist/mapbox-gl.css' +import { MapPin } from 'lucide-react' +import { settings } from '@/settings' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { formatCoord } from './inventoryFields' + +interface LocationPickerModalProps { + /** Current latitude, if any (recenters + preloads the pin). */ + lat: number | null + /** Current longitude, if any. */ + lon: number | null + onConfirm: (lat: number, lon: number) => void + onClose: () => void +} + +// Default view: roughly centered on New Mexico. +const NM_VIEW = { longitude: -106, latitude: 34.4, zoom: 5.5 } + +/** + * Modal map for picking a well's location. Click the map to drop/move a pin; + * confirming returns its latitude/longitude. Rendered per row (mount it keyed + * by row so it opens fresh with that row's current coordinates). + */ +export function LocationPickerModal({ + lat, + lon, + onConfirm, + onClose, +}: LocationPickerModalProps) { + const [pin, setPin] = useState<{ lat: number; lon: number } | null>( + lat != null && lon != null ? { lat, lon } : null + ) + + const initialViewState = pin + ? { longitude: pin.lon, latitude: pin.lat, zoom: 10 } + : NM_VIEW + + return ( + { if (!v) onClose() }}> + + + Pick location + + +
+ + setPin({ lat: e.lngLat.lat, lon: e.lngLat.lng }) + } + > + {pin && ( + + + + )} + +
+ +
+ + {pin + ? `${formatCoord(pin.lat)}, ${formatCoord(pin.lon)}` + : 'Click the map to drop a pin.'} + +
+ + +
+
+
+
+ ) +} diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index 10296800..50291462 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -19,11 +19,13 @@ import { NUMBER_FIELDS, TEXT_FIELDS, cleanDraft, + formatCoord, isBlankDraft, missingRequired, type WellDraft, } from './inventoryFields' import { buildTemplateCsv, parseCsvFile } from './inventoryCsv' +import { LocationPickerModal } from './LocationPickerModal' const BOOLEAN_FIELD: keyof WellDraft = 'has_geothermal_data' @@ -62,6 +64,8 @@ function textCol(id: keyof WellDraft): GridColumnSpec { } } +const COORD_FIELDS = new Set(['latitude', 'longitude']) + function numberCol(id: keyof WellDraft): GridColumnSpec { return { id, @@ -71,6 +75,10 @@ function numberCol(id: keyof WellDraft): GridColumnSpec { editable: true, getValue: (r) => (r[id] as CellValue) ?? null, setValue: (r, v) => ({ ...r, [id]: v as number | null }), + // Coordinates: show 7 sig figs; full precision is kept for save. + ...(COORD_FIELDS.has(id) + ? { format: (v: CellValue) => (typeof v === 'number' ? formatCoord(v) : '') } + : {}), } } @@ -161,6 +169,37 @@ export const GeoThermalWellInventory = () => { const draft = loadDraft() return draft ? `Restored ${draft.length} saved rows` : null }) + // Row whose location is being picked on the map (null = closed). + const [pickerRow, setPickerRow] = useState(null) + + // A read-only "Location" column that opens the map picker for its row. + const columns = useMemo[]>( + () => [ + ...COLUMNS, + { + id: '__location', + title: 'Location', + width: 140, + getValue: (r) => + r.latitude != null && r.longitude != null + ? `${formatCoord(r.latitude)}, ${formatCoord(r.longitude)}` + : '📍 Pin on map', + onClick: (_r, rowIndex) => setPickerRow(rowIndex), + }, + ], + [] + ) + + const applyLocation = useCallback( + (rowIndex: number, lat: number, lon: number) => { + setRows((prev) => + prev.map((r, i) => + i === rowIndex ? { ...r, latitude: lat, longitude: lon } : r + ) + ) + }, + [] + ) const filledCount = useMemo( () => rows.filter((r) => !isBlankDraft(r)).length, @@ -402,13 +441,26 @@ export const GeoThermalWellInventory = () => {
+ + {pickerRow !== null && ( + { + applyLocation(pickerRow, lat, lon) + setPickerRow(null) + }} + onClose={() => setPickerRow(null)} + /> + )}
) } diff --git a/src/pages/geothermal/wells/inventoryFields.ts b/src/pages/geothermal/wells/inventoryFields.ts index f0f8aa5e..e7c69e1f 100644 --- a/src/pages/geothermal/wells/inventoryFields.ts +++ b/src/pages/geothermal/wells/inventoryFields.ts @@ -43,7 +43,7 @@ export const HEADERS: Record = { has_geothermal_data: 'Geo data?', county: 'County', state: 'State', - total_depth: 'Total depth', + total_depth: 'Total Depth (ft)', latitude: 'Latitude', longitude: 'Longitude', } @@ -81,6 +81,11 @@ export function missingRequired(r: WellDraft): Record { return errors } +/** Round a coordinate to 7 decimal places for display (value unchanged). */ +export function formatCoord(n: number): string { + return Number(n.toFixed(7)).toString() +} + export function isBlankDraft(r: WellDraft): boolean { return ALL_FIELDS.every((k) => { const v = r[k] From 5732c51c1ebc14582b57b3bef545b1b096310a30 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 27 Jul 2026 12:03:52 -0500 Subject: [PATCH 028/144] feat(package): add unsafe lintting and formatting options --- package.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d0da8e9..c8c5cf32 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,12 @@ "test:coverage": "vitest run --coverage", "lint": "biome lint .", "lint:fix": "biome lint . --write", + "lint:fix:unsafe": "biome lint . --write --unsafe", "format": "biome format . --write", "format:check": "biome format .", + "check": "biome check .", + "check:fix": "biome check . --write", + "check:fix:unsafe": "biome check . --write --unsafe", "typecheck": "tsc", "mock:server:vitest": "prism mock openapi-auth.json --dynamic=false --port 4010", "mock:server:cypress": "prism mock openapi-auth.json --dynamic=true --port 4010 --seed 12345", @@ -140,4 +144,4 @@ "refine": { "projectId": "wCqQ1f-agx0FN-70pXIr" } -} +} \ No newline at end of file From 813939382ca846285e8392b73db4de68d066c237 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 10:48:19 -0700 Subject: [PATCH 029/144] BDMS-878: column header tooltips for the inventory grid - EditableDataGrid: add an optional `tooltip` per column; on header hover (onItemHovered, kind 'header') render a small positioned overlay with the column's description. - Inventory: describe every column via DESCRIPTIONS in inventoryFields (name, api, well fields, county/state server-derivation note, lat/lon, total depth) plus the Location column's "Pick the well location on a map". Co-Authored-By: Claude Opus 4.8 --- src/components/grid/EditableDataGrid.tsx | 80 ++++++++++++++----- src/pages/geothermal/wells/inventory.tsx | 6 ++ src/pages/geothermal/wells/inventoryFields.ts | 19 +++++ 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/src/components/grid/EditableDataGrid.tsx b/src/components/grid/EditableDataGrid.tsx index b6b0ad88..f85ffadc 100644 --- a/src/components/grid/EditableDataGrid.tsx +++ b/src/components/grid/EditableDataGrid.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import '@glideapps/glide-data-grid/dist/index.css' import '@glideapps/glide-data-grid-cells/dist/index.css' import { @@ -8,6 +8,7 @@ import { type GridCell, GridCellKind, type GridColumn, + type GridMouseEventArgs, type Item, } from '@glideapps/glide-data-grid' import { allCells } from '@glideapps/glide-data-grid-cells' @@ -33,6 +34,8 @@ export interface GridColumnSpec { id: string /** Header label. */ title: string + /** Optional description shown as a tooltip when hovering the column header. */ + tooltip?: string /** Column width in px. */ width?: number /** Optional group header label (for grouped grids). */ @@ -127,6 +130,30 @@ export function EditableDataGrid({ }: EditableDataGridProps) { const theme = useGdgTheme() const [containerRef, size] = useElementSize() + // Header tooltip: text + position (relative to the grid container). + const [tooltip, setTooltip] = useState<{ + text: string + x: number + y: number + } | null>(null) + + const onItemHovered = useCallback( + (args: GridMouseEventArgs) => { + if (args.kind === 'header') { + const text = columns[args.location[0]]?.tooltip + if (text) { + setTooltip({ + text, + x: args.bounds.x + args.bounds.width / 2, + y: args.bounds.y + args.bounds.height, + }) + return + } + } + setTooltip(null) + }, + [columns] + ) const gridColumns: GridColumn[] = columns.map((c) => ({ id: c.id, @@ -248,31 +275,42 @@ export function EditableDataGrid({ ) return ( -
+
{isLoading || size.width === 0 ? (
{isLoading ? loadingMessage : null}
) : ( - ({ name: group })} - /> + <> + ({ name: group })} + /> + {tooltip && ( +
+ {tooltip.text} +
+ )} + )}
) diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index 50291462..5b35edf6 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -18,6 +18,7 @@ import { HEADERS, NUMBER_FIELDS, TEXT_FIELDS, + DESCRIPTIONS, cleanDraft, formatCoord, isBlankDraft, @@ -35,6 +36,7 @@ function textCol(id: keyof WellDraft): GridColumnSpec { return { id, title: HEADERS[id], + tooltip: DESCRIPTIONS[id], width: 140, kind: 'dropdown', options: ENUM_OPTIONS[id], @@ -47,6 +49,7 @@ function textCol(id: keyof WellDraft): GridColumnSpec { return { id, title: HEADERS[id], + tooltip: DESCRIPTIONS[id], width: 110, kind: 'boolean', editable: true, @@ -57,6 +60,7 @@ function textCol(id: keyof WellDraft): GridColumnSpec { return { id, title: HEADERS[id], + tooltip: DESCRIPTIONS[id], width: 150, editable: true, getValue: (r) => (r[id] as CellValue) ?? '', @@ -70,6 +74,7 @@ function numberCol(id: keyof WellDraft): GridColumnSpec { return { id, title: HEADERS[id], + tooltip: DESCRIPTIONS[id], width: 130, kind: 'number', editable: true, @@ -179,6 +184,7 @@ export const GeoThermalWellInventory = () => { { id: '__location', title: 'Location', + tooltip: 'Pick the well location on a map', width: 140, getValue: (r) => r.latitude != null && r.longitude != null diff --git a/src/pages/geothermal/wells/inventoryFields.ts b/src/pages/geothermal/wells/inventoryFields.ts index e7c69e1f..f52b2b0e 100644 --- a/src/pages/geothermal/wells/inventoryFields.ts +++ b/src/pages/geothermal/wells/inventoryFields.ts @@ -86,6 +86,25 @@ export function formatCoord(n: number): string { return Number(n.toFixed(7)).toString() } +// Column header tooltips — one per field. +export const DESCRIPTIONS: Record = { + name: 'Well name / identifier', + api: 'API well number', + well_number: 'Well number', + well_class: 'Well class (e.g. Oil & Gas)', + well_type: 'Well type (Wildcat, Production, Exploration)', + status: 'Well status (Active, Abandoned, Plugged)', + operator: 'Operator company', + owner: 'Owner', + completion_date: 'Completion date (ISO, e.g. 2006-04-06)', + has_geothermal_data: 'Whether the well has geothermal data', + county: 'County — server-derived from lat/lon when left blank', + state: 'State — server-derived from lat/lon when blank (default NM)', + total_depth: 'Total depth, in feet', + latitude: 'Latitude in decimal degrees', + longitude: 'Longitude in decimal degrees', +} + export function isBlankDraft(r: WellDraft): boolean { return ALL_FIELDS.every((k) => { const v = r[k] From 4f8996557d1e56350a31af11bebd337d646e4f45 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 10:57:41 -0700 Subject: [PATCH 030/144] Add geothermal gap analysis vs the legacy NM_Wells DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings doc mapping the authoritative legacy Access model (NM_Wells Geothermal) + a sample temperature-depth export against our current inventory + records grids. Priority gaps: (G1) the core geothermal measurements (temp-depth / gradient / heat flow) are unmodeled and the sample CSV can't be imported; (G2) location is far simpler than the source (NAD27/NAD83 datums, PLSS, accuracy, multi-location provenance); (G3) missing well-header fields (TVD, spud/plug dates, Fm/Age_TD, current-vs-historical, the ~9 data-existence flags); (G4) API is structured not free text; (G5) missing Sources / Sample Sets / Lithology / Perf / Production / Audit entities. Analysis only — for scope alignment with the data owner + backend before building. Co-Authored-By: Claude Opus 4.8 --- docs/geothermal-gap-analysis.md | 135 ++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/geothermal-gap-analysis.md diff --git a/docs/geothermal-gap-analysis.md b/docs/geothermal-gap-analysis.md new file mode 100644 index 00000000..b4aa6640 --- /dev/null +++ b/docs/geothermal-gap-analysis.md @@ -0,0 +1,135 @@ +# Geothermal — Gap Analysis vs the Legacy System + +**Status:** Findings / for review with the data owner + backend +**Ticket:** BDMS-878 +**Source:** Screenshots of the legacy Access DB **NM_Wells Geothermal (SQL2019)** +(`NM_Wells_FE_GeoThermal_ver03`) + a sample temperature-depth export +(`Borderplex_2026_03_31Td.csv`), provided by the geothermal data owner. + +The legacy Access app is the **authoritative data model** the new Ocotillo +geothermal UI/API is meant to replace. This doc maps what it holds against what +we've built so far (inventory grid + records grid) and lists the gaps. + +> Note: screenshot 3 shows a VBA "must be updated for use on 64-bit systems" +> compile error — the Access front end is breaking on 64-bit Office. That's +> context for *why* they're migrating off Access, not a data-model gap. + +--- + +## Current Ocotillo model (what we have) + +- **Well** (`IWell`, inventory grid): `well_data_id`, `api`, `name`, + `well_number`, `well_class`, `well_type`, `status`, `operator`, `owner`, + `total_depth`, `completion_date`, `has_geothermal_data`, `county`, `state`, + `latitude`, `longitude`. +- **Record** (`IWellRecord`, records grid): `OBJECTID`, `WellDataID`, + `WellName`, `WellNumber`, `API_suffix`, `ActionDate`, `EntryDate`, + `EnteredBy`, `RecrdSetID`, `SourceID`, `Comments`. + +This covers the **well identity / header** layer at a basic level. The gaps +below are what's missing. + +--- + +## Gaps (priority-ordered) + +### 🔴 G1 — The core geothermal measurements are not modeled + +The whole purpose of the system is thermal data: +**temperature-depth logs → thermal gradient → heat flow**. The legacy DB has +dedicated subforms for exactly this (visible in the Forms list): +`GTTempDepth_Subform_new`, `GT_HeatFlowDataSbfrm`, `GTSumHeatFlow_Subform`, +`GT_Heatflow_sbfrm`. + +The sample CSV is one such log: + +| Column | Meaning | +|--------|---------| +| `Depth_m`, `Depth_ft` | measurement depth (both units) | +| `Resistance` | probe resistance | +| `Temp_F`, `Temp_C` | temperature (both units) | +| `Gradient_C_km` | thermal gradient (°C/km) | +| free-text notes | formation ("Camp Rice Formation"), "fine slots in screen" | + +Our `IWellRecord` (WellName / Comments / dates) has **no fields for +depth/temperature/gradient/resistance/heat-flow**. The CSV import we built +ingests *well* rows, not temp-depth logs — so **this file has nowhere to land**. + +**Needed:** a measurement model (temp-depth points + derived gradient / thermal +conductivity / heat-flow summary), plus a log importer that matches this CSV +shape. Backend endpoint required. + +### 🔴 G2 — Location is drastically oversimplified + +We store a single `latitude`/`longitude`. The legacy Well_Location record has: + +- **Two datums**: `Lat_dd27`/`Long_dd27` (NAD27) **and** `Lat_dd83`/`Long_dd83` + (NAD83), plus DMS (D/M/S), with `SourceUnits` + `SourceDatum`. +- **PLSS**: `UnitLetter`, `Sectn`, `Township` + `NorS_TDir`, `Range` + + `EorW_RDir`, `SectnPart`, `Footage_NS`/`Footage_EW` + `NorS_FDir`/`EorW_FDir`, + `UTM_zone`. +- **Basin** (e.g. San Juan) — missing entirely. +- **Location accuracy**: `LocAccType`, `LocAccMeas`, `LocAccVal`. +- **Multiple locations per well** ("Add New Location") with `Duplicated` / + `Exclude` flags and a per-location `SourceID` (provenance). + +Our county/state server-derivation plan is compatible, but the datum + PLSS + +accuracy + multi-location provenance model is absent. + +### 🟠 G3 — Well header is missing many fields + +| Legacy field | Ours | +|--------------|------| +| `Well_TVD` (true vertical depth) | only `total_depth` (measured) | +| `SpudDate`, `ComplDate`, `PlugDate`, `PlugBack` | only `completion_date` | +| `Fm_TD`, `Age_TD` (formation / age at TD) | — | +| `WellOrient` (vertical/deviated) | — | +| `CurOperatr`, `CurStatus`, `CurWellNam`, `CurWellNum`, `CurOwner` | flat operator/status/name/owner (no current-vs-historical split) | +| `PrdPoolCount` (producing pool count) | — | +| `Import_ID`, `Import_DB`, GUID | — (import provenance) | +| Data-existence flags: `ScoutTickt`, `DwnHoleSur`, `GeoLog`, `Geophyslog`, **`GthrmExist`**, `PetroData`, `CoreExists`, `Cuttings`, `SampleDat` | only `has_geothermal_data` (= `GthrmExist`) | + +`has_geothermal_data` is **one flag in a family of ~9** yes/no data-presence +flags. + +### 🟠 G4 — API is structured, not free text + +Legacy: `API = 30-039-05212` (state `30`=NM · county `039` · well `05212`), +`Well_ID = 3003905212` (concatenated), plus a separate `API_suffix`. Ours is a +plain string with no structure or validation. Worth a parsed/validated API. + +### 🟡 G5 — Missing supporting entities + +- **Sources** — `SourceID` is a foreign key to a bibliography record + (e.g. "Engler, Brister, Chen, Teufel, 2001"), not free text. +- **Records provenance/content** — `RecrdSetID`, `RecrdClass`, `EnteredBy`, + `EntryDate`, Sample Sets, and **Lithology** (color / grain size / texture), + `LithStrat`, `LithLog`. +- **Perf intervals**, **Production** (legacy `PerfIntrval_sbfrm`, `Prdctn_sc`). +- **Audit trails** — "Audits: Well Header / Locations / LithStrat / LithLog". + +--- + +## Summary + +Our inventory + records grids model the **well identity/header** layer at a +basic level. The three biggest gaps: + +1. **Geothermal measurements (temp-depth, gradient, heat flow) are unmodeled** — + the reason the system exists. The provided CSV can't be imported anywhere. +2. **Location** is ~10× simpler than the source (datums, PLSS, accuracy, + multi-location provenance). +3. **Records** is a stub vs the real provenance / lithology / sample-set model. + +## Recommended sequencing (proposal) + +1. Confirm scope with the data owner: is Ocotillo replacing the *full* NM_Wells + Geothermal DB, or just the well-inventory + thermal-log capture? +2. Backend: define the **temperature-depth / heat-flow** entities + endpoints + (blocks G1) and the **richer location** entity (G2). +3. Frontend: extend the well header (G3), add a **temp-depth log importer** + matching the CSV shape, and the location detail model (G2). +4. Later: Sources, Sample Sets, Lithology, Perf/Production, Audits (G5). + +Nothing here is implemented yet — this is a findings doc to align on scope +before building. From 490f3b2302b35743a7e2989e46d3883cd1ae0bf2 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 11:09:19 -0700 Subject: [PATCH 031/144] =?UTF-8?q?BDMS-878:=20expand=20well=20model=20?= =?UTF-8?q?=E2=80=94=20location,=20header,=20structured=20API=20(G2/G3/G4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address gap-analysis items G2 (location), G3 (header), G4 (API). All new field names are provisional pending the backend contract. - IWell: add G2 location (source_datum, basin, PLSS township/range/ section/unit/section-part/footage±dir/UTM, accuracy type/meas/val), G3 header (well_tvd, plug_back, fm_td, age_td, well_orient, prd_pool_count, spud/plug dates, import_id/import_db/guid, and the ~9 data-existence flags), and G4 api_suffix. - inventoryFields: rewrite around a single FIELD_SPECS table (id, header, group, kind, description, options, required, coord, validate). Derive headers/tooltips/options/required/number+boolean sets from it. validateApi/parseApi for the structured API (SS-CCC-NNNNN); validateDraft runs required + per-field validators. - inventory: build grouped columns from FIELD_SPECS (Glide group headers: Identity, Classification, Operator, Depth, Dates, Location, PLSS, Accuracy, Data flags) via specToColumn; validation now covers format errors (API), not just required. Toolbar reads "N rows with errors". - inventoryCsv: coerce boolean fields on import; NUMBER_FIELDS is now a Set. - EditableDataGrid: header tooltip now follows the cursor (fixed position via mousemove) instead of a mis-offset absolute overlay. Verified live: grouped columns render; a bad API (99) tints its cell and blocks Create; a valid API (30-039-05212) is clean. Co-Authored-By: Claude Opus 4.8 --- src/components/grid/EditableDataGrid.tsx | 38 +-- src/interfaces/geothermal/IWell.ts | 95 ++++++-- src/pages/geothermal/wells/inventory.tsx | 108 ++++----- src/pages/geothermal/wells/inventoryCsv.ts | 9 +- src/pages/geothermal/wells/inventoryFields.ts | 216 +++++++++++------- 5 files changed, 281 insertions(+), 185 deletions(-) diff --git a/src/components/grid/EditableDataGrid.tsx b/src/components/grid/EditableDataGrid.tsx index f85ffadc..7fc74633 100644 --- a/src/components/grid/EditableDataGrid.tsx +++ b/src/components/grid/EditableDataGrid.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import '@glideapps/glide-data-grid/dist/index.css' import '@glideapps/glide-data-grid-cells/dist/index.css' import { @@ -130,7 +130,9 @@ export function EditableDataGrid({ }: EditableDataGridProps) { const theme = useGdgTheme() const [containerRef, size] = useElementSize() - // Header tooltip: text + position (relative to the grid container). + // Header tooltip: text follows the cursor (fixed viewport position). The + // hovered header's description is tracked in a ref; mousemove positions it. + const hoverTextRef = useRef(null) const [tooltip, setTooltip] = useState<{ text: string x: number @@ -139,22 +141,19 @@ export function EditableDataGrid({ const onItemHovered = useCallback( (args: GridMouseEventArgs) => { - if (args.kind === 'header') { - const text = columns[args.location[0]]?.tooltip - if (text) { - setTooltip({ - text, - x: args.bounds.x + args.bounds.width / 2, - y: args.bounds.y + args.bounds.height, - }) - return - } - } - setTooltip(null) + const text = + (args.kind === 'header' && columns[args.location[0]]?.tooltip) || null + hoverTextRef.current = text + if (!text) setTooltip(null) }, [columns] ) + const onMouseMove = useCallback((e: React.MouseEvent) => { + const text = hoverTextRef.current + setTooltip(text ? { text, x: e.clientX, y: e.clientY } : null) + }, []) + const gridColumns: GridColumn[] = columns.map((c) => ({ id: c.id, title: c.title, @@ -275,7 +274,12 @@ export function EditableDataGrid({ ) return ( -
+
setTooltip(null)} + className="relative flex flex-col flex-1 min-w-0" + > {isLoading || size.width === 0 ? (
{isLoading ? loadingMessage : null} @@ -304,8 +308,8 @@ export function EditableDataGrid({ /> {tooltip && (
{tooltip.text}
diff --git a/src/interfaces/geothermal/IWell.ts b/src/interfaces/geothermal/IWell.ts index e68a4bc6..90a02fe9 100644 --- a/src/interfaces/geothermal/IWell.ts +++ b/src/interfaces/geothermal/IWell.ts @@ -1,21 +1,78 @@ -// Shape of a geothermal well from GET /thing/geothermal-well. -// `well_data_id` (UUID) is the identifier used for detail/records routes. +// Shape of a geothermal well. The first block matches the live +// GET /thing/geothermal-well response; the rest (G2 location, G3 header, G4 +// api) are PROVISIONAL field names modeled from the legacy NM_Wells Geothermal +// DB — confirm against the backend contract once it lands. export interface IWell { - well_data_id: string; - thing_id: number | null; - api: string | null; - name: string | null; - well_number: string | null; - well_class: string | null; - well_type: string | null; - status: string | null; - operator: string | null; - owner: string | null; - total_depth: number | null; - completion_date: string | null; - has_geothermal_data: boolean | null; - county: string | null; - state: string | null; - latitude: number | null; - longitude: number | null; + well_data_id: string + thing_id: number | null + + // ── Identity (G4: api is state-county-well; api_suffix is separate) ── + api: string | null + api_suffix: string | null + name: string | null + well_number: string | null + import_id: string | null + import_db: string | null + guid: string | null + + // ── Classification ── + well_class: string | null + well_type: string | null + well_orient: string | null + status: string | null + + // ── Operator ── + operator: string | null + owner: string | null + prd_pool_count: number | null + + // ── Depth (G3) ── + total_depth: number | null + well_tvd: number | null + plug_back: number | null + fm_td: string | null + age_td: string | null + + // ── Dates (G3) ── + spud_date: string | null + completion_date: string | null + plug_date: string | null + + // ── Location (G2) ── + latitude: number | null + longitude: number | null + source_datum: string | null + basin: string | null + county: string | null + state: string | null + + // ── PLSS (G2) ── + township: number | null + township_dir: string | null + range: number | null + range_dir: string | null + section: number | null + unit_letter: string | null + section_part: string | null + footage_ns: number | null + footage_ns_dir: string | null + footage_ew: number | null + footage_ew_dir: string | null + utm_zone: string | null + + // ── Location accuracy (G2) ── + loc_acc_type: string | null + loc_acc_meas: string | null + loc_acc_val: string | null + + // ── Data-existence flags (G3) ── + scout_ticket: boolean | null + downhole_survey: boolean | null + geo_log: boolean | null + geophys_log: boolean | null + has_geothermal_data: boolean | null + petro_data: boolean | null + core_exists: boolean | null + cuttings: boolean | null + sample_data: boolean | null } diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index 5b35edf6..e010c982 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -13,84 +13,71 @@ import { type FieldErrors, } from './recordsGridLogic' import { - ALL_FIELDS, - ENUM_OPTIONS, - HEADERS, - NUMBER_FIELDS, - TEXT_FIELDS, - DESCRIPTIONS, + FIELD_SPECS, + type FieldSpec, cleanDraft, formatCoord, isBlankDraft, - missingRequired, + validateDraft, type WellDraft, } from './inventoryFields' import { buildTemplateCsv, parseCsvFile } from './inventoryCsv' import { LocationPickerModal } from './LocationPickerModal' -const BOOLEAN_FIELD: keyof WellDraft = 'has_geothermal_data' - -function textCol(id: keyof WellDraft): GridColumnSpec { - // Enum fields render as dropdowns; has_geothermal_data as a checkbox. - if (ENUM_OPTIONS[id]) { +// Build a grid column from a field spec, dispatching by kind. +function specToColumn(spec: FieldSpec): GridColumnSpec { + const id = spec.id + const base = { + id, + title: spec.header, + tooltip: spec.description, + group: spec.group, + editable: true, + } + if (spec.kind === 'boolean') { return { - id, - title: HEADERS[id], - tooltip: DESCRIPTIONS[id], - width: 140, + ...base, + width: 90, + kind: 'boolean', + getValue: (r) => (r[id] as boolean | null) ?? false, + setValue: (r, v) => ({ ...r, [id]: v === true }), + } + } + if (spec.kind === 'dropdown') { + return { + ...base, + width: 130, kind: 'dropdown', - options: ENUM_OPTIONS[id], - editable: true, + options: spec.options ?? [], getValue: (r) => (r[id] as CellValue) ?? '', setValue: (r, v) => ({ ...r, [id]: v ?? '' }), } } - if (id === BOOLEAN_FIELD) { + if (spec.kind === 'number') { return { - id, - title: HEADERS[id], - tooltip: DESCRIPTIONS[id], - width: 110, - kind: 'boolean', - editable: true, - getValue: (r) => r.has_geothermal_data ?? false, - setValue: (r, v) => ({ ...r, has_geothermal_data: v === true }), + ...base, + width: 120, + kind: 'number', + getValue: (r) => (r[id] as CellValue) ?? null, + setValue: (r, v) => ({ ...r, [id]: v as number | null }), + // Coordinates display rounded; full precision kept for save. + ...(spec.coord + ? { + format: (v: CellValue) => + typeof v === 'number' ? formatCoord(v) : '', + } + : {}), } } return { - id, - title: HEADERS[id], - tooltip: DESCRIPTIONS[id], + ...base, width: 150, - editable: true, getValue: (r) => (r[id] as CellValue) ?? '', setValue: (r, v) => ({ ...r, [id]: v ?? '' }), } } -const COORD_FIELDS = new Set(['latitude', 'longitude']) - -function numberCol(id: keyof WellDraft): GridColumnSpec { - return { - id, - title: HEADERS[id], - tooltip: DESCRIPTIONS[id], - width: 130, - kind: 'number', - editable: true, - getValue: (r) => (r[id] as CellValue) ?? null, - setValue: (r, v) => ({ ...r, [id]: v as number | null }), - // Coordinates: show 7 sig figs; full precision is kept for save. - ...(COORD_FIELDS.has(id) - ? { format: (v: CellValue) => (typeof v === 'number' ? formatCoord(v) : '') } - : {}), - } -} - -const COLUMNS: GridColumnSpec[] = [ - ...TEXT_FIELDS.map(textCol), - ...NUMBER_FIELDS.map(numberCol), -] +const COLUMNS: GridColumnSpec[] = FIELD_SPECS.map(specToColumn) interface CreateSummary { created: number @@ -183,7 +170,8 @@ export const GeoThermalWellInventory = () => { ...COLUMNS, { id: '__location', - title: 'Location', + title: 'Pick', + group: 'Location', tooltip: 'Pick the well location on a map', width: 140, getValue: (r) => @@ -212,13 +200,14 @@ export const GeoThermalWellInventory = () => { [rows] ) - // Client-side validation: required fields missing on any non-blank row. + // Client-side validation: required-empty + per-field validators (e.g. API + // format) on any non-blank row. const validationErrors = useMemo(() => { const map = new Map() rows.forEach((r, i) => { if (isBlankDraft(r)) return - const missing = missingRequired(r) - if (Object.keys(missing).length > 0) map.set(i, missing) + const errs = validateDraft(r) + if (Object.keys(errs).length > 0) map.set(i, errs) }) return map }, [rows]) @@ -389,8 +378,7 @@ export const GeoThermalWellInventory = () => { )} {invalidCount > 0 ? ( - {invalidCount} {invalidCount === 1 ? 'row' : 'rows'} missing - required fields + {invalidCount} {invalidCount === 1 ? 'row' : 'rows'} with errors ) : ( diff --git a/src/pages/geothermal/wells/inventoryCsv.ts b/src/pages/geothermal/wells/inventoryCsv.ts index 8bf63e75..2626c69b 100644 --- a/src/pages/geothermal/wells/inventoryCsv.ts +++ b/src/pages/geothermal/wells/inventoryCsv.ts @@ -1,12 +1,11 @@ import Papa from 'papaparse' import { ALL_FIELDS, + BOOLEAN_FIELDS, NUMBER_FIELDS, type WellDraft, } from './inventoryFields' -const NUMBER_FIELD_SET = new Set(NUMBER_FIELDS as string[]) - // Canonical field name for a CSV header, matched case-insensitively and // trimmed. Returns undefined for headers that aren't well fields. const fieldForHeader = (() => { @@ -45,10 +44,14 @@ export function mapRecordsToDrafts( if (!field) continue const value = (raw ?? '').trim() if (value === '') continue - if (NUMBER_FIELD_SET.has(field)) { + if (NUMBER_FIELDS.has(field)) { const n = Number(value) if (Number.isNaN(n)) continue ;(draft as Record)[field] = n + } else if (BOOLEAN_FIELDS.has(field)) { + ;(draft as Record)[field] = /^(1|true|yes|y)$/i.test( + value + ) } else { ;(draft as Record)[field] = value } diff --git a/src/pages/geothermal/wells/inventoryFields.ts b/src/pages/geothermal/wells/inventoryFields.ts index f52b2b0e..6dd246b6 100644 --- a/src/pages/geothermal/wells/inventoryFields.ts +++ b/src/pages/geothermal/wells/inventoryFields.ts @@ -4,111 +4,155 @@ import type { IWell } from '@/interfaces/geothermal' // server-assigned, so drafts hold only the user-entered fields. export type WellDraft = Partial> -// Fields the user fills when inventorying a new well (everything except the -// server-assigned id). Enum fields (well_class/well_type/status) are plain text -// in P1–P3 — dropdowns land in P4. -export const TEXT_FIELDS: (keyof WellDraft)[] = [ - 'name', - 'api', - 'well_number', - 'well_class', - 'well_type', - 'status', - 'operator', - 'owner', - 'completion_date', - 'has_geothermal_data', - 'county', - 'state', -] +export type FieldKind = 'text' | 'number' | 'dropdown' | 'boolean' -export const NUMBER_FIELDS: (keyof WellDraft)[] = [ - 'total_depth', - 'latitude', - 'longitude', -] +export interface FieldSpec { + id: keyof WellDraft + header: string + group: string + kind: FieldKind + description: string + options?: string[] + required?: boolean + /** 7-decimal coordinate display. */ + coord?: boolean + /** Extra per-value validation (empty is handled by `required`). */ + validate?: (value: unknown) => string | undefined +} + +const NS = ['N', 'S'] +const EW = ['E', 'W'] + +/** Structured API number, e.g. 30-039-05212 (state-county-well). */ +export function validateApi(value: unknown): string | undefined { + const s = String(value).trim() + return /^\d{2}-\d{3}-\d{4,5}$/.test(s) ? undefined : 'Format: SS-CCC-NNNNN' +} -export const ALL_FIELDS: (keyof WellDraft)[] = [...TEXT_FIELDS, ...NUMBER_FIELDS] - -export const HEADERS: Record = { - name: 'Name', - api: 'API', - well_number: 'Well #', - well_class: 'Class', - well_type: 'Type', - status: 'Status', - operator: 'Operator', - owner: 'Owner', - completion_date: 'Completion', - has_geothermal_data: 'Geo data?', - county: 'County', - state: 'State', - total_depth: 'Total Depth (ft)', - latitude: 'Latitude', - longitude: 'Longitude', +export interface ParsedApi { + stateCode: string + countyCode: string + wellId: string } -// Allowed values for the enum (dropdown) fields. -// PROVISIONAL — observed from the live data (8 wells); confirm the full lists -// with the backend once the contract lands. -export const ENUM_OPTIONS: Partial> = { - well_type: ['Wildcat', 'Production', 'Exploration'], - well_class: ['Oil & Gas'], - status: ['Active', 'Abandoned', 'Plugged'], +export function parseApi(value: string): ParsedApi | null { + const m = String(value) + .trim() + .match(/^(\d{2})-(\d{3})-(\d{4,5})$/) + if (!m) return null + return { stateCode: m[1], countyCode: m[2], wellId: m[3] } } -// Fields required to create a well. county/state are intentionally NOT -// required — the backend reverse-geocodes them from lat/lon when left blank -// (auto-fill empty only), so latitude/longitude are the required location -// inputs instead. -// PROVISIONAL — the create schema isn't in the (stripped) OpenAPI; confirm the -// real required set with the backend. -export const REQUIRED_FIELDS: (keyof WellDraft)[] = [ - 'name', - 'api', - 'well_type', - 'latitude', - 'longitude', +// Single source of truth for every editable well field. All derived maps +// (headers, tooltips, options, required set, number/boolean fields) come from +// here. PROVISIONAL — see IWell. +export const FIELD_SPECS: FieldSpec[] = [ + // ── Identity ── + { id: 'name', header: 'Name', group: 'Identity', kind: 'text', description: 'Well name / identifier', required: true }, + { id: 'api', header: 'API', group: 'Identity', kind: 'text', description: 'API well number (SS-CCC-NNNNN, e.g. 30-039-05212)', required: true, validate: validateApi }, + { id: 'api_suffix', header: 'API suffix', group: 'Identity', kind: 'text', description: 'API suffix' }, + { id: 'well_number', header: 'Well #', group: 'Identity', kind: 'text', description: 'Well number' }, + { id: 'import_id', header: 'Import ID', group: 'Identity', kind: 'text', description: 'Source import identifier' }, + { id: 'import_db', header: 'Import DB', group: 'Identity', kind: 'text', description: 'Source import database' }, + { id: 'guid', header: 'GUID', group: 'Identity', kind: 'text', description: 'Globally unique identifier' }, + + // ── Classification ── + { id: 'well_class', header: 'Class', group: 'Classification', kind: 'dropdown', description: 'Well class', options: ['Oil & Gas', 'Water', 'Geothermal'] }, + { id: 'well_type', header: 'Type', group: 'Classification', kind: 'dropdown', description: 'Well type', options: ['Wildcat', 'Production', 'Exploration', 'Gas', 'Oil'], required: true }, + { id: 'well_orient', header: 'Orientation', group: 'Classification', kind: 'dropdown', description: 'Well orientation', options: ['vertical', 'deviated', 'horizontal'] }, + { id: 'status', header: 'Status', group: 'Classification', kind: 'dropdown', description: 'Well status', options: ['New', 'Active', 'Abandoned', 'Plugged'] }, + + // ── Operator ── + { id: 'operator', header: 'Operator', group: 'Operator', kind: 'text', description: 'Current operator company' }, + { id: 'owner', header: 'Owner', group: 'Operator', kind: 'text', description: 'Current owner' }, + { id: 'prd_pool_count', header: 'Pool count', group: 'Operator', kind: 'number', description: 'Producing pool count' }, + + // ── Depth ── + { id: 'total_depth', header: 'Total Depth (ft)', group: 'Depth', kind: 'number', description: 'Total (measured) depth, in feet' }, + { id: 'well_tvd', header: 'TVD (ft)', group: 'Depth', kind: 'number', description: 'True vertical depth, in feet' }, + { id: 'plug_back', header: 'Plug back (ft)', group: 'Depth', kind: 'number', description: 'Plug-back depth, in feet' }, + { id: 'fm_td', header: 'Fm @ TD', group: 'Depth', kind: 'text', description: 'Formation at total depth' }, + { id: 'age_td', header: 'Age @ TD', group: 'Depth', kind: 'text', description: 'Age at total depth' }, + + // ── Dates ── + { id: 'spud_date', header: 'Spud', group: 'Dates', kind: 'text', description: 'Spud date (ISO, e.g. 1956-06-07)' }, + { id: 'completion_date', header: 'Completion', group: 'Dates', kind: 'text', description: 'Completion date (ISO)' }, + { id: 'plug_date', header: 'Plug', group: 'Dates', kind: 'text', description: 'Plug date (ISO)' }, + + // ── Location ── + { id: 'latitude', header: 'Latitude', group: 'Location', kind: 'number', description: 'Latitude in decimal degrees', coord: true, required: true }, + { id: 'longitude', header: 'Longitude', group: 'Location', kind: 'number', description: 'Longitude in decimal degrees', coord: true, required: true }, + { id: 'source_datum', header: 'Datum', group: 'Location', kind: 'dropdown', description: 'Datum the lat/lon are in', options: ['NAD27', 'NAD83', 'WGS84'] }, + { id: 'basin', header: 'Basin', group: 'Location', kind: 'text', description: 'Geologic basin' }, + { id: 'county', header: 'County', group: 'Location', kind: 'text', description: 'County — server-derived from lat/lon when blank' }, + { id: 'state', header: 'State', group: 'Location', kind: 'text', description: 'State — server-derived from lat/lon when blank (default NM)' }, + + // ── PLSS ── + { id: 'township', header: 'Township', group: 'PLSS', kind: 'number', description: 'PLSS township' }, + { id: 'township_dir', header: 'T dir', group: 'PLSS', kind: 'dropdown', description: 'Township direction', options: NS }, + { id: 'range', header: 'Range', group: 'PLSS', kind: 'number', description: 'PLSS range' }, + { id: 'range_dir', header: 'R dir', group: 'PLSS', kind: 'dropdown', description: 'Range direction', options: EW }, + { id: 'section', header: 'Section', group: 'PLSS', kind: 'number', description: 'PLSS section' }, + { id: 'unit_letter', header: 'Unit', group: 'PLSS', kind: 'text', description: 'PLSS unit letter' }, + { id: 'section_part', header: 'Sec part', group: 'PLSS', kind: 'text', description: 'Section part (e.g. SE-SE)' }, + { id: 'footage_ns', header: 'Ftg NS', group: 'PLSS', kind: 'number', description: 'Footage north/south' }, + { id: 'footage_ns_dir', header: 'NS dir', group: 'PLSS', kind: 'dropdown', description: 'Footage NS direction', options: NS }, + { id: 'footage_ew', header: 'Ftg EW', group: 'PLSS', kind: 'number', description: 'Footage east/west' }, + { id: 'footage_ew_dir', header: 'EW dir', group: 'PLSS', kind: 'dropdown', description: 'Footage EW direction', options: EW }, + { id: 'utm_zone', header: 'UTM zone', group: 'PLSS', kind: 'text', description: 'UTM zone' }, + + // ── Location accuracy ── + { id: 'loc_acc_type', header: 'Acc type', group: 'Accuracy', kind: 'text', description: 'Location accuracy type' }, + { id: 'loc_acc_meas', header: 'Acc meas', group: 'Accuracy', kind: 'text', description: 'Location accuracy measure' }, + { id: 'loc_acc_val', header: 'Acc val', group: 'Accuracy', kind: 'text', description: 'Location accuracy value' }, + + // ── Data-existence flags ── + { id: 'scout_ticket', header: 'Scout', group: 'Data flags', kind: 'boolean', description: 'Scout ticket exists' }, + { id: 'downhole_survey', header: 'Dwn hole', group: 'Data flags', kind: 'boolean', description: 'Downhole survey exists' }, + { id: 'geo_log', header: 'Geo log', group: 'Data flags', kind: 'boolean', description: 'Geologic log exists' }, + { id: 'geophys_log', header: 'Geophys', group: 'Data flags', kind: 'boolean', description: 'Geophysical log exists' }, + { id: 'has_geothermal_data', header: 'Geothermal', group: 'Data flags', kind: 'boolean', description: 'Geothermal data exists' }, + { id: 'petro_data', header: 'Petro', group: 'Data flags', kind: 'boolean', description: 'Petrophysical data exists' }, + { id: 'core_exists', header: 'Core', group: 'Data flags', kind: 'boolean', description: 'Core exists' }, + { id: 'cuttings', header: 'Cuttings', group: 'Data flags', kind: 'boolean', description: 'Cuttings exist' }, + { id: 'sample_data', header: 'Samples', group: 'Data flags', kind: 'boolean', description: 'Sample data exists' }, ] -/** Required fields that are empty on a draft, keyed field → message. */ -export function missingRequired(r: WellDraft): Record { - const errors: Record = {} - for (const f of REQUIRED_FIELDS) { - const v = r[f] - if (v == null || v === '') errors[f] = 'Required' - } - return errors -} +export const ALL_FIELDS: (keyof WellDraft)[] = FIELD_SPECS.map((s) => s.id) +export const NUMBER_FIELDS = new Set( + FIELD_SPECS.filter((s) => s.kind === 'number').map((s) => s.id) +) +export const BOOLEAN_FIELDS = new Set( + FIELD_SPECS.filter((s) => s.kind === 'boolean').map((s) => s.id) +) /** Round a coordinate to 7 decimal places for display (value unchanged). */ export function formatCoord(n: number): string { return Number(n.toFixed(7)).toString() } -// Column header tooltips — one per field. -export const DESCRIPTIONS: Record = { - name: 'Well name / identifier', - api: 'API well number', - well_number: 'Well number', - well_class: 'Well class (e.g. Oil & Gas)', - well_type: 'Well type (Wildcat, Production, Exploration)', - status: 'Well status (Active, Abandoned, Plugged)', - operator: 'Operator company', - owner: 'Owner', - completion_date: 'Completion date (ISO, e.g. 2006-04-06)', - has_geothermal_data: 'Whether the well has geothermal data', - county: 'County — server-derived from lat/lon when left blank', - state: 'State — server-derived from lat/lon when blank (default NM)', - total_depth: 'Total depth, in feet', - latitude: 'Latitude in decimal degrees', - longitude: 'Longitude in decimal degrees', +/** Validate a draft: required-empty + per-field validators. field → message. */ +export function validateDraft(r: WellDraft): Record { + const errors: Record = {} + for (const spec of FIELD_SPECS) { + const v = r[spec.id] + const empty = v == null || v === '' + if (spec.required && empty) { + errors[spec.id] = 'Required' + continue + } + if (spec.validate && !empty) { + const e = spec.validate(v) + if (e) errors[spec.id] = e + } + } + return errors } export function isBlankDraft(r: WellDraft): boolean { return ALL_FIELDS.every((k) => { const v = r[k] - return v == null || v === '' + return v == null || v === '' || v === false }) } From e323d5689d6fcc6642772665af04a76c3a4b883f Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 11:25:12 -0700 Subject: [PATCH 032/144] BDMS-878: single-well Create Well form for the inventory Add a "Create Well" button + slide-out form panel to the inventory, modeled on the Data Grid example's Create Well panel. No bulk-add. - CreateWellPanel: a form built from FIELD_SPECS, grouped into EditPanelSections (Identity, Classification, Operator, Depth, Dates, Location, PLSS, Accuracy, Data flags). Inputs dispatch by kind (text/number Input, dropdown Select, boolean Checkbox), required markers from the spec. On submit it client-validates (validateDraft), then POSTs one well via the geothermal provider; server field errors (422/409) surface under the offending inputs. - Inventory: a "Create Well" toolbar button toggles the panel; the grid is wrapped in EditPanelLayout so the form slides in beside it. Verified live: the panel opens with grouped sections, required asterisks, and dropdowns; the grid stays alongside. Co-Authored-By: Claude Opus 4.8 --- .../geothermal/wells/CreateWellPanel.tsx | 201 ++++++++++++++++++ src/pages/geothermal/wells/inventory.tsx | 40 +++- 2 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 src/pages/geothermal/wells/CreateWellPanel.tsx diff --git a/src/pages/geothermal/wells/CreateWellPanel.tsx b/src/pages/geothermal/wells/CreateWellPanel.tsx new file mode 100644 index 00000000..97e16dc4 --- /dev/null +++ b/src/pages/geothermal/wells/CreateWellPanel.tsx @@ -0,0 +1,201 @@ +import { useCallback, useState } from 'react' +import { useDataProvider } from '@refinedev/core' +import { + EditPanel, + EditPanelField, + EditPanelSection, +} from '@/components/editing' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + FIELD_SPECS, + type FieldSpec, + cleanDraft, + validateDraft, + type WellDraft, +} from './inventoryFields' +import { flattenFieldErrors } from './recordsGridLogic' + +// FIELD_SPECS grouped into ordered sections for the form. +const SECTIONS: { title: string; specs: FieldSpec[] }[] = (() => { + const order: string[] = [] + const byGroup = new Map() + for (const spec of FIELD_SPECS) { + if (!byGroup.has(spec.group)) { + byGroup.set(spec.group, []) + order.push(spec.group) + } + byGroup.get(spec.group)!.push(spec) + } + return order.map((title) => ({ title, specs: byGroup.get(title)! })) +})() + +interface CreateWellPanelProps { + onClose: () => void + /** Called after a well is successfully created. */ + onCreated: () => void +} + +/** + * Single-well entry form for the geothermal inventory — a slide-out panel + * (modeled on the Data Grid example's Create Well panel) built from FIELD_SPECS. + * Client-validates, then POSTs one well through the geothermal provider; server + * field errors surface under the offending inputs. (No bulk add here — the grid + * covers batch entry.) + */ +export function CreateWellPanel({ onClose, onCreated }: CreateWellPanelProps) { + const dataProvider = useDataProvider() + const [draft, setDraft] = useState({}) + const [errors, setErrors] = useState>({}) + const [message, setMessage] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const setField = useCallback((id: keyof WellDraft, value: unknown) => { + setDraft((d) => ({ ...d, [id]: value })) + }, []) + + const handleCreate = useCallback(async () => { + const clientErrors = validateDraft(draft) + if (Object.keys(clientErrors).length > 0) { + setErrors(clientErrors) + setMessage('Fix the highlighted fields.') + return + } + setSubmitting(true) + setErrors({}) + setMessage(null) + try { + await dataProvider('geothermal').create({ + resource: 'thing/geothermal-well', + variables: cleanDraft(draft), + }) + onCreated() + } catch (reason) { + const fe = flattenFieldErrors( + (reason as { fieldErrors?: unknown })?.fieldErrors + ) + if (fe) { + setErrors(fe) + setMessage('The server rejected some fields.') + } else { + setMessage('Could not create the well (create endpoint unavailable).') + } + } finally { + setSubmitting(false) + } + }, [draft, dataProvider, onCreated]) + + return ( + + + + + } + > + {message && ( +
+ {message} +
+ )} + + {SECTIONS.map((section) => ( + + {section.specs.map((spec) => ( + + + {errors[spec.id] && ( + + {errors[spec.id]} + + )} + + ))} + + ))} +
+ ) +} + +function FieldInput({ + spec, + draft, + setField, +}: { + spec: FieldSpec + draft: WellDraft + setField: (id: keyof WellDraft, value: unknown) => void +}) { + const value = draft[spec.id] + + if (spec.kind === 'boolean') { + return ( +
+ setField(spec.id, c === true)} + /> +
+ ) + } + + if (spec.kind === 'dropdown') { + return ( + + ) + } + + if (spec.kind === 'number') { + return ( + + setField(spec.id, e.target.value === '' ? null : Number(e.target.value)) + } + /> + ) + } + + return ( + setField(spec.id, e.target.value === '' ? null : e.target.value)} + /> + ) +} diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index e010c982..ff2dd6cc 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -7,11 +7,13 @@ import { type GridColumnSpec, } from '@/components/grid' import { Button } from '@/components/ui/button' +import { EditPanelLayout } from '@/components/editing' import { canEnterGeothermalData, flattenFieldErrors, type FieldErrors, } from './recordsGridLogic' +import { CreateWellPanel } from './CreateWellPanel' import { FIELD_SPECS, type FieldSpec, @@ -161,6 +163,7 @@ export const GeoThermalWellInventory = () => { const draft = loadDraft() return draft ? `Restored ${draft.length} saved rows` : null }) + const [createOpen, setCreateOpen] = useState(false) // Row whose location is being picked on the map (null = closed). const [pickerRow, setPickerRow] = useState(null) @@ -408,6 +411,14 @@ export const GeoThermalWellInventory = () => { className="hidden" onChange={handleUploadCsv} /> +
- + setCreateOpen(false)} + onCreated={() => { + setCreateOpen(false) + setSummary({ created: 1, failed: 0 }) + }} + /> + } + > + + {pickerRow !== null && ( Date: Mon, 27 Jul 2026 12:19:52 -0700 Subject: [PATCH 033/144] BDMS-878: move map picker into Create Well form; grid fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateWellPanel: add a "Pick on map" button in the Location section that opens the LocationPickerModal; confirming sets latitude/longitude on the draft (shown rounded). - Inventory: remove the grid's read-only Location column and its per-row map-picker wiring (now handled by the form). - Fix grid visibility: wrapping it in EditPanelLayout made it size to 0 until the panel opened (split mode only sets h-full when open). Give the layout flex-1/min-h-0 so the grid always fills and renders. - Drop GUID as a user field (server-assigned) — removed from FIELD_SPECS (kept on IWell for read). Verified live: grid renders on load without opening the form; the Create Well panel shows grouped sections with no GUID; the map picker lives in the form's Location section. Co-Authored-By: Claude Opus 4.8 --- .../geothermal/wells/CreateWellPanel.tsx | 31 ++++++++++++ src/pages/geothermal/wells/inventory.tsx | 50 +------------------ src/pages/geothermal/wells/inventoryFields.ts | 2 +- 3 files changed, 34 insertions(+), 49 deletions(-) diff --git a/src/pages/geothermal/wells/CreateWellPanel.tsx b/src/pages/geothermal/wells/CreateWellPanel.tsx index 97e16dc4..cefc7c99 100644 --- a/src/pages/geothermal/wells/CreateWellPanel.tsx +++ b/src/pages/geothermal/wells/CreateWellPanel.tsx @@ -19,10 +19,12 @@ import { FIELD_SPECS, type FieldSpec, cleanDraft, + formatCoord, validateDraft, type WellDraft, } from './inventoryFields' import { flattenFieldErrors } from './recordsGridLogic' +import { LocationPickerModal } from './LocationPickerModal' // FIELD_SPECS grouped into ordered sections for the form. const SECTIONS: { title: string; specs: FieldSpec[] }[] = (() => { @@ -57,6 +59,7 @@ export function CreateWellPanel({ onClose, onCreated }: CreateWellPanelProps) { const [errors, setErrors] = useState>({}) const [message, setMessage] = useState(null) const [submitting, setSubmitting] = useState(false) + const [pickerOpen, setPickerOpen] = useState(false) const setField = useCallback((id: keyof WellDraft, value: unknown) => { setDraft((d) => ({ ...d, [id]: value })) @@ -130,8 +133,36 @@ export function CreateWellPanel({ onClose, onCreated }: CreateWellPanelProps) { )} ))} + {section.title === 'Location' && ( + + + + )} ))} + + {pickerOpen && ( + { + setField('latitude', lat) + setField('longitude', lon) + setPickerOpen(false) + }} + onClose={() => setPickerOpen(false)} + /> + )} ) } diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index ff2dd6cc..efed8ede 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -24,7 +24,6 @@ import { type WellDraft, } from './inventoryFields' import { buildTemplateCsv, parseCsvFile } from './inventoryCsv' -import { LocationPickerModal } from './LocationPickerModal' // Build a grid column from a field spec, dispatching by kind. function specToColumn(spec: FieldSpec): GridColumnSpec { @@ -164,39 +163,6 @@ export const GeoThermalWellInventory = () => { return draft ? `Restored ${draft.length} saved rows` : null }) const [createOpen, setCreateOpen] = useState(false) - // Row whose location is being picked on the map (null = closed). - const [pickerRow, setPickerRow] = useState(null) - - // A read-only "Location" column that opens the map picker for its row. - const columns = useMemo[]>( - () => [ - ...COLUMNS, - { - id: '__location', - title: 'Pick', - group: 'Location', - tooltip: 'Pick the well location on a map', - width: 140, - getValue: (r) => - r.latitude != null && r.longitude != null - ? `${formatCoord(r.latitude)}, ${formatCoord(r.longitude)}` - : '📍 Pin on map', - onClick: (_r, rowIndex) => setPickerRow(rowIndex), - }, - ], - [] - ) - - const applyLocation = useCallback( - (rowIndex: number, lat: number, lon: number) => { - setRows((prev) => - prev.map((r, i) => - i === rowIndex ? { ...r, latitude: lat, longitude: lon } : r - ) - ) - }, - [] - ) const filledCount = useMemo( () => rows.filter((r) => !isBlankDraft(r)).length, @@ -446,6 +412,7 @@ export const GeoThermalWellInventory = () => {
{ } > { freezeColumns={1} /> - - {pickerRow !== null && ( - { - applyLocation(pickerRow, lat, lon) - setPickerRow(null) - }} - onClose={() => setPickerRow(null)} - /> - )}
) } diff --git a/src/pages/geothermal/wells/inventoryFields.ts b/src/pages/geothermal/wells/inventoryFields.ts index 6dd246b6..32253298 100644 --- a/src/pages/geothermal/wells/inventoryFields.ts +++ b/src/pages/geothermal/wells/inventoryFields.ts @@ -54,7 +54,7 @@ export const FIELD_SPECS: FieldSpec[] = [ { id: 'well_number', header: 'Well #', group: 'Identity', kind: 'text', description: 'Well number' }, { id: 'import_id', header: 'Import ID', group: 'Identity', kind: 'text', description: 'Source import identifier' }, { id: 'import_db', header: 'Import DB', group: 'Identity', kind: 'text', description: 'Source import database' }, - { id: 'guid', header: 'GUID', group: 'Identity', kind: 'text', description: 'Globally unique identifier' }, + // guid is server-assigned — not a user field. // ── Classification ── { id: 'well_class', header: 'Class', group: 'Classification', kind: 'dropdown', description: 'Well class', options: ['Oil & Gas', 'Water', 'Geothermal'] }, From ac8456b672b35db013b5ea8bb3633fe7c8d6aa41 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 12:27:52 -0700 Subject: [PATCH 034/144] BDMS-878: temperature-depth log grid + CSV importer (G1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model the core geothermal measurement — depth vs temperature — that was previously unmodeled (gap G1). - ITempDepthPoint: depth_m/ft, temp_f/c, resistance, gradient_c_km, comment (provisional names). - tempDepth.ts: grid columns + a CSV importer that maps the legacy Depth/Temp export headers (case-insensitive, with Temp/Depth_ft dups and blank/notes columns tolerated and reported), plus a template. - GeoThermalTempDepthGrid: per-well editable log grid with Upload CSV, Download template, Add rows, and a single batch "Save log" write to the well's temp-depth endpoint. Admin-gated. - GeoThermalTempDepthPicker: pick a well to open its log. - Route wells/temp-depth(/:id) + a Sandbox "Geothermal Temp-Depth" nav entry. Verified live: the messy Borderplex CSV imports — recognized columns map, and the dup/blank/formation-note columns are ignored and reported. Backend temp-depth endpoint (GET + batch POST) still needed to persist. Co-Authored-By: Claude Opus 4.8 --- docs/geothermal-gap-analysis.md | 7 + src/components/AppShell.tsx | 16 +- src/interfaces/geothermal/ITempDepthPoint.ts | 12 + src/interfaces/geothermal/index.ts | 1 + src/pages/geothermal/wells/index.ts | 2 + .../geothermal/wells/temp-depth-grid.tsx | 206 ++++++++++++++++++ .../geothermal/wells/temp-depth-picker.tsx | 90 ++++++++ src/pages/geothermal/wells/tempDepth.ts | 148 +++++++++++++ src/routes/geothermal.tsx | 4 + 9 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 src/interfaces/geothermal/ITempDepthPoint.ts create mode 100644 src/pages/geothermal/wells/temp-depth-grid.tsx create mode 100644 src/pages/geothermal/wells/temp-depth-picker.tsx create mode 100644 src/pages/geothermal/wells/tempDepth.ts diff --git a/docs/geothermal-gap-analysis.md b/docs/geothermal-gap-analysis.md index b4aa6640..c2dfc06f 100644 --- a/docs/geothermal-gap-analysis.md +++ b/docs/geothermal-gap-analysis.md @@ -33,6 +33,13 @@ below are what's missing. ## Gaps (priority-ordered) +> **Update:** G1 frontend built — a per-well **temp-depth log grid** +> (`temp-depth-grid.tsx`) with a CSV importer that handles the legacy export +> shape (the Borderplex file), a template, editable grid, and a batch "Save +> log" write. `ITempDepthPoint` models depth/temp/resistance/gradient. Still +> needs the backend temp-depth endpoint (GET + batch POST) to persist. G2/G3/G4 +> frontend also built (see commits). + ### 🔴 G1 — The core geothermal measurements are not modeled The whole purpose of the system is thermal data: diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 1891e5ea..3f46bf84 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -470,12 +470,14 @@ function AppSidebar() { const SANDBOX_GEOTHERMAL_GRID = '/geothermal/wells/records-grid' const SANDBOX_GEOTHERMAL_INVENTORY = '/geothermal/wells/inventory' +const SANDBOX_GEOTHERMAL_TEMP_DEPTH = '/geothermal/wells/temp-depth' function isSandboxPath(pathname: string): boolean { return ( pathname.startsWith('/example') || pathname.startsWith(SANDBOX_GEOTHERMAL_GRID) || - pathname.startsWith(SANDBOX_GEOTHERMAL_INVENTORY) + pathname.startsWith(SANDBOX_GEOTHERMAL_INVENTORY) || + pathname.startsWith(SANDBOX_GEOTHERMAL_TEMP_DEPTH) ) } @@ -541,6 +543,18 @@ function ExampleNavItem() { + + + + Geothermal Temp-Depth + + + diff --git a/src/interfaces/geothermal/ITempDepthPoint.ts b/src/interfaces/geothermal/ITempDepthPoint.ts new file mode 100644 index 00000000..3e8f85e5 --- /dev/null +++ b/src/interfaces/geothermal/ITempDepthPoint.ts @@ -0,0 +1,12 @@ +// One measurement in a well's temperature-depth log (the core geothermal data: +// depth vs temperature, from which thermal gradient and heat flow are derived). +// PROVISIONAL field names — confirm against the backend once the endpoint lands. +export interface ITempDepthPoint { + depth_m: number | null + depth_ft: number | null + temp_f: number | null + temp_c: number | null + resistance: number | null + gradient_c_km: number | null + comment: string | null +} diff --git a/src/interfaces/geothermal/index.ts b/src/interfaces/geothermal/index.ts index 5407360f..ac9f586a 100644 --- a/src/interfaces/geothermal/index.ts +++ b/src/interfaces/geothermal/index.ts @@ -1,2 +1,3 @@ export * from "./IWell"; export * from "./IWellRecord"; +export * from "./ITempDepthPoint"; diff --git a/src/pages/geothermal/wells/index.ts b/src/pages/geothermal/wells/index.ts index 1a35b2be..0b08336e 100644 --- a/src/pages/geothermal/wells/index.ts +++ b/src/pages/geothermal/wells/index.ts @@ -3,3 +3,5 @@ export * from "./show"; export * from "./records-grid"; export * from "./records-grid-picker"; export * from "./inventory"; +export * from "./temp-depth-grid"; +export * from "./temp-depth-picker"; diff --git a/src/pages/geothermal/wells/temp-depth-grid.tsx b/src/pages/geothermal/wells/temp-depth-grid.tsx new file mode 100644 index 00000000..50ccf564 --- /dev/null +++ b/src/pages/geothermal/wells/temp-depth-grid.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useDataProvider, useList, useParsed } from '@refinedev/core' +import type { ITempDepthPoint } from '@/interfaces/geothermal' +import { useAccessCapabilities } from '@/hooks' +import { EditableDataGrid } from '@/components/grid' +import { Button } from '@/components/ui/button' +import { canEnterGeothermalData } from './recordsGridLogic' +import { + TEMP_DEPTH_COLUMNS, + buildTempDepthTemplate, + isBlankPoint, + makeBlankPoint, + parseTempDepthCsv, +} from './tempDepth' + +const INITIAL_ROWS = 20 +const ADD_ROW_COUNT = 20 +const TEMPLATE_FILENAME = 'geothermal-temp-depth-template.csv' + +function blankPoints(n: number): ITempDepthPoint[] { + return Array.from({ length: n }, makeBlankPoint) +} + +/** + * G1 — Temperature-depth log for one geothermal well. + * + * The core geothermal measurement (depth vs temperature, from which thermal + * gradient and heat flow derive). Points come from typing/pasting into the grid + * or from a CSV upload (the legacy Depth/Temp export shape). "Save log" writes + * the whole set to the well's temp-depth endpoint in one request. Admin-gated + * per BDMS-878. PROVISIONAL — the backend endpoint isn't built yet. + */ +export const GeoThermalTempDepthGrid = () => { + const { id } = useParsed() + const { canManageGeothermal, isLoading: permLoading } = + useAccessCapabilities() + const dataProvider = useDataProvider() + const fileInputRef = useRef(null) + + const resource = `thing/geothermal-well/${id}/temp-depth` + const { query } = useList({ + resource, + dataProviderName: 'geothermal', + pagination: { pageSize: 5000, mode: 'server' }, + queryOptions: { enabled: canEnterGeothermalData(canManageGeothermal) && id != null }, + }) + + const [points, setPoints] = useState(() => + blankPoints(INITIAL_ROWS) + ) + const [saving, setSaving] = useState(false) + const [status, setStatus] = useState(null) + + useEffect(() => { + const data = query.data?.data + if (!data || data.length === 0) return + setPoints([...data, ...blankPoints(3)]) + }, [query.data]) + + const filledCount = useMemo( + () => points.filter((p) => !isBlankPoint(p)).length, + [points] + ) + + const handleAddRows = useCallback( + () => setPoints((prev) => [...prev, ...blankPoints(ADD_ROW_COUNT)]), + [] + ) + + const handleDownloadTemplate = useCallback(() => { + const blob = new Blob([buildTempDepthTemplate()], { + type: 'text/csv;charset=utf-8', + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = TEMPLATE_FILENAME + a.click() + URL.revokeObjectURL(url) + }, []) + + const handleUploadCsv = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + try { + const { points: parsed, unknownHeaders, errorCount } = + await parseTempDepthCsv(file) + setPoints([...parsed, ...blankPoints(3)]) + const notes = [`Loaded ${parsed.length} points`] + if (unknownHeaders.length > 0) { + notes.push(`ignored columns: ${unknownHeaders.join(', ')}`) + } + if (errorCount > 0) notes.push(`${errorCount} malformed rows skipped`) + setStatus(notes.join(' · ')) + } catch { + setStatus('Could not parse that CSV file.') + } + }, + [] + ) + + const handleSave = useCallback(async () => { + const toSave = points.filter((p) => !isBlankPoint(p)) + if (toSave.length === 0) return + setSaving(true) + setStatus(null) + try { + // Single batch write of the whole log (no per-point endpoint). + await dataProvider('geothermal').create({ + resource, + variables: { points: toSave }, + }) + setStatus(`Saved ${toSave.length} points`) + } catch { + setStatus('Save failed — temp-depth endpoint not available yet.') + } finally { + setSaving(false) + } + }, [points, dataProvider, resource]) + + if (permLoading) { + return ( +
+ Checking access… +
+ ) + } + if (!canEnterGeothermalData(canManageGeothermal)) { + return ( +
+ You need the Geothermal Admin role to enter temp-depth data. +
+ ) + } + + return ( +
+
+ Temp-depth log + {id != null && ( + + Well {String(id)} + + )} +
+ {status && ( + {status} + )} + + {filledCount} {filledCount === 1 ? 'point' : 'points'} + + + + + + +
+
+ + +
+ ) +} diff --git a/src/pages/geothermal/wells/temp-depth-picker.tsx b/src/pages/geothermal/wells/temp-depth-picker.tsx new file mode 100644 index 00000000..e4e8e1d4 --- /dev/null +++ b/src/pages/geothermal/wells/temp-depth-picker.tsx @@ -0,0 +1,90 @@ +import { useList, useGo } from '@refinedev/core' +import type { IWell } from '@/interfaces/geothermal' +import { useAccessCapabilities } from '@/hooks' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { canEnterGeothermalData } from './recordsGridLogic' + +function wellLabel(w: IWell): string { + const parts = [w.county, w.well_type].filter(Boolean) + const suffix = parts.length ? ` — ${parts.join(', ')}` : '' + return `${w.name ?? w.well_data_id}${suffix}` +} + +/** + * Sandbox entry point for the temp-depth log. Pick a well to open its + * temperature-depth grid. Admin-gated per BDMS-878. + */ +export const GeoThermalTempDepthPicker = () => { + const go = useGo() + const { canManageGeothermal, isLoading: permLoading } = + useAccessCapabilities() + + const { query } = useList({ + resource: 'thing/geothermal-well', + dataProviderName: 'geothermal', + pagination: { pageSize: 500, mode: 'server' }, + queryOptions: { enabled: canEnterGeothermalData(canManageGeothermal) }, + }) + + if (permLoading) { + return ( +
+ Checking access… +
+ ) + } + if (!canEnterGeothermalData(canManageGeothermal)) { + return ( +
+ You need the Geothermal Admin role to enter temp-depth data. +
+ ) + } + + const wells = (query.data?.data ?? []).filter( + (w) => w.well_data_id != null && w.well_data_id !== '' + ) + + return ( +
+
+

Temp-depth log

+

+ Pick a well to open its temperature-depth grid. +

+
+ + +
+ ) +} diff --git a/src/pages/geothermal/wells/tempDepth.ts b/src/pages/geothermal/wells/tempDepth.ts new file mode 100644 index 00000000..25b49c93 --- /dev/null +++ b/src/pages/geothermal/wells/tempDepth.ts @@ -0,0 +1,148 @@ +import Papa from 'papaparse' +import type { ITempDepthPoint } from '@/interfaces/geothermal' +import { + type CellValue, + type GridColumnSpec, +} from '@/components/grid' + +export interface TempDepthFieldSpec { + id: keyof ITempDepthPoint + header: string + description: string + kind: 'number' | 'text' +} + +// Columns of a temp-depth log. depth_ft + temp are the essential measurement; +// resistance/gradient/comment are supporting. PROVISIONAL names. +export const TEMP_DEPTH_SPECS: TempDepthFieldSpec[] = [ + { id: 'depth_m', header: 'Depth (m)', description: 'Measurement depth, meters', kind: 'number' }, + { id: 'depth_ft', header: 'Depth (ft)', description: 'Measurement depth, feet', kind: 'number' }, + { id: 'temp_f', header: 'Temp (°F)', description: 'Temperature, °F', kind: 'number' }, + { id: 'temp_c', header: 'Temp (°C)', description: 'Temperature, °C', kind: 'number' }, + { id: 'resistance', header: 'Resistance', description: 'Probe resistance', kind: 'number' }, + { id: 'gradient_c_km', header: 'Gradient (°C/km)', description: 'Thermal gradient, °C/km', kind: 'number' }, + { id: 'comment', header: 'Comment', description: 'Notes (e.g. formation, screen)', kind: 'text' }, +] + +export const TEMP_DEPTH_FIELDS: (keyof ITempDepthPoint)[] = TEMP_DEPTH_SPECS.map( + (s) => s.id +) +const NUMBER_FIELDS = new Set( + TEMP_DEPTH_SPECS.filter((s) => s.kind === 'number').map((s) => s.id) +) + +export function makeBlankPoint(): ITempDepthPoint { + return { + depth_m: null, + depth_ft: null, + temp_f: null, + temp_c: null, + resistance: null, + gradient_c_km: null, + comment: null, + } +} + +export function isBlankPoint(p: ITempDepthPoint): boolean { + return TEMP_DEPTH_FIELDS.every((k) => { + const v = p[k] + return v == null || v === '' + }) +} + +/** Grid columns for the temp-depth log. */ +export const TEMP_DEPTH_COLUMNS: GridColumnSpec[] = + TEMP_DEPTH_SPECS.map((spec) => ({ + id: spec.id, + title: spec.header, + tooltip: spec.description, + width: spec.id === 'comment' ? 260 : 120, + kind: spec.kind === 'number' ? 'number' : 'text', + editable: true, + getValue: (p) => + (p[spec.id] as CellValue) ?? (spec.kind === 'number' ? null : ''), + setValue: (p, v) => + spec.kind === 'number' + ? { ...p, [spec.id]: v as number | null } + : { ...p, [spec.id]: v ?? null }, + })) + +// CSV header → field, case-insensitive and trimmed. Accepts the legacy export +// headers (Depth_m, Temp_F, Gradient_C_km, …) and our snake_case ids. +const HEADER_ALIASES: Record = { + depth_m: 'depth_m', + 'depth (m)': 'depth_m', + depth_ft: 'depth_ft', + 'depth (ft)': 'depth_ft', + temp_f: 'temp_f', + 'temp (°f)': 'temp_f', + temp_c: 'temp_c', + temp: 'temp_c', + 'temp (°c)': 'temp_c', + resistance: 'resistance', + gradient_c_km: 'gradient_c_km', + 'gradient (°c/km)': 'gradient_c_km', + comment: 'comment', + comments: 'comment', +} + +function fieldForHeader(header: string): keyof ITempDepthPoint | undefined { + return HEADER_ALIASES[header.trim().toLowerCase()] +} + +export interface TempDepthCsvResult { + points: ITempDepthPoint[] + unknownHeaders: string[] + errorCount: number +} + +export function mapRecordsToPoints( + records: Record[], + headers: string[] +): { points: ITempDepthPoint[]; unknownHeaders: string[] } { + const unknownHeaders = headers.filter((h) => h.trim() && !fieldForHeader(h)) + const points: ITempDepthPoint[] = [] + for (const record of records) { + const point = makeBlankPoint() + let hasValue = false + for (const [header, raw] of Object.entries(record)) { + const field = fieldForHeader(header) + if (!field) continue + const value = (raw ?? '').trim() + if (value === '') continue + if (NUMBER_FIELDS.has(field)) { + const n = Number(value) + if (Number.isNaN(n)) continue + ;(point as unknown as Record)[field] = n + } else { + ;(point as unknown as Record)[field] = value + } + hasValue = true + } + if (hasValue) points.push(point) + } + return { points, unknownHeaders } +} + +export function parseTempDepthCsv(file: File): Promise { + return new Promise((resolve, reject) => { + Papa.parse>(file, { + header: true, + skipEmptyLines: 'greedy', + transformHeader: (h) => h.trim(), + complete: (results) => { + const headers = results.meta.fields ?? [] + const { points, unknownHeaders } = mapRecordsToPoints( + results.data, + headers + ) + resolve({ points, unknownHeaders, errorCount: results.errors.length }) + }, + error: (err) => reject(err), + }) + }) +} + +export function buildTempDepthTemplate(): string { + return Papa.unparse({ fields: TEMP_DEPTH_FIELDS as string[], data: [] }) +} diff --git a/src/routes/geothermal.tsx b/src/routes/geothermal.tsx index 882e5a08..19eb9343 100644 --- a/src/routes/geothermal.tsx +++ b/src/routes/geothermal.tsx @@ -7,6 +7,8 @@ import { GeoThermalRecordsGrid, GeoThermalRecordsGridPicker, GeoThermalWellInventory, + GeoThermalTempDepthGrid, + GeoThermalTempDepthPicker, } from '@/pages/geothermal' export const GeothermalRoutes = () => { @@ -19,6 +21,8 @@ export const GeothermalRoutes = () => { } /> } /> } /> + } /> + } /> } /> From 0b1bdb4e531bda47544a14d82fd0e3426b91e0e2 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 12:51:36 -0700 Subject: [PATCH 035/144] BDMS-878: auto-set datum to WGS84 when picking location on the map Map pins are WGS84, so confirming the location picker now also sets source_datum = WGS84 on the draft. Co-Authored-By: Claude Opus 4.8 --- src/pages/geothermal/wells/CreateWellPanel.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/geothermal/wells/CreateWellPanel.tsx b/src/pages/geothermal/wells/CreateWellPanel.tsx index cefc7c99..821c548b 100644 --- a/src/pages/geothermal/wells/CreateWellPanel.tsx +++ b/src/pages/geothermal/wells/CreateWellPanel.tsx @@ -158,6 +158,8 @@ export function CreateWellPanel({ onClose, onCreated }: CreateWellPanelProps) { onConfirm={(lat, lon) => { setField('latitude', lat) setField('longitude', lon) + // Map pins are WGS84 — record the datum for the picked coordinates. + setField('source_datum', 'WGS84') setPickerOpen(false) }} onClose={() => setPickerOpen(false)} From 0f37055f9b37d73de6401669c45b65b2b52af993 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 12:53:55 -0700 Subject: [PATCH 036/144] BDMS-878: collapse PLSS into one regex-validated field Replace the granular PLSS fields (township/range/section/unit/section part/footages + directions) with a single `plss` legal-description text field, validated by regex (e.g. "T24N R5W S33 SE-SE"). Keeps utm_zone separate. - IWell: drop the granular PLSS fields, add `plss: string | null`. - inventoryFields: PLSS_RE + validatePlss; one FIELD_SPEC for plss; removed the now-unused N/S and E/W option consts. Co-Authored-By: Claude Opus 4.8 --- src/interfaces/geothermal/IWell.ts | 14 ++-------- src/pages/geothermal/wells/inventoryFields.ts | 26 ++++++++----------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/src/interfaces/geothermal/IWell.ts b/src/interfaces/geothermal/IWell.ts index 90a02fe9..926a875e 100644 --- a/src/interfaces/geothermal/IWell.ts +++ b/src/interfaces/geothermal/IWell.ts @@ -46,18 +46,8 @@ export interface IWell { county: string | null state: string | null - // ── PLSS (G2) ── - township: number | null - township_dir: string | null - range: number | null - range_dir: string | null - section: number | null - unit_letter: string | null - section_part: string | null - footage_ns: number | null - footage_ns_dir: string | null - footage_ew: number | null - footage_ew_dir: string | null + // ── PLSS (G2) ── single legal-description string, e.g. "T24N R5W S33 SE-SE" + plss: string | null utm_zone: string | null // ── Location accuracy (G2) ── diff --git a/src/pages/geothermal/wells/inventoryFields.ts b/src/pages/geothermal/wells/inventoryFields.ts index 32253298..3b49d62d 100644 --- a/src/pages/geothermal/wells/inventoryFields.ts +++ b/src/pages/geothermal/wells/inventoryFields.ts @@ -20,15 +20,21 @@ export interface FieldSpec { validate?: (value: unknown) => string | undefined } -const NS = ['N', 'S'] -const EW = ['E', 'W'] - /** Structured API number, e.g. 30-039-05212 (state-county-well). */ export function validateApi(value: unknown): string | undefined { const s = String(value).trim() return /^\d{2}-\d{3}-\d{4,5}$/.test(s) ? undefined : 'Format: SS-CCC-NNNNN' } +// PLSS legal description in one field: township + N/S, range + E/W, section, +// optional aliquot part. Examples: "T24N R5W S33", "24N 5W 33 SE-SE". +const PLSS_RE = + /^\s*T?\s*\d{1,3}\s*[NS]\s+R?\s*\d{1,3}\s*[EW]\s+(?:S|SEC\.?)?\s*\d{1,2}(?:\s+[A-Z0-9/-]+)?\s*$/i + +export function validatePlss(value: unknown): string | undefined { + return PLSS_RE.test(String(value)) ? undefined : 'Format: T24N R5W S33 [part]' +} + export interface ParsedApi { stateCode: string countyCode: string @@ -87,18 +93,8 @@ export const FIELD_SPECS: FieldSpec[] = [ { id: 'county', header: 'County', group: 'Location', kind: 'text', description: 'County — server-derived from lat/lon when blank' }, { id: 'state', header: 'State', group: 'Location', kind: 'text', description: 'State — server-derived from lat/lon when blank (default NM)' }, - // ── PLSS ── - { id: 'township', header: 'Township', group: 'PLSS', kind: 'number', description: 'PLSS township' }, - { id: 'township_dir', header: 'T dir', group: 'PLSS', kind: 'dropdown', description: 'Township direction', options: NS }, - { id: 'range', header: 'Range', group: 'PLSS', kind: 'number', description: 'PLSS range' }, - { id: 'range_dir', header: 'R dir', group: 'PLSS', kind: 'dropdown', description: 'Range direction', options: EW }, - { id: 'section', header: 'Section', group: 'PLSS', kind: 'number', description: 'PLSS section' }, - { id: 'unit_letter', header: 'Unit', group: 'PLSS', kind: 'text', description: 'PLSS unit letter' }, - { id: 'section_part', header: 'Sec part', group: 'PLSS', kind: 'text', description: 'Section part (e.g. SE-SE)' }, - { id: 'footage_ns', header: 'Ftg NS', group: 'PLSS', kind: 'number', description: 'Footage north/south' }, - { id: 'footage_ns_dir', header: 'NS dir', group: 'PLSS', kind: 'dropdown', description: 'Footage NS direction', options: NS }, - { id: 'footage_ew', header: 'Ftg EW', group: 'PLSS', kind: 'number', description: 'Footage east/west' }, - { id: 'footage_ew_dir', header: 'EW dir', group: 'PLSS', kind: 'dropdown', description: 'Footage EW direction', options: EW }, + // ── PLSS ── one legal-description string, regex-validated. + { id: 'plss', header: 'PLSS', group: 'PLSS', kind: 'text', description: 'PLSS legal description, e.g. T24N R5W S33 SE-SE', validate: validatePlss }, { id: 'utm_zone', header: 'UTM zone', group: 'PLSS', kind: 'text', description: 'UTM zone' }, // ── Location accuracy ── From 11cb8d9d5afaafe5970443b1e172945fc8a2c13a Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 12:58:42 -0700 Subject: [PATCH 037/144] BDMS-878: temp-depth chart + hover validation errors with guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TempDepthChart: a d3 temperature-depth plot (temp on X, depth on Y increasing downward) drawn from the log's points; shown beside the grid on the temp-depth page and updating live as points change. - EditableDataGrid: hovering an errored cell now shows its validation message in the cursor tooltip (reuses the header-tooltip mechanism). - Validation messages are now actionable guidance: required = " is required — enter a value."; API and PLSS give the expected format with an example. Verified live: importing a temp-depth CSV renders the profile curve. Co-Authored-By: Claude Opus 4.8 --- src/components/grid/EditableDataGrid.tsx | 13 +- src/pages/geothermal/wells/TempDepthChart.tsx | 113 ++++++++++++++++++ src/pages/geothermal/wells/inventoryFields.ts | 10 +- .../geothermal/wells/temp-depth-grid.tsx | 26 ++-- 4 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 src/pages/geothermal/wells/TempDepthChart.tsx diff --git a/src/components/grid/EditableDataGrid.tsx b/src/components/grid/EditableDataGrid.tsx index 7fc74633..91822a22 100644 --- a/src/components/grid/EditableDataGrid.tsx +++ b/src/components/grid/EditableDataGrid.tsx @@ -141,12 +141,19 @@ export function EditableDataGrid({ const onItemHovered = useCallback( (args: GridMouseEventArgs) => { - const text = - (args.kind === 'header' && columns[args.location[0]]?.tooltip) || null + const [col, row] = args.location + let text: string | null = null + if (args.kind === 'header') { + text = columns[col]?.tooltip ?? null + } else if (args.kind === 'cell') { + // On an errored cell, show its validation message (guidance). + const colId = columns[col]?.id + text = (colId && cellErrors?.(row)?.[colId]) || null + } hoverTextRef.current = text if (!text) setTooltip(null) }, - [columns] + [columns, cellErrors] ) const onMouseMove = useCallback((e: React.MouseEvent) => { diff --git a/src/pages/geothermal/wells/TempDepthChart.tsx b/src/pages/geothermal/wells/TempDepthChart.tsx new file mode 100644 index 00000000..de11c44c --- /dev/null +++ b/src/pages/geothermal/wells/TempDepthChart.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react' +import { extent, line, scaleLinear } from 'd3' +import type { ITempDepthPoint } from '@/interfaces/geothermal' + +const W = 360 +const H = 460 +const M = { top: 16, right: 16, bottom: 40, left: 52 } + +/** + * Temperature-depth plot: temperature on X, depth on Y (increasing downward). + * Renders the log's points as a connected line + dots. Pure SVG + d3 scales. + */ +export function TempDepthChart({ points }: { points: ITempDepthPoint[] }) { + const data = useMemo( + () => + points + .filter((p) => p.depth_ft != null && p.temp_c != null) + .map((p) => ({ depth: p.depth_ft as number, temp: p.temp_c as number })) + .sort((a, b) => a.depth - b.depth), + [points] + ) + + if (data.length < 2) { + return ( +
+ Add depth (ft) and temp (°C) to plot the temperature-depth profile. +
+ ) + } + + const tExtent = extent(data, (d) => d.temp) as [number, number] + const dExtent = extent(data, (d) => d.depth) as [number, number] + const x = scaleLinear().domain(tExtent).nice().range([M.left, W - M.right]) + // Depth increases downward: min depth at top, max at bottom. + const y = scaleLinear().domain(dExtent).nice().range([M.top, H - M.bottom]) + const path = + line<{ depth: number; temp: number }>() + .x((d) => x(d.temp)) + .y((d) => y(d.depth))(data) ?? '' + + return ( + + {/* Y axis (depth) */} + {y.ticks(6).map((t) => ( + + + + {t} + + + ))} + {/* X axis (temp) */} + {x.ticks(5).map((t) => ( + + + {t} + + + ))} + + + Temp (°C) + + + Depth (ft) + + + + {data.map((d, i) => ( + + ))} + + ) +} diff --git a/src/pages/geothermal/wells/inventoryFields.ts b/src/pages/geothermal/wells/inventoryFields.ts index 3b49d62d..2d7e52a3 100644 --- a/src/pages/geothermal/wells/inventoryFields.ts +++ b/src/pages/geothermal/wells/inventoryFields.ts @@ -23,7 +23,9 @@ export interface FieldSpec { /** Structured API number, e.g. 30-039-05212 (state-county-well). */ export function validateApi(value: unknown): string | undefined { const s = String(value).trim() - return /^\d{2}-\d{3}-\d{4,5}$/.test(s) ? undefined : 'Format: SS-CCC-NNNNN' + return /^\d{2}-\d{3}-\d{4,5}$/.test(s) + ? undefined + : 'Invalid API. Use SS-CCC-NNNNN (2-3-4/5 digits), e.g. 30-039-05212.' } // PLSS legal description in one field: township + N/S, range + E/W, section, @@ -32,7 +34,9 @@ const PLSS_RE = /^\s*T?\s*\d{1,3}\s*[NS]\s+R?\s*\d{1,3}\s*[EW]\s+(?:S|SEC\.?)?\s*\d{1,2}(?:\s+[A-Z0-9/-]+)?\s*$/i export function validatePlss(value: unknown): string | undefined { - return PLSS_RE.test(String(value)) ? undefined : 'Format: T24N R5W S33 [part]' + return PLSS_RE.test(String(value)) + ? undefined + : 'Invalid PLSS. Use township-range-section, e.g. T24N R5W S33 SE-SE.' } export interface ParsedApi { @@ -134,7 +138,7 @@ export function validateDraft(r: WellDraft): Record { const v = r[spec.id] const empty = v == null || v === '' if (spec.required && empty) { - errors[spec.id] = 'Required' + errors[spec.id] = `${spec.header} is required — enter a value.` continue } if (spec.validate && !empty) { diff --git a/src/pages/geothermal/wells/temp-depth-grid.tsx b/src/pages/geothermal/wells/temp-depth-grid.tsx index 50ccf564..2d7a7e4a 100644 --- a/src/pages/geothermal/wells/temp-depth-grid.tsx +++ b/src/pages/geothermal/wells/temp-depth-grid.tsx @@ -12,6 +12,7 @@ import { makeBlankPoint, parseTempDepthCsv, } from './tempDepth' +import { TempDepthChart } from './TempDepthChart' const INITIAL_ROWS = 20 const ADD_ROW_COUNT = 20 @@ -192,15 +193,22 @@ export const GeoThermalTempDepthGrid = () => {
- +
+
+ +
+
+ +
+
) } From 08f90bb3e6358da7e287eac7cf7b66b992c34dc4 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 13:04:46 -0700 Subject: [PATCH 038/144] BDMS-878: plot any measurement vs depth (selectable series) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the temp-depth chart to plot depth against a chosen measurement, with a "Depth vs …" selector. - tempDepth: PLOT_FIELDS lists the plottable series (Temp °C, Temp °F, Resistance, Gradient °C/km). - TempDepthChart: takes xField/xLabel and plots that field vs depth (depth on Y, increasing downward) instead of a hard-coded temp. - temp-depth page: a Select above the chart switches the plotted series; the chart re-renders live. Verified live: switching Temp → Gradient re-plots depth vs gradient. Co-Authored-By: Claude Opus 4.8 --- src/pages/geothermal/wells/TempDepthChart.tsx | 35 ++++++++++++------ .../geothermal/wells/temp-depth-grid.tsx | 37 ++++++++++++++++++- src/pages/geothermal/wells/tempDepth.ts | 8 ++++ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/pages/geothermal/wells/TempDepthChart.tsx b/src/pages/geothermal/wells/TempDepthChart.tsx index de11c44c..1c639ec1 100644 --- a/src/pages/geothermal/wells/TempDepthChart.tsx +++ b/src/pages/geothermal/wells/TempDepthChart.tsx @@ -7,35 +7,46 @@ const H = 460 const M = { top: 16, right: 16, bottom: 40, left: 52 } /** - * Temperature-depth plot: temperature on X, depth on Y (increasing downward). + * Depth profile: a chosen measurement on X, depth on Y (increasing downward). * Renders the log's points as a connected line + dots. Pure SVG + d3 scales. */ -export function TempDepthChart({ points }: { points: ITempDepthPoint[] }) { +export function TempDepthChart({ + points, + xField, + xLabel, +}: { + points: ITempDepthPoint[] + xField: keyof ITempDepthPoint + xLabel: string +}) { const data = useMemo( () => points - .filter((p) => p.depth_ft != null && p.temp_c != null) - .map((p) => ({ depth: p.depth_ft as number, temp: p.temp_c as number })) + .filter((p) => p.depth_ft != null && p[xField] != null) + .map((p) => ({ + depth: p.depth_ft as number, + value: p[xField] as number, + })) .sort((a, b) => a.depth - b.depth), - [points] + [points, xField] ) if (data.length < 2) { return (
- Add depth (ft) and temp (°C) to plot the temperature-depth profile. + Add depth (ft) and {xLabel} to plot the profile.
) } - const tExtent = extent(data, (d) => d.temp) as [number, number] + const vExtent = extent(data, (d) => d.value) as [number, number] const dExtent = extent(data, (d) => d.depth) as [number, number] - const x = scaleLinear().domain(tExtent).nice().range([M.left, W - M.right]) + const x = scaleLinear().domain(vExtent).nice().range([M.left, W - M.right]) // Depth increases downward: min depth at top, max at bottom. const y = scaleLinear().domain(dExtent).nice().range([M.top, H - M.bottom]) const path = - line<{ depth: number; temp: number }>() - .x((d) => x(d.temp)) + line<{ depth: number; value: number }>() + .x((d) => x(d.value)) .y((d) => y(d.depth))(data) ?? '' return ( @@ -86,7 +97,7 @@ export function TempDepthChart({ points }: { points: ITempDepthPoint[] }) { textAnchor="middle" className="fill-muted-foreground text-[10px]" > - Temp (°C) + {xLabel} ( { ) const [saving, setSaving] = useState(false) const [status, setStatus] = useState(null) + const [plotField, setPlotField] = useState('temp_c') useEffect(() => { const data = query.data?.data @@ -205,8 +214,32 @@ export const GeoThermalTempDepthGrid = () => { freezeColumns={1} />
-
- +
+
+ Depth vs + +
+ f.id === plotField)?.label ?? plotField + } + />
diff --git a/src/pages/geothermal/wells/tempDepth.ts b/src/pages/geothermal/wells/tempDepth.ts index 25b49c93..35dbbcab 100644 --- a/src/pages/geothermal/wells/tempDepth.ts +++ b/src/pages/geothermal/wells/tempDepth.ts @@ -27,6 +27,14 @@ export const TEMP_DEPTH_SPECS: TempDepthFieldSpec[] = [ export const TEMP_DEPTH_FIELDS: (keyof ITempDepthPoint)[] = TEMP_DEPTH_SPECS.map( (s) => s.id ) + +// Measurements that can be plotted against depth. +export const PLOT_FIELDS: { id: keyof ITempDepthPoint; label: string }[] = [ + { id: 'temp_c', label: 'Temp (°C)' }, + { id: 'temp_f', label: 'Temp (°F)' }, + { id: 'resistance', label: 'Resistance' }, + { id: 'gradient_c_km', label: 'Gradient (°C/km)' }, +] const NUMBER_FIELDS = new Set( TEMP_DEPTH_SPECS.filter((s) => s.kind === 'number').map((s) => s.id) ) From 5c5741c0390fe8c934df598c2835892ba7f76626 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 27 Jul 2026 14:20:07 -0700 Subject: [PATCH 039/144] BDMS-878: edit an existing grid row via the side panel Generalize the Create Well panel into a dual-mode WellFormPanel (create a new well, or edit an existing grid row) and add a per-row Edit entry. - WellFormPanel: takes title/submitLabel/initial/onSubmit; inits the draft from `initial`, client-validates, then calls onSubmit (mount it keyed by target to reset). Replaces CreateWellPanel. - Inventory: a leading read-only "Edit" grid column opens the panel for that row (prefilled); the toolbar "Create Well" opens it blank. Create submits POST + maps server field errors; edit writes the draft back to the grid row. tsc + prod build clean. (Live-verify after a dev-server restart: HMR got wedged locally by deleting the old CreateWellPanel.tsx mid-session.) Co-Authored-By: Claude Opus 4.8 --- ...{CreateWellPanel.tsx => WellFormPanel.tsx} | 85 ++++++++++-------- src/pages/geothermal/wells/inventory.tsx | 90 ++++++++++++++++--- 2 files changed, 124 insertions(+), 51 deletions(-) rename src/pages/geothermal/wells/{CreateWellPanel.tsx => WellFormPanel.tsx} (75%) diff --git a/src/pages/geothermal/wells/CreateWellPanel.tsx b/src/pages/geothermal/wells/WellFormPanel.tsx similarity index 75% rename from src/pages/geothermal/wells/CreateWellPanel.tsx rename to src/pages/geothermal/wells/WellFormPanel.tsx index 821c548b..59c7a61c 100644 --- a/src/pages/geothermal/wells/CreateWellPanel.tsx +++ b/src/pages/geothermal/wells/WellFormPanel.tsx @@ -1,5 +1,4 @@ import { useCallback, useState } from 'react' -import { useDataProvider } from '@refinedev/core' import { EditPanel, EditPanelField, @@ -18,12 +17,10 @@ import { import { FIELD_SPECS, type FieldSpec, - cleanDraft, formatCoord, validateDraft, type WellDraft, } from './inventoryFields' -import { flattenFieldErrors } from './recordsGridLogic' import { LocationPickerModal } from './LocationPickerModal' // FIELD_SPECS grouped into ordered sections for the form. @@ -40,22 +37,36 @@ const SECTIONS: { title: string; specs: FieldSpec[] }[] = (() => { return order.map((title) => ({ title, specs: byGroup.get(title)! })) })() -interface CreateWellPanelProps { +export interface SubmitResult { + ok: boolean + fieldErrors?: Record + message?: string +} + +interface WellFormPanelProps { + title: string + submitLabel: string + /** Starting values (a blank draft to create, or an existing row to edit). */ + initial: WellDraft + /** Persist/apply the draft; return ok or per-field errors. */ + onSubmit: (draft: WellDraft) => Promise onClose: () => void - /** Called after a well is successfully created. */ - onCreated: () => void } /** - * Single-well entry form for the geothermal inventory — a slide-out panel - * (modeled on the Data Grid example's Create Well panel) built from FIELD_SPECS. - * Client-validates, then POSTs one well through the geothermal provider; server - * field errors surface under the offending inputs. (No bulk add here — the grid - * covers batch entry.) + * Slide-out well form (built from FIELD_SPECS) used to create a new well or + * edit an existing grid row. Client-validates, then hands the draft to + * `onSubmit`; server/field errors surface under the offending inputs. Mount it + * keyed by the target so it re-initializes from `initial`. */ -export function CreateWellPanel({ onClose, onCreated }: CreateWellPanelProps) { - const dataProvider = useDataProvider() - const [draft, setDraft] = useState({}) +export function WellFormPanel({ + title, + submitLabel, + initial, + onSubmit, + onClose, +}: WellFormPanelProps) { + const [draft, setDraft] = useState(initial) const [errors, setErrors] = useState>({}) const [message, setMessage] = useState(null) const [submitting, setSubmitting] = useState(false) @@ -65,7 +76,7 @@ export function CreateWellPanel({ onClose, onCreated }: CreateWellPanelProps) { setDraft((d) => ({ ...d, [id]: value })) }, []) - const handleCreate = useCallback(async () => { + const handleSubmit = useCallback(async () => { const clientErrors = validateDraft(draft) if (Object.keys(clientErrors).length > 0) { setErrors(clientErrors) @@ -75,38 +86,32 @@ export function CreateWellPanel({ onClose, onCreated }: CreateWellPanelProps) { setSubmitting(true) setErrors({}) setMessage(null) - try { - await dataProvider('geothermal').create({ - resource: 'thing/geothermal-well', - variables: cleanDraft(draft), - }) - onCreated() - } catch (reason) { - const fe = flattenFieldErrors( - (reason as { fieldErrors?: unknown })?.fieldErrors - ) - if (fe) { - setErrors(fe) - setMessage('The server rejected some fields.') - } else { - setMessage('Could not create the well (create endpoint unavailable).') - } - } finally { - setSubmitting(false) + const result = await onSubmit(draft) + if (result.ok) { + onClose() + return } - }, [draft, dataProvider, onCreated]) + if (result.fieldErrors) setErrors(result.fieldErrors) + setMessage(result.message ?? 'Could not save.') + setSubmitting(false) + }, [draft, onSubmit, onClose]) return ( - - } @@ -228,7 +233,9 @@ function FieldInput({ setField(spec.id, e.target.value === '' ? null : e.target.value)} + onChange={(e) => + setField(spec.id, e.target.value === '' ? null : e.target.value) + } /> ) } diff --git a/src/pages/geothermal/wells/inventory.tsx b/src/pages/geothermal/wells/inventory.tsx index efed8ede..0900cf0f 100644 --- a/src/pages/geothermal/wells/inventory.tsx +++ b/src/pages/geothermal/wells/inventory.tsx @@ -13,7 +13,7 @@ import { flattenFieldErrors, type FieldErrors, } from './recordsGridLogic' -import { CreateWellPanel } from './CreateWellPanel' +import { WellFormPanel, type SubmitResult } from './WellFormPanel' import { FIELD_SPECS, type FieldSpec, @@ -162,7 +162,61 @@ export const GeoThermalWellInventory = () => { const draft = loadDraft() return draft ? `Restored ${draft.length} saved rows` : null }) - const [createOpen, setCreateOpen] = useState(false) + // The side-panel form: create a new well, or edit an existing grid row. + const [panel, setPanel] = useState< + { mode: 'create' } | { mode: 'edit'; index: number } | null + >(null) + + // Grid columns with a leading read-only "Edit" cell that opens the panel. + const columns = useMemo[]>( + () => [ + { + id: '__edit', + title: 'Edit', + group: 'Identity', + tooltip: 'Edit this row in the side panel', + width: 70, + getValue: () => '✎ Edit', + onClick: (_r, index) => setPanel({ mode: 'edit', index }), + }, + ...COLUMNS, + ], + [] + ) + + const createSubmit = useCallback( + async (draft: WellDraft): Promise => { + try { + await dataProvider('geothermal').create({ + resource: 'thing/geothermal-well', + variables: cleanDraft(draft), + }) + setSummary({ created: 1, failed: 0 }) + return { ok: true } + } catch (reason) { + const fe = flattenFieldErrors( + (reason as { fieldErrors?: unknown })?.fieldErrors + ) + return { + ok: false, + fieldErrors: fe, + message: fe + ? 'The server rejected some fields.' + : 'Could not create the well (create endpoint unavailable).', + } + } + }, + [dataProvider] + ) + + const editSubmit = useCallback( + (index: number) => + async (draft: WellDraft): Promise => { + setRows((prev) => prev.map((r, i) => (i === index ? draft : r))) + return { ok: true } + }, + [] + ) const filledCount = useMemo( () => rows.filter((r) => !isBlankDraft(r)).length, @@ -380,7 +434,7 @@ export const GeoThermalWellInventory = () => { - - - } - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -