Files
route-commerce/src/components/admin/WaterLogAdminPanel.tsx
T
Nora 29d9d23a26 fix: react-doctor → 64/100 (Bugs 122, Perf 286, A11y 613, Maint 436)
- CartContext: lazy initializers replace mount-only useEffect
  hydration; remove 8 no-initialize-state warnings
- Toast/AdminSearchInput: React 19 useContext/use + drop
  forwardRef (3 no-react19-deprecated-apis)
- ProductFormModal: lazy initializers + useSyncExternalStore
  for mount; parent adds key=editingProduct.id
- InstallPrompt: useReducer for prompt state (no-cascading-set-state)
- QRScanModal: ref-based latest-callback pattern replaces
  useEffectEvent deps mistake
- OnboardingFlow: functional setState (rerender-functional-setstate)
- UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers
  (rerender-lazy-state-init)
- FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic
  in layout; remove supabase import
- LandingPageWrapper: href='#' → href='#top' (anchor-is-valid)
- TuxedoVideoHero: replace animate-bounce with ease-out-expo
  (no-inline-bounce-easing)
- ProductTableClient: useCallback for handleDeleted
  (jsx-no-new-function-as-prop)
- excel-parser: pre-compile delimiter regexes (js-hoist-regexp)
- water-log/settings: Promise.all for parallel DB calls
  (async-parallel)
- ToastNotification: extract toast store to separate file
  (only-export-components)
- WholesaleClient: inline <WholesaleIcon/> instead of hoisting to
  const (rendering-hoist-jsx)
2026-06-26 02:41:56 -06:00

1410 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* WaterLogAdminPanel — the main Water Log admin dashboard.
*
* Visual language: "Field Almanac." A rugged, light-themed almanac of
* the day's water use. The aesthetic borrows from USDA agricultural
* bulletins and old USGS survey markers — serif display type (Fraunces)
* for titles, mono (Fragment Mono) for measurements, and a single
* custom SVG (WaterGauge) that becomes the visual signature of the
* whole feature.
*
* - No purple-gradient SaaS look.
* - No glassmorphism. Strong borders, not soft shadows.
* - Field-notebook tone: precise, agricultural, slightly utilitarian.
* - Cream + forest + clay palette pulled from the project's existing
* `forest` / `sage` / `gold` tokens so the panel sits naturally
* inside the rest of /admin.
*
* The component is "use client" because it owns the filters and the
* entry-adding modals. All DB access happens through server actions in
* `src/actions/water-log/admin.ts`.
*/
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
createWaterHeadgate,
createWaterUser,
deleteWaterHeadgate,
deleteWaterUser,
getWaterEntries,
regenerateHeadgateToken,
resetWaterIrrigatorPin,
type AdminEntry,
type AdminHeadgate,
type AdminIrrigator,
} from "@/actions/water-log/admin";
import {
downloadWaterLogCSV,
filterWaterLogEntries,
formatDailyWaterReport,
isInIrrigationSeason,
shapeWaterLogEntry,
type IrrigationSeasonSettings,
type WaterLogFilter,
type WaterLogReportRow,
} from "@/lib/water-log-reporting";
import { WaterGauge, SeasonMark } from "@/components/water/icons";
import { AdminButton } from "./design-system";
import { formatDate, formatDateTime } from "@/lib/format-date";
type Props = {
initialUsers: AdminIrrigator[];
initialHeadgates: AdminHeadgate[];
initialEntries: AdminEntry[];
brandId: string;
canManage: boolean;
};
const DEFAULT_SEASON: IrrigationSeasonSettings = {
seasonStartMonth: 3,
seasonStartDay: 15,
seasonEndMonth: 10,
seasonEndDay: 15,
};
export default function WaterLogAdminPanel({
initialUsers,
initialHeadgates,
initialEntries,
brandId,
canManage,
}: Props) {
const router = useRouter();
const [headgates, setHeadgates] = useState<AdminHeadgate[]>(initialHeadgates);
const [users, setUsers] = useState<AdminIrrigator[]>(initialUsers);
const [entries] = useState<AdminEntry[]>(initialEntries);
const [toast, setToast] = useState<{ msg: string; kind: "ok" | "err" } | null>(null);
// ── Forms ──────────────────────────────────────────────────────────
const [showAddHg, setShowAddHg] = useState(false);
const [newHg, setNewHg] = useState({ name: "", unit: "CFS", notes: "" });
const [savingHg, setSavingHg] = useState(false);
const [showAddUser, setShowAddUser] = useState(false);
const [newUser, setNewUser] = useState({
name: "",
role: "irrigator" as "irrigator" | "water_admin",
lang: "en",
phone: "",
});
const [savingUser, setSavingUser] = useState(false);
const [newPin, setNewPin] = useState<{ name: string; pin: string } | null>(null);
const [resetPin, setResetPin] = useState<{ name: string; pin: string } | null>(
null,
);
// ── Filters ────────────────────────────────────────────────────────
const [filterDateFrom, setFilterDateFrom] = useState("");
const [filterDateTo, setFilterDateTo] = useState("");
const [filterHeadgate, setFilterHeadgate] = useState("");
const [filterUser, setFilterUser] = useState("");
const [filterVia, setFilterVia] = useState("");
const [csvLoading, setCsvLoading] = useState(false);
// `today` and the human-readable date are read from the clock; compute
// them in the browser only to avoid hydration mismatches. The setStates
// are wrapped in an async IIFE so the effect body does not call setState
// synchronously (avoids the cascading-renders ESLint rule).
const [today, setToday] = useState<string>("");
const [todayLabel, setTodayLabel] = useState<string>("");
useEffect(() => {
void (async () => {
const now = new Date();
setToday(now.toISOString().slice(0, 10));
setTodayLabel(formatDate(now));
})();
}, []);
// ── Season + report settings (localStorage) ───────────────────────
const [showReportSettings, setShowReportSettings] = useState(false);
const [seasonStart, setSeasonStart] = useState<IrrigationSeasonSettings>(() => {
if (typeof window === "undefined") return DEFAULT_SEASON;
try {
const saved = localStorage.getItem("wl_season_settings:v1");
return saved ? { ...DEFAULT_SEASON, ...JSON.parse(saved) } : DEFAULT_SEASON;
} catch {
return DEFAULT_SEASON;
}
});
const [recipientName, setRecipientName] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem("wl_recipient_name") ?? "" : "",
);
// ── Derived data ──────────────────────────────────────────────────
const inSeason = useMemo(
() => isInIrrigationSeason(new Date(), seasonStart),
[seasonStart],
);
const todayEntries = useMemo(
() =>
today
? entries.filter((e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today)
: [],
[entries, today],
);
const todayTotal = todayEntries.reduce((s, e) => s + e.measurement, 0);
const lastEntryByHeadgate = useMemo(() => {
const map = new Map<string, AdminEntry>();
for (const e of entries) {
const existing = map.get(e.headgate_id);
if (!existing || e.logged_at > existing.logged_at) {
map.set(e.headgate_id, e);
}
}
return map;
}, [entries]);
const filteredEntries = useMemo(() => {
return entries.filter((e) => {
if (filterDateFrom && e.logged_at < filterDateFrom) return false;
if (filterDateTo && e.logged_at > filterDateTo + "T23:59:59.999Z") return false;
if (filterHeadgate && e.headgate_id !== filterHeadgate) return false;
if (filterUser && e.user_id !== filterUser) return false;
if (filterVia && e.submitted_via !== filterVia) return false;
return true;
});
}, [entries, filterDateFrom, filterDateTo, filterHeadgate, filterUser, filterVia]);
// ── Toast helper ──────────────────────────────────────────────────
function notify(msg: string, kind: "ok" | "err" = "ok") {
setToast({ msg, kind });
setTimeout(() => setToast(null), 2800);
}
// ── Handlers ──────────────────────────────────────────────────────
async function handleAddHg(e: React.FormEvent) {
e.preventDefault();
if (!newHg.name.trim()) return;
setSavingHg(true);
const res = await createWaterHeadgate(brandId, newHg.name.trim(), newHg.unit, {
notes: newHg.notes.trim() || null,
});
if (res.success && res.headgate) {
setHeadgates((prev) => [res.headgate!, ...prev]);
setNewHg({ name: "", unit: "CFS", notes: "" });
setShowAddHg(false);
notify("Headgate added");
} else {
notify(res.error ?? "Failed", "err");
}
setSavingHg(false);
}
async function handleDeleteHg(h: AdminHeadgate) {
if (!window.confirm(`Delete "${h.name}"? Existing log entries will be preserved.`)) return;
const res = await deleteWaterHeadgate(h.id);
if (res.success) {
setHeadgates((prev) => prev.filter((x) => x.id !== h.id));
notify("Headgate removed");
} else {
notify(res.error ?? "Failed", "err");
}
}
async function handleRegenerateToken(h: AdminHeadgate) {
if (
!window.confirm(
`Regenerate QR token for "${h.name}"? Existing printed QR codes will stop working.`,
)
) return;
const res = await regenerateHeadgateToken(h.id);
if (res.success && res.token) {
setHeadgates((prev) =>
prev.map((x) => (x.id === h.id ? { ...x, headgate_token: res.token! } : x)),
);
notify("Token regenerated");
} else {
notify(res.error ?? "Failed", "err");
}
}
async function handleAddUser(e: React.FormEvent) {
e.preventDefault();
if (!newUser.name.trim()) return;
setSavingUser(true);
const res = await createWaterUser(
brandId,
newUser.name.trim(),
newUser.role,
newUser.lang,
newUser.phone.trim() || null,
);
if (res.success && res.user && res.pin) {
setUsers((prev) => [res.user!, ...prev]);
setNewPin({ name: res.user.name, pin: res.pin });
setNewUser({ name: "", role: "irrigator", lang: "en", phone: "" });
setShowAddUser(false);
notify("User created");
} else {
notify(res.error ?? "Failed", "err");
}
setSavingUser(false);
}
async function handleResetPin(u: AdminIrrigator) {
if (!window.confirm(`Reset PIN for "${u.name}"? Their current PIN will stop working.`))
return;
const res = await resetWaterIrrigatorPin(u.id);
if (res.success && res.pin) {
setResetPin({ name: u.name, pin: res.pin });
notify("PIN reset");
} else {
notify(res.error ?? "Failed", "err");
}
}
async function handleDeleteUser(u: AdminIrrigator) {
if (!window.confirm(`Deactivate "${u.name}"? Their existing log entries will be preserved.`))
return;
const res = await deleteWaterUser(u.id);
if (res.success) {
setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, active: false } : x)));
notify("User deactivated");
} else {
notify(res.error ?? "Failed", "err");
}
}
async function handlePreviewReport() {
const all = await getWaterEntries(brandId, 1000);
const todayRows: WaterLogReportRow[] = all
.filter(
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today,
)
.map(shapeWaterLogEntry);
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}`);
}
async function handleExportCSV() {
setCsvLoading(true);
try {
const all = await getWaterEntries(brandId, 5000);
const shaped: WaterLogReportRow[] = all.map(shapeWaterLogEntry);
const filters: WaterLogFilter = {
dateFrom: filterDateFrom || undefined,
dateTo: filterDateTo || undefined,
headgateId: filterHeadgate || undefined,
userId: filterUser || undefined,
submittedVia: filterVia || undefined,
};
const filtered = filterWaterLogEntries(shaped, filters);
downloadWaterLogCSV(`water-log-${today}.csv`, filtered);
notify(`Exported ${filtered.length} entries`);
} finally {
setCsvLoading(false);
}
}
function persistSeason(s: IrrigationSeasonSettings) {
setSeasonStart(s);
try {
localStorage.setItem("wl_season_settings:v1", JSON.stringify(s));
} catch {}
}
// ── Render ────────────────────────────────────────────────────────
return (
<div className="relative">
{/* Top tabs */}
<div className="mb-6 flex flex-wrap items-center gap-2">
<Link href="/admin/water-log/headgates">
<AdminButton variant="secondary" size="sm" icon={<QrIcon />}>
QR Codes
</AdminButton>
</Link>
<Link href="/admin/water-log/settings">
<AdminButton variant="secondary" size="sm" icon={<GearIcon />}>
Settings
</AdminButton>
</Link>
<Link href="/water/admin" target="_blank" rel="noopener noreferrer">
<AdminButton variant="secondary" size="sm" icon={<FieldIcon />}>
Field Admin
</AdminButton>
</Link>
</div>
{/* ── Today's Summary ─────────────────────────────────────── */}
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
{/* Subtle topographic pattern overlay */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.18]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, transparent 0 14px, rgba(26,77,46,0.06) 14px 15px), repeating-linear-gradient(-45deg, transparent 0 14px, rgba(202,138,4,0.04) 14px 15px)",
}}
/>
<div className="relative grid grid-cols-1 gap-6 p-6 md:grid-cols-[200px_1fr] md:gap-8">
{/* Hero gauge */}
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Tuxedo Ditch Co.
</div>
<WaterGauge
size={120}
level={
todayEntries.length === 0
? null
: Math.min(1, todayTotal / Math.max(todayTotal, 100))
}
status="open"
ariaLabel="Today's water level"
/>
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Vol. {todayTotal.toFixed(2)} {headgates[0]?.unit ?? "CFS"}
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<h2
className="text-2xl font-semibold tracking-tight text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Today&apos;s Summary
</h2>
<p className="mt-1 flex items-center gap-2 text-sm text-[#57694e]">
<SeasonMark inSeason={inSeason} size={14} />
{inSeason ? (
<span className="font-medium text-[#1a4d2e]">In irrigation season</span>
) : (
<span className="font-medium text-[#a16207]">Outside irrigation season</span>
)}
<span className="text-[#57694e]/60">·</span>
<span className="font-mono text-[#1d1d1f]">
{todayEntries.length} {todayEntries.length === 1 ? "entry" : "entries"}
</span>
<span className="text-[#57694e]/60">·</span>
<span className="text-[#57694e]">
{todayLabel}
</span>
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile
label="Total volume"
value={todayTotal.toFixed(2)}
unit={headgates[0]?.unit ?? "CFS"}
accent
/>
<StatTile
label="Headgates"
value={headgates.filter((h) => h.active).length.toString()}
unit={`of ${headgates.length}`}
/>
<StatTile
label="Active irrigators"
value={users.filter((u) => u.active).length.toString()}
unit={`of ${users.length}`}
/>
<StatTile
label="Last entry"
value={
todayEntries.length
? formatDateTime(todayEntries[0].logged_at).split(",").pop()?.trim() ?? "—"
: "—"
}
unit=""
mono
/>
</div>
<div className="flex flex-wrap gap-2">
<AdminButton
variant="secondary"
size="sm"
icon={<ReportIcon />}
onClick={handlePreviewReport}
>
Preview Report
</AdminButton>
<AdminButton
variant={showReportSettings ? "primary" : "secondary"}
size="sm"
icon={<GearIcon />}
onClick={() => setShowReportSettings((v) => !v)}
>
Report Settings
</AdminButton>
</div>
{showReportSettings && (
<ReportSettingsPanel
season={seasonStart}
onSeasonChange={persistSeason}
recipientName={recipientName}
onRecipientNameChange={(v) => {
setRecipientName(v);
try {
localStorage.setItem("wl_recipient_name", v);
} catch {}
}}
/>
)}
</div>
</div>
</section>
{/* ── Headgates ───────────────────────────────────────────── */}
<SectionHeader
index="§ 01"
title="Headgates"
subtitle="Physical gates in the ditch network. Each gets a printable QR code so field workers can scan to log a reading without typing the gate name."
action={
canManage ? (
<AdminButton size="sm" onClick={() => setShowAddHg((v) => !v)}>
{showAddHg ? "Cancel" : "+ Add Headgate"}
</AdminButton>
) : null
}
/>
{showAddHg && canManage && (
<form
onSubmit={handleAddHg}
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_120px_1fr_auto]"
>
<Input
label="Name"
value={newHg.name}
onChange={(v) => setNewHg((s) => ({ ...s, name: v }))}
placeholder="e.g. North Field Gate 1"
required
autoFocus
/>
<Select
label="Unit"
value={newHg.unit}
onChange={(v) => setNewHg((s) => ({ ...s, unit: v }))}
options={["CFS", "GPM", "Inches", "AF/Day", "gal", "ac-ft"]}
/>
<Input
label="Notes"
value={newHg.notes}
onChange={(v) => setNewHg((s) => ({ ...s, notes: v }))}
placeholder="Location, GPS, contact…"
/>
<div className="flex items-end">
<AdminButton type="submit" disabled={savingHg} isLoading={savingHg} fullWidth>
Add
</AdminButton>
</div>
</form>
)}
{headgates.length === 0 ? (
<EmptyState
icon={<WaterGauge size={36} level={null} status="open" />}
title="No headgates yet"
body="Add a headgate to start logging. Each one gets a unique QR code you can print and post at the gate."
/>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{headgates.map((h) => {
const last = lastEntryByHeadgate.get(h.id);
const level =
last && h.high_threshold
? Math.min(1, last.measurement / Number(h.high_threshold))
: null;
return (
<li
key={h.id}
className="group rounded-xl border border-[#d4d9d3] bg-white p-4 transition hover:border-[#1a4d2e]/40 hover:shadow-[0_4px_16px_rgba(26,77,46,0.08)]"
>
<div className="flex items-start gap-3">
<WaterGauge
size={36}
level={level}
status={h.status as "open" | "closed" | "maintenance"}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-[#1d1d1f]">
{h.name}
</h3>
<StatusPill
kind={
!h.active
? "inactive"
: h.status === "closed"
? "closed"
: h.status === "maintenance"
? "warn"
: "active"
}
/>
</div>
<p className="mt-0.5 font-mono text-[11px] uppercase tracking-wider text-[#86868b]">
Unit: {h.unit} · Token: {h.headgate_token.slice(0, 8)}
</p>
{last ? (
<p className="mt-2 text-sm text-[#57694e]">
Last:{" "}
<span className="font-mono font-semibold text-[#1d1d1f]">
{last.measurement} {h.unit}
</span>{" "}
<span className="text-[#86868b]">
by {last.user_name} · {formatDateTime(last.logged_at)}
</span>
</p>
) : (
<p className="mt-2 text-sm italic text-[#86868b]">
No readings yet
</p>
)}
{(h.high_threshold != null || h.low_threshold != null) && (
<p className="mt-1 font-mono text-[11px] text-[#57694e]">
{h.high_threshold != null && (
<>Hi&nbsp;&nbsp;{h.high_threshold}&nbsp;&nbsp;</>
)}
{h.low_threshold != null && (
<>Lo&nbsp;&nbsp;{h.low_threshold}</>
)}
</p>
)}
</div>
</div>
{canManage && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-1 border-t border-[#e8ebe8] pt-3 text-[11px]">
<button type="button"
onClick={() => router.push(`/admin/water-log/headgates/${h.id}`)}
className="font-medium text-[#1a4d2e] hover:underline"
>
Edit
</button>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => handleRegenerateToken(h)}
className="text-[#57694e] hover:text-[#1a4d2e]"
>
Rotate token
</button>
<span className="text-[#d4d9d3]">·</span>
<button type="button"
onClick={() => handleDeleteHg(h)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
>
Delete
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
{/* ── Water Users ──────────────────────────────────────────── */}
<SectionHeader
index="§ 02"
title="Water Users"
subtitle="The people who log readings in the field. Each gets a 4-digit PIN. Admin role can manage headgates, users, and entries; Irrigators can only submit."
action={
canManage ? (
<AdminButton size="sm" onClick={() => setShowAddUser((v) => !v)}>
{showAddUser ? "Cancel" : "+ Add User"}
</AdminButton>
) : null
}
/>
{newPin && (
<PinBanner
title={`New PIN for ${newPin.name}`}
pin={newPin.pin}
onDismiss={() => setNewPin(null)}
/>
)}
{resetPin && (
<PinBanner
title={`Reset PIN for ${resetPin.name}`}
pin={resetPin.pin}
tone="warn"
onDismiss={() => setResetPin(null)}
/>
)}
{showAddUser && canManage && (
<form
onSubmit={handleAddUser}
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_140px_120px_1fr_auto]"
>
<Input
label="Name"
value={newUser.name}
onChange={(v) => setNewUser((s) => ({ ...s, name: v }))}
placeholder="Full name"
required
autoFocus
/>
<Select
label="Role"
value={newUser.role}
onChange={(v) =>
setNewUser((s) => ({ ...s, role: v as "irrigator" | "water_admin" }))
}
options={[
{ value: "irrigator", label: "Irrigator" },
{ value: "water_admin", label: "Admin" },
]}
/>
<Select
label="Language"
value={newUser.lang}
onChange={(v) => setNewUser((s) => ({ ...s, lang: v }))}
options={[
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
]}
/>
<Input
label="Phone"
value={newUser.phone}
onChange={(v) => setNewUser((s) => ({ ...s, phone: v }))}
placeholder="+1…"
type="tel"
/>
<div className="flex items-end">
<AdminButton type="submit" disabled={savingUser} isLoading={savingUser} fullWidth>
Add
</AdminButton>
</div>
</form>
)}
<RoleLegend />
{users.length === 0 ? (
<EmptyState
icon={<UserIcon />}
title="No water users yet"
body="Add an irrigator or admin to grant field access. Their PIN is generated automatically — write it down before dismissing."
/>
) : (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<li
key={u.id}
className={`flex items-center gap-3 rounded-xl border bg-white px-3 py-2 ${
u.active ? "border-[#d4d9d3]" : "border-[#d4d9d3] bg-[#f5f3ef] opacity-70"
}`}
>
<Avatar name={u.name} role={u.role} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-[#1d1d1f]">{u.name}</p>
<p className="font-mono text-[11px] text-[#86868b]">
{u.role === "water_admin" ? "ADMIN" : "IRRIGATOR"} · {u.language_preference === "es" ? "ES" : "EN"}
{u.last_used_at ? ` · seen ${formatDate(u.last_used_at)}` : " · never signed in"}
</p>
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1 text-[11px]">
<button type="button"
onClick={() => handleResetPin(u)}
className="text-[#57694e] hover:text-[#1a4d2e]"
>
PIN
</button>
<span className="text-[#d4d9d3]">·</span>
<button type="button"
onClick={() => handleDeleteUser(u)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
>
Off
</button>
</div>
)}
</li>
))}
</ul>
)}
{/* ── Recent Entries ──────────────────────────────────────── */}
<SectionHeader
index="§ 03"
title="Recent Entries"
subtitle="The last {n} readings logged from the field or the admin panel. Use the filters to narrow, then export to CSV."
action={
<AdminButton
size="sm"
onClick={handleExportCSV}
isLoading={csvLoading}
icon={<DownloadIcon />}
>
Export CSV
</AdminButton>
}
/>
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-[#d4d9d3] bg-white p-2.5">
<FilterChip label="From">
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: from date"
/>
</FilterChip>
<FilterChip label="To">
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: to date"
/>
</FilterChip>
<FilterChip label="Headgate">
<select
value={filterHeadgate}
onChange={(e) => setFilterHeadgate(e.target.value)}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: headgate"
>
<option value="">All</option>
{headgates.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</FilterChip>
<FilterChip label="User">
<select
value={filterUser}
onChange={(e) => setFilterUser(e.target.value)}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: user"
>
<option value="">All</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FilterChip>
<FilterChip label="Via">
<select
value={filterVia}
onChange={(e) => setFilterVia(e.target.value)}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: submission source"
>
<option value="">All</option>
<option value="field">Field</option>
<option value="admin">Admin</option>
<option value="app">App</option>
</select>
</FilterChip>
{(filterDateFrom ||
filterDateTo ||
filterHeadgate ||
filterUser ||
filterVia) && (
<button type="button"
onClick={() => {
setFilterDateFrom("");
setFilterDateTo("");
setFilterHeadgate("");
setFilterUser("");
setFilterVia("");
}}
className="text-xs font-medium text-[#57694e] hover:text-[#1a4d2e] hover:underline"
>
Clear filters
</button>
)}
<span className="ml-auto font-mono text-[11px] text-[#86868b]">
{filteredEntries.length} of {entries.length} entries
</span>
</div>
{filteredEntries.length === 0 ? (
<EmptyState
icon={<TableIcon />}
title="No entries match"
body={entries.length === 0 ? "No readings have been logged yet." : "Try clearing the filters above."}
/>
) : (
<div className="overflow-hidden rounded-xl border border-[#d4d9d3] bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#d4d9d3] bg-[#f5f3ef] text-left font-mono text-[10px] uppercase tracking-[0.15em] text-[#57694e]">
<th className="px-4 py-2.5">When</th>
<th className="px-4 py-2.5">User</th>
<th className="px-4 py-2.5">Headgate</th>
<th className="px-4 py-2.5 text-right">Measurement</th>
<th className="px-4 py-2.5 text-right">Gallons</th>
<th className="px-4 py-2.5">Method</th>
<th className="px-4 py-2.5">Via</th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody>
{filteredEntries.map((e, i) => (
<tr
key={e.id}
className={`group border-b border-[#e8ebe8] last:border-0 transition-colors hover:bg-[#fdfaf2] ${
i % 2 === 0 ? "" : "bg-[#fafaf7]"
}`}
>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-[#57694e]">
{formatDateTime(e.logged_at)}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
{e.user_name}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
{e.headgate_name}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono font-semibold text-[#1d1d1f]">
{e.measurement}{" "}
<span className="text-[10px] text-[#86868b]">{e.unit}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono text-[#57694e]">
{e.total_gallons != null ? e.total_gallons.toFixed(1) : "—"}
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-[11px] uppercase text-[#57694e]">
{e.method}
</td>
<td className="whitespace-nowrap px-4 py-2.5">
<span
className={`inline-block rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
e.submitted_via === "field"
? "bg-[#dcfce7] text-[#15803d]"
: "bg-[#e8ebe8] text-[#57694e]"
}`}
>
{e.submitted_via}
</span>
</td>
<td className="px-4 py-2.5 text-right text-[11px]">
<button type="button"
onClick={() =>
router.push(`/admin/water-log/entries/${e.id}`)
}
className="font-medium text-[#1a4d2e] opacity-0 transition-opacity group-hover:opacity-100 hover:underline"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Toast */}
{toast && (
<div
role="status"
aria-live="polite"
className={`fixed bottom-6 right-6 z-50 rounded-xl px-4 py-3 font-medium shadow-lg transition-all ${
toast.kind === "ok"
? "border border-[#15803d] bg-[#dcfce7] text-[#15803d]"
: "border border-[#b91c1c] bg-[#fee2e2] text-[#b91c1c]"
}`}
>
{toast.msg}
</div>
)}
</div>
);
}
// ─── Sub-components ──────────────────────────────────────────────────────
function SectionHeader({
index,
title,
subtitle,
action,
}: {
index: string;
title: string;
subtitle: string;
action?: React.ReactNode;
}) {
return (
<header className="mb-3 mt-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3] pb-3">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#1a4d2e]">
{index}
</div>
<h2
className="mt-0.5 text-xl font-semibold text-[#1d1d1f]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{title}
</h2>
<p className="mt-0.5 max-w-xl text-xs text-[#57694e]">{subtitle}</p>
</div>
{action}
</header>
);
}
function StatTile({
label,
value,
unit,
accent,
mono,
}: {
label: string;
value: string;
unit: string;
accent?: boolean;
mono?: boolean;
}) {
return (
<div
className={`rounded-lg border p-3 ${
accent
? "border-[#1a4d2e]/30 bg-[#f0fdf4]"
: "border-[#d4d9d3] bg-white"
}`}
>
<div className="text-[10px] font-mono uppercase tracking-wider text-[#57694e]">
{label}
</div>
<div className="mt-0.5 flex items-baseline gap-1">
<span
className={`text-lg font-semibold text-[#1a4d2e] ${
mono ? "font-mono" : ""
}`}
>
{value}
</span>
{unit && (
<span className="font-mono text-[10px] uppercase text-[#57694e]">
{unit}
</span>
)}
</div>
</div>
);
}
function StatusPill({ kind }: { kind: "active" | "inactive" | "closed" | "warn" }) {
const map = {
active: { bg: "bg-[#dcfce7]", fg: "text-[#15803d]", label: "Active" },
inactive: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Inactive" },
closed: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Closed" },
warn: { bg: "bg-[#fef9c3]", fg: "text-[#a16207]", label: "Maint." },
} as const;
const s = map[kind];
return (
<span
className={`inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${s.bg} ${s.fg}`}
>
{s.label}
</span>
);
}
function PinBanner({
title,
pin,
tone = "ok",
onDismiss,
}: {
title: string;
pin: string;
tone?: "ok" | "warn";
onDismiss: () => void;
}) {
return (
<div
className={`mb-4 flex items-center gap-4 rounded-xl border p-4 ${
tone === "warn"
? "border-[#fde047] bg-[#fef9c3]"
: "border-[#15803d]/40 bg-[#dcfce7]"
}`}
>
<KeyIcon />
<div className="flex-1">
<p className="font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{title}
</p>
<p
className="font-mono text-2xl font-bold tracking-[0.5em] text-[#1a4d2e]"
aria-label={`PIN ${pin.split("").join(" ")}`}
>
{pin}
</p>
</div>
<button type="button"
onClick={onDismiss}
className="rounded-lg border border-[#1a4d2e]/30 px-3 py-1.5 text-xs font-semibold text-[#1a4d2e] hover:bg-white"
>
Dismiss
</button>
</div>
);
}
function RoleLegend() {
return (
<p className="mb-3 flex items-center gap-3 font-mono text-[11px] text-[#57694e]">
<span>
<span className="font-bold text-[#1e3a8a]">ADMIN</span>
<span className="text-[#86868b]"> manage headgates, users, entries</span>
</span>
<span className="text-[#d4d9d3]">·</span>
<span>
<span className="font-bold text-[#15803d]">IRRIGATOR</span>
<span className="text-[#86868b]"> submit entries only</span>
</span>
</p>
);
}
function EmptyState({
icon,
title,
body,
}: {
icon: React.ReactNode;
title: string;
body: string;
}) {
return (
<div className="rounded-xl border border-dashed border-[#d4d9d3] bg-white px-6 py-10 text-center">
<div className="mx-auto mb-3 flex w-12 items-center justify-center text-[#86868b]">
{icon}
</div>
<p
className="text-base font-semibold text-[#1d1d1f]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{title}
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-[#57694e]">{body}</p>
</div>
);
}
function Input({
label,
value,
onChange,
placeholder,
required,
autoFocus,
type = "text",
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
autoFocus?: boolean;
type?: string;
}) {
return (
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
autoFocus={autoFocus}
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
/>
</label>
);
}
function Select({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: (string | { value: string; label: string })[];
}) {
return (
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
>
{options.map((o) => {
if (typeof o === "string") {
return (
<option key={o} value={o}>
{o}
</option>
);
}
return (
<option key={o.value} value={o.value}>
{o.label}
</option>
);
})}
</select>
</label>
);
}
function FilterChip({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-1.5 rounded-lg border border-[#d4d9d3] bg-[#fafaf7] px-2 py-1">
<span className="font-mono text-[10px] uppercase tracking-wider text-[#86868b]">
{label}
</span>
{children}
</div>
);
}
function Avatar({ name, role }: { name: string; role: string }) {
const initials = name
.split(/\s+/)
.map((n) => n[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
const bg = role === "water_admin" ? "#1a4d2e" : "#57694e";
return (
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-mono text-[11px] font-bold text-white"
style={{ background: bg }}
aria-hidden
>
{initials || "·"}
</span>
);
}
function ReportSettingsPanel({
season,
onSeasonChange,
recipientName,
onRecipientNameChange,
}: {
season: IrrigationSeasonSettings;
onSeasonChange: (s: IrrigationSeasonSettings) => void;
recipientName: string;
onRecipientNameChange: (v: string) => void;
}) {
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<p className="font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
Report / Messaging Settings
</p>
</div>
<p className="mb-3 rounded-lg bg-[#fef9c3] px-3 py-2 text-xs text-[#a16207]">
<strong>Preview only.</strong> Daily-report delivery is wired through
the scheduled <code>api/cron/water-report</code> endpoint and respects
these settings.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
Season start
</span>
<div className="flex gap-1">
<MonthSelect
value={season.seasonStartMonth}
onChange={(m) => onSeasonChange({ ...season, seasonStartMonth: m })}
/>
<DayInput
value={season.seasonStartDay}
onChange={(d) => onSeasonChange({ ...season, seasonStartDay: d })}
/>
</div>
</div>
<div>
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
Season end
</span>
<div className="flex gap-1">
<MonthSelect
value={season.seasonEndMonth}
onChange={(m) => onSeasonChange({ ...season, seasonEndMonth: m })}
/>
<DayInput
value={season.seasonEndDay}
onChange={(d) => onSeasonChange({ ...season, seasonEndDay: d })}
/>
</div>
</div>
<div className="sm:col-span-2">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
Recipient name (used in report salutation)
</span>
<input aria-label="David"
type="text"
value={recipientName}
onChange={(e) => onRecipientNameChange(e.target.value)}
placeholder="David"
className="w-full rounded-lg border border-[#d4d9d3] px-3 py-2 text-sm outline-none focus:border-[#1a4d2e]"
/>
</div>
</div>
</div>
);
}
function MonthSelect({
value,
onChange,
}: {
value: number;
onChange: (m: number) => void;
}) {
return (
<select
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className="flex-1 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
aria-label="Month"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(0, i).toLocaleString("en", { month: "short" })}
</option>
))}
</select>
);
}
function DayInput({
value,
onChange,
}: {
value: number;
onChange: (d: number) => void;
}) {
return (
<input
type="number"
min={1}
max={31}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10) || 1)}
className="w-16 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
aria-label="Day"
/>
);
}
// ─── Icons (1620px, currentColor) ──────────────────────────────────────
function QrIcon() {
return (
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4}>
<rect x="2" y="2" width="5" height="5" />
<rect x="9" y="2" width="5" height="5" />
<rect x="2" y="9" width="5" height="5" />
<path d="M9 9 H11 V11 H9 Z" />
<path d="M12 9 H14 V12" />
<path d="M9 12 V14 H14" />
</svg>
);
}
function GearIcon() {
return (
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4}>
<circle cx="8" cy="8" r="2.2" />
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
</svg>
);
}
function FieldIcon() {
return (
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4}>
<path d="M2 11 L8 5 L11 8 L14 5" />
<path d="M2 14 H14" />
</svg>
);
}
function ReportIcon() {
return (
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4}>
<rect x="3" y="2" width="10" height="12" rx="1" />
<path d="M5 5 H11 M5 8 H11 M5 11 H8" />
</svg>
);
}
function DownloadIcon() {
return (
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4}>
<path d="M8 2 V10 M5 7 L8 10 L11 7" />
<path d="M2 12 V14 H14 V12" />
</svg>
);
}
function KeyIcon() {
return (
<svg width={28} height={28} viewBox="0 0 24 24" fill="none" stroke="#1a4d2e" strokeWidth={1.6}>
<circle cx="8" cy="12" r="4" />
<path d="M12 12 H22 M18 12 V16 M22 12 V15" />
</svg>
);
}
function UserIcon() {
return (
<svg width={36} height={36} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.4}>
<circle cx="12" cy="8" r="4" />
<path d="M4 21 C4 16 8 14 12 14 C16 14 20 16 20 21" />
</svg>
);
}
function TableIcon() {
return (
<svg width={36} height={36} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.4}>
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M3 10 H21 M9 4 V20" />
</svg>
);
}