From 060520b7be68089ed9d5e2f5fb00ecca05ca79f3 Mon Sep 17 00:00:00 2001 From: Tyler Date: Thu, 2 Jul 2026 12:08:24 -0600 Subject: [PATCH] feat(water-log): replace alert() report preview with 'Field Bulletin' modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ReportPreviewModal styled as a USGS/SCS field bulletin printout: forest header strip, perforated tear-strip, crop marks, Fraunces masthead, rotated PREVIEW rubber stamp, hairline-ruled data table with stagger-row reveal, first/last entry + top-gate stats, and a plain-text 'Copy' action backed by formatDailyWaterReport(). - ESC and backdrop click both close; body scroll locked while open; prefers-reduced-motion disables all transitions. - Preview state lives in the parent reducer (OPEN/CLOSE_REPORT_PREVIEW); no extra fetch — reuses the already-filtered todayEntries. --- src/components/admin/WaterLogAdminPanel.tsx | 817 +++++++++++++++++++- 1 file changed, 787 insertions(+), 30 deletions(-) diff --git a/src/components/admin/WaterLogAdminPanel.tsx b/src/components/admin/WaterLogAdminPanel.tsx index 398024a..560b580 100644 --- a/src/components/admin/WaterLogAdminPanel.tsx +++ b/src/components/admin/WaterLogAdminPanel.tsx @@ -22,7 +22,7 @@ * `src/actions/water-log/admin.ts`. */ -import { useReducer, useEffect, useMemo } from "react"; +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { @@ -68,6 +68,17 @@ type UserForm = { }; type PinInfo = { name: string; pin: string }; +type ReportPreviewData = { + rows: WaterLogReportRow[]; + total: number; + unit: string; + dateLabel: string; + dateIso: string; + recipientName?: string; + inSeason: boolean; + bulletinNumber: number; +}; + type State = { headgates: AdminHeadgate[]; users: AdminIrrigator[]; @@ -96,6 +107,7 @@ type State = { showReportSettings: boolean; seasonStart: IrrigationSeasonSettings; recipientName: string; + reportPreview: ReportPreviewData | null; }; type Action = @@ -140,7 +152,9 @@ type Action = | { type: "SET_TODAY"; today: string; label: string } | { type: "TOGGLE_REPORT_SETTINGS" } | { type: "SET_SEASON_START"; value: IrrigationSeasonSettings } - | { type: "SET_RECIPIENT_NAME"; value: string }; + | { type: "SET_RECIPIENT_NAME"; value: string } + | { type: "OPEN_REPORT_PREVIEW"; preview: ReportPreviewData } + | { type: "CLOSE_REPORT_PREVIEW" }; function initSeasonStart(): IrrigationSeasonSettings { if (typeof window === "undefined") return DEFAULT_SEASON; @@ -184,6 +198,7 @@ function initState(props: { showReportSettings: false, seasonStart: initSeasonStart(), recipientName: initRecipientName(), + reportPreview: null, }; } @@ -358,6 +373,10 @@ function reducer(state: State, action: Action): State { return { ...state, seasonStart: action.value }; case "SET_RECIPIENT_NAME": return { ...state, recipientName: action.value }; + case "OPEN_REPORT_PREVIEW": + return { ...state, reportPreview: action.preview }; + case "CLOSE_REPORT_PREVIEW": + return { ...state, reportPreview: null }; } } @@ -466,14 +485,54 @@ export default function WaterLogAdminPanel({ } catch {} } + function openReportPreview() { + // The "Today's Summary" section has already filtered the rows for us; + // shape them for the report view (adds user_role default) and push + // the preview payload into state. The modal renders from that payload. + const rows: WaterLogReportRow[] = todayEntries.map(shapeWaterLogEntry); + const unit = + rows.find((r) => r.unit)?.unit ?? state.headgates[0]?.unit ?? "CFS"; + const now = new Date(); + // Deterministic-ish bulletin number from today's date + entry count so + // it changes per-day but feels like a real reference code. + const dayKey = + state.today.replace(/-/g, "") || + `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const bulletinNumber = + (parseInt(dayKey, 10) % 1000) + (state.entries.length % 7) * 13 + 1; + + if (rows.length === 0) { + notify( + `No entries today — report would be skipped (season: ${inSeason ? "in" : "out"}).`, + ); + return; + } + + dispatch({ + type: "OPEN_REPORT_PREVIEW", + preview: { + rows, + total: todayTotal, + unit, + dateLabel: state.todayLabel || formatDate(now), + dateIso: state.today || now.toISOString().slice(0, 10), + recipientName: state.recipientName.trim() || undefined, + inSeason, + bulletinNumber, + }, + }); + } + + function closeReportPreview() { + dispatch({ type: "CLOSE_REPORT_PREVIEW" }); + } + // ── Render ──────────────────────────────────────────────────────── return (
dispatch({ type: "TOGGLE_REPORT_SETTINGS" })} onSeasonChange={persistSeason} onRecipientNameChange={persistRecipientName} + onPreviewReport={openReportPreview} /> + +
); } @@ -559,8 +624,6 @@ function HeaderNav() { } type TodaySummarySectionProps = { - brandId: string; - today: string; todayLabel: string; todayEntries: AdminEntry[]; todayTotal: number; @@ -570,15 +633,13 @@ type TodaySummarySectionProps = { showReportSettings: boolean; seasonStart: IrrigationSeasonSettings; recipientName: string; - notify: (msg: string, kind?: "ok" | "err") => void; onToggleReportSettings: () => void; onSeasonChange: (s: IrrigationSeasonSettings) => void; onRecipientNameChange: (v: string) => void; + onPreviewReport: () => void; }; function TodaySummarySection({ - brandId, - today, todayLabel, todayEntries, todayTotal, @@ -588,30 +649,13 @@ function TodaySummarySection({ showReportSettings, seasonStart, recipientName, - notify, onToggleReportSettings, onSeasonChange, onRecipientNameChange, + onPreviewReport, }: TodaySummarySectionProps) { - async function handlePreviewReport() { - const all = await getWaterEntries(brandId, 1000); - const todayRows: WaterLogReportRow[] = []; - for (const e of all) { - if ((e.logged_date ?? e.logged_at.slice(0, 10)) === today) { - todayRows.push(shapeWaterLogEntry(e)); - } - } - const report = formatDailyWaterReport(todayRows, { - recipientName: recipientName || undefined, - previewMode: true, - }); - if (!report) { - notify(`No entries today — report would be skipped (season: ${inSeason ? "in" : "out"}).`); - return; - } - // The browser's `alert` blocks; the modal preview is the better UX in - // a future iteration, but alert is dependency-free today. - window.alert(`Daily Report — ${inSeason ? "in season" : "out of season"}\n\n${report}`); + function handlePreviewReport() { + onPreviewReport(); } return (
@@ -1786,6 +1830,719 @@ function DayInput({ ); } +// ─── Report Preview Modal ───────────────────────────────────────────────── +// +// A "Field Bulletin" — modeled after USDA Soil Conservation Service / USGS +// survey bulletins. Cream paper, deep forest ink, monospaced data with +// hairline rules, perforated tear-strip on the left, and a rotated +// PREVIEW rubber stamp to keep it clearly distinct from the actual +// deliverable. The composition is a single tactile sheet that opens with +// a soft paper-curl motion and staggers each entry row in. + +function ReportPreviewModal({ + data, + onClose, + notify, +}: { + data: ReportPreviewData | null; + onClose: () => void; + notify: (msg: string, kind?: "ok" | "err") => void; +}) { + const dialogRef = useRef(null); + const [copied, setCopied] = useState(false); + + // ESC closes; lock body scroll while open. + useEffect(() => { + if (!data) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + // Move focus into the dialog for keyboard users. + const t = setTimeout(() => dialogRef.current?.focus(), 30); + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + clearTimeout(t); + }; + }, [data, onClose]); + + if (!data) return null; + + const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason, bulletinNumber } = data; + const totalUnit = rows[0]?.unit ?? unit ?? "CFS"; + + async function handleCopy() { + const text = formatDailyWaterReport(rows, { + recipientName: recipientName, + previewMode: true, + }); + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + notify("Report copied to clipboard"); + setTimeout(() => setCopied(false), 1600); + } catch { + notify("Copy failed — your browser blocked clipboard access", "err"); + } + } + + // Earliest entry time → "shift open", latest → "shift close" — a nice + // touch pulled straight from irrigation logbook conventions. + const times = rows + .map((r) => new Date(r.logged_at)) + .filter((d) => !Number.isNaN(d.getTime())); + const firstTime = times.length + ? times.reduce((a, b) => (a < b ? a : b)) + : null; + const lastTime = times.length + ? times.reduce((a, b) => (a > b ? a : b)) + : null; + const fmtTime = (d: Date | null) => + d + ? d.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }) + : "—"; + + // Most active headgate (max sum of measurement) — useful color piece. + const byHeadgate = new Map(); + for (const r of rows) { + byHeadgate.set(r.headgate_name, (byHeadgate.get(r.headgate_name) ?? 0) + r.measurement); + } + const topHeadgate = Array.from(byHeadgate.entries()).sort((a, b) => b[1] - a[1])[0]; + + return ( +
{ + // Click on the backdrop (not the paper) closes. + if (e.target === e.currentTarget) onClose(); + }} + > + {/* Backdrop wash — deep forest tint with subtle radial vignette. */} +
+ + {/* The paper itself */} +
+ {/* Paper grain overlay — extremely subtle */} +
+ + {/* Perforated left tear-strip — like a printed form */} +
+ + {/* Crop marks at the four corners — printer's registration marks */} + + + + + + {/* Header band — forest ink strip with cream mono caps */} +
+
+
+ Tuxedo Ditch Co. · Water Log +
+
+ Daily Field Bulletin +
+
+
+
Bulletin №
+
+ {String(bulletinNumber).padStart(4, "0")} +
+
+
+ + {/* Title row + PREVIEW stamp */} +
+

+ Daily Water Report +

+

+ {dateLabel} + / + + {dateIso.replace(/-/g, " · ")} + +

+ + {/* Rotated rubber stamp — PREVIEW */} +
+ PREVIEW + + ONLY + +
+
+ + {/* Meta strip — date / season / total / count */} +
+ + + + +
+ + {/* Salutation */} +
+ {recipientName ? ( +

+ {recipientName}, +

+ ) : ( +

+ To the irrigator — +

+ )} +

+ Below is the day's record of measured flow at every headgate + in service. Times are local; volumes are reported in the unit + declared for each gate.{" "} + + This bulletin is a preview — no message has been sent. + +

+
+ + {/* Data table */} +
+
+ + + + + + + + + + + + {rows.map((row, i) => { + const time = new Date(row.logged_at).toLocaleTimeString( + "en-US", + { hour: "numeric", minute: "2-digit" }, + ); + const isTop = + topHeadgate && + row.headgate_name === topHeadgate[0] && + row.measurement === topHeadgate[1]; + return ( + + + + + + + + ); + })} + + + + + + + + + + +
TimeHeadgateIrrigatorVolumeNote
{time} + + + {row.headgate_name} + + {row.user_name} + + {row.measurement.toFixed(2)} + + + {row.unit} + + + {row.notes ? ( + + “{row.notes}” + + ) : ( + · + )} +
+ Day total + + · + + Σ + + + {total.toFixed(2)} + + + {totalUnit} + + + {rows.length} logs +
+
+
+ + {/* Side stats — first/last entry, top headgate */} +
+ + + +
+ + {/* Footer signature */} +
+
+

+ — End of transmission — +

+

+ Filed by Route Commerce Water Log +

+
+ + {/* Action bar — sticky to the bottom of the paper */} +
+ + +
+
+ + {/* Component-local animations */} + +
+ ); +} + +function MetaCell({ + label, + value, + mono = false, + emphasis = false, + accent, +}: { + label: string; + value: string; + mono?: boolean; + emphasis?: boolean; + accent?: "green" | "amber"; +}) { + const accentColor = + accent === "green" + ? "#1a4d2e" + : accent === "amber" + ? "#a16207" + : undefined; + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +function Td({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +function SideStat({ + label, + value, + sub, +}: { + label: string; + value: string; + sub?: string; +}) { + return ( +
+ + {label} + + + {value} + + {sub ? ( + + {sub} + + ) : null} +
+ ); +} + +function CropMark({ className = "", rotate = 0 }: { className?: string; rotate?: number }) { + return ( + + + + ); +} + +function CopyMark() { + return ( + + + + + ); +} + +function CheckMark() { + return ( + + + + ); +} + // ─── Icons (16–20px, currentColor) ────────────────────────────────────── function QrIcon() {