diff --git a/package-lock.json b/package-lock.json index de8833d0..c2855d0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ocotillo-ui", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ocotillo-ui", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@base-ui-components/react": "^1.0.0-alpha.6", "@casl/ability": "^6.7.3", @@ -36,6 +36,7 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-query": "^5.67.3", + "@tanstack/react-table": "^8.21.3", "@tiptap/extension-color": "^2.9.1", "@tiptap/pm": "^2.9.1", "@tiptap/react": "^2.9.1", @@ -8020,6 +8021,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index f426e70a..a6f219b4 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-query": "^5.67.3", + "@tanstack/react-table": "^8.21.3", "@tiptap/extension-color": "^2.9.1", "@tiptap/pm": "^2.9.1", "@tiptap/react": "^2.9.1", @@ -145,4 +146,4 @@ "refine": { "projectId": "wCqQ1f-agx0FN-70pXIr" } -} \ No newline at end of file +} diff --git a/src/components/ContactShow/AssociatedSiteReportActions.tsx b/src/components/ContactShow/AssociatedSiteReportActions.tsx new file mode 100644 index 00000000..6dfae3dd --- /dev/null +++ b/src/components/ContactShow/AssociatedSiteReportActions.tsx @@ -0,0 +1,140 @@ +import { pdf } from '@react-pdf/renderer' +import { useGo, useNotification } from '@refinedev/core' +import { FileTextIcon, Loader2Icon, UserIcon } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +// Imported by path, not through '@/components': that barrel re-exports +// ContactShow, so going through it puts this module in a cycle with the table +// that renders it, and the cell reads as undefined at render time. +import { FieldCompilationNotesPdf } from '@/components/pdf/FieldCompilationNotesPdf' +import { Button } from '@/components/ui/button' +import { useAccessCapabilities, useWellPdfData } from '@/hooks' +import type { AssociatedSiteRow } from '@/hooks/useAssociatedSiteRows' +import { buildPdfFilename } from '@/utils' + +/** + * Per-row report actions for the associated-sites table. + * + * Field builds the well's field sheet here, so the reader does not lose the + * contact page to fetch one. Owner hands off to the chemistry report exporter, + * which owns the year picker and section toggles. + * + * A field sheet needs the well's full payload — observations, assets, + * contacts, sensors, sample — so the fetch is armed by the click rather than + * on mount. Arming on mount would pull all of that for every row on the page. + */ +export const AssociatedSiteReportActions = ({ + row, +}: { + row: AssociatedSiteRow +}) => { + const go = useGo() + const { open: notify } = useNotification() + const { canManageAmp, canViewConfidential } = useAccessCapabilities() + + const [armed, setArmed] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) + const generatedFor = useRef(null) + + const { well, observations, assets, contacts, sensorDeployments, isLoading } = + useWellPdfData({ thingId: armed ? row.id : undefined }) + + useEffect(() => { + if (!armed || isLoading || !well?.id) return + // The effect re-runs as each underlying query settles; the ref keeps one + // click from producing several downloads. + if (generatedFor.current === String(row.id)) return + generatedFor.current = String(row.id) + + const generate = async () => { + setIsGenerating(true) + try { + const filename = buildPdfFilename(well) + // Same renderer the bulk field-sheet export uses, so a sheet pulled + // from here matches one pulled from a batch run. standalone defaults + // true, which wraps this single well in its own document. + const blob = await pdf( + + ).toBlob() + + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename.endsWith('.pdf') + ? filename + : `${filename}.pdf` + anchor.click() + URL.revokeObjectURL(url) + + notify?.({ + message: 'PDF generated successfully', + type: 'success', + description: anchor.download, + }) + } catch (error) { + console.error(error) + notify?.({ message: 'PDF Generation Failed', type: 'error' }) + } finally { + setIsGenerating(false) + setArmed(false) + generatedFor.current = null + } + } + + generate() + }, [ + armed, + isLoading, + well, + assets, + contacts, + observations, + sensorDeployments, + canViewConfidential, + notify, + row.id, + ]) + + const isBusy = armed || isGenerating + const disabled = !canManageAmp || isBusy + + return ( +
+ + +
+ ) +} diff --git a/src/components/ContactShow/AssociatedSiteSummaryCard.tsx b/src/components/ContactShow/AssociatedSiteSummaryCard.tsx deleted file mode 100644 index 17608f2c..00000000 --- a/src/components/ContactShow/AssociatedSiteSummaryCard.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useMemo } from 'react' -import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material' -import { Link } from '@refinedev/core' -import { useOne } from '@refinedev/core' -import { useDataGrid } from '@refinedev/mui' -import type { IThing, IWell, IObservation, ISample } from '@/interfaces/ocotillo' -import { formatAppDateTime } from '@/utils' - -type AssociatedSiteSummaryCardProps = { - thing: IThing -} - -const getShowPath = (thing: IThing) => { - const type = (thing.thing_type || '').toLowerCase() - if (type === 'water well' || type === 'geothermal well') { - return `/ocotillo/well/show/${thing.id}` - } - if (type === 'spring') { - return `/ocotillo/spring/show/${thing.id}` - } - return `/ocotillo/well/show/${thing.id}` -} - -export const AssociatedSiteSummaryCard = ({ thing }: AssociatedSiteSummaryCardProps) => { - const { result: well, query: wellQuery } = useOne({ - resource: 'ocotillo.thing-well', - id: thing.id, - dataProviderName: 'ocotillo', - queryOptions: { - enabled: !!thing.id, - }, - }) - - const { - dataGridProps: { rows: observations }, - } = useDataGrid({ - resource: 'observation/groundwater-level', - dataProviderName: 'ocotillo', - meta: { - params: { - thing_id: thing.id, - }, - }, - pagination: { - pageSize: 5, - mode: 'server', - }, - sorters: { - initial: [{ field: 'observation_datetime', order: 'desc' }], - }, - queryOptions: { - enabled: !!thing.id, - gcTime: 10 * 60 * 1000, - staleTime: 5 * 60 * 1000, - }, - }) - - const sampleId = useMemo(() => { - const sorted = (observations ?? []) - .filter((o: IObservation) => o.observation_datetime) - .sort( - (a: IObservation, b: IObservation) => - new Date(b.observation_datetime!).getTime() - - new Date(a.observation_datetime!).getTime() - ) - return sorted[0]?.sample_id ?? null - }, [observations]) - - const { result: sample } = useOne({ - resource: 'ocotillo.sample', - id: sampleId, - dataProviderName: 'ocotillo', - queryOptions: { - enabled: !!sampleId, - }, - }) - - const latestObs = useMemo(() => { - const sorted = (observations ?? []) - .filter((o: IObservation) => o.observation_datetime) - .sort( - (a: IObservation, b: IObservation) => - new Date(b.observation_datetime!).getTime() - - new Date(a.observation_datetime!).getTime() - ) - return sorted[0] as IObservation | undefined - }, [observations]) - - const lastCheckedBy = - sample?.contact?.name && sample?.contact?.organization - ? `${sample.contact.name} (${sample.contact.organization})` - : sample?.contact?.name ?? sample?.sampler_name ?? null - - const lastCheckedDate = - sample?.field_event?.event_date ?? - sample?.sample_date ?? - latestObs?.observation_datetime ?? - null - - const depthToWater = latestObs?.depth_to_water_bgs ?? null - - const isLoading = wellQuery?.isLoading === true - - if (isLoading) { - return - } - - const coords = (well ?? thing)?.current_location?.geometry?.coordinates as - | [number, number, number?] - | undefined - const [lon, lat] = coords ?? [] - const locProps = (well ?? thing)?.current_location?.properties - const elevation = locProps?.elevation - const elevationUnit = locProps?.elevation_unit ?? 'ft' - - return ( - - - - - {thing.name || `Site ${thing.id}`} - - - - - - - - - - - - {lat != null && lon != null && ( - - )} - - - - ) -} - -const DetailRow = ({ - label, - value, -}: { - label: string - value: string -}) => ( - - - {label}: - - {value} - -) - -const LoadingCard = ({ siteName }: { siteName?: string }) => ( - - - - - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - -) diff --git a/src/components/ContactShow/AssociatedSitesDetailsCard.tsx b/src/components/ContactShow/AssociatedSitesDetailsCard.tsx index c47ab8ce..8451d524 100644 --- a/src/components/ContactShow/AssociatedSitesDetailsCard.tsx +++ b/src/components/ContactShow/AssociatedSitesDetailsCard.tsx @@ -1,14 +1,108 @@ -import { Box, Stack, Typography } from '@mui/material' import { Place } from '@mui/icons-material' -import type { IThing } from '@/interfaces/ocotillo' -import { AssociatedSiteSummaryCard } from './AssociatedSiteSummaryCard' +import { Box, Typography } from '@mui/material' +import { + type ColumnDef, + type SortingState, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useMemo, useState } from 'react' +import { AssociatedSiteReportActions } from '@/components/ContactShow/AssociatedSiteReportActions' +import { DataTable, DataTableColumnHeader } from '@/components/DataTable' +import type { AssociatedSiteRow } from '@/hooks/useAssociatedSiteRows' +import { formatAppDateTime } from '@/utils' + +// Depths come back at full float precision (272.08212570033396), which is +// unreadable in a table column. +const measure = ( + value: number | null, + unit: string | null, + fallback: string +) => (value != null ? `${value.toFixed(1)} ${unit ?? ''}`.trim() : fallback) export const AssociatedSitesDetailsCard = ({ - things, + rows, }: { - things?: IThing[] | null + rows: AssociatedSiteRow[] }) => { - const items = things ?? [] + const [sorting, setSorting] = useState([]) + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.name} + ), + }, + { + accessorKey: 'lastCheckedDate', + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.lastCheckedDate + ? formatAppDateTime(row.original.lastCheckedDate) + : 'No data', + }, + { + accessorKey: 'lastCheckedBy', + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.lastCheckedBy ?? 'Unknown', + }, + { + accessorKey: 'depthToWater', + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.depthToWater != null + ? `${row.original.depthToWater.toFixed(1)} ft bgs` + : 'No measurements', + }, + { + accessorKey: 'wellDepth', + header: ({ column }) => ( + + ), + cell: ({ row }) => + measure(row.original.wellDepth, row.original.wellDepthUnit, 'N/A'), + }, + { + accessorKey: 'holeDepth', + header: ({ column }) => ( + + ), + cell: ({ row }) => + measure(row.original.holeDepth, row.original.holeDepthUnit, 'N/A'), + }, + { + id: 'report', + header: 'Report', + enableSorting: false, + cell: ({ row }) => , + }, + ], + [] + ) + + const table = useReactTable({ + data: rows, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => String(row.id), + }) + + const isLoading = rows.some((row) => row.isLoading) return ( @@ -18,17 +112,13 @@ export const AssociatedSitesDetailsCard = ({ Associated Sites - {items.length === 0 ? ( - - No associated sites. - - ) : ( - - {items.map((thing) => ( - - ))} - - )} + row.showPath} + skeletonRowCount={3} + /> ) } diff --git a/src/components/ContactShow/AssociatedSitesMapCard.tsx b/src/components/ContactShow/AssociatedSitesMapCard.tsx index f8543a5e..d46257c6 100644 --- a/src/components/ContactShow/AssociatedSitesMapCard.tsx +++ b/src/components/ContactShow/AssociatedSitesMapCard.tsx @@ -2,59 +2,48 @@ import { useEffect, useRef, useState } from 'react' import { Box, Paper, Typography } from '@mui/material' import { Map } from '@mui/icons-material' import { Layer, MapRef, Source } from 'react-map-gl/maplibre' -import { Link } from '@refinedev/core' -import type { IThing } from '@/interfaces/ocotillo' +import type { AssociatedSiteRow } from '@/hooks/useAssociatedSiteRows' import { MapComponent } from '@/components' +import { settings } from '@/settings' import { MAP_LAYER_COLORS, MAP_SYMBOL_STROKE_COLOR, } from '@/constants/mapColors' type AssociatedSitesMapCardProps = { - things?: IThing[] | null + /** + * Enriched rows rather than raw things: the contact endpoint returns its + * things without a current_location, so nothing was ever mappable from them. + * The per-well records these rows are built from carry the coordinates. + */ + rows?: AssociatedSiteRow[] | null } -const getShowPath = (thingType: string, id: number) => { - const type = (thingType || '').toLowerCase() - if (type === 'water well' || type === 'geothermal well') { - return `/ocotillo/well/show/${id}` - } - if (type === 'spring') { - return `/ocotillo/spring/show/${id}` - } - return `/ocotillo/well/show/${id}` -} - -export const AssociatedSitesMapCard = ({ things }: AssociatedSitesMapCardProps) => { +export const AssociatedSitesMapCard = ({ rows }: AssociatedSitesMapCardProps) => { const mapRef = useRef(null) const containerRef = useRef(null) const [popupContent, setPopupContent] = useState<{ coordinates: [number, number] name: string - id: number - thingType: string + showPath: string } | null>(null) - const thingsWithCoords = (things ?? []).filter((t) => { - const coords = t.current_location?.geometry?.coordinates - return coords && coords.length >= 2 - }) + const sitesWithCoords = (rows ?? []).filter( + (row) => row.latitude != null && row.longitude != null + ) - const features = thingsWithCoords.map((t) => { - const coords = t.current_location!.geometry!.coordinates as [number, number, number?] - return { - type: 'Feature' as const, - geometry: { - type: 'Point' as const, - coordinates: [coords[0], coords[1]], - }, - properties: { - name: t.name, - thing_id: t.id, - thing_type: t.thing_type, - }, - } - }) + const features = sitesWithCoords.map((row) => ({ + type: 'Feature' as const, + geometry: { + type: 'Point' as const, + coordinates: [row.longitude as number, row.latitude as number], + }, + properties: { + name: row.name, + thing_id: row.id, + show_path: row.showPath, + }, + })) const featureCollection = features.length > 0 @@ -97,7 +86,7 @@ export const AssociatedSitesMapCard = ({ things }: AssociatedSitesMapCardProps) duration: 0, }) } - }, [thingsWithCoords.map((t) => t.id).join(','), features.length]) + }, [sitesWithCoords.map((row) => row.id).join(','), features.length]) const onMapPointClick = ( _e: unknown, @@ -115,8 +104,7 @@ export const AssociatedSitesMapCard = ({ things }: AssociatedSitesMapCardProps) setPopupContent({ coordinates: coords, name: String(point.properties.name ?? 'Site'), - id: Number(point.properties.thing_id), - thingType: String(point.properties.thing_type ?? ''), + showPath: String(point.properties.show_path ?? ''), }) } @@ -133,7 +121,7 @@ export const AssociatedSitesMapCard = ({ things }: AssociatedSitesMapCardProps) } } - if (thingsWithCoords.length === 0) { + if (sitesWithCoords.length === 0) { return null } @@ -177,12 +165,14 @@ export const AssociatedSitesMapCard = ({ things }: AssociatedSitesMapCardProps) {popupContent.name} - View details - + ), maxWidth: '300px', diff --git a/src/components/ContactShow/ContactDetailsCard.tsx b/src/components/ContactShow/ContactDetailsCard.tsx index 3255477f..a57771b2 100644 --- a/src/components/ContactShow/ContactDetailsCard.tsx +++ b/src/components/ContactShow/ContactDetailsCard.tsx @@ -13,7 +13,7 @@ export const ContactDetailsCard = ({ contact }: ContactDetailsCardProps) => { const hasContactInfo = phones.length > 0 || emails.length > 0 const hasAddresses = addresses.length > 0 - const hasMetadata = contact?.created_at || contact?.release_status + const hasMetadata = Boolean(contact?.created_at) const isEmpty = !contact?.name && !hasContactInfo && !hasAddresses && !hasMetadata @@ -99,11 +99,6 @@ export const ContactDetailsCard = ({ contact }: ContactDetailsCardProps) => { Created: {formatAppDateTime(contact.created_at as unknown as string)} )} - {contact?.release_status && ( - - {contact.release_status} - - )} )} diff --git a/src/components/ContactShow/index.ts b/src/components/ContactShow/index.ts index 3c4a7875..03c505a8 100644 --- a/src/components/ContactShow/index.ts +++ b/src/components/ContactShow/index.ts @@ -6,3 +6,4 @@ export * from './ContactDetailsCard' export * from './ContactEmails' export * from './ContactPhones' export * from './CoreContactInfo' +export * from './AssociatedSiteReportActions' diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx new file mode 100644 index 00000000..922676e4 --- /dev/null +++ b/src/components/DataTable/DataTable.tsx @@ -0,0 +1,178 @@ +import { flexRender, type Table as TanstackTable } from '@tanstack/react-table' +import type { MouseEvent } from 'react' +import { useNavigate } from 'react-router' +import { + isNewWindowClick, + openInNewWindow, +} from '@/components/DataTable/rowNavigation' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { cn } from '@/lib/utils' + +/** + * Renders a TanStack table instance with the shadcn table primitives. The page + * owns the table instance, which is what lets the same component back both the + * client-side lists and the server-paginated ones. + */ + +const ALIGNMENT_CLASS = { + left: 'text-left', + center: 'text-center', + right: 'text-right', +} as const + +export interface DataTableProps { + table: TanstackTable + isLoading?: boolean + emptyMessage?: string + /** Row destination; a modifier click opens it in a new window instead. */ + rowHref?: (row: TData) => string | undefined + /** Runs before navigation. Use for analytics. */ + onRowClick?: (row: TData) => void + isRowSelected?: (row: TData) => boolean + skeletonRowCount?: number + className?: string +} + +export function DataTable({ + table, + isLoading = false, + emptyMessage = 'No records match these filters.', + rowHref, + onRowClick, + isRowSelected, + skeletonRowCount = 8, + className, +}: DataTableProps) { + const navigate = useNavigate() + const visibleColumnCount = table.getVisibleLeafColumns().length + + const handleRowClick = ( + event: MouseEvent, + row: TData + ) => { + onRowClick?.(row) + + const href = rowHref?.(row) + if (!href) return + + if (isNewWindowClick(event)) { + openInNewWindow(href) + return + } + + navigate(href) + } + + const rows = table.getRowModel().rows + + return ( +
+ {/* Compact rows: shorter header, tighter cell padding than the shadcn + default. Row height is floored by the tallest cell content, so action + buttons are icon-xs to keep them under the text line box. */} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta + const sorted = header.column.getIsSorted() + + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + + {isLoading ? ( + Array.from({ length: skeletonRowCount }).map((_, rowIndex) => ( + + {table.getVisibleLeafColumns().map((column) => ( + + + + ))} + + )) + ) : rows.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + rows.map((row) => ( + handleRowClick(event, row.original)} + onAuxClick={(event) => { + // Middle click: open elsewhere without following the row. + if (event.button === 1) handleRowClick(event, row.original) + }} + className={cn( + rowHref || onRowClick ? 'cursor-pointer' : undefined + )} + > + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta + + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ) + })} + + )) + )} + +
+
+ ) +} diff --git a/src/components/DataTable/DataTableColumnHeader.tsx b/src/components/DataTable/DataTableColumnHeader.tsx new file mode 100644 index 00000000..a80918c6 --- /dev/null +++ b/src/components/DataTable/DataTableColumnHeader.tsx @@ -0,0 +1,314 @@ +import type { Column } from '@tanstack/react-table' +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, + ChevronsUpDownIcon, + FilterIcon, +} from 'lucide-react' +import { useEffect, useState } from 'react' +import { + COMPARISON_OPERATOR_LABELS, + type DataTableComparisonOperator, + isComparisonValue, +} from '@/components/DataTable/types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { cn } from '@/lib/utils' + +/** + * Header cell content for a DataTable column: the label, a tri-state sort + * toggle (ascending, descending, unsorted) and — when the column declares a + * `filter` in its meta — a filter popover. Filtering and sorting state live in + * the table instance, so this component works the same whether the page runs + * client side or hands the state to the server. + */ + +const FILTER_DEBOUNCE_MS = 400 + +function TextFilter({ + column, + label, + placeholder, +}: { + column: Column + label: string + placeholder?: string +}) { + const committed = (column.getFilterValue() as string | undefined) ?? '' + const [draft, setDraft] = useState(committed) + + // Re-sync when the filter is cleared from a chip or by another control. + useEffect(() => { + setDraft(committed) + }, [committed]) + + useEffect(() => { + if (draft === committed) return + + const timer = setTimeout(() => { + const next = draft.trim() + column.setFilterValue(next === '' ? undefined : next) + }, FILTER_DEBOUNCE_MS) + + return () => clearTimeout(timer) + }, [column, committed, draft]) + + return ( +
+ setDraft(event.target.value)} + placeholder={placeholder ?? `Filter by ${label.toLowerCase()}…`} + aria-label={`Filter by ${label}`} + className="h-8 text-sm" + /> + +
+ ) +} + +function SelectFilter({ + column, + label, + options, +}: { + column: Column + label: string + options: { label: string; value: string }[] +}) { + const selected = column.getFilterValue() as string | undefined + + return ( +
+ + {options.map((option) => ( + + ))} +
+ ) +} + +function ComparisonFilter({ + column, + label, + inputType, + defaultOperator = 'eq', +}: { + column: Column + label: string + inputType: 'number' | 'date' + defaultOperator?: DataTableComparisonOperator +}) { + const committed = column.getFilterValue() + const current = isComparisonValue(committed) ? committed : undefined + const [operator, setOperator] = useState( + current?.operator ?? defaultOperator + ) + const [draft, setDraft] = useState(current?.value ?? '') + + const commit = (nextOperator: DataTableComparisonOperator, next: string) => { + const trimmed = next.trim() + column.setFilterValue( + trimmed === '' ? undefined : { operator: nextOperator, value: trimmed } + ) + } + + return ( +
+
+ + + setDraft(event.target.value)} + onBlur={() => commit(operator, draft)} + onKeyDown={(event) => { + if (event.key === 'Enter') commit(operator, draft) + }} + aria-label={`Filter by ${label}`} + className="h-8 flex-1 text-sm" + /> +
+ + +
+ ) +} + +export function DataTableColumnHeader({ + column, + title, +}: { + column: Column + title: string +}) { + const filter = column.columnDef.meta?.filter + const description = column.columnDef.meta?.description + const canFilter = Boolean(filter) && column.getCanFilter() + const canSort = column.getCanSort() + const sorted = column.getIsSorted() + const hasFilter = column.getFilterValue() !== undefined + + const SortIcon = !sorted + ? ChevronsUpDownIcon + : sorted === 'asc' + ? ArrowUpIcon + : ArrowDownIcon + + return ( +
+ {canSort ? ( + + ) : ( + {title} + )} + + {canFilter && filter ? ( + + + + + + {filter.type === 'text' ? ( + + ) : filter.type === 'select' ? ( + + ) : ( + + )} + + + ) : null} +
+ ) +} diff --git a/src/components/DataTable/index.ts b/src/components/DataTable/index.ts new file mode 100644 index 00000000..f2899c42 --- /dev/null +++ b/src/components/DataTable/index.ts @@ -0,0 +1,4 @@ +export * from './DataTable' +export * from './DataTableColumnHeader' +export * from './rowNavigation' +export * from './types' diff --git a/src/components/DataTable/rowNavigation.ts b/src/components/DataTable/rowNavigation.ts new file mode 100644 index 00000000..d99bb632 --- /dev/null +++ b/src/components/DataTable/rowNavigation.ts @@ -0,0 +1,22 @@ +import { settings } from '@/settings' + +/** + * Table rows are not anchors, so modifier clicks would otherwise navigate in + * place. Treat the browser conventions for "open elsewhere" as new-window + * intent. Shared by the MUI ListPage and the shadcn DataTable. + */ +export function isNewWindowClick(event: { + ctrlKey?: boolean + metaKey?: boolean + button?: number +}): boolean { + // Shift is left alone: grids use it for row range selection. + return Boolean(event.ctrlKey || event.metaKey || event.button === 1) +} + +export function openInNewWindow(href: string) { + // Router paths are basename-relative; window.open is not. + const target = href.startsWith('/') ? `${settings.urlprefix}${href}` : href + const opened = window.open(target, '_blank', 'noopener,noreferrer') + if (opened) opened.opener = null +} diff --git a/src/components/DataTable/types.ts b/src/components/DataTable/types.ts new file mode 100644 index 00000000..3e9145de --- /dev/null +++ b/src/components/DataTable/types.ts @@ -0,0 +1,77 @@ +import type { CrudOperators } from '@refinedev/core' +import type { RowData } from '@tanstack/react-table' + +/** + * Column metadata shared by every DataTable. Column definitions carry their own + * label, alignment and filter shape so the toolbar, the visibility menu and the + * filter chips can all describe a column without the page repeating itself. + */ + +export type DataTableFilterOption = { label: string; value: string } + +/** Comparisons offered by the numeric and date filters. */ +export type DataTableComparisonOperator = Extract< + CrudOperators, + 'eq' | 'gte' | 'lte' | 'gt' | 'lt' +> + +/** Value stored for a numeric or date column filter. */ +export type DataTableComparisonValue = { + operator: DataTableComparisonOperator + value: string +} + +export const isComparisonValue = ( + value: unknown +): value is DataTableComparisonValue => + typeof value === 'object' && + value !== null && + 'operator' in value && + 'value' in value + +export const COMPARISON_OPERATOR_LABELS: Record< + DataTableComparisonOperator, + string +> = { + eq: '=', + gte: '≥', + lte: '≤', + gt: '>', + lt: '<', +} + +export type DataTableFilterConfig = + /** Free text match; `contains` unless the API only understands equality. */ + | { + type: 'text' + operator?: Extract + placeholder?: string + } + /** Single choice from a known vocabulary. */ + | { + type: 'select' + options: DataTableFilterOption[] + operator?: Extract + } + /** Comparison against a number or a date; the operator ships with the value. */ + | { + type: 'number' | 'date' + defaultOperator?: DataTableComparisonOperator + } + +declare module '@tanstack/react-table' { + // The generics have to mirror the upstream declaration to merge with it. + interface ColumnMeta { + /** Human label used by the visibility menu, filter chips and export. */ + label?: string + /** Long-form help shown as the header tooltip. */ + description?: string + align?: 'left' | 'center' | 'right' + headClassName?: string + cellClassName?: string + filter?: DataTableFilterConfig + } +} + +export const DEFAULT_TEXT_FILTER_OPERATOR = 'contains' as const +export const DEFAULT_SELECT_FILTER_OPERATOR = 'eq' as const diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 00000000..540d1269 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,87 @@ +import { Popover as PopoverPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<'p'>) { + return ( +

+ ) +} + +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..b01ab008 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Table({ className, ...props }: React.ComponentProps<'table'>) { + return ( +

+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { + return ( + tr]:last:border-b-0', + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( +
[role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<'td'>) { + return ( + [role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<'caption'>) { + return ( +
+ ) +} + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2cc94a83..a6887879 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -23,3 +23,4 @@ export * from './useSearchModalState' export * from './useSidebarPanelSync' export * from './useWellDetails' export * from './useContainerMinWidth' +export * from './useAssociatedSiteRows' diff --git a/src/hooks/useAssociatedSiteRows.ts b/src/hooks/useAssociatedSiteRows.ts new file mode 100644 index 00000000..7b45925b --- /dev/null +++ b/src/hooks/useAssociatedSiteRows.ts @@ -0,0 +1,209 @@ +import { useMemo } from 'react' +import { useDataProvider } from '@refinedev/core' +import { useQueries } from '@tanstack/react-query' +import type { + IObservation, + ISample, + IThing, + IWell, +} from '@/interfaces/ocotillo' + +const STALE_TIME_MS = 5 * 60 * 1000 +const GC_TIME_MS = 10 * 60 * 1000 + +/** A site as the associated-sites grid needs it: one flat, sortable row. */ +export type AssociatedSiteRow = { + id: IThing['id'] + name: string + thingType: string | null + showPath: string + lastCheckedDate: string | null + lastCheckedBy: string | null + depthToWater: number | null + wellDepth: number | null + wellDepthUnit: string | null + holeDepth: number | null + holeDepthUnit: string | null + elevation: number | null + elevationUnit: string + latitude: number | null + longitude: number | null + isLoading: boolean +} + +export const getSiteShowPath = (thing: Pick) => { + const type = (thing.thing_type || '').toLowerCase() + if (type === 'spring') return `/ocotillo/spring/show/${thing.id}` + return `/ocotillo/well/show/${thing.id}` +} + +export const latestObservation = (observations: IObservation[]) => + observations + .filter((observation) => observation.observation_datetime) + .sort( + (a, b) => + new Date(b.observation_datetime!).getTime() - + new Date(a.observation_datetime!).getTime() + )[0] + +/** + * Flattens a site and whatever has loaded for it into one grid row. + * + * Kept pure and separate from the fetching so the fallback chains — which + * source wins for the last-checked date, and how a sampler is named — can be + * tested without standing up a data provider. + */ +export function buildAssociatedSiteRow({ + thing, + well, + observations, + sample, + isLoading = false, +}: { + thing: IThing + well?: IWell + observations?: IObservation[] + sample?: ISample + isLoading?: boolean +}): AssociatedSiteRow { + const observation = latestObservation(observations ?? []) + + // The well record is the better source once it loads, but a site without one + // still has its location on the thing. + const source = well ?? thing + const coordinates = source?.current_location?.geometry?.coordinates as + | [number, number, number?] + | undefined + const locationProps = source?.current_location?.properties + + return { + id: thing.id, + name: thing.name || `Site ${thing.id}`, + thingType: thing.thing_type ?? null, + showPath: getSiteShowPath(thing), + lastCheckedDate: + sample?.field_event?.event_date ?? + sample?.sample_date ?? + observation?.observation_datetime ?? + null, + lastCheckedBy: + sample?.contact?.name && sample?.contact?.organization + ? `${sample.contact.name} (${sample.contact.organization})` + : (sample?.contact?.name ?? sample?.sampler_name ?? null), + depthToWater: observation?.depth_to_water_bgs ?? null, + wellDepth: well?.well_depth ?? null, + wellDepthUnit: well?.well_depth_unit ?? 'ft', + holeDepth: well?.hole_depth ?? null, + holeDepthUnit: well?.hole_depth_unit ?? 'ft', + elevation: locationProps?.elevation ?? null, + elevationUnit: locationProps?.elevation_unit ?? 'ft', + latitude: coordinates?.[1] ?? null, + longitude: coordinates?.[0] ?? null, + isLoading, + } +} + +/** + * Builds the associated-sites rows for a contact. + * + * `contact.things` carries only the bare thing, so each site's depths, latest + * reading, and who took it have to be fetched. The card this replaced ran + * those queries inside a per-site component; a grid cannot, so they run here + * with `useQueries` and land in the row model — which is also what lets the + * enriched columns sort. + * + * The sample naming who took the reading is only identified by the latest + * observation, so it is fetched in a second pass once those resolve. + */ +export function useAssociatedSiteRows( + things: IThing[] | null | undefined +): AssociatedSiteRow[] { + const dataProvider = useDataProvider() + const ocotillo = useMemo(() => dataProvider('ocotillo'), [dataProvider]) + + const items = useMemo(() => things ?? [], [things]) + + // `combine` is what keeps these referentially stable. Without it useQueries + // hands back a fresh array every render, which defeats the useMemo below and + // feeds the table a new `data` identity on every pass. + const wells = useQueries({ + queries: items.map((thing) => ({ + queryKey: ['associated-site', 'well', String(thing.id)], + staleTime: STALE_TIME_MS, + gcTime: GC_TIME_MS, + queryFn: async () => { + const response = await ocotillo.getOne({ + resource: 'ocotillo.thing-well', + id: thing.id, + }) + return response.data as IWell + }, + })), + combine: (results) => + results.map((result) => ({ + data: result.data as IWell | undefined, + isLoading: result.isLoading, + })), + }) + + const observations = useQueries({ + queries: items.map((thing) => ({ + queryKey: ['associated-site', 'observations', String(thing.id)], + staleTime: STALE_TIME_MS, + gcTime: GC_TIME_MS, + queryFn: async () => { + const response = await ocotillo.getList({ + resource: 'observation/groundwater-level', + pagination: { currentPage: 1, pageSize: 5, mode: 'server' as const }, + sorters: [{ field: 'observation_datetime', order: 'desc' as const }], + meta: { params: { thing_id: thing.id } }, + }) + return (response.data ?? []) as IObservation[] + }, + })), + combine: (results) => + results.map((result) => ({ + data: result.data as IObservation[] | undefined, + isLoading: result.isLoading, + })), + }) + + const samples = useQueries({ + queries: items.map((thing, index) => { + const sampleId = + latestObservation(observations[index]?.data ?? [])?.sample_id ?? null + + return { + queryKey: ['associated-site', 'sample', String(sampleId ?? 'none')], + enabled: sampleId != null, + staleTime: STALE_TIME_MS, + gcTime: GC_TIME_MS, + queryFn: async () => { + const response = await ocotillo.getOne({ + resource: 'ocotillo.sample', + id: sampleId!, + }) + return response.data as ISample + }, + } + }), + combine: (results) => + results.map((result) => result.data as ISample | undefined), + }) + + return useMemo( + () => + items.map((thing, index) => + buildAssociatedSiteRow({ + thing, + well: wells[index]?.data, + observations: observations[index]?.data, + sample: samples[index], + isLoading: + wells[index]?.isLoading === true || + observations[index]?.isLoading === true, + }) + ), + [items, wells, observations, samples] + ) +} diff --git a/src/pages/ocotillo/contact/show.tsx b/src/pages/ocotillo/contact/show.tsx index 2a79c212..f8b61278 100644 --- a/src/pages/ocotillo/contact/show.tsx +++ b/src/pages/ocotillo/contact/show.tsx @@ -3,11 +3,14 @@ import { Show } from '@refinedev/mui' import { useResourceParams } from '@refinedev/core' import { PencilIcon } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useAccessCapabilities, useSidebarPanelSync } from '@/hooks' +import { + useAccessCapabilities, + useAssociatedSiteRows, + useSidebarPanelSync, +} from '@/hooks' import { sanitizeContact } from '@/utils' import { getContactDisplayName } from '@/utils/contactDisplayName' import { Chip } from '@mui/material' -import Grid from '@mui/material/Grid2' import { Stack } from '@mui/material' import { IContact } from '@/interfaces/ocotillo' import { @@ -35,6 +38,9 @@ export const ContactShow = () => { const contact = record + // One fetch feeds both the table and the map. + const associatedSiteRows = useAssociatedSiteRows(contact?.things) + const { isPanelOpen: isEditPanelOpen, closePanel: closeEditPanel, @@ -102,22 +108,9 @@ export const ContactShow = () => { )} > - - {/* Left column: 8 cols */} - - - - - - - - {/* Right column: 4 cols */} - - - - - - + + + diff --git a/src/test/hooks/associatedSiteRows.test.ts b/src/test/hooks/associatedSiteRows.test.ts new file mode 100644 index 00000000..3551c480 --- /dev/null +++ b/src/test/hooks/associatedSiteRows.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import { + buildAssociatedSiteRow, + getSiteShowPath, + latestObservation, +} from '@/hooks/useAssociatedSiteRows' +import type { + IObservation, + ISample, + IThing, + IWell, +} from '@/interfaces/ocotillo' + +const thing = (overrides: Partial = {}) => + ({ + id: 42, + name: 'WL-0260', + thing_type: 'water well', + location_id: 1, + created_at: '2025-01-01', + release_status: 'public', + ...overrides, + }) as IThing + +const observation = (overrides: Partial = {}) => + ({ + observation_datetime: '2025-06-01T00:00:00Z', + ...overrides, + }) as IObservation + +describe('getSiteShowPath', () => { + it('routes springs to the spring page', () => { + expect(getSiteShowPath({ id: 7, thing_type: 'spring' } as IThing)).toBe( + '/ocotillo/spring/show/7' + ) + }) + + it('routes wells, and anything unrecognised, to the well page', () => { + expect(getSiteShowPath({ id: 7, thing_type: 'water well' } as IThing)).toBe( + '/ocotillo/well/show/7' + ) + expect(getSiteShowPath({ id: 7, thing_type: '' } as IThing)).toBe( + '/ocotillo/well/show/7' + ) + }) +}) + +describe('latestObservation', () => { + it('picks the most recent, ignoring undated readings', () => { + const result = latestObservation([ + observation({ observation_datetime: '2024-01-01T00:00:00Z' }), + observation({ observation_datetime: undefined }), + observation({ observation_datetime: '2026-01-01T00:00:00Z' }), + ]) + expect(result?.observation_datetime).toBe('2026-01-01T00:00:00Z') + }) + + it('returns nothing for an empty list', () => { + expect(latestObservation([])).toBeUndefined() + }) +}) + +describe('buildAssociatedSiteRow', () => { + it('falls back to the thing when the well has not loaded', () => { + const row = buildAssociatedSiteRow({ thing: thing() }) + + expect(row.name).toBe('WL-0260') + expect(row.wellDepth).toBeNull() + expect(row.lastCheckedDate).toBeNull() + expect(row.lastCheckedBy).toBeNull() + }) + + it('names an unnamed site by its id, so the link is never blank', () => { + expect(buildAssociatedSiteRow({ thing: thing({ name: '' }) }).name).toBe( + 'Site 42' + ) + }) + + it('prefers the well location over the thing location', () => { + const row = buildAssociatedSiteRow({ + thing: thing({ + current_location: { + geometry: { coordinates: [-106, 36] }, + properties: { elevation: 1000, elevation_unit: 'm' }, + }, + } as unknown as Partial), + well: { + current_location: { + geometry: { coordinates: [-107.5, 35.25] }, + properties: { elevation: 6812, elevation_unit: 'ft' }, + }, + } as unknown as IWell, + }) + + expect(row.latitude).toBe(35.25) + expect(row.longitude).toBe(-107.5) + expect(row.elevation).toBe(6812) + expect(row.elevationUnit).toBe('ft') + }) + + it('prefers the field event date over the sample date and the reading', () => { + const row = buildAssociatedSiteRow({ + thing: thing(), + observations: [observation({ observation_datetime: '2023-01-01' })], + sample: { + field_event: { event_date: '2025-05-05' }, + sample_date: '2024-04-04', + } as unknown as ISample, + }) + + expect(row.lastCheckedDate).toBe('2025-05-05') + }) + + it('falls back to the sample date, then the latest reading', () => { + expect( + buildAssociatedSiteRow({ + thing: thing(), + observations: [observation({ observation_datetime: '2023-01-01' })], + sample: { sample_date: '2024-04-04' } as unknown as ISample, + }).lastCheckedDate + ).toBe('2024-04-04') + + expect( + buildAssociatedSiteRow({ + thing: thing(), + observations: [observation({ observation_datetime: '2023-01-01' })], + }).lastCheckedDate + ).toBe('2023-01-01') + }) + + it('qualifies the sampler with their organisation when both are known', () => { + expect( + buildAssociatedSiteRow({ + thing: thing(), + sample: { + contact: { name: 'Joseph Beman', organization: 'NMBGMR' }, + } as unknown as ISample, + }).lastCheckedBy + ).toBe('Joseph Beman (NMBGMR)') + }) + + it('uses the bare contact name, then the sampler name, when it cannot', () => { + expect( + buildAssociatedSiteRow({ + thing: thing(), + sample: { contact: { name: 'Joseph Beman' } } as unknown as ISample, + }).lastCheckedBy + ).toBe('Joseph Beman') + + expect( + buildAssociatedSiteRow({ + thing: thing(), + sample: { sampler_name: 'Field crew' } as unknown as ISample, + }).lastCheckedBy + ).toBe('Field crew') + }) + + it('takes depth to water from the most recent reading', () => { + const row = buildAssociatedSiteRow({ + thing: thing(), + observations: [ + observation({ + observation_datetime: '2024-01-01T00:00:00Z', + depth_to_water_bgs: 10, + }), + observation({ + observation_datetime: '2026-01-01T00:00:00Z', + depth_to_water_bgs: 42, + }), + ], + }) + + expect(row.depthToWater).toBe(42) + }) + + it('defaults depth units to feet when the well omits them', () => { + const row = buildAssociatedSiteRow({ + thing: thing(), + well: { well_depth: 200, hole_depth: 220 } as unknown as IWell, + }) + + expect(row.wellDepthUnit).toBe('ft') + expect(row.holeDepthUnit).toBe('ft') + }) +})