feat(water-log): replace alert() report preview with 'Field Bulletin' modal
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s
- 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.
This commit is contained in:
@@ -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 (
|
||||
<div className="relative">
|
||||
<HeaderNav />
|
||||
|
||||
<TodaySummarySection
|
||||
brandId={brandId}
|
||||
today={state.today}
|
||||
todayLabel={state.todayLabel}
|
||||
todayEntries={todayEntries}
|
||||
todayTotal={todayTotal}
|
||||
@@ -483,10 +542,10 @@ export default function WaterLogAdminPanel({
|
||||
showReportSettings={state.showReportSettings}
|
||||
seasonStart={state.seasonStart}
|
||||
recipientName={state.recipientName}
|
||||
notify={notify}
|
||||
onToggleReportSettings={() => dispatch({ type: "TOGGLE_REPORT_SETTINGS" })}
|
||||
onSeasonChange={persistSeason}
|
||||
onRecipientNameChange={persistRecipientName}
|
||||
onPreviewReport={openReportPreview}
|
||||
/>
|
||||
|
||||
<HeadgatesSection
|
||||
@@ -530,6 +589,12 @@ export default function WaterLogAdminPanel({
|
||||
/>
|
||||
|
||||
<ToastNotification toast={state.toast} />
|
||||
|
||||
<ReportPreviewModal
|
||||
data={state.reportPreview}
|
||||
onClose={closeReportPreview}
|
||||
notify={notify}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
|
||||
@@ -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<HTMLDivElement>(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<string, number>();
|
||||
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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Daily water report preview"
|
||||
className="wl-modal-backdrop fixed inset-0 z-50 flex items-start justify-center overflow-y-auto px-4 py-8 sm:py-14"
|
||||
onMouseDown={(e) => {
|
||||
// Click on the backdrop (not the paper) closes.
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop wash — deep forest tint with subtle radial vignette. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="wl-modal-backdrop__wash fixed inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(120% 80% at 50% 30%, rgba(26,77,46,0.42) 0%, rgba(15,42,28,0.72) 60%, rgba(8,28,18,0.82) 100%)",
|
||||
backdropFilter: "blur(3px) saturate(0.9)",
|
||||
WebkitBackdropFilter: "blur(3px) saturate(0.9)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The paper itself */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className="wl-modal-paper relative z-10 w-full max-w-2xl outline-none"
|
||||
style={{
|
||||
// soft drop shadow + warm paper gradient
|
||||
background:
|
||||
"linear-gradient(180deg, #fdfaf2 0%, #f7f1de 100%)",
|
||||
boxShadow:
|
||||
"0 1px 0 rgba(255,255,255,0.6) inset, 0 30px 60px -20px rgba(15,42,28,0.45), 0 18px 32px -22px rgba(15,42,28,0.6)",
|
||||
border: "1px solid #1a4d2e",
|
||||
}}
|
||||
>
|
||||
{/* Paper grain overlay — extremely subtle */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.10] mix-blend-multiply"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 25% 15%, rgba(122,90,38,0.22) 0, transparent 35%), radial-gradient(circle at 80% 80%, rgba(26,77,46,0.18) 0, transparent 40%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Perforated left tear-strip — like a printed form */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="wl-modal-perf absolute left-0 top-0 h-full w-3"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, #fdfaf2 1.2px, transparent 1.6px)",
|
||||
backgroundSize: "8px 12px",
|
||||
backgroundPosition: "2px 6px",
|
||||
opacity: 0.55,
|
||||
maskImage:
|
||||
"linear-gradient(180deg, transparent 0, black 6%, black 94%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(180deg, transparent 0, black 6%, black 94%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Crop marks at the four corners — printer's registration marks */}
|
||||
<CropMark className="absolute left-3 top-3" rotate={0} />
|
||||
<CropMark className="absolute right-3 top-3" rotate={90} />
|
||||
<CropMark className="absolute right-3 bottom-3" rotate={180} />
|
||||
<CropMark className="absolute left-3 bottom-3" rotate={270} />
|
||||
|
||||
{/* Header band — forest ink strip with cream mono caps */}
|
||||
<div
|
||||
className="relative flex items-stretch border-b border-[#1a4d2e]"
|
||||
style={{ background: "#1a4d2e", color: "#fdfaf2" }}
|
||||
>
|
||||
<div className="flex flex-1 flex-col justify-center gap-0.5 px-6 py-3">
|
||||
<div
|
||||
className="text-[10px] uppercase"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
letterSpacing: "0.32em",
|
||||
}}
|
||||
>
|
||||
Tuxedo Ditch Co. · Water Log
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] uppercase opacity-80"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
letterSpacing: "0.24em",
|
||||
}}
|
||||
>
|
||||
Daily Field Bulletin
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col items-end justify-center border-l border-[#fdfaf2]/30 px-5 py-3 text-right"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
letterSpacing: "0.18em",
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] uppercase opacity-80">Bulletin №</div>
|
||||
<div className="text-[15px] font-medium tracking-[0.18em]">
|
||||
{String(bulletinNumber).padStart(4, "0")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title row + PREVIEW stamp */}
|
||||
<div className="relative px-6 pb-2 pt-5 sm:px-8 sm:pt-6">
|
||||
<h2
|
||||
className="leading-[0.95] tracking-tight text-[#1a4d2e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-display, Georgia, serif)",
|
||||
fontWeight: 600,
|
||||
fontSize: "clamp(34px, 5.4vw, 48px)",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
Daily Water Report
|
||||
</h2>
|
||||
<p
|
||||
className="mt-1 text-[12px] uppercase tracking-[0.24em] text-[#57694e]"
|
||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
||||
>
|
||||
{dateLabel}
|
||||
<span className="mx-2 text-[#d4d9d3]">/</span>
|
||||
<span className="text-[#1a4d2e]">
|
||||
{dateIso.replace(/-/g, " · ")}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Rotated rubber stamp — PREVIEW */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="wl-modal-stamp pointer-events-none absolute right-4 top-4 select-none sm:right-8 sm:top-5"
|
||||
style={{
|
||||
transform: "rotate(-9deg)",
|
||||
color: "#b1452a",
|
||||
fontFamily: "var(--font-display, Georgia, serif)",
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.18em",
|
||||
border: "2.5px double currentColor",
|
||||
padding: "4px 12px 5px",
|
||||
borderRadius: "3px",
|
||||
opacity: 0.78,
|
||||
boxShadow: "0 0 0 2px rgba(177,69,42,0.05)",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
PREVIEW
|
||||
<span
|
||||
className="ml-2 text-[10px] tracking-[0.3em]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
fontWeight: 500,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
ONLY
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta strip — date / season / total / count */}
|
||||
<div className="relative grid grid-cols-2 gap-px border-y border-[#1a4d2e] bg-[#1a4d2e] sm:grid-cols-4">
|
||||
<MetaCell label="Date" value={dateLabel} mono />
|
||||
<MetaCell
|
||||
label="Season"
|
||||
value={inSeason ? "In season" : "Out of season"}
|
||||
accent={inSeason ? "green" : "amber"}
|
||||
/>
|
||||
<MetaCell
|
||||
label="Total volume"
|
||||
value={`${total.toFixed(2)} ${totalUnit}`}
|
||||
mono
|
||||
emphasis
|
||||
/>
|
||||
<MetaCell
|
||||
label="Entries"
|
||||
value={`${rows.length} ${rows.length === 1 ? "log" : "logs"}`}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Salutation */}
|
||||
<div className="relative px-6 pt-5 sm:px-8">
|
||||
{recipientName ? (
|
||||
<p
|
||||
className="text-[#1a4d2e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-display, Georgia, serif)",
|
||||
fontStyle: "italic",
|
||||
fontSize: "20px",
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{recipientName},
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className="text-[#57694e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
fontSize: "11px",
|
||||
letterSpacing: "0.22em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
To the irrigator —
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className="mt-2 text-[13px] leading-relaxed text-[#3a4a36]"
|
||||
style={{ fontFamily: "var(--font-manrope, system-ui, sans-serif)" }}
|
||||
>
|
||||
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.{" "}
|
||||
<span className="text-[#a16207]">
|
||||
This bulletin is a preview — no message has been sent.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Data table */}
|
||||
<div className="relative mt-5 px-6 sm:px-8">
|
||||
<div className="overflow-hidden rounded-sm border border-[#1a4d2e]/40 bg-[#fffaf0]/60">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
className="border-b border-[#1a4d2e]/50"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(26,77,46,0.08) 0%, rgba(26,77,46,0.02) 100%)",
|
||||
}}
|
||||
>
|
||||
<Th className="w-[68px]">Time</Th>
|
||||
<Th>Headgate</Th>
|
||||
<Th>Irrigator</Th>
|
||||
<Th className="w-[86px] text-right">Volume</Th>
|
||||
<Th className="w-[28%]">Note</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={i}
|
||||
className="wl-modal-row border-b border-[#1a4d2e]/15 transition-colors last:border-b-0 hover:bg-[#fdfaf2]"
|
||||
style={{
|
||||
animationDelay: `${120 + i * 35}ms`,
|
||||
}}
|
||||
>
|
||||
<Td className="text-[#3a4a36]">{time}</Td>
|
||||
<Td>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[#1a4d2e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
background: isTop ? "#ca8a04" : "#1a4d2e",
|
||||
opacity: isTop ? 1 : 0.55,
|
||||
}}
|
||||
/>
|
||||
{row.headgate_name}
|
||||
</span>
|
||||
</Td>
|
||||
<Td className="text-[#3a4a36]">{row.user_name}</Td>
|
||||
<Td className="text-right">
|
||||
<span
|
||||
className="text-[#1a4d2e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
}}
|
||||
>
|
||||
{row.measurement.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
className="ml-1 text-[10px] text-[#57694e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
letterSpacing: "0.08em",
|
||||
}}
|
||||
>
|
||||
{row.unit}
|
||||
</span>
|
||||
</Td>
|
||||
<Td className="text-[#57694e]">
|
||||
{row.notes ? (
|
||||
<span
|
||||
className="italic"
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-display, Georgia, serif)",
|
||||
}}
|
||||
>
|
||||
“{row.notes}”
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[#d4d9d3]">·</span>
|
||||
)}
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-[#1a4d2e]">
|
||||
<Td className="!border-0 pt-2 text-[11px] uppercase tracking-[0.22em] text-[#57694e]">
|
||||
Day total
|
||||
</Td>
|
||||
<Td className="!border-0 pt-2">
|
||||
<span className="text-[#d4d9d3]">·</span>
|
||||
</Td>
|
||||
<Td className="!border-0 pt-2 text-right text-[11px] uppercase tracking-[0.22em] text-[#57694e]">
|
||||
Σ
|
||||
</Td>
|
||||
<Td className="!border-0 pt-2 text-right">
|
||||
<span
|
||||
className="text-[#1a4d2e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
fontSize: "16px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{total.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
className="ml-1 text-[11px] text-[#57694e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
letterSpacing: "0.08em",
|
||||
}}
|
||||
>
|
||||
{totalUnit}
|
||||
</span>
|
||||
</Td>
|
||||
<Td className="!border-0 pt-2 text-right text-[11px] text-[#57694e]">
|
||||
{rows.length} logs
|
||||
</Td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side stats — first/last entry, top headgate */}
|
||||
<div className="relative grid grid-cols-3 gap-px border-y border-[#1a4d2e]/40 bg-[#1a4d2e]/10 px-6 sm:px-8 mt-5">
|
||||
<SideStat label="First entry" value={fmtTime(firstTime)} />
|
||||
<SideStat label="Last entry" value={fmtTime(lastTime)} />
|
||||
<SideStat
|
||||
label="Top gate"
|
||||
value={topHeadgate ? topHeadgate[0] : "—"}
|
||||
sub={
|
||||
topHeadgate ? `${topHeadgate[1].toFixed(2)} ${totalUnit}` : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer signature */}
|
||||
<div className="relative px-6 pb-6 pt-4 text-center sm:px-8">
|
||||
<div className="mx-auto mb-3 h-px w-24 bg-[#1a4d2e]/40" />
|
||||
<p
|
||||
className="text-[10px] uppercase tracking-[0.36em] text-[#57694e]"
|
||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
||||
>
|
||||
— End of transmission —
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 text-[11px] text-[#57694e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-display, Georgia, serif)",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
Filed by Route Commerce Water Log
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action bar — sticky to the bottom of the paper */}
|
||||
<div
|
||||
className="relative flex flex-col-reverse items-stretch gap-2 border-t border-[#1a4d2e] px-5 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6"
|
||||
style={{ background: "rgba(26,77,46,0.04)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-sm border border-[#1a4d2e]/40 px-4 py-2 text-[11px] uppercase tracking-[0.24em] text-[#57694e] transition-colors hover:border-[#1a4d2e] hover:text-[#1a4d2e]"
|
||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
||||
>
|
||||
Close · Esc
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="group inline-flex items-center justify-center gap-2 rounded-sm border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-[11px] uppercase tracking-[0.24em] text-[#fdfaf2] transition-all hover:bg-[#163f25] active:translate-y-px"
|
||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckMark /> Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyMark /> Copy plain text
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component-local animations */}
|
||||
<style>{`
|
||||
@keyframes wl-modal-pop {
|
||||
0% { opacity: 0; transform: translateY(14px) scale(0.97); }
|
||||
60% { opacity: 1; transform: translateY(-2px) scale(1.005); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes wl-modal-fade {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes wl-modal-row {
|
||||
0% { opacity: 0; transform: translateX(-6px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes wl-modal-stamp {
|
||||
0% { opacity: 0; transform: rotate(-9deg) scale(1.4); }
|
||||
60% { opacity: 0.78; transform: rotate(-9deg) scale(0.96); }
|
||||
100% { opacity: 0.78; transform: rotate(-9deg) scale(1); }
|
||||
}
|
||||
.wl-modal-backdrop { animation: wl-modal-fade 200ms ease-out both; }
|
||||
.wl-modal-paper {
|
||||
animation: wl-modal-pop 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
.wl-modal-row {
|
||||
animation: wl-modal-row 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
opacity: 0;
|
||||
}
|
||||
.wl-modal-stamp { animation: wl-modal-stamp 480ms ease-out 220ms both; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wl-modal-backdrop,
|
||||
.wl-modal-paper,
|
||||
.wl-modal-row,
|
||||
.wl-modal-stamp {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-0.5 bg-[#fdfaf2] px-4 py-2.5">
|
||||
<span
|
||||
className="text-[9px] uppercase tracking-[0.28em] text-[#57694e]"
|
||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
emphasis
|
||||
? "text-[16px] text-[#1a4d2e]"
|
||||
: mono
|
||||
? "text-[13px] text-[#1a4d2e]"
|
||||
: "text-[13px] text-[#1a4d2e]"
|
||||
}
|
||||
style={{
|
||||
fontFamily: mono
|
||||
? "var(--font-fragment-mono, monospace)"
|
||||
: "var(--font-manrope, system-ui, sans-serif)",
|
||||
fontWeight: emphasis ? 600 : 500,
|
||||
letterSpacing: mono ? "0.02em" : undefined,
|
||||
color: accentColor,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<th
|
||||
className={`px-3 py-2 text-left text-[10px] uppercase tracking-[0.22em] text-[#1a4d2e] ${className}`}
|
||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
className={`px-3 py-2 text-[13px] text-[#1d1d1f] ${className}`}
|
||||
style={{
|
||||
fontFamily: "var(--font-manrope, system-ui, sans-serif)",
|
||||
verticalAlign: "top",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function SideStat({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 bg-[#fdfaf2] px-4 py-2.5">
|
||||
<span
|
||||
className="text-[9px] uppercase tracking-[0.28em] text-[#57694e]"
|
||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[14px] text-[#1a4d2e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{sub ? (
|
||||
<span
|
||||
className="text-[10px] text-[#57694e]"
|
||||
style={{
|
||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
>
|
||||
{sub}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CropMark({ className = "", rotate = 0 }: { className?: string; rotate?: number }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
className={className}
|
||||
style={{ transform: `rotate(${rotate}deg)`, opacity: 0.5 }}
|
||||
>
|
||||
<path d="M0 5 L10 5 M5 0 L5 10" stroke="#1a4d2e" strokeWidth="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyMark() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<rect x="5" y="5" width="9" height="9" rx="1" />
|
||||
<path d="M11 5 V3 a1 1 0 0 0 -1 -1 H3 a1 1 0 0 0 -1 1 V10 a1 1 0 0 0 1 1 H5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckMark() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M3 8 L7 12 L13 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Icons (16–20px, currentColor) ──────────────────────────────────────
|
||||
|
||||
function QrIcon() {
|
||||
|
||||
Reference in New Issue
Block a user