"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(initialHeadgates); const [users, setUsers] = useState(initialUsers); const [entries] = useState(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(""); const [todayLabel, setTodayLabel] = useState(""); 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(() => { 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(); 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 (
{/* Top tabs */}
}> QR Codes }> Settings }> Field Admin ↗
{/* ── Today's Summary ─────────────────────────────────────── */}
{/* Subtle topographic pattern overlay */}
{/* Hero gauge */}
Tuxedo Ditch Co.
Vol. {todayTotal.toFixed(2)} {headgates[0]?.unit ?? "CFS"}

Today's Summary

{inSeason ? ( In irrigation season ) : ( Outside irrigation season )} · {todayEntries.length} {todayEntries.length === 1 ? "entry" : "entries"} · {todayLabel}

h.active).length.toString()} unit={`of ${headgates.length}`} /> u.active).length.toString()} unit={`of ${users.length}`} />
} onClick={handlePreviewReport} > Preview Report } onClick={() => setShowReportSettings((v) => !v)} > Report Settings
{showReportSettings && ( { setRecipientName(v); try { localStorage.setItem("wl_recipient_name", v); } catch {} }} /> )}
{/* ── Headgates ───────────────────────────────────────────── */} setShowAddHg((v) => !v)}> {showAddHg ? "Cancel" : "+ Add Headgate"} ) : null } /> {showAddHg && canManage && (
setNewHg((s) => ({ ...s, name: v }))} placeholder="e.g. North Field Gate 1" required autoFocus /> setNewHg((s) => ({ ...s, notes: v }))} placeholder="Location, GPS, contact…" />
Add
)} {headgates.length === 0 ? ( } 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." /> ) : (
    {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 (
  • {h.name}

    Unit: {h.unit} · Token: {h.headgate_token.slice(0, 8)}…

    {last ? (

    Last:{" "} {last.measurement} {h.unit} {" "} by {last.user_name} · {formatDateTime(last.logged_at)}

    ) : (

    No readings yet

    )} {(h.high_threshold != null || h.low_threshold != null) && (

    {h.high_threshold != null && ( <>Hi ≥ {h.high_threshold}   )} {h.low_threshold != null && ( <>Lo ≤ {h.low_threshold} )}

    )}
    {canManage && (
    ·
    )}
  • ); })}
)} {/* ── Water Users ──────────────────────────────────────────── */} setShowAddUser((v) => !v)}> {showAddUser ? "Cancel" : "+ Add User"} ) : null } /> {newPin && ( setNewPin(null)} /> )} {resetPin && ( setResetPin(null)} /> )} {showAddUser && canManage && (
setNewUser((s) => ({ ...s, name: v }))} placeholder="Full name" required autoFocus /> setNewUser((s) => ({ ...s, lang: v }))} options={[ { value: "en", label: "English" }, { value: "es", label: "Español" }, ]} /> setNewUser((s) => ({ ...s, phone: v }))} placeholder="+1…" type="tel" />
Add
)} {users.length === 0 ? ( } 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." /> ) : (
    {users.map((u) => (
  • {u.name}

    {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"}

    {canManage && (
    ·
    )}
  • ))}
)} {/* ── Recent Entries ──────────────────────────────────────── */} } > Export CSV } />
setFilterDateFrom(e.target.value)} className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none" aria-label="Filter: from date" /> setFilterDateTo(e.target.value)} className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none" aria-label="Filter: to date" /> {(filterDateFrom || filterDateTo || filterHeadgate || filterUser || filterVia) && ( )} {filteredEntries.length} of {entries.length} entries
{filteredEntries.length === 0 ? ( } title="No entries match" body={entries.length === 0 ? "No readings have been logged yet." : "Try clearing the filters above."} /> ) : (
{filteredEntries.map((e, i) => ( ))}
When User Headgate Measurement Gallons Method Via
{formatDateTime(e.logged_at)} {e.user_name} {e.headgate_name} {e.measurement}{" "} {e.unit} {e.total_gallons != null ? e.total_gallons.toFixed(1) : "—"} {e.method} {e.submitted_via}
)} {/* Toast */} {toast && (
{toast.msg}
)}
); } // ─── Sub-components ────────────────────────────────────────────────────── function SectionHeader({ index, title, subtitle, action, }: { index: string; title: string; subtitle: string; action?: React.ReactNode; }) { return (
{index}

{title}

{subtitle}

{action}
); } function StatTile({ label, value, unit, accent, mono, }: { label: string; value: string; unit: string; accent?: boolean; mono?: boolean; }) { return (
{label}
{value} {unit && ( {unit} )}
); } 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 ( {s.label} ); } function PinBanner({ title, pin, tone = "ok", onDismiss, }: { title: string; pin: string; tone?: "ok" | "warn"; onDismiss: () => void; }) { return (

{title}

{pin}

); } function RoleLegend() { return (

ADMIN — manage headgates, users, entries · IRRIGATOR — submit entries only

); } function EmptyState({ icon, title, body, }: { icon: React.ReactNode; title: string; body: string; }) { return (
{icon}

{title}

{body}

); } 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 ( ); } function Select({ label, value, onChange, options, }: { label: string; value: string; onChange: (v: string) => void; options: (string | { value: string; label: string })[]; }) { return ( ); } function FilterChip({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } 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 ( {initials || "·"} ); } function ReportSettingsPanel({ season, onSeasonChange, recipientName, onRecipientNameChange, }: { season: IrrigationSeasonSettings; onSeasonChange: (s: IrrigationSeasonSettings) => void; recipientName: string; onRecipientNameChange: (v: string) => void; }) { return (

Report / Messaging Settings

Preview only. Daily-report delivery is wired through the scheduled api/cron/water-report endpoint and respects these settings.

Season start
onSeasonChange({ ...season, seasonStartMonth: m })} /> onSeasonChange({ ...season, seasonStartDay: d })} />
Season end
onSeasonChange({ ...season, seasonEndMonth: m })} /> onSeasonChange({ ...season, seasonEndDay: d })} />
Recipient name (used in report salutation) 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]" />
); } function MonthSelect({ value, onChange, }: { value: number; onChange: (m: number) => void; }) { return ( ); } function DayInput({ value, onChange, }: { value: number; onChange: (d: number) => void; }) { return ( 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 (16–20px, currentColor) ────────────────────────────────────── function QrIcon() { return ( ); } function GearIcon() { return ( ); } function FieldIcon() { return ( ); } function ReportIcon() { return ( ); } function DownloadIcon() { return ( ); } function KeyIcon() { return ( ); } function UserIcon() { return ( ); } function TableIcon() { return ( ); }