From 75c3dbe26965da8494f5ecb497f23a379b6022a2 Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 21 Aug 2026 09:36:02 -0700 Subject: [PATCH 1/6] feat(chemistry-report): add annual water quality report exporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sandbox page that renders an owner-facing annual water quality report for a single well as a PDF: pick a well and a calendar year, preview it inline, download it. The report compares each result against EPA primary (MCL) and secondary (SMCL) drinking water standards so exceedances are called out rather than left for the reader to look up. Results print at the lab's precision — rounding an arsenic result of 0.012 mg/L to 0.01 would hide that it sits at the limit it is being compared against. Gated behind a new AMP.staging group while the report layout is under review. AMP.staging is deliberately not a rung on the Viewer -> Editor -> Admin ladder: holding AMP.Admin does not imply it, so the report cannot leak to admins before it is signed off. The local test identity holds it explicitly or the page is unreachable in dev. Refs BDMS-1189 Co-Authored-By: Claude Opus 5 --- src/components/AppShell.tsx | 15 + .../Button/ChemistryReportDownload.tsx | 83 ++++ src/components/Button/index.ts | 1 + .../pdf/chemistry/ChemistryReportPdf.tsx | 403 ++++++++++++++++++ src/components/pdf/chemistry/index.ts | 2 + src/components/pdf/chemistry/styles.ts | 148 +++++++ src/components/pdf/index.ts | 1 + src/constants/drinkingWaterStandards.ts | 110 +++++ src/hooks/index.ts | 1 + src/hooks/useChemistryReportData.ts | 75 ++++ .../ocotillo/chemistry-report/export.tsx | 196 +++++++++ src/pages/ocotillo/chemistry-report/index.tsx | 1 + src/providers/authentik-provider.ts | 3 + src/resources/ocotillo.tsx | 12 +- src/routes/ocotillo.tsx | 11 + src/test/utils/accessControl.test.ts | 24 +- src/test/utils/chemistryReport.test.ts | 137 ++++++ src/utils/accessControl.ts | 33 +- src/utils/chemistryReport.ts | 120 ++++++ src/utils/index.ts | 1 + 20 files changed, 1373 insertions(+), 4 deletions(-) create mode 100644 src/components/Button/ChemistryReportDownload.tsx create mode 100644 src/components/pdf/chemistry/ChemistryReportPdf.tsx create mode 100644 src/components/pdf/chemistry/index.ts create mode 100644 src/components/pdf/chemistry/styles.ts create mode 100644 src/constants/drinkingWaterStandards.ts create mode 100644 src/hooks/useChemistryReportData.ts create mode 100644 src/pages/ocotillo/chemistry-report/export.tsx create mode 100644 src/pages/ocotillo/chemistry-report/index.tsx create mode 100644 src/test/utils/chemistryReport.test.ts create mode 100644 src/utils/chemistryReport.ts diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 0083504d..a98f8443 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -469,6 +469,7 @@ function AppSidebar() { ) } +const SANDBOX_CHEMISTRY_REPORT = '/ocotillo/chemistry-report' const SANDBOX_GEOTHERMAL_GRID = '/geothermal/wells/records-grid' const SANDBOX_GEOTHERMAL_INVENTORY = '/geothermal/wells/inventory' const SANDBOX_GEOTHERMAL_TEMP_DEPTH = '/geothermal/wells/temp-depth' @@ -476,6 +477,7 @@ const SANDBOX_GEOTHERMAL_TEMP_DEPTH = '/geothermal/wells/temp-depth' function isSandboxPath(pathname: string): boolean { return ( pathname.startsWith('/example') || + pathname.startsWith(SANDBOX_CHEMISTRY_REPORT) || pathname.startsWith(SANDBOX_GEOTHERMAL_GRID) || pathname.startsWith(SANDBOX_GEOTHERMAL_INVENTORY) || pathname.startsWith(SANDBOX_GEOTHERMAL_TEMP_DEPTH) @@ -516,6 +518,19 @@ function ExampleNavItem() { Typography + {/* Gated on the AMP.staging group via ocotillo.chemistry-report. */} + + + + Chemistry Reports + + + { + const { open: notify } = useNotification() + const [isGenerating, setIsGenerating] = useState(false) + + const handleDownload = async () => { + if (!well) return + + try { + setIsGenerating(true) + const filename = buildChemistryReportFilename(well, year) + + const blob = await pdf( + + ).toBlob() + + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) + + notify?.({ + message: 'Chemistry report generated', + type: 'success', + description: filename, + }) + } catch (error) { + console.error(error) + notify?.({ + message: 'Chemistry report generation failed', + type: 'error', + }) + } finally { + setIsGenerating(false) + } + } + + return ( + + ) +} diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index ff3bb34b..5cfd441b 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1,3 +1,4 @@ +export * from './ChemistryReportDownload' export * from './ReportBugButton' export * from './WellPDFPreview' export * from './WellPDFDownload' diff --git a/src/components/pdf/chemistry/ChemistryReportPdf.tsx b/src/components/pdf/chemistry/ChemistryReportPdf.tsx new file mode 100644 index 00000000..52471640 --- /dev/null +++ b/src/components/pdf/chemistry/ChemistryReportPdf.tsx @@ -0,0 +1,403 @@ +import { Page, Text, View } from '@react-pdf/renderer' +import { useMemo } from 'react' +import type { ChemistryObservation } from '@/hooks/useChemistryReportData' +import type { IContact, IWell } from '@/interfaces/ocotillo' +import { + type ChemistryResultRow, + formatReportDate, + formatResultValue, + summarizeChemistry, +} from '@/utils/chemistryReport' +import { formatContactAddress } from '@/utils/FormatAddress' +import { OcotilloDocument } from '../OcotilloDocument' +import { chemReportStyles as s } from './styles' + +export type ChemistryReportSections = { + wellInformation: boolean + fieldParameters: boolean + chemistryResults: boolean + standardsComparison: boolean + howToRead: boolean +} + +export const CHEMISTRY_REPORT_DEFAULT_SECTIONS: ChemistryReportSections = { + wellInformation: true, + fieldParameters: true, + chemistryResults: true, + standardsComparison: true, + howToRead: true, +} + +export const CHEMISTRY_REPORT_SECTION_LABELS: Record< + keyof ChemistryReportSections, + string +> = { + wellInformation: 'Well information & construction', + fieldParameters: 'Field parameters', + chemistryResults: 'Chemistry results', + standardsComparison: 'Drinking water standards & exceedances', + howToRead: 'How to read this report', +} + +type ChemistryReportPdfProps = { + well?: IWell + contacts?: readonly IContact[] + observations: readonly ChemistryObservation[] + year: number + sections?: ChemistryReportSections +} + +const SectionHeading = ({ children }: { children: string }) => ( + {children} +) + +const KeyValue = ({ + label, + value, +}: { + label: string + value: string | number | null | undefined +}) => ( + + {label} + + {value === null || value === undefined || value === '' ? '—' : value} + + +) + +const ResultsTable = ({ + rows, + showStandard, +}: { + rows: readonly ChemistryResultRow[] + showStandard: boolean +}) => ( + + + Parameter + Result + Unit + {showStandard ? ( + <> + Standard + Status + + ) : null} + Sampled + + {rows.map((row) => { + const emphasis = !row.exceeds + ? undefined + : row.standard?.kind === 'MCL' + ? s.tdExceeds + : s.tdSecondary + + return ( + + {row.parameterName} + + {formatResultValue(row.value)} + + {row.unit ?? '—'} + {showStandard ? ( + <> + + {row.standard + ? `${row.standard.limit} ${row.standard.unit}` + : '—'} + + + {/* A non-detect was never measured against the limit, so it + is not reported as having passed one. */} + {row.value == null + ? '—' + : !row.standard + ? 'No standard' + : row.exceeds + ? `Above ${row.standard.kind}` + : 'Within limit'} + + + ) : null} + + {formatReportDate(row.sampledOn)} + + + ) + })} + +) + +export const ChemistryReportPdf = ({ + well, + contacts = [], + observations, + year, + sections = CHEMISTRY_REPORT_DEFAULT_SECTIONS, +}: ChemistryReportPdfProps) => { + const summary = useMemo( + () => summarizeChemistry(observations), + [observations] + ) + + const owner = contacts[0] + const ownerAddress = owner?.addresses?.[0] + ? formatContactAddress(owner.addresses[0]) + : null + const locationProperties = well?.current_location?.properties as + | { county?: string | null; elevation?: number | null } + | undefined + + const wellLabel = well?.name ?? 'Unknown well' + const hasSamples = summary.rows.length > 0 + + return ( + + + + + New Mexico Bureau of Geology & Mineral Resources · Aquifer + Mapping Program + + Annual Water Quality Report + + {`Reporting year ${year} · Well ${wellLabel}`} + {well?.site_name ? ` — ${well.site_name}` : ''} + + + + {owner + ? `Prepared for ${owner.name}, owner of record` + : 'No owner of record on file'} + + {ownerAddress ? {ownerAddress} : null} + + {`Issued ${formatReportDate(new Date().toISOString())}`} + + + + + + This report summarizes the water quality data on file for your well + for the {year} calendar year, and how those results compare to federal + drinking water standards. It is provided as a courtesy and is not a + certification that the water is safe to drink. + + + + At a Glance + + + Samples this year + {summary.sampleDates.length} + + {summary.sampleDates.length + ? summary.sampleDates.map(formatReportDate).join(' · ') + : 'No samples on file'} + + + + Parameters tested + {summary.parameterCount} + + {`${summary.comparedCount} with a standard`} + + + + Above health limit + + {summary.mclExceedances.length} + + MCL + + + Above taste/odor limit + + {summary.smclExceedances.length} + + SMCL + + + + + {sections.standardsComparison && summary.mclExceedances.length > 0 ? ( + + {summary.mclExceedances.map((row) => ( + + + {`${row.parameterName} was above a federal health limit`} + + + {`${formatResultValue(row.value)} ${row.unit ?? ''} measured ${formatReportDate( + row.sampledOn + )} — the limit is ${row.standard?.limit} ${row.standard?.unit}.`} + {row.standard?.note ? ` ${row.standard.note}` : ''} + + + The NM Environment Department Drinking Water Bureau advises + private well owners at (505) 476-8620. + + + ))} + + ) : null} + + {sections.standardsComparison && summary.smclExceedances.length > 0 ? ( + + + + {`${summary.smclExceedances.length} result(s) above a taste, odor, or staining guideline`} + + + {summary.smclExceedances + .map( + (row) => + `${row.parameterName} ${formatResultValue(row.value)} ${row.unit ?? ''}` + ) + .join('; ')} + . Secondary standards are not health limits — they describe how + the water looks, tastes, and smells. + + + + ) : null} + + {sections.wellInformation ? ( + + Well Information & Construction + + + + + + + + + + + + + ) : null} + + {sections.fieldParameters ? ( + + Field Parameters + {summary.fieldParameters.length ? ( + + ) : ( + + No field parameters were recorded for this period. + + )} + + ) : null} + + {sections.chemistryResults ? ( + 12}> + Laboratory Results + {summary.labResults.length ? ( + + ) : ( + + {hasSamples + ? 'No laboratory results were recorded for this period.' + : `No water chemistry was collected at this well during ${year}.`} + + )} + + ) : null} + + {sections.howToRead ? ( + + How to Read This Report + + MCL (Maximum Contaminant Level) — an enforceable, health-based + federal limit for public water systems. Private wells are not + regulated, but the limit is the best available yardstick. + + + SMCL (Secondary Maximum Contaminant Level) — a non-enforceable + guideline for taste, odor, color, and staining. Exceeding it is a + nuisance, not a health finding. + + + "Not detected" means the laboratory did not measure the + parameter above its detection limit; it does not mean the + parameter is absent. + + + Parameters shown with no standard have no federal drinking water + limit. They are reported for completeness. + + + A single sample describes the well on the day it was collected. + Water quality changes over time; repeat sampling is the only way + to see a trend. + + + ) : null} + + + + {`${wellLabel} · Annual Water Quality Report ${year}`} + + + `Page ${pageNumber} of ${totalPages}` + } + /> + + + + ) +} diff --git a/src/components/pdf/chemistry/index.ts b/src/components/pdf/chemistry/index.ts new file mode 100644 index 00000000..028e55b2 --- /dev/null +++ b/src/components/pdf/chemistry/index.ts @@ -0,0 +1,2 @@ +export * from './ChemistryReportPdf' +export * from './styles' diff --git a/src/components/pdf/chemistry/styles.ts b/src/components/pdf/chemistry/styles.ts new file mode 100644 index 00000000..5c6fbfd4 --- /dev/null +++ b/src/components/pdf/chemistry/styles.ts @@ -0,0 +1,148 @@ +import { StyleSheet } from '@react-pdf/renderer' + +/** + * Print palette for the owner-facing chemistry report. Values mirror the + * light-mode tokens in the OcotilloMockups design system; the PDF has no dark + * mode, so they are literals rather than variables. + */ +export const CHEM_REPORT_COLORS = { + primary: '#0e6da8', + foreground: '#0f172a', + muted: '#64748b', + border: '#e5e5e5', + destructive: '#dc2626', + warningText: '#c2410c', + wrapper: '#fafafa', +} as const + +export const chemReportStyles = StyleSheet.create({ + page: { + flexDirection: 'column', + backgroundColor: '#ffffff', + paddingTop: 32, + paddingBottom: 44, + paddingHorizontal: 36, + color: CHEM_REPORT_COLORS.foreground, + }, + + masthead: { + borderBottomWidth: 2, + borderBottomColor: CHEM_REPORT_COLORS.primary, + paddingBottom: 10, + marginBottom: 14, + }, + org: { + fontSize: 8, + color: CHEM_REPORT_COLORS.muted, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + reportTitle: { + fontSize: 20, + fontWeight: 'bold', + color: CHEM_REPORT_COLORS.primary, + marginTop: 4, + }, + reportSubtitle: { fontSize: 10, marginTop: 3 }, + ownerBlock: { fontSize: 9, marginTop: 8, lineHeight: 1.4 }, + dim: { color: CHEM_REPORT_COLORS.muted }, + + lede: { fontSize: 9, lineHeight: 1.5, marginBottom: 12 }, + + section: { marginBottom: 12 }, + sectionHeading: { + fontSize: 11, + fontWeight: 'bold', + borderBottomWidth: 1, + borderBottomColor: CHEM_REPORT_COLORS.border, + paddingBottom: 3, + marginBottom: 6, + }, + + statRow: { flexDirection: 'row', flexWrap: 'wrap' }, + stat: { + width: '25%', + paddingRight: 8, + marginBottom: 4, + }, + statLabel: { fontSize: 7.5, color: CHEM_REPORT_COLORS.muted }, + statValue: { fontSize: 15, fontWeight: 'bold', marginTop: 1 }, + statNote: { fontSize: 7.5, color: CHEM_REPORT_COLORS.muted }, + + callout: { + borderWidth: 1, + borderColor: CHEM_REPORT_COLORS.destructive, + borderLeftWidth: 3, + backgroundColor: '#fef2f2', + padding: 8, + marginBottom: 8, + }, + calloutWarn: { + borderColor: CHEM_REPORT_COLORS.warningText, + backgroundColor: '#fff7ed', + }, + calloutTitle: { + fontSize: 10, + fontWeight: 'bold', + marginBottom: 3, + color: CHEM_REPORT_COLORS.destructive, + }, + calloutTitleWarn: { color: CHEM_REPORT_COLORS.warningText }, + calloutBody: { fontSize: 8.5, lineHeight: 1.45 }, + + kvRow: { flexDirection: 'row', flexWrap: 'wrap' }, + kvCell: { width: '33.33%', paddingRight: 8, marginBottom: 5 }, + kvLabel: { fontSize: 7.5, color: CHEM_REPORT_COLORS.muted }, + kvValue: { fontSize: 9 }, + + table: { borderWidth: 1, borderColor: CHEM_REPORT_COLORS.border }, + tableHeaderRow: { + flexDirection: 'row', + backgroundColor: CHEM_REPORT_COLORS.wrapper, + borderBottomWidth: 1, + borderBottomColor: CHEM_REPORT_COLORS.border, + paddingVertical: 4, + paddingHorizontal: 5, + }, + tableRow: { + flexDirection: 'row', + borderBottomWidth: 0.5, + borderBottomColor: CHEM_REPORT_COLORS.border, + paddingVertical: 3, + paddingHorizontal: 5, + }, + th: { fontSize: 7.5, fontWeight: 'bold' }, + td: { fontSize: 8 }, + tdExceeds: { color: CHEM_REPORT_COLORS.destructive, fontWeight: 'bold' }, + tdSecondary: { color: CHEM_REPORT_COLORS.warningText, fontWeight: 'bold' }, + + // Right-aligned numeric columns carry their own gutter so a long result or + // limit never butts up against the unit or status that follows it. + colParameter: { flex: 3, paddingRight: 6 }, + colValue: { flex: 1.6, textAlign: 'right', paddingRight: 8 }, + colUnit: { flex: 1.2, paddingRight: 6 }, + colStandard: { flex: 1.8, textAlign: 'right', paddingRight: 8 }, + colStatus: { flex: 1.8, paddingRight: 6 }, + colDate: { flex: 1.8 }, + + bullet: { fontSize: 8.5, lineHeight: 1.45, marginBottom: 2 }, + emptyNote: { + fontSize: 9, + color: CHEM_REPORT_COLORS.muted, + fontStyle: 'italic', + marginBottom: 8, + }, + + footer: { + position: 'absolute', + bottom: 22, + left: 36, + right: 36, + borderTopWidth: 0.5, + borderTopColor: CHEM_REPORT_COLORS.border, + paddingTop: 4, + flexDirection: 'row', + justifyContent: 'space-between', + }, + footerText: { fontSize: 7, color: CHEM_REPORT_COLORS.muted }, +}) diff --git a/src/components/pdf/index.ts b/src/components/pdf/index.ts index 3609a8ac..68fd7f88 100644 --- a/src/components/pdf/index.ts +++ b/src/components/pdf/index.ts @@ -1,4 +1,5 @@ export * from './OcotilloDocument' +export * from './chemistry' export * from './well' export * from './AdditionalInformation' export * from './CoreInformation' diff --git a/src/constants/drinkingWaterStandards.ts b/src/constants/drinkingWaterStandards.ts new file mode 100644 index 00000000..5ec8937d --- /dev/null +++ b/src/constants/drinkingWaterStandards.ts @@ -0,0 +1,110 @@ +import type { ParameterName } from '@/generated/types.gen' + +/** + * Federal drinking water standards used to flag owner-facing chemistry + * reports. + * + * - MCL (Maximum Contaminant Level) is an enforceable health-based limit. + * - SMCL (Secondary MCL) is a non-enforceable taste, odor, or staining + * guideline. + * + * Values are EPA National Primary/Secondary Drinking Water Regulations, in + * mg/L unless noted. Parameters absent from this table are reported without a + * comparison rather than being reported as passing. + */ +export type StandardKind = 'MCL' | 'SMCL' + +export type DrinkingWaterStandard = { + kind: StandardKind + /** Threshold in `unit`. A result strictly above this is an exceedance. */ + limit: number + unit: string + /** Plain-language note printed under the exceedance callout. */ + note?: string +} + +export const DRINKING_WATER_STANDARDS: Partial< + Record +> = { + Arsenic: { + kind: 'MCL', + limit: 0.01, + unit: 'mg/L', + note: 'Arsenic occurs naturally in New Mexico groundwater. Long-term consumption above the limit is associated with health risk.', + }, + Barium: { kind: 'MCL', limit: 2, unit: 'mg/L' }, + Antimony: { kind: 'MCL', limit: 0.006, unit: 'mg/L' }, + Beryllium: { kind: 'MCL', limit: 0.004, unit: 'mg/L' }, + Cadmium: { kind: 'MCL', limit: 0.005, unit: 'mg/L' }, + Chromium: { kind: 'MCL', limit: 0.1, unit: 'mg/L' }, + Cyanide: { kind: 'MCL', limit: 0.2, unit: 'mg/L' }, + Fluoride: { + kind: 'MCL', + limit: 4, + unit: 'mg/L', + note: 'Fluoride above 2 mg/L can stain children’s teeth; above 4 mg/L is a health limit.', + }, + Mercury: { kind: 'MCL', limit: 0.002, unit: 'mg/L' }, + 'Nitrate (as N)': { + kind: 'MCL', + limit: 10, + unit: 'mg/L', + note: 'Nitrate above the limit is an immediate risk to infants under six months and to pregnant people.', + }, + 'Nitrite (as N)': { kind: 'MCL', limit: 1, unit: 'mg/L' }, + Selenium: { kind: 'MCL', limit: 0.05, unit: 'mg/L' }, + Thallium: { kind: 'MCL', limit: 0.002, unit: 'mg/L' }, + Lead: { + kind: 'MCL', + limit: 0.015, + unit: 'mg/L', + note: 'Lead in a private well is usually contributed by household plumbing rather than by the aquifer.', + }, + 'Uranium (total, by ICP-MS)': { kind: 'MCL', limit: 0.03, unit: 'mg/L' }, + + Aluminum: { kind: 'SMCL', limit: 0.2, unit: 'mg/L' }, + Chloride: { kind: 'SMCL', limit: 250, unit: 'mg/L' }, + Copper: { kind: 'SMCL', limit: 1, unit: 'mg/L' }, + Iron: { kind: 'SMCL', limit: 0.3, unit: 'mg/L' }, + Manganese: { kind: 'SMCL', limit: 0.05, unit: 'mg/L' }, + Silver: { kind: 'SMCL', limit: 0.1, unit: 'mg/L' }, + Sulfate: { kind: 'SMCL', limit: 250, unit: 'mg/L' }, + 'Total Dissolved Solids': { kind: 'SMCL', limit: 500, unit: 'mg/L' }, + Zinc: { kind: 'SMCL', limit: 5, unit: 'mg/L' }, +} + +export const getDrinkingWaterStandard = ( + parameterName?: string | null +): DrinkingWaterStandard | undefined => + parameterName + ? DRINKING_WATER_STANDARDS[parameterName as ParameterName] + : undefined + +export type StandardComparison = { + standard?: DrinkingWaterStandard + /** True only when a standard exists and the value is strictly above it. */ + exceeds: boolean +} + +/** + * Compares a result against its standard. Units are not converted: a result + * reported in a unit other than the standard's is treated as not comparable, + * so a mg/L limit is never silently applied to a µg/L number. + */ +export const compareToStandard = ( + parameterName: string | null | undefined, + value: number | null | undefined, + unit: string | null | undefined +): StandardComparison => { + const standard = getDrinkingWaterStandard(parameterName) + + if (!standard || value == null || Number.isNaN(value)) { + return { standard, exceeds: false } + } + + if (unit && unit !== standard.unit) { + return { standard, exceeds: false } + } + + return { standard, exceeds: value > standard.limit } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index b39fcf72..f0396570 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './useAccessCapabilities' export * from './useSearchHistory' export * from './useAll' export * from './useAllNotes' +export * from './useChemistryReportData' export * from './useDebounce' export * from './useElevation' export * from './useLayer' diff --git a/src/hooks/useChemistryReportData.ts b/src/hooks/useChemistryReportData.ts new file mode 100644 index 00000000..304646a0 --- /dev/null +++ b/src/hooks/useChemistryReportData.ts @@ -0,0 +1,75 @@ +import { useList, useOne } from '@refinedev/core' +import { useMemo } from 'react' +import type { WaterChemistryObservationResponse } from '@/generated/types.gen' +import type { IContact, IWell } from '@/interfaces/ocotillo' + +export type ChemistryObservation = WaterChemistryObservationResponse + +/** + * Everything the chemistry report needs for one well and one reporting + * period. The period is inclusive of Jan 1 and exclusive of Jan 1 of the + * following year, which is how the API's start_time/end_time filter behaves. + */ +export const useChemistryReportData = ({ + thingId, + year, +}: { + thingId: string | number | undefined + year: number +}) => { + const enabled = Boolean(thingId) + + const { result: well, query: wellQuery } = useOne({ + resource: 'thing-well', + id: thingId, + queryOptions: { enabled }, + }) + + const { result: contactResult, query: contactQuery } = useList({ + resource: 'contact', + dataProviderName: 'ocotillo', + meta: { params: { thing_id: thingId } }, + queryOptions: { enabled }, + }) + + const { result: observationResult, query: observationQuery } = + useList({ + resource: 'observation/water-chemistry', + dataProviderName: 'ocotillo', + pagination: { currentPage: 1, pageSize: 500, mode: 'server' }, + meta: { + params: { + thing_id: thingId, + start_time: `${year}-01-01T00:00:00`, + end_time: `${year + 1}-01-01T00:00:00`, + }, + }, + queryOptions: { enabled }, + }) + + const observations = useMemo( + () => + [...(observationResult?.data ?? [])].sort((a, b) => { + const byDate = + new Date(a.observation_datetime).getTime() - + new Date(b.observation_datetime).getTime() + if (byDate !== 0) return byDate + return (a.parameter?.parameter_name ?? '').localeCompare( + b.parameter?.parameter_name ?? '' + ) + }), + [observationResult?.data] + ) + + const isLoading = + wellQuery.isLoading || contactQuery.isLoading || observationQuery.isLoading + + return { + well: well as IWell | undefined, + contacts: contactResult?.data ?? [], + observations, + isLoading: enabled ? isLoading : false, + isError: + wellQuery.isError || contactQuery.isError || observationQuery.isError, + } +} diff --git a/src/pages/ocotillo/chemistry-report/export.tsx b/src/pages/ocotillo/chemistry-report/export.tsx new file mode 100644 index 00000000..cb65daa1 --- /dev/null +++ b/src/pages/ocotillo/chemistry-report/export.tsx @@ -0,0 +1,196 @@ +import { + Alert, + Autocomplete, + Box, + Checkbox, + FormControlLabel, + MenuItem, + Paper, + Skeleton, + Stack, + TextField, + Typography, +} from '@mui/material' +import Grid from '@mui/material/Grid2' +import { PDFViewer } from '@react-pdf/renderer' +import { useAutocomplete } from '@refinedev/mui' +import { useMemo, useState } from 'react' +import { ChemistryReportDownloadButton } from '@/components/Button' +import { OcotilloPageTitle } from '@/components/OcotilloPageHeader' +import { + CHEMISTRY_REPORT_DEFAULT_SECTIONS, + CHEMISTRY_REPORT_SECTION_LABELS, + ChemistryReportPdf, + type ChemistryReportSections, +} from '@/components/pdf/chemistry' +import { useChemistryReportData, useDebounce } from '@/hooks' +import type { IWell } from '@/interfaces/ocotillo' + +/** Reporting periods offered in the picker: this year and the four before it. */ +const buildYearOptions = (): number[] => { + const current = new Date().getFullYear() + return Array.from({ length: 5 }, (_, index) => current - index) +} + +export const ChemistryReportExport = () => { + const yearOptions = useMemo(buildYearOptions, []) + const [selectedWell, setSelectedWell] = useState(null) + const [year, setYear] = useState(yearOptions[0]) + const [sections, setSections] = useState( + CHEMISTRY_REPORT_DEFAULT_SECTIONS + ) + + const [wellSearch, setWellSearch] = useState('') + const debouncedWellSearch = useDebounce(wellSearch, 300) + + // The API filters wells by the `name_contains` query param rather than by a + // Refine filter, so the search term is threaded through meta.params — the + // same shape the Wells list uses. + const { autocompleteProps } = useAutocomplete({ + resource: 'thing/water-well', + dataProviderName: 'ocotillo', + meta: { + params: { + include_contacts: true, + ...(debouncedWellSearch ? { name_contains: debouncedWellSearch } : {}), + }, + }, + }) + + const { well, contacts, observations, isLoading, isError } = + useChemistryReportData({ thingId: selectedWell?.id, year }) + + const toggleSection = (key: keyof ChemistryReportSections) => + setSections((previous) => ({ ...previous, [key]: !previous[key] })) + + const isReady = Boolean(selectedWell) && !isLoading && !isError + + return ( + + + + + + + Generate an owner-facing annual water quality report for a single well. + Multi-well runs, delivery, and scheduling are not implemented yet. + + + + + + setSelectedWell(newValue)} + getOptionKey={(option) => option.id} + getOptionLabel={(option) => `${option.name} (${option.id})`} + isOptionEqualToValue={(option, value) => option.id === value?.id} + inputValue={wellSearch} + onInputChange={(_, newInput) => setWellSearch(newInput)} + filterOptions={(options) => options} + renderInput={(params) => ( + + )} + /> + + + setYear(Number(event.target.value))} + > + {yearOptions.map((option) => ( + + {`Calendar year ${option}`} + + ))} + + + + + + Sections + + + {( + Object.keys( + CHEMISTRY_REPORT_SECTION_LABELS + ) as (keyof ChemistryReportSections)[] + ).map((key) => ( + toggleSection(key)} + /> + } + label={ + + {CHEMISTRY_REPORT_SECTION_LABELS[key]} + + } + /> + ))} + + + + + + + {isError ? ( + + Could not load chemistry data for this well. + + ) : null} + + {selectedWell && !isLoading && observations.length === 0 ? ( + + {`No water chemistry is on file for ${selectedWell.name} in ${year}. The report still generates, marked as having no results.`} + + ) : null} + + + {!selectedWell ? ( + + + Select a well to preview its report. + + + ) : isLoading ? ( + + ) : ( + + + + )} + + + ) +} diff --git a/src/pages/ocotillo/chemistry-report/index.tsx b/src/pages/ocotillo/chemistry-report/index.tsx new file mode 100644 index 00000000..dfbc7a6a --- /dev/null +++ b/src/pages/ocotillo/chemistry-report/index.tsx @@ -0,0 +1 @@ +export * from './export' diff --git a/src/providers/authentik-provider.ts b/src/providers/authentik-provider.ts index d885e02f..54d99a53 100644 --- a/src/providers/authentik-provider.ts +++ b/src/providers/authentik-provider.ts @@ -49,6 +49,9 @@ const TEST_AUTH_GROUPS: AuthentikPermissions = [ 'AMP.Viewer', 'AMP.Editor', 'AMP.Admin', + // Not implied by AMP.Admin, so the local test identity has to hold it + // explicitly or staging-gated screens are unreachable in dev. + 'AMP.staging', 'Geothermal.Viewer', 'Geothermal.Editor', ] diff --git a/src/resources/ocotillo.tsx b/src/resources/ocotillo.tsx index 34cc9580..ded11c34 100644 --- a/src/resources/ocotillo.tsx +++ b/src/resources/ocotillo.tsx @@ -13,7 +13,7 @@ import { PictureAsPdfOutlined, Place, ScaleOutlined, - // ScienceOutlined, + ScienceOutlined, // SettingsInputAntenna, // Spa, Timeline, @@ -337,6 +337,16 @@ const ocotillo = [ icon: , }, }, + { + name: 'chemistry-report', + list: '/ocotillo/chemistry-report', + meta: { + label: 'Chemistry Reports', + parent: 'Sandbox', + nestedLevel: 1, + icon: , + }, + }, { name: 'observation', icon: , diff --git a/src/routes/ocotillo.tsx b/src/routes/ocotillo.tsx index 38b836f7..2b6275a2 100644 --- a/src/routes/ocotillo.tsx +++ b/src/routes/ocotillo.tsx @@ -8,6 +8,7 @@ import { AssetShow, UnassociatedAssetList, } from '@/pages/ocotillo/asset' +import { ChemistryReportExport } from '@/pages/ocotillo/chemistry-report' import { CollectionsPage } from '@/pages/ocotillo/collections' import { ContactList, ContactShow } from '@/pages/ocotillo/contact' import { GroundwaterLevelForm } from '@/pages/ocotillo/groundwater-level-form/stepperform' @@ -265,6 +266,16 @@ export const OcotilloRoutes = () => { } /> + + + + + } + /> + resource.name) + .filter( + (name) => + !name.startsWith('geothermal.') && + !STAGING_ONLY_ROUTABLE.includes(name) + ), + }, + { + name: 'AMP.staging', + groups: ['AMP.staging'], + allowedResources: [...STAGING_ONLY_ROUTABLE], + }, + { + name: 'AMP.Admin + AMP.staging', + groups: ['AMP.Admin', 'AMP.staging'], allowedResources: routableResources .map((resource) => resource.name) .filter((name) => !name.startsWith('geothermal.')), diff --git a/src/test/utils/chemistryReport.test.ts b/src/test/utils/chemistryReport.test.ts new file mode 100644 index 00000000..dbda6c20 --- /dev/null +++ b/src/test/utils/chemistryReport.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { compareToStandard } from '@/constants/drinkingWaterStandards' +import type { ChemistryObservation } from '@/hooks/useChemistryReportData' +import { + buildChemistryReportFilename, + formatResultValue, + summarizeChemistry, +} from '@/utils/chemistryReport' + +const observation = ( + overrides: Partial & { + parameterName: string + parameterType?: string | null + } +): ChemistryObservation => { + const { parameterName, parameterType = 'Metal', ...rest } = overrides + + return { + id: 1, + created_at: '2026-05-15T00:00:00Z', + release_status: 'public', + sample_id: 1, + sensor_id: null, + observation_datetime: '2026-05-15T00:00:00Z', + value: 0, + unit: 'mg/L', + parameter: { + id: 1, + created_at: '2026-01-01T00:00:00Z', + release_status: 'public', + parameter_name: parameterName, + matrix: 'water', + parameter_type: parameterType, + cas_number: null, + default_unit: 'mg/L', + }, + ...rest, + } as ChemistryObservation +} + +describe('compareToStandard', () => { + it('flags a result above its MCL', () => { + expect(compareToStandard('Arsenic', 0.012, 'mg/L')).toMatchObject({ + exceeds: true, + standard: { kind: 'MCL', limit: 0.01 }, + }) + }) + + it('treats a result exactly at the limit as within the limit', () => { + expect(compareToStandard('Arsenic', 0.01, 'mg/L').exceeds).toBe(false) + }) + + it('refuses to compare across units rather than misapplying the limit', () => { + // 12 µg/L is 0.012 mg/L — above the limit — but the numbers are not + // comparable as given, so the row must not be flagged from the raw value. + expect(compareToStandard('Arsenic', 12, 'ug/L').exceeds).toBe(false) + }) + + it('reports no standard for an unregulated parameter', () => { + expect(compareToStandard('Calcium', 90, 'mg/L')).toEqual({ + standard: undefined, + exceeds: false, + }) + }) +}) + +describe('summarizeChemistry', () => { + const rows = [ + observation({ id: 1, parameterName: 'Arsenic', value: 0.012 }), + observation({ id: 2, parameterName: 'Iron', value: 0.9 }), + observation({ id: 3, parameterName: 'Calcium', value: 90 }), + observation({ + id: 4, + parameterName: 'pH', + parameterType: 'Field Parameter', + value: 7.8, + unit: 'dimensionless', + observation_datetime: '2026-02-04T00:00:00Z', + }), + ] + + const summary = summarizeChemistry(rows) + + it('splits field parameters from laboratory results', () => { + expect(summary.fieldParameters.map((row) => row.parameterName)).toEqual([ + 'pH', + ]) + expect(summary.labResults).toHaveLength(3) + }) + + it('separates health limits from taste and odor guidelines', () => { + expect(summary.mclExceedances.map((row) => row.parameterName)).toEqual([ + 'Arsenic', + ]) + expect(summary.smclExceedances.map((row) => row.parameterName)).toEqual([ + 'Iron', + ]) + }) + + it('counts distinct sample dates and compared parameters', () => { + expect(summary.sampleDates).toEqual(['2026-02-04', '2026-05-15']) + expect(summary.parameterCount).toBe(4) + expect(summary.comparedCount).toBe(2) + }) + + it('handles a well with no chemistry on file', () => { + expect(summarizeChemistry([])).toMatchObject({ + sampleDates: [], + parameterCount: 0, + mclExceedances: [], + }) + }) +}) + +describe('formatResultValue', () => { + it('preserves lab precision instead of rounding to the limit', () => { + expect(formatResultValue(0.012)).toBe('0.012') + }) + + it('labels a null result rather than printing zero', () => { + expect(formatResultValue(null)).toBe('Not detected') + }) +}) + +describe('buildChemistryReportFilename', () => { + it('slugifies the well name', () => { + expect( + buildChemistryReportFilename({ id: 1187, name: 'WL-1187' }, 2026) + ).toBe('chemistry-report-WL-1187-2026.pdf') + }) + + it('falls back to the id when the well has no name', () => { + expect(buildChemistryReportFilename(undefined, 2026)).toBe( + 'chemistry-report-well-unknown-2026.pdf' + ) + }) +}) diff --git a/src/utils/accessControl.ts b/src/utils/accessControl.ts index 95d61d6b..4e82a571 100644 --- a/src/utils/accessControl.ts +++ b/src/utils/accessControl.ts @@ -6,21 +6,35 @@ import type { } from '@/interfaces/ocotillo/IContact' export type AmpRole = 'AMP.Viewer' | 'AMP.Editor' | 'AMP.Admin' +/** + * Opt-in flag group for features that are still being reviewed. It is not a + * rung on the AMP.Viewer → AMP.Editor → AMP.Admin ladder: holding it grants + * nothing else, and holding AMP.Admin does not imply it. A user has to be put + * in the group deliberately. + */ +export type AmpStagingRole = 'AMP.staging' export type GeothermalRole = | 'Geothermal.Viewer' | 'Geothermal.Editor' | 'Geothermal.Admin' -export type PortalRole = AmpRole | GeothermalRole +export type PortalRole = AmpRole | AmpStagingRole | GeothermalRole const roleOrder: PortalRole[] = [ 'AMP.Viewer', 'AMP.Editor', 'AMP.Admin', + 'AMP.staging', 'Geothermal.Viewer', 'Geothermal.Editor', 'Geothermal.Admin', ] +/** + * Roles that carry no hierarchy — they pass through normalization as-is + * instead of being expanded from a domain ladder. + */ +const standaloneRoles: PortalRole[] = ['AMP.staging'] + export const wipResources = new Set([ 'water.dashboard', 'water.reportbuilder', @@ -51,6 +65,7 @@ const geothermalEditorRoles: PortalRole[] = [ 'Geothermal.Admin', ] const geothermalAdminRoles: PortalRole[] = ['Geothermal.Admin'] +const stagingRoles: PortalRole[] = ['AMP.staging'] const adminOnlyRoles = new Set(['AMP.Admin', 'Geothermal.Admin']) const resourcePolicies: Record = { @@ -116,6 +131,7 @@ const resourcePolicies: Record = { manage: adminRoles, }, Sandbox: { list: adminRoles, show: adminRoles }, + 'ocotillo.chemistry-report': { list: stagingRoles, show: stagingRoles }, geothermal: { list: geothermalViewerRoles, show: geothermalViewerRoles }, 'water.locations': { list: ['AMP.Admin', 'Geothermal.Admin'], @@ -169,6 +185,12 @@ export const normalizeAccessControlGroups = ( ['Geothermal.Viewer', 'Geothermal.Editor', 'Geothermal.Admin'], ] + for (const role of standaloneRoles) { + if (normalized.has(role)) { + expandedRoles.add(role) + } + } + for (const hierarchy of domainHierarchies) { if (normalized.has(hierarchy[2])) { hierarchy.forEach((role) => expandedRoles.add(role)) @@ -190,7 +212,11 @@ export const normalizeAccessControlGroups = ( export const getPrimaryRole = ( groups: string[] | null | undefined ): PortalRole | null => { - const normalized = normalizeAccessControlGroups(groups) + // Standalone flag groups are not a rank, so they never become the label a + // user is shown as holding. + const normalized = normalizeAccessControlGroups(groups).filter( + (role) => !standaloneRoles.includes(role) + ) return normalized.length > 0 ? normalized[normalized.length - 1] : null } @@ -205,6 +231,7 @@ export const getAccessCapabilities = (groups: string[] | null | undefined) => { const canManageAmp = roles.includes('AMP.Admin') const canViewConfidential = canEditAmp const canViewUnfinished = canManageAmp + const canViewAmpStaging = roles.includes('AMP.staging') const canViewGeothermal = roles.includes('Geothermal.Viewer') || roles.includes('Geothermal.Editor') || @@ -221,6 +248,7 @@ export const getAccessCapabilities = (groups: string[] | null | undefined) => { canManageAmp, canViewConfidential, canViewUnfinished, + canViewAmpStaging, canViewGeothermal, canEditGeothermal, canManageGeothermal, @@ -266,6 +294,7 @@ export const canAccessResource = ({ if ( resource === 'ocotillo.hydrograph-correction' || resource === 'ocotillo.thing-well-pdf-preview' || + resource === 'ocotillo.chemistry-report' || resource === 'Sandbox' ) { const policy = resourcePolicies[resource] diff --git a/src/utils/chemistryReport.ts b/src/utils/chemistryReport.ts new file mode 100644 index 00000000..eba07ea3 --- /dev/null +++ b/src/utils/chemistryReport.ts @@ -0,0 +1,120 @@ +import { + compareToStandard, + type DrinkingWaterStandard, +} from '@/constants/drinkingWaterStandards' +import type { ChemistryObservation } from '@/hooks/useChemistryReportData' +import type { IWell } from '@/interfaces/ocotillo' + +export type ChemistryResultRow = { + key: string + parameterName: string + parameterType: string | null + value: number | null + unit: string | null + sampledOn: string + standard?: DrinkingWaterStandard + exceeds: boolean +} + +export type ChemistryReportSummary = { + rows: ChemistryResultRow[] + fieldParameters: ChemistryResultRow[] + labResults: ChemistryResultRow[] + sampleDates: string[] + parameterCount: number + comparedCount: number + mclExceedances: ChemistryResultRow[] + smclExceedances: ChemistryResultRow[] +} + +const FIELD_PARAMETER_TYPE = 'Field Parameter' + +/** + * Sample and completion dates are calendar dates, not instants. The API sends + * them as UTC (or as a bare `YYYY-MM-DD`, which parses as UTC midnight), so + * they are formatted in UTC — formatting in the viewer's local zone would + * print a sample collected Feb 04 as Feb 03 anywhere west of Greenwich. + */ +export const formatReportDate = (value?: string | null): string => { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '—' + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + timeZone: 'UTC', + }) +} + +/** + * Results are reported at the precision the lab gave us rather than a fixed + * number of decimals — an arsenic result of 0.012 mg/L must not be rounded to + * 0.01 mg/L, which is the limit it is being compared against. + */ +export const formatResultValue = (value: number | null): string => + value == null ? 'Not detected' : String(value) + +export const summarizeChemistry = ( + observations: readonly ChemistryObservation[] +): ChemistryReportSummary => { + const rows: ChemistryResultRow[] = observations.map((observation) => { + const parameterName = observation.parameter?.parameter_name ?? 'Unknown' + const unit = observation.unit ?? observation.parameter?.default_unit ?? null + const { standard, exceeds } = compareToStandard( + parameterName, + observation.value, + unit + ) + + return { + key: String(observation.id), + parameterName, + parameterType: observation.parameter?.parameter_type ?? null, + value: observation.value, + unit, + sampledOn: observation.observation_datetime, + standard, + exceeds, + } + }) + + const sampleDates = Array.from( + new Set(rows.map((row) => row.sampledOn.slice(0, 10))) + ).sort() + + return { + rows, + fieldParameters: rows.filter( + (row) => row.parameterType === FIELD_PARAMETER_TYPE + ), + labResults: rows.filter( + (row) => row.parameterType !== FIELD_PARAMETER_TYPE + ), + sampleDates, + parameterCount: new Set(rows.map((row) => row.parameterName)).size, + comparedCount: new Set( + rows.filter((row) => row.standard).map((row) => row.parameterName) + ).size, + mclExceedances: rows.filter( + (row) => row.exceeds && row.standard?.kind === 'MCL' + ), + smclExceedances: rows.filter( + (row) => row.exceeds && row.standard?.kind === 'SMCL' + ), + } +} + +/** + * `WL-1187 Vigil Ranch Well` → `chemistry-report-WL-1187-2026.pdf` + */ +export const buildChemistryReportFilename = ( + well: Pick | undefined, + year: number +): string => { + const slug = (well?.name ?? `well-${well?.id ?? 'unknown'}`) + .trim() + .replace(/\s+/g, '-') + .replace(/[^A-Za-z0-9._-]/g, '') + return `chemistry-report-${slug}-${year}.pdf` +} diff --git a/src/utils/index.ts b/src/utils/index.ts index bc91b2c0..6233421c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,7 @@ export * from './accessControl' export * from './ApiUriBuilder' export * from './Auth' export * from './BuildPdfFilename' +export * from './chemistryReport' export * from './CreatePdfStyles' export * from './Date' export * from './FallbackWithDefault' From ba87395db47fc4dd739f172ff756435e950659b8 Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 21 Aug 2026 09:50:39 -0700 Subject: [PATCH 2/6] feat(well-show): generate the chemistry report from well details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts the annual water quality report one click from the well it describes, instead of only in the standalone exporter where the well has to be looked up again. The report covers a single calendar year, so the button has to pick one. It reports on the most recent year the well was actually sampled rather than the current year — a well last sampled in 2024 would otherwise hand back an empty report, which reads as a broken button. Finding that year costs one row (newest sample, sorted descending), and the same row answers whether the well has any chemistry at all: with none, the button is disabled and says so on hover. The year's results are not fetched until the button is pressed. Most visits to a well page are not after a report, and the well and its contacts are already loaded by the page, so nothing extra is pulled for the common case. Generating and downloading the PDF is shared with the exporter's button rather than copied, and the reporting-window and sort rules now live in one place so the two entry points cannot drift into disagreeing about what a reporting year is. Gated on AMP.staging, matching the exporter. Closes BDMS-1189 Co-Authored-By: Claude Opus 5 --- .../Button/ChemistryReportDownload.tsx | 187 +++++++++++++++--- .../pdf/chemistry/downloadChemistryReport.tsx | 47 +++++ src/components/pdf/chemistry/index.ts | 1 + src/hooks/useChemistryReportData.ts | 25 ++- src/pages/ocotillo/thing/well-show.tsx | 10 +- .../WellChemistryReportButton.test.tsx | 163 +++++++++++++++ src/test/pages/well-show.test.tsx | 1 + src/test/utils/chemistryReport.test.ts | 68 +++++++ src/utils/chemistryReport.ts | 43 ++++ 9 files changed, 507 insertions(+), 38 deletions(-) create mode 100644 src/components/pdf/chemistry/downloadChemistryReport.tsx create mode 100644 src/test/components/WellChemistryReportButton.test.tsx diff --git a/src/components/Button/ChemistryReportDownload.tsx b/src/components/Button/ChemistryReportDownload.tsx index fa7a1905..c935d013 100644 --- a/src/components/Button/ChemistryReportDownload.tsx +++ b/src/components/Button/ChemistryReportDownload.tsx @@ -1,15 +1,26 @@ -import { pdf } from '@react-pdf/renderer' -import { useNotification } from '@refinedev/core' -import { DownloadIcon } from 'lucide-react' -import { useState } from 'react' +import { useDataProvider, useList, useNotification } from '@refinedev/core' +import { DownloadIcon, FlaskConicalIcon } from 'lucide-react' +import { useMemo, useState } from 'react' import { - ChemistryReportPdf, type ChemistryReportSections, + downloadChemistryReport, } from '@/components/pdf/chemistry' import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import type { ChemistryObservation } from '@/hooks/useChemistryReportData' import type { IContact, IWell } from '@/interfaces/ocotillo' -import { buildChemistryReportFilename } from '@/utils/chemistryReport' +import { + CHEMISTRY_REPORT_PAGE_SIZE, + chemistryReportYearOf, + chemistryReportYearParams, + sortChemistryObservations, +} from '@/utils/chemistryReport' + +const CHEMISTRY_RESOURCE = 'observation/water-chemistry' export const ChemistryReportDownloadButton = ({ well, @@ -34,24 +45,13 @@ export const ChemistryReportDownloadButton = ({ try { setIsGenerating(true) - const filename = buildChemistryReportFilename(well, year) - - const blob = await pdf( - - ).toBlob() - - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = filename - anchor.click() - URL.revokeObjectURL(url) + const filename = await downloadChemistryReport({ + well, + contacts, + observations, + year, + sections, + }) notify?.({ message: 'Chemistry report generated', @@ -81,3 +81,142 @@ export const ChemistryReportDownloadButton = ({ ) } + +/** + * Well-details entry point to the annual water quality report. The well and + * its contacts are already on the page, so the only thing this looks up is + * which year to report on — and it does not pull that year's results until the + * button is actually pressed, since most visits to a well page are not after a + * report. + */ +export const WellChemistryReportButton = ({ + well, + contacts, + isLoading = false, +}: { + well?: IWell + contacts: readonly IContact[] + isLoading?: boolean +}) => { + const { open: notify } = useNotification() + const dataProvider = useDataProvider() + const ocotilloDataProvider = useMemo( + () => dataProvider('ocotillo'), + [dataProvider] + ) + const [isGenerating, setIsGenerating] = useState(false) + + const wellId = well?.id + + // The report covers one calendar year, and the year worth reporting on is + // the most recent one sampled: a well last sampled in 2024 would otherwise + // produce an empty report for the current year. One row is enough to find + // it, and it doubles as the "has any chemistry at all" check. + const { result: latestResult, query: latestQuery } = + useList({ + resource: CHEMISTRY_RESOURCE, + dataProviderName: 'ocotillo', + pagination: { currentPage: 1, pageSize: 1, mode: 'server' }, + sorters: [{ field: 'observation_datetime', order: 'desc' }], + meta: { params: { thing_id: wellId } }, + queryOptions: { + enabled: Boolean(wellId), + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }, + }) + + const reportYear = chemistryReportYearOf( + latestResult?.data?.[0]?.observation_datetime + ) + + const fetchYearObservations = async ( + year: number, + thingId: string | number + ) => { + const params = { thing_id: thingId, ...chemistryReportYearParams(year) } + const collected: ChemistryObservation[] = [] + let currentPage = 1 + + while (true) { + const page = await ocotilloDataProvider.getList({ + resource: CHEMISTRY_RESOURCE, + pagination: { currentPage, pageSize: CHEMISTRY_REPORT_PAGE_SIZE }, + meta: { params }, + }) + + collected.push(...(page.data as ChemistryObservation[])) + + if (page.data.length === 0 || collected.length >= page.total) break + currentPage += 1 + } + + return collected + } + + const handleGenerate = async () => { + if (!well || wellId == null || reportYear == null) return + + try { + setIsGenerating(true) + const observations = await fetchYearObservations(reportYear, wellId) + + const filename = await downloadChemistryReport({ + well, + contacts, + observations: sortChemistryObservations(observations), + year: reportYear, + }) + + notify?.({ + message: `Chemistry report generated for ${reportYear}`, + type: 'success', + description: filename, + }) + } catch (error) { + console.error(error) + notify?.({ + message: 'Chemistry report generation failed', + type: 'error', + }) + } finally { + setIsGenerating(false) + } + } + + const hasChemistry = reportYear != null + const isBusy = isLoading || latestQuery.isLoading + const isDisabled = isBusy || !well || !hasChemistry || isGenerating + + const tooltip = isGenerating + ? 'Generating…' + : isBusy + ? 'Checking for water chemistry…' + : hasChemistry + ? `Annual water quality report for ${reportYear}` + : 'No water chemistry on file for this well' + + return ( + + {/* Wrapped so the tooltip still explains the button while it is + disabled — a disabled button emits no pointer events of its own. */} + + + + + + {tooltip} + + ) +} diff --git a/src/components/pdf/chemistry/downloadChemistryReport.tsx b/src/components/pdf/chemistry/downloadChemistryReport.tsx new file mode 100644 index 00000000..f6522456 --- /dev/null +++ b/src/components/pdf/chemistry/downloadChemistryReport.tsx @@ -0,0 +1,47 @@ +import { pdf } from '@react-pdf/renderer' +import type { ChemistryObservation } from '@/hooks/useChemistryReportData' +import type { IContact, IWell } from '@/interfaces/ocotillo' +import { buildChemistryReportFilename } from '@/utils/chemistryReport' +import { + ChemistryReportPdf, + type ChemistryReportSections, +} from './ChemistryReportPdf' + +/** + * Renders the report and hands it to the browser as a download. Returns the + * filename so the caller can name it in a notification. + */ +export const downloadChemistryReport = async ({ + well, + contacts, + observations, + year, + sections, +}: { + well: IWell + contacts: readonly IContact[] + observations: readonly ChemistryObservation[] + year: number + sections?: ChemistryReportSections +}): Promise => { + const filename = buildChemistryReportFilename(well, year) + + const blob = await pdf( + + ).toBlob() + + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) + + return filename +} diff --git a/src/components/pdf/chemistry/index.ts b/src/components/pdf/chemistry/index.ts index 028e55b2..a814ce78 100644 --- a/src/components/pdf/chemistry/index.ts +++ b/src/components/pdf/chemistry/index.ts @@ -1,2 +1,3 @@ export * from './ChemistryReportPdf' +export * from './downloadChemistryReport' export * from './styles' diff --git a/src/hooks/useChemistryReportData.ts b/src/hooks/useChemistryReportData.ts index 304646a0..9f8583a3 100644 --- a/src/hooks/useChemistryReportData.ts +++ b/src/hooks/useChemistryReportData.ts @@ -2,6 +2,11 @@ import { useList, useOne } from '@refinedev/core' import { useMemo } from 'react' import type { WaterChemistryObservationResponse } from '@/generated/types.gen' import type { IContact, IWell } from '@/interfaces/ocotillo' +import { + CHEMISTRY_REPORT_PAGE_SIZE, + chemistryReportYearParams, + sortChemistryObservations, +} from '@/utils/chemistryReport' export type ChemistryObservation = WaterChemistryObservationResponse @@ -36,28 +41,22 @@ export const useChemistryReportData = ({ useList({ resource: 'observation/water-chemistry', dataProviderName: 'ocotillo', - pagination: { currentPage: 1, pageSize: 500, mode: 'server' }, + pagination: { + currentPage: 1, + pageSize: CHEMISTRY_REPORT_PAGE_SIZE, + mode: 'server', + }, meta: { params: { thing_id: thingId, - start_time: `${year}-01-01T00:00:00`, - end_time: `${year + 1}-01-01T00:00:00`, + ...chemistryReportYearParams(year), }, }, queryOptions: { enabled }, }) const observations = useMemo( - () => - [...(observationResult?.data ?? [])].sort((a, b) => { - const byDate = - new Date(a.observation_datetime).getTime() - - new Date(b.observation_datetime).getTime() - if (byDate !== 0) return byDate - return (a.parameter?.parameter_name ?? '').localeCompare( - b.parameter?.parameter_name ?? '' - ) - }), + () => sortChemistryObservations(observationResult?.data ?? []), [observationResult?.data] ) diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 56f9eb66..8fd62152 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -42,6 +42,7 @@ import { USGSInfoCard, OSEPODInfoCard, WellPDFActionsButton, + WellChemistryReportButton, WellScreensCard, EquipmentCard, NotesAccordion, @@ -99,7 +100,7 @@ export const WellShow = () => { isLoading: isDetailsLoading, } = useWellDetails(id) const viewWell = well as IWell - const { canViewAmp, canEditWell } = useAccessCapabilities() + const { canViewAmp, canEditWell, canViewAmpStaging } = useAccessCapabilities() const { result: assetResult, query: assetQuery } = useList({ resource: 'asset', @@ -509,6 +510,13 @@ export const WellShow = () => { sensorDeployments={sensorDeployments} /> ) : null} + {canViewAmpStaging ? ( + + ) : null} {canEditWell ? ( ) } - -/** - * Well-details entry point to the annual water quality report. The well and - * its contacts are already on the page, so the only thing this looks up is - * which year to report on — and it does not pull that year's results until the - * button is actually pressed, since most visits to a well page are not after a - * report. - */ -export const WellChemistryReportButton = ({ - well, - contacts, - isLoading = false, -}: { - well?: IWell - contacts: readonly IContact[] - isLoading?: boolean -}) => { - const { open: notify } = useNotification() - const dataProvider = useDataProvider() - const ocotilloDataProvider = useMemo( - () => dataProvider('ocotillo'), - [dataProvider] - ) - const [isGenerating, setIsGenerating] = useState(false) - - const wellId = well?.id - - // The report covers one calendar year, and the year worth reporting on is - // the most recent one sampled: a well last sampled in 2024 would otherwise - // produce an empty report for the current year. One row is enough to find - // it, and it doubles as the "has any chemistry at all" check. - const { result: latestResult, query: latestQuery } = - useList({ - resource: CHEMISTRY_RESOURCE, - dataProviderName: 'ocotillo', - pagination: { currentPage: 1, pageSize: 1, mode: 'server' }, - sorters: [{ field: 'observation_datetime', order: 'desc' }], - meta: { params: { thing_id: wellId } }, - queryOptions: { - enabled: Boolean(wellId), - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - }, - }) - - const reportYear = chemistryReportYearOf( - latestResult?.data?.[0]?.observation_datetime - ) - - const fetchYearObservations = async ( - year: number, - thingId: string | number - ) => { - const params = { thing_id: thingId, ...chemistryReportYearParams(year) } - const collected: ChemistryObservation[] = [] - let currentPage = 1 - - while (true) { - const page = await ocotilloDataProvider.getList({ - resource: CHEMISTRY_RESOURCE, - pagination: { currentPage, pageSize: CHEMISTRY_REPORT_PAGE_SIZE }, - meta: { params }, - }) - - collected.push(...(page.data as ChemistryObservation[])) - - if (page.data.length === 0 || collected.length >= page.total) break - currentPage += 1 - } - - return collected - } - - const handleGenerate = async () => { - if (!well || wellId == null || reportYear == null) return - - try { - setIsGenerating(true) - const observations = await fetchYearObservations(reportYear, wellId) - - const filename = await downloadChemistryReport({ - well, - contacts, - observations: sortChemistryObservations(observations), - year: reportYear, - }) - - notify?.({ - message: `Chemistry report generated for ${reportYear}`, - type: 'success', - description: filename, - }) - } catch (error) { - console.error(error) - notify?.({ - message: 'Chemistry report generation failed', - type: 'error', - }) - } finally { - setIsGenerating(false) - } - } - - const hasChemistry = reportYear != null - const isBusy = isLoading || latestQuery.isLoading - const isDisabled = isBusy || !well || !hasChemistry || isGenerating - - const tooltip = isGenerating - ? 'Generating…' - : isBusy - ? 'Checking for water chemistry…' - : hasChemistry - ? `Annual water quality report for ${reportYear}` - : 'No water chemistry on file for this well' - - return ( - - {/* Wrapped so the tooltip still explains the button while it is - disabled — a disabled button emits no pointer events of its own. */} - - - - - - {tooltip} - - ) -} diff --git a/src/components/Button/WellPDFActions.tsx b/src/components/Button/WellPDFActions.tsx index ddd61c17..f58933e5 100644 --- a/src/components/Button/WellPDFActions.tsx +++ b/src/components/Button/WellPDFActions.tsx @@ -1,20 +1,36 @@ -import { useState } from 'react' +import { pdf } from '@react-pdf/renderer' import { BaseRecord, useGo, useNotification } from '@refinedev/core' -import { useParams } from 'react-router' import { DownloadIcon, EyeIcon } from 'lucide-react' +import { useState } from 'react' +import { useParams } from 'react-router' +import { WellPDF } from '@/components' +import { downloadChemistryReport } from '@/components/pdf/chemistry' import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' -import { WellPDF } from '@/components' -import { buildPdfFilename, SensorDeploymentRow } from '@/utils' -import { pdf } from '@react-pdf/renderer' -import { IContact, IObservation, ISample, IWell } from '@/interfaces/ocotillo' -import { IPdfOptions } from '@/interfaces' import { PDF_SINGLE_PAGE_OPTION } from '@/config' -import { useAccessCapabilities } from '@/hooks' +import { useAccessCapabilities, useWellChemistryReport } from '@/hooks' +import { IPdfOptions } from '@/interfaces' +import { IContact, IObservation, ISample, IWell } from '@/interfaces/ocotillo' +import { buildPdfFilename, SensorDeploymentRow } from '@/utils' + +/** The kinds of PDF the well details page can produce. */ +export type WellReportType = 'field-sheet' | 'chemistry-report' + +const REPORT_TYPE_LABELS: Record = { + 'field-sheet': 'Field sheet', + 'chemistry-report': 'Chemistry report', +} type WellPDFActionsButtonProps = { isPreviewLoading: boolean @@ -48,60 +64,122 @@ export const WellPDFActionsButton = ({ isLoading: isPermissionsLoading, canManageAmp, canViewConfidential, + canViewAmpStaging, } = useAccessCapabilities() + const [reportType, setReportType] = useState('field-sheet') const [isGenerating, setIsGenerating] = useState(false) + const isChemistry = reportType === 'chemistry-report' + + const { + reportYear, + hasChemistry, + isLoading: isChemistryLoading, + fetchYearObservations, + } = useWellChemistryReport({ + thingId: id, + // Only worth asking once the report is on offer at all. + enabled: canViewAmpStaging, + }) + + // The chemistry report covers a calendar year, so a well with nothing on + // file has no year to report on and neither action can do anything. + const chemistryUnavailable = + isChemistry && !isChemistryLoading && !hasChemistry + const chemistryReason = chemistryUnavailable + ? 'No water chemistry on file for this well' + : undefined + const previewDisabled = - isPreviewLoading || isPermissionsLoading || !canManageAmp + isPreviewLoading || + isPermissionsLoading || + !canManageAmp || + (isChemistry && (isChemistryLoading || !hasChemistry)) const downloadDisabled = isDownloadLoading || isPermissionsLoading || !canManageAmp || - isGenerating + isGenerating || + (isChemistry && (isChemistryLoading || !hasChemistry)) const handlePreview = () => { + if (isChemistry) { + // The chemistry exporter already renders a full preview; hand it the + // well and year so it opens on this report rather than an empty picker. + go({ + to: '/ocotillo/chemistry-report', + query: { thing_id: id, year: reportYear }, + type: 'push', + }) + return + } + go({ to: `/ocotillo/well/pdf-preview/${id}`, type: 'push' }) } + const handleDownloadFieldSheet = async (opts: IPdfOptions) => { + const filename = buildPdfFilename(well) + + const blob = await pdf( + + ).toBlob() + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf` + a.click() + URL.revokeObjectURL(url) + + return a.download + } + + const handleDownloadChemistryReport = async (year: number) => { + const yearObservations = await fetchYearObservations(year) + + return downloadChemistryReport({ + well, + contacts, + observations: yearObservations, + year, + }) + } + const handleDownload = async (opts: IPdfOptions) => { if (!well?.id) return + if (isChemistry && reportYear == null) return try { setIsGenerating(true) - const filename = buildPdfFilename(well) - - const blob = await pdf( - - ).toBlob() - - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf` - a.click() - URL.revokeObjectURL(url) + const filename = isChemistry + ? await handleDownloadChemistryReport(reportYear as number) + : await handleDownloadFieldSheet(opts) notify?.({ - message: 'PDF generated successfully', + message: isChemistry + ? `Chemistry report generated for ${reportYear}` + : 'PDF generated successfully', type: 'success', - description: a.download, + description: filename, }) } catch (error) { console.error(error) notify?.({ - message: 'PDF Generation Failed', + message: isChemistry + ? 'Chemistry report generation failed' + : 'PDF Generation Failed', type: 'error', }) } finally { @@ -109,14 +187,44 @@ export const WellPDFActionsButton = ({ } } + const downloadTooltip = isGenerating + ? 'Generating…' + : (chemistryReason ?? + `Download ${REPORT_TYPE_LABELS[reportType].toLowerCase()}`) + return (
+ +
+ {/* Wrapped so the tooltip still explains the button while it is + disabled — a disabled button emits no pointer events. */} + + + - - {isGenerating ? 'Generating…' : 'Download PDF'} - + {downloadTooltip}
) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f0396570..830dc00f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,4 +22,5 @@ export * from './useViewportBbox' export * from './useSearchModalState' export * from './useSidebarPanelSync' export * from './useWellDetails' +export * from './useWellChemistryReport' export * from './useContainerMinWidth' diff --git a/src/hooks/useWellChemistryReport.ts b/src/hooks/useWellChemistryReport.ts new file mode 100644 index 00000000..aa92a9dd --- /dev/null +++ b/src/hooks/useWellChemistryReport.ts @@ -0,0 +1,86 @@ +import { useDataProvider, useList } from '@refinedev/core' +import { useCallback, useMemo } from 'react' +import { + CHEMISTRY_REPORT_PAGE_SIZE, + chemistryReportYearOf, + chemistryReportYearParams, + sortChemistryObservations, +} from '@/utils/chemistryReport' +import type { ChemistryObservation } from './useChemistryReportData' + +const CHEMISTRY_RESOURCE = 'observation/water-chemistry' + +/** + * Which year of chemistry a well's report should cover, and a way to pull it. + * + * The report covers one calendar year, and the year worth reporting on is the + * most recent one sampled: a well last sampled in 2024 would otherwise produce + * an empty report for the current year. One row is enough to find it, and it + * doubles as the "has any chemistry at all" check. + * + * The year's results are left until `fetchYearObservations` is called, since + * most visits to a well page are not after a chemistry report. + */ +export const useWellChemistryReport = ({ + thingId, + enabled = true, +}: { + thingId: string | number | undefined + enabled?: boolean +}) => { + const dataProvider = useDataProvider() + const ocotilloDataProvider = useMemo( + () => dataProvider('ocotillo'), + [dataProvider] + ) + + const { result, query } = useList({ + resource: CHEMISTRY_RESOURCE, + dataProviderName: 'ocotillo', + pagination: { currentPage: 1, pageSize: 1, mode: 'server' }, + sorters: [{ field: 'observation_datetime', order: 'desc' }], + meta: { params: { thing_id: thingId } }, + queryOptions: { + enabled: enabled && Boolean(thingId), + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }, + }) + + const reportYear = chemistryReportYearOf( + result?.data?.[0]?.observation_datetime + ) + + const fetchYearObservations = useCallback( + async (year: number) => { + if (thingId == null) return [] + + const params = { thing_id: thingId, ...chemistryReportYearParams(year) } + const collected: ChemistryObservation[] = [] + let currentPage = 1 + + while (true) { + const page = await ocotilloDataProvider.getList({ + resource: CHEMISTRY_RESOURCE, + pagination: { currentPage, pageSize: CHEMISTRY_REPORT_PAGE_SIZE }, + meta: { params }, + }) + + collected.push(...(page.data as ChemistryObservation[])) + + if (page.data.length === 0 || collected.length >= page.total) break + currentPage += 1 + } + + return sortChemistryObservations(collected) + }, + [ocotilloDataProvider, thingId] + ) + + return { + reportYear, + hasChemistry: reportYear != null, + isLoading: enabled && Boolean(thingId) ? query.isLoading : false, + fetchYearObservations, + } +} diff --git a/src/pages/ocotillo/chemistry-report/export.tsx b/src/pages/ocotillo/chemistry-report/export.tsx index cb65daa1..973a2fd5 100644 --- a/src/pages/ocotillo/chemistry-report/export.tsx +++ b/src/pages/ocotillo/chemistry-report/export.tsx @@ -13,8 +13,10 @@ import { } from '@mui/material' import Grid from '@mui/material/Grid2' import { PDFViewer } from '@react-pdf/renderer' +import { useOne } from '@refinedev/core' import { useAutocomplete } from '@refinedev/mui' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router' import { ChemistryReportDownloadButton } from '@/components/Button' import { OcotilloPageTitle } from '@/components/OcotilloPageHeader' import { @@ -26,16 +28,44 @@ import { import { useChemistryReportData, useDebounce } from '@/hooks' import type { IWell } from '@/interfaces/ocotillo' -/** Reporting periods offered in the picker: this year and the four before it. */ -const buildYearOptions = (): number[] => { +/** + * Reporting periods offered in the picker: this year and the four before it, + * plus whatever year was linked to. A well last sampled outside that window + * still has to be selectable, or arriving from its details page would land on + * a year the picker cannot show. + */ +const buildYearOptions = (linkedYear?: number): number[] => { const current = new Date().getFullYear() - return Array.from({ length: 5 }, (_, index) => current - index) + const years = Array.from({ length: 5 }, (_, index) => current - index) + if (linkedYear && !years.includes(linkedYear)) years.push(linkedYear) + return years.sort((a, b) => b - a) +} + +const parseYearParam = (value: string | null): number | undefined => { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 1900 ? parsed : undefined } export const ChemistryReportExport = () => { - const yearOptions = useMemo(buildYearOptions, []) + // The well details page links here with the report it wants already chosen. + const [searchParams] = useSearchParams() + const linkedThingId = searchParams.get('thing_id') + const linkedYear = parseYearParam(searchParams.get('year')) + + const yearOptions = useMemo(() => buildYearOptions(linkedYear), [linkedYear]) const [selectedWell, setSelectedWell] = useState(null) - const [year, setYear] = useState(yearOptions[0]) + const [year, setYear] = useState(linkedYear ?? yearOptions[0]) + + const { result: linkedWell } = useOne({ + resource: 'thing-well', + id: linkedThingId ?? undefined, + queryOptions: { enabled: Boolean(linkedThingId) }, + }) + + useEffect(() => { + // Only seeds the picker — once the user changes it, this stops applying. + if (linkedWell && !selectedWell) setSelectedWell(linkedWell as IWell) + }, [linkedWell, selectedWell]) const [sections, setSections] = useState( CHEMISTRY_REPORT_DEFAULT_SECTIONS ) diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 8fd62152..56f9eb66 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -42,7 +42,6 @@ import { USGSInfoCard, OSEPODInfoCard, WellPDFActionsButton, - WellChemistryReportButton, WellScreensCard, EquipmentCard, NotesAccordion, @@ -100,7 +99,7 @@ export const WellShow = () => { isLoading: isDetailsLoading, } = useWellDetails(id) const viewWell = well as IWell - const { canViewAmp, canEditWell, canViewAmpStaging } = useAccessCapabilities() + const { canViewAmp, canEditWell } = useAccessCapabilities() const { result: assetResult, query: assetQuery } = useList({ resource: 'asset', @@ -510,13 +509,6 @@ export const WellShow = () => { sensorDeployments={sensorDeployments} /> ) : null} - {canViewAmpStaging ? ( - - ) : null} {canEditWell ? (