refactor(water-log): tone down report preview modal to production-appropriate GlassModal
Deploy to route.crispygoat.com / deploy (push) Successful in 3m55s
Deploy to route.crispygoat.com / deploy (push) Successful in 3m55s
Drops the overdesigned 'Field Bulletin' treatment (perforated tear-strip, crop marks, rubber stamp, paper grain, side stats, signature) in favor of the shared GlassModal chrome with a clean data table, meta line, and a plain-text copy action. Matches the rest of the admin UI.
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
* `src/actions/water-log/admin.ts`.
|
* `src/actions/water-log/admin.ts`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
} from "@/lib/water-log-reporting";
|
} from "@/lib/water-log-reporting";
|
||||||
import { WaterGauge, SeasonMark } from "@/components/water/icons";
|
import { WaterGauge, SeasonMark } from "@/components/water/icons";
|
||||||
import AdminButton from "./design-system/AdminButton";
|
import AdminButton from "./design-system/AdminButton";
|
||||||
|
import GlassModal from "@/components/admin/GlassModal";
|
||||||
import { formatDate, formatDateTime } from "@/lib/format-date";
|
import { formatDate, formatDateTime } from "@/lib/format-date";
|
||||||
|
|
||||||
const DEFAULT_SEASON: IrrigationSeasonSettings = {
|
const DEFAULT_SEASON: IrrigationSeasonSettings = {
|
||||||
@@ -76,7 +77,6 @@ type ReportPreviewData = {
|
|||||||
dateIso: string;
|
dateIso: string;
|
||||||
recipientName?: string;
|
recipientName?: string;
|
||||||
inSeason: boolean;
|
inSeason: boolean;
|
||||||
bulletinNumber: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -493,13 +493,6 @@ export default function WaterLogAdminPanel({
|
|||||||
const unit =
|
const unit =
|
||||||
rows.find((r) => r.unit)?.unit ?? state.headgates[0]?.unit ?? "CFS";
|
rows.find((r) => r.unit)?.unit ?? state.headgates[0]?.unit ?? "CFS";
|
||||||
const now = new Date();
|
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) {
|
if (rows.length === 0) {
|
||||||
notify(
|
notify(
|
||||||
@@ -518,7 +511,6 @@ export default function WaterLogAdminPanel({
|
|||||||
dateIso: state.today || now.toISOString().slice(0, 10),
|
dateIso: state.today || now.toISOString().slice(0, 10),
|
||||||
recipientName: state.recipientName.trim() || undefined,
|
recipientName: state.recipientName.trim() || undefined,
|
||||||
inSeason,
|
inSeason,
|
||||||
bulletinNumber,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1832,12 +1824,10 @@ function DayInput({
|
|||||||
|
|
||||||
// ─── Report Preview Modal ─────────────────────────────────────────────────
|
// ─── Report Preview Modal ─────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A "Field Bulletin" — modeled after USDA Soil Conservation Service / USGS
|
// Simple, production-appropriate preview of the daily water report that
|
||||||
// survey bulletins. Cream paper, deep forest ink, monospaced data with
|
// would be sent by the cron. Built on the shared GlassModal so ESC, focus
|
||||||
// hairline rules, perforated tear-strip on the left, and a rotated
|
// trapping, scroll-lock, and the standard admin header/close chrome all
|
||||||
// PREVIEW rubber stamp to keep it clearly distinct from the actual
|
// match the rest of /admin.
|
||||||
// deliverable. The composition is a single tactile sheet that opens with
|
|
||||||
// a soft paper-curl motion and staggers each entry row in.
|
|
||||||
|
|
||||||
function ReportPreviewModal({
|
function ReportPreviewModal({
|
||||||
data,
|
data,
|
||||||
@@ -1848,30 +1838,11 @@ function ReportPreviewModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
notify: (msg: string, kind?: "ok" | "err") => void;
|
notify: (msg: string, kind?: "ok" | "err") => void;
|
||||||
}) {
|
}) {
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [copied, setCopied] = useState(false);
|
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;
|
if (!data) return null;
|
||||||
|
|
||||||
const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason, bulletinNumber } = data;
|
const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason } = data;
|
||||||
const totalUnit = rows[0]?.unit ?? unit ?? "CFS";
|
const totalUnit = rows[0]?.unit ?? unit ?? "CFS";
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
@@ -1890,560 +1861,159 @@ function ReportPreviewModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Earliest entry time → "shift open", latest → "shift close" — a nice
|
const subtitle =
|
||||||
// touch pulled straight from irrigation logbook conventions.
|
`${rows.length} ${rows.length === 1 ? "entry" : "entries"} · ` +
|
||||||
const times = rows
|
`${total.toFixed(2)} ${totalUnit} total · ` +
|
||||||
.map((r) => new Date(r.logged_at))
|
(inSeason ? "in season" : "out of season");
|
||||||
.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 (
|
return (
|
||||||
<div
|
<GlassModal
|
||||||
role="dialog"
|
title="Daily water report — preview"
|
||||||
aria-modal="true"
|
subtitle={subtitle}
|
||||||
aria-label="Daily water report preview"
|
onClose={onClose}
|
||||||
className="wl-modal-backdrop fixed inset-0 z-50 flex items-start justify-center overflow-y-auto px-4 py-8 sm:py-14"
|
maxWidth="max-w-2xl"
|
||||||
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 className="space-y-4">
|
||||||
<div
|
{/* Meta line */}
|
||||||
aria-hidden
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
className="wl-modal-backdrop__wash fixed inset-0"
|
<span>
|
||||||
style={{
|
<span className="font-semibold text-[var(--admin-text-secondary)]">Date:</span>{" "}
|
||||||
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}
|
{dateLabel}
|
||||||
<span className="mx-2 text-[#d4d9d3]">/</span>
|
</span>
|
||||||
<span className="text-[#1a4d2e]">
|
<span className="hidden sm:inline text-[var(--admin-border)]">·</span>
|
||||||
{dateIso.replace(/-/g, " · ")}
|
<span className="font-mono text-[var(--admin-text-secondary)]">
|
||||||
</span>
|
{dateIso}
|
||||||
</p>
|
</span>
|
||||||
|
|
||||||
{/* 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 ? (
|
{recipientName ? (
|
||||||
<p
|
<>
|
||||||
className="text-[#1a4d2e]"
|
<span className="hidden sm:inline text-[var(--admin-border)]">·</span>
|
||||||
style={{
|
<span>
|
||||||
fontFamily: "var(--font-display, Georgia, serif)",
|
<span className="font-semibold text-[var(--admin-text-secondary)]">
|
||||||
fontStyle: "italic",
|
Recipient:
|
||||||
fontSize: "20px",
|
</span>{" "}
|
||||||
lineHeight: 1.25,
|
{recipientName}
|
||||||
}}
|
</span>
|
||||||
>
|
</>
|
||||||
{recipientName},
|
) : null}
|
||||||
</p>
|
</div>
|
||||||
) : (
|
|
||||||
<p
|
{/* Preview banner — keep it clear this hasn't been sent */}
|
||||||
className="text-[#57694e]"
|
<div
|
||||||
style={{
|
className="rounded-lg px-3 py-2 text-xs"
|
||||||
fontFamily: "var(--font-fragment-mono, monospace)",
|
style={{
|
||||||
fontSize: "11px",
|
backgroundColor: "var(--admin-warning-bg, #fef9c3)",
|
||||||
letterSpacing: "0.22em",
|
color: "var(--admin-warning-text, #a16207)",
|
||||||
textTransform: "uppercase",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Preview only. No message has been sent. Daily-report delivery is
|
||||||
To the irrigator —
|
handled by the scheduled{" "}
|
||||||
</p>
|
<code className="font-mono">api/cron/water-report</code> endpoint.
|
||||||
)}
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Data table */}
|
{/* Data table */}
|
||||||
<div className="relative mt-5 px-6 sm:px-8">
|
<div className="overflow-hidden rounded-lg border border-[var(--admin-border)]">
|
||||||
<div className="overflow-hidden rounded-sm border border-[#1a4d2e]/40 bg-[#fffaf0]/60">
|
<table className="w-full border-collapse text-sm">
|
||||||
<table className="w-full border-collapse">
|
<thead>
|
||||||
<thead>
|
<tr style={{ backgroundColor: "var(--admin-bg-subtle)" }}>
|
||||||
<tr
|
<Th className="w-[72px]">Time</Th>
|
||||||
className="border-b border-[#1a4d2e]/50"
|
<Th>Headgate</Th>
|
||||||
style={{
|
<Th>Irrigator</Th>
|
||||||
background:
|
<Th className="w-[88px] text-right">Volume</Th>
|
||||||
"linear-gradient(180deg, rgba(26,77,46,0.08) 0%, rgba(26,77,46,0.02) 100%)",
|
<Th>Note</Th>
|
||||||
}}
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const time = new Date(row.logged_at).toLocaleTimeString(
|
||||||
|
"en-US",
|
||||||
|
{ hour: "numeric", minute: "2-digit" },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className="border-t border-[var(--admin-border-light)]"
|
||||||
|
>
|
||||||
|
<Td className="font-mono text-[var(--admin-text-secondary)]">
|
||||||
|
{time}
|
||||||
|
</Td>
|
||||||
|
<Td className="font-medium text-[var(--admin-text-primary)]">
|
||||||
|
{row.headgate_name}
|
||||||
|
</Td>
|
||||||
|
<Td className="text-[var(--admin-text-secondary)]">
|
||||||
|
{row.user_name}
|
||||||
|
</Td>
|
||||||
|
<Td className="text-right">
|
||||||
|
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">
|
||||||
|
{row.measurement.toFixed(2)}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{row.unit}
|
||||||
|
</span>
|
||||||
|
</Td>
|
||||||
|
<Td className="text-[var(--admin-text-muted)]">
|
||||||
|
{row.notes ?? <span className="opacity-40">—</span>}
|
||||||
|
</Td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr
|
||||||
|
className="border-t-2"
|
||||||
|
style={{ borderColor: "var(--admin-border)" }}
|
||||||
|
>
|
||||||
|
<Td
|
||||||
|
className="!border-0 pt-2 text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]"
|
||||||
|
colSpan={3}
|
||||||
>
|
>
|
||||||
<Th className="w-[68px]">Time</Th>
|
Total
|
||||||
<Th>Headgate</Th>
|
</Td>
|
||||||
<Th>Irrigator</Th>
|
<Td className="!border-0 pt-2 text-right">
|
||||||
<Th className="w-[86px] text-right">Volume</Th>
|
<span className="font-mono font-bold text-[var(--admin-text-primary)]">
|
||||||
<Th className="w-[28%]">Note</Th>
|
{total.toFixed(2)}
|
||||||
</tr>
|
</span>{" "}
|
||||||
</thead>
|
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||||
<tbody>
|
{totalUnit}
|
||||||
{rows.map((row, i) => {
|
</span>
|
||||||
const time = new Date(row.logged_at).toLocaleTimeString(
|
</Td>
|
||||||
"en-US",
|
<Td className="!border-0 pt-2">
|
||||||
{ hour: "numeric", minute: "2-digit" },
|
<span className="opacity-40">—</span>
|
||||||
);
|
</Td>
|
||||||
const isTop =
|
</tr>
|
||||||
topHeadgate &&
|
</tfoot>
|
||||||
row.headgate_name === topHeadgate[0] &&
|
</table>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Side stats — first/last entry, top headgate */}
|
{/* Actions */}
|
||||||
<div className="relative grid grid-cols-3 gap-px border-y border-[#1a4d2e]/40 bg-[#1a4d2e]/10 px-6 sm:px-8 mt-5">
|
<div className="flex flex-col-reverse items-stretch gap-2 pt-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
<SideStat label="First entry" value={fmtTime(firstTime)} />
|
<button type="button"
|
||||||
<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}
|
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]"
|
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
|
||||||
>
|
>
|
||||||
Close · Esc
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button"
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
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"
|
className="rounded-lg bg-[var(--admin-accent)] px-4 py-2 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? "Copied" : "Copy plain text"}
|
||||||
<>
|
|
||||||
<CheckMark /> Copied
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CopyMark /> Copy plain text
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</GlassModal>
|
||||||
{/* 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({
|
function Th({
|
||||||
label,
|
children,
|
||||||
value,
|
className = "",
|
||||||
mono = false,
|
|
||||||
emphasis = false,
|
|
||||||
accent,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
children: React.ReactNode;
|
||||||
value: string;
|
className?: 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 (
|
return (
|
||||||
<th
|
<th
|
||||||
className={`px-3 py-2 text-left text-[10px] uppercase tracking-[0.22em] text-[#1a4d2e] ${className}`}
|
className={`px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)] ${className}`}
|
||||||
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</th>
|
</th>
|
||||||
@@ -2453,96 +2023,21 @@ function Th({ children, className = "" }: { children: React.ReactNode; className
|
|||||||
function Td({
|
function Td({
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
|
colSpan,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
colSpan?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
className={`px-3 py-2 text-[13px] text-[#1d1d1f] ${className}`}
|
colSpan={colSpan}
|
||||||
style={{
|
className={`px-3 py-2 text-sm text-[var(--admin-text-primary)] ${className}`}
|
||||||
fontFamily: "var(--font-manrope, system-ui, sans-serif)",
|
|
||||||
verticalAlign: "top",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</td>
|
</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) ──────────────────────────────────────
|
// ─── Icons (16–20px, currentColor) ──────────────────────────────────────
|
||||||
|
|
||||||
function QrIcon() {
|
function QrIcon() {
|
||||||
|
|||||||
Reference in New Issue
Block a user