From 290429ea7551275567c167f80055ca3a35c27098 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 31 Aug 2026 16:30:19 -0700 Subject: [PATCH 1/4] feat(contact-show): list associated sites in a table, not a card each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One card per associated well pushed the rest of the page below the fold and gave no way to compare sites. They become rows in a shadcn DataTable, sortable on every column, and the contact details move out of the narrow right-hand column into the main flow. The card fetched each site's well record, latest reading, and sampler from inside a per-site component. A table cannot do that, so the fetching moves into useAssociatedSiteRows, which runs the queries with useQueries and flattens the results into the row model — which is also what lets the enriched columns sort. Row building is a pure function so the fallback chains (which source wins for the last-checked date, how a sampler is named) can be tested without a data provider. Elevation and coordinates are dropped; the map card below already places the sites. The shadcn table components and @tanstack/react-table are ported from #347, which is not yet on staging. Copied unchanged so the two resolve cleanly when that lands. --- package-lock.json | 38 ++- package.json | 3 +- .../ContactShow/AssociatedSiteSummaryCard.tsx | 236 ------------- .../AssociatedSitesDetailsCard.tsx | 114 ++++++- src/components/DataTable/DataTable.tsx | 178 ++++++++++ .../DataTable/DataTableColumnHeader.tsx | 314 ++++++++++++++++++ src/components/DataTable/index.ts | 4 + src/components/DataTable/rowNavigation.ts | 22 ++ src/components/DataTable/types.ts | 77 +++++ src/components/ui/popover.tsx | 87 +++++ src/components/ui/table.tsx | 114 +++++++ src/hooks/index.ts | 1 + src/hooks/useAssociatedSiteRows.ts | 196 +++++++++++ src/pages/ocotillo/contact/show.tsx | 20 +- src/test/hooks/associatedSiteRows.test.ts | 185 +++++++++++ 15 files changed, 1319 insertions(+), 270 deletions(-) delete mode 100644 src/components/ContactShow/AssociatedSiteSummaryCard.tsx create mode 100644 src/components/DataTable/DataTable.tsx create mode 100644 src/components/DataTable/DataTableColumnHeader.tsx create mode 100644 src/components/DataTable/index.ts create mode 100644 src/components/DataTable/rowNavigation.ts create mode 100644 src/components/DataTable/types.ts create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/hooks/useAssociatedSiteRows.ts create mode 100644 src/test/hooks/associatedSiteRows.test.ts 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/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..39ae709f 100644 --- a/src/components/ContactShow/AssociatedSitesDetailsCard.tsx +++ b/src/components/ContactShow/AssociatedSitesDetailsCard.tsx @@ -1,14 +1,104 @@ -import { Box, Stack, Typography } from '@mui/material' import { Place } from '@mui/icons-material' +import { Box, Typography } from '@mui/material' +import { + type ColumnDef, + type SortingState, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useMemo, useState } from 'react' +import { DataTable, DataTableColumnHeader } from '@/components/DataTable' +import { + type AssociatedSiteRow, + useAssociatedSiteRows, +} from '@/hooks/useAssociatedSiteRows' import type { IThing } from '@/interfaces/ocotillo' -import { AssociatedSiteSummaryCard } from './AssociatedSiteSummaryCard' +import { formatAppDateTime } from '@/utils' + +const measure = ( + value: number | null, + unit: string | null, + fallback: string +) => (value != null ? `${value} ${unit ?? ''}`.trim() : fallback) export const AssociatedSitesDetailsCard = ({ things, }: { things?: IThing[] | null }) => { - const items = things ?? [] + const rows = useAssociatedSiteRows(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} 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'), + }, + ], + [] + ) + + 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 +108,13 @@ export const AssociatedSitesDetailsCard = ({ Associated Sites - {items.length === 0 ? ( - - No associated sites. - - ) : ( - - {items.map((thing) => ( - - ))} - - )} + row.showPath} + skeletonRowCount={3} + /> ) } 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..cd0c53d1 --- /dev/null +++ b/src/hooks/useAssociatedSiteRows.ts @@ -0,0 +1,196 @@ +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]) + + const wellQueries = 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 + }, + })), + }) + + const observationQueries = 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[] + }, + })), + }) + + const sampleIds = observationQueries.map( + (query) => latestObservation(query.data ?? [])?.sample_id ?? null + ) + + const sampleQueries = useQueries({ + queries: items.map((thing, index) => { + const sampleId = sampleIds[index] + 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 + }, + } + }), + }) + + return useMemo( + () => + items.map((thing, index) => + buildAssociatedSiteRow({ + thing, + well: wellQueries[index]?.data, + observations: observationQueries[index]?.data, + sample: sampleQueries[index]?.data, + isLoading: + wellQueries[index]?.isLoading === true || + observationQueries[index]?.isLoading === true, + }) + ), + [items, wellQueries, observationQueries, sampleQueries] + ) +} diff --git a/src/pages/ocotillo/contact/show.tsx b/src/pages/ocotillo/contact/show.tsx index 2a79c212..339dce52 100644 --- a/src/pages/ocotillo/contact/show.tsx +++ b/src/pages/ocotillo/contact/show.tsx @@ -7,7 +7,6 @@ import { useAccessCapabilities, 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 { @@ -102,22 +101,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') + }) +}) From 431b6c6756a0d6491f42438192cb99a7ac2d8294 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 31 Aug 2026 16:54:40 -0700 Subject: [PATCH 2/4] feat(contact-show): add per-row Owner and Field reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Report column to the associated-sites table. Field builds that well's sheet in place, so pulling one does not cost the contact page. Owner hands off to the chemistry report exporter, which owns the year picker and section toggles. Field renders FieldCompilationNotesPdf — the same component the bulk field sheet export uses — so a sheet pulled from here matches one from a batch run. A sheet needs the well's whole payload, so useWellPdfData is armed by the click; arming on mount would fetch all of it for every row. Owner links to /ocotillo/chemistry-report, which arrives with #354. The button is dead until that lands. Two fixes found while verifying this in the browser: - Import the PDF component by path, not through '@/components'. That barrel re-exports ContactShow, so importing through it put this module in a cycle with the table rendering it and the cell was undefined at render time, which took out the page — the Edit button included. - Round depths for display. They arrive at full float precision (272.08212570033396), which a table column cannot carry. --- .../AssociatedSiteReportActions.tsx | 140 ++++++++++++++++++ .../AssociatedSitesDetailsCard.tsx | 13 +- src/components/ContactShow/index.ts | 1 + src/hooks/useAssociatedSiteRows.ts | 41 +++-- 4 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 src/components/ContactShow/AssociatedSiteReportActions.tsx 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/AssociatedSitesDetailsCard.tsx b/src/components/ContactShow/AssociatedSitesDetailsCard.tsx index 39ae709f..b72e6347 100644 --- a/src/components/ContactShow/AssociatedSitesDetailsCard.tsx +++ b/src/components/ContactShow/AssociatedSitesDetailsCard.tsx @@ -8,6 +8,7 @@ import { 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, @@ -16,11 +17,13 @@ import { import type { IThing } from '@/interfaces/ocotillo' 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} ${unit ?? ''}`.trim() : fallback) +) => (value != null ? `${value.toFixed(1)} ${unit ?? ''}`.trim() : fallback) export const AssociatedSitesDetailsCard = ({ things, @@ -65,7 +68,7 @@ export const AssociatedSitesDetailsCard = ({ ), cell: ({ row }) => row.original.depthToWater != null - ? `${row.original.depthToWater} ft bgs` + ? `${row.original.depthToWater.toFixed(1)} ft bgs` : 'No measurements', }, { @@ -84,6 +87,12 @@ export const AssociatedSitesDetailsCard = ({ cell: ({ row }) => measure(row.original.holeDepth, row.original.holeDepthUnit, 'N/A'), }, + { + id: 'report', + header: 'Report', + enableSorting: false, + cell: ({ row }) => , + }, ], [] ) 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/hooks/useAssociatedSiteRows.ts b/src/hooks/useAssociatedSiteRows.ts index cd0c53d1..7b45925b 100644 --- a/src/hooks/useAssociatedSiteRows.ts +++ b/src/hooks/useAssociatedSiteRows.ts @@ -123,7 +123,10 @@ export function useAssociatedSiteRows( const items = useMemo(() => things ?? [], [things]) - const wellQueries = useQueries({ + // `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, @@ -136,9 +139,14 @@ export function useAssociatedSiteRows( return response.data as IWell }, })), + combine: (results) => + results.map((result) => ({ + data: result.data as IWell | undefined, + isLoading: result.isLoading, + })), }) - const observationQueries = useQueries({ + const observations = useQueries({ queries: items.map((thing) => ({ queryKey: ['associated-site', 'observations', String(thing.id)], staleTime: STALE_TIME_MS, @@ -153,15 +161,18 @@ export function useAssociatedSiteRows( return (response.data ?? []) as IObservation[] }, })), + combine: (results) => + results.map((result) => ({ + data: result.data as IObservation[] | undefined, + isLoading: result.isLoading, + })), }) - const sampleIds = observationQueries.map( - (query) => latestObservation(query.data ?? [])?.sample_id ?? null - ) - - const sampleQueries = useQueries({ + const samples = useQueries({ queries: items.map((thing, index) => { - const sampleId = sampleIds[index] + const sampleId = + latestObservation(observations[index]?.data ?? [])?.sample_id ?? null + return { queryKey: ['associated-site', 'sample', String(sampleId ?? 'none')], enabled: sampleId != null, @@ -176,6 +187,8 @@ export function useAssociatedSiteRows( }, } }), + combine: (results) => + results.map((result) => result.data as ISample | undefined), }) return useMemo( @@ -183,14 +196,14 @@ export function useAssociatedSiteRows( items.map((thing, index) => buildAssociatedSiteRow({ thing, - well: wellQueries[index]?.data, - observations: observationQueries[index]?.data, - sample: sampleQueries[index]?.data, + well: wells[index]?.data, + observations: observations[index]?.data, + sample: samples[index], isLoading: - wellQueries[index]?.isLoading === true || - observationQueries[index]?.isLoading === true, + wells[index]?.isLoading === true || + observations[index]?.isLoading === true, }) ), - [items, wellQueries, observationQueries, sampleQueries] + [items, wells, observations, samples] ) } From 6765c07608e4d78458f6b55bcf68eabedb6a888b Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 31 Aug 2026 17:16:04 -0700 Subject: [PATCH 3/4] fix(contact-show): render the associated sites on the map again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map read coordinates off contact.things, which the contact endpoint returns without a current_location — so no site ever passed the filter and the card returned null. Nothing was rendering at all. Point it at the enriched rows instead. The per-well records those are built from do carry coordinates, and they already hold the name and show path the popup needs, so the card's own getShowPath goes with them. useAssociatedSiteRows moves up to the page, which passes the same rows to the table and the map rather than each fetching its own. --- .../AssociatedSitesDetailsCard.tsx | 11 +-- .../ContactShow/AssociatedSitesMapCard.tsx | 68 ++++++++----------- src/pages/ocotillo/contact/show.tsx | 13 +++- 3 files changed, 41 insertions(+), 51 deletions(-) diff --git a/src/components/ContactShow/AssociatedSitesDetailsCard.tsx b/src/components/ContactShow/AssociatedSitesDetailsCard.tsx index b72e6347..8451d524 100644 --- a/src/components/ContactShow/AssociatedSitesDetailsCard.tsx +++ b/src/components/ContactShow/AssociatedSitesDetailsCard.tsx @@ -10,11 +10,7 @@ import { import { useMemo, useState } from 'react' import { AssociatedSiteReportActions } from '@/components/ContactShow/AssociatedSiteReportActions' import { DataTable, DataTableColumnHeader } from '@/components/DataTable' -import { - type AssociatedSiteRow, - useAssociatedSiteRows, -} from '@/hooks/useAssociatedSiteRows' -import type { IThing } from '@/interfaces/ocotillo' +import type { AssociatedSiteRow } from '@/hooks/useAssociatedSiteRows' import { formatAppDateTime } from '@/utils' // Depths come back at full float precision (272.08212570033396), which is @@ -26,11 +22,10 @@ const measure = ( ) => (value != null ? `${value.toFixed(1)} ${unit ?? ''}`.trim() : fallback) export const AssociatedSitesDetailsCard = ({ - things, + rows, }: { - things?: IThing[] | null + rows: AssociatedSiteRow[] }) => { - const rows = useAssociatedSiteRows(things) const [sorting, setSorting] = useState([]) const columns = useMemo[]>( diff --git a/src/components/ContactShow/AssociatedSitesMapCard.tsx b/src/components/ContactShow/AssociatedSitesMapCard.tsx index f8543a5e..1d9805f0 100644 --- a/src/components/ContactShow/AssociatedSitesMapCard.tsx +++ b/src/components/ContactShow/AssociatedSitesMapCard.tsx @@ -3,7 +3,7 @@ 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 { MAP_LAYER_COLORS, @@ -11,50 +11,39 @@ import { } 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 } @@ -178,7 +166,7 @@ export const AssociatedSitesMapCard = ({ things }: AssociatedSitesMapCardProps) {popupContent.name} View details diff --git a/src/pages/ocotillo/contact/show.tsx b/src/pages/ocotillo/contact/show.tsx index 339dce52..f8b61278 100644 --- a/src/pages/ocotillo/contact/show.tsx +++ b/src/pages/ocotillo/contact/show.tsx @@ -3,7 +3,11 @@ 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' @@ -34,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,8 +109,8 @@ export const ContactShow = () => { > - - + + From 35273b365dd4c5a816a615edd8418694342ec0d1 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 31 Aug 2026 17:20:06 -0700 Subject: [PATCH 4/4] feat(contact-show): open the map popup link in a new tab, drop release status The popup's "View details" now opens in a new tab, so following a site does not lose the contact you were reading. Refine's Link is router-bound and cannot target one, so it becomes a plain anchor; the href carries the basename the router would otherwise have applied. Also drops the release status from the contact details card. --- src/components/ContactShow/AssociatedSitesMapCard.tsx | 10 ++++++---- src/components/ContactShow/ContactDetailsCard.tsx | 7 +------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/components/ContactShow/AssociatedSitesMapCard.tsx b/src/components/ContactShow/AssociatedSitesMapCard.tsx index 1d9805f0..d46257c6 100644 --- a/src/components/ContactShow/AssociatedSitesMapCard.tsx +++ b/src/components/ContactShow/AssociatedSitesMapCard.tsx @@ -2,9 +2,9 @@ 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 { AssociatedSiteRow } from '@/hooks/useAssociatedSiteRows' import { MapComponent } from '@/components' +import { settings } from '@/settings' import { MAP_LAYER_COLORS, MAP_SYMBOL_STROKE_COLOR, @@ -165,12 +165,14 @@ export const AssociatedSitesMapCard = ({ rows }: 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} - - )} )}