"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 { useReducer, useEffect, useMemo } 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/AdminButton"; import { formatDate, formatDateTime } from "@/lib/format-date"; const DEFAULT_SEASON: IrrigationSeasonSettings = { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15, }; type Toast = { msg: string; kind: "ok" | "err" }; type HgForm = { name: string; unit: string; notes: string }; type UserForm = { name: string; role: "irrigator" | "water_admin"; lang: string; phone: string; }; type PinInfo = { name: string; pin: string }; type State = { headgates: AdminHeadgate[]; users: AdminIrrigator[]; entries: AdminEntry[]; toast: Toast | null; forms: { showAddHg: boolean; newHg: HgForm; savingHg: boolean; showAddUser: boolean; newUser: UserForm; savingUser: boolean; newPin: PinInfo | null; resetPin: PinInfo | null; }; filters: { dateFrom: string; dateTo: string; headgate: string; user: string; via: string; }; csvLoading: boolean; today: string; todayLabel: string; showReportSettings: boolean; seasonStart: IrrigationSeasonSettings; recipientName: string; }; type Action = // Headgates | { type: "ADD_HEADGATE"; headgate: AdminHeadgate } | { type: "REMOVE_HEADGATE"; id: string } | { type: "UPDATE_HEADGATE_TOKEN"; id: string; token: string } // Users | { type: "ADD_USER"; user: AdminIrrigator } | { type: "DEACTIVATE_USER"; id: string } // Toast | { type: "SET_TOAST"; toast: Toast } | { type: "CLEAR_TOAST" } // Forms - Headgate | { type: "TOGGLE_ADD_HG" } | { type: "SET_NEW_HG_NAME"; value: string } | { type: "SET_NEW_HG_UNIT"; value: string } | { type: "SET_NEW_HG_NOTES"; value: string } | { type: "RESET_NEW_HG" } | { type: "SET_SAVING_HG"; value: boolean } // Forms - User | { type: "TOGGLE_ADD_USER" } | { type: "SET_NEW_USER_NAME"; value: string } | { type: "SET_NEW_USER_ROLE"; value: "irrigator" | "water_admin" } | { type: "SET_NEW_USER_LANG"; value: string } | { type: "SET_NEW_USER_PHONE"; value: string } | { type: "RESET_NEW_USER" } | { type: "SET_SAVING_USER"; value: boolean } | { type: "SET_NEW_PIN"; pin: PinInfo } | { type: "CLEAR_NEW_PIN" } | { type: "SET_RESET_PIN"; pin: PinInfo } | { type: "CLEAR_RESET_PIN" } // Filters | { type: "SET_FILTER_DATE_FROM"; value: string } | { type: "SET_FILTER_DATE_TO"; value: string } | { type: "SET_FILTER_HEADGATE"; value: string } | { type: "SET_FILTER_USER"; value: string } | { type: "SET_FILTER_VIA"; value: string } | { type: "CLEAR_FILTERS" } // Misc UI | { type: "SET_CSV_LOADING"; value: boolean } | { type: "SET_TODAY"; today: string; label: string } | { type: "TOGGLE_REPORT_SETTINGS" } | { type: "SET_SEASON_START"; value: IrrigationSeasonSettings } | { type: "SET_RECIPIENT_NAME"; value: string }; function initSeasonStart(): 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; } } function initRecipientName(): string { if (typeof window === "undefined") return ""; return localStorage.getItem("wl_recipient_name") ?? ""; } function initState(props: { initialHeadgates: AdminHeadgate[]; initialUsers: AdminIrrigator[]; initialEntries: AdminEntry[]; }): State { return { headgates: props.initialHeadgates, users: props.initialUsers, entries: props.initialEntries, toast: null, forms: { showAddHg: false, newHg: { name: "", unit: "CFS", notes: "" }, savingHg: false, showAddUser: false, newUser: { name: "", role: "irrigator", lang: "en", phone: "" }, savingUser: false, newPin: null, resetPin: null, }, filters: { dateFrom: "", dateTo: "", headgate: "", user: "", via: "" }, csvLoading: false, today: "", todayLabel: "", showReportSettings: false, seasonStart: initSeasonStart(), recipientName: initRecipientName(), }; } function reducer(state: State, action: Action): State { switch (action.type) { case "ADD_HEADGATE": return { ...state, headgates: [action.headgate, ...state.headgates] }; case "REMOVE_HEADGATE": return { ...state, headgates: state.headgates.filter((h) => h.id !== action.id), }; case "UPDATE_HEADGATE_TOKEN": return { ...state, headgates: state.headgates.map((h) => h.id === action.id ? { ...h, headgate_token: action.token } : h, ), }; case "ADD_USER": return { ...state, users: [action.user, ...state.users] }; case "DEACTIVATE_USER": return { ...state, users: state.users.map((u) => u.id === action.id ? { ...u, active: false } : u, ), }; case "SET_TOAST": return { ...state, toast: action.toast }; case "CLEAR_TOAST": return { ...state, toast: null }; case "TOGGLE_ADD_HG": return { ...state, forms: { ...state.forms, showAddHg: !state.forms.showAddHg }, }; case "SET_NEW_HG_NAME": return { ...state, forms: { ...state.forms, newHg: { ...state.forms.newHg, name: action.value }, }, }; case "SET_NEW_HG_UNIT": return { ...state, forms: { ...state.forms, newHg: { ...state.forms.newHg, unit: action.value }, }, }; case "SET_NEW_HG_NOTES": return { ...state, forms: { ...state.forms, newHg: { ...state.forms.newHg, notes: action.value }, }, }; case "RESET_NEW_HG": return { ...state, forms: { ...state.forms, newHg: { name: "", unit: "CFS", notes: "" }, showAddHg: false, }, }; case "SET_SAVING_HG": return { ...state, forms: { ...state.forms, savingHg: action.value }, }; case "TOGGLE_ADD_USER": return { ...state, forms: { ...state.forms, showAddUser: !state.forms.showAddUser }, }; case "SET_NEW_USER_NAME": return { ...state, forms: { ...state.forms, newUser: { ...state.forms.newUser, name: action.value }, }, }; case "SET_NEW_USER_ROLE": return { ...state, forms: { ...state.forms, newUser: { ...state.forms.newUser, role: action.value }, }, }; case "SET_NEW_USER_LANG": return { ...state, forms: { ...state.forms, newUser: { ...state.forms.newUser, lang: action.value }, }, }; case "SET_NEW_USER_PHONE": return { ...state, forms: { ...state.forms, newUser: { ...state.forms.newUser, phone: action.value }, }, }; case "RESET_NEW_USER": return { ...state, forms: { ...state.forms, newUser: { name: "", role: "irrigator", lang: "en", phone: "" }, showAddUser: false, }, }; case "SET_SAVING_USER": return { ...state, forms: { ...state.forms, savingUser: action.value }, }; case "SET_NEW_PIN": return { ...state, forms: { ...state.forms, newPin: action.pin } }; case "CLEAR_NEW_PIN": return { ...state, forms: { ...state.forms, newPin: null } }; case "SET_RESET_PIN": return { ...state, forms: { ...state.forms, resetPin: action.pin } }; case "CLEAR_RESET_PIN": return { ...state, forms: { ...state.forms, resetPin: null } }; case "SET_FILTER_DATE_FROM": return { ...state, filters: { ...state.filters, dateFrom: action.value }, }; case "SET_FILTER_DATE_TO": return { ...state, filters: { ...state.filters, dateTo: action.value }, }; case "SET_FILTER_HEADGATE": return { ...state, filters: { ...state.filters, headgate: action.value }, }; case "SET_FILTER_USER": return { ...state, filters: { ...state.filters, user: action.value }, }; case "SET_FILTER_VIA": return { ...state, filters: { ...state.filters, via: action.value }, }; case "CLEAR_FILTERS": return { ...state, filters: { dateFrom: "", dateTo: "", headgate: "", user: "", via: "" }, }; case "SET_CSV_LOADING": return { ...state, csvLoading: action.value }; case "SET_TODAY": return { ...state, today: action.today, todayLabel: action.label }; case "TOGGLE_REPORT_SETTINGS": return { ...state, showReportSettings: !state.showReportSettings }; case "SET_SEASON_START": return { ...state, seasonStart: action.value }; case "SET_RECIPIENT_NAME": return { ...state, recipientName: action.value }; } } type Props = { initialUsers: AdminIrrigator[]; initialHeadgates: AdminHeadgate[]; initialEntries: AdminEntry[]; brandId: string; canManage: boolean; }; export default function WaterLogAdminPanel({ initialUsers, initialHeadgates, initialEntries, brandId, canManage, }: Props) { const router = useRouter(); const [state, dispatch] = useReducer(reducer, { initialHeadgates, initialUsers, initialEntries, }, initState); // `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). useEffect(() => { void (async () => { const now = new Date(); dispatch({ type: "SET_TODAY", today: now.toISOString().slice(0, 10), label: formatDate(now), }); })(); }, []); // ── Derived data ────────────────────────────────────────────────── const inSeason = useMemo( () => isInIrrigationSeason(new Date(), state.seasonStart), [state.seasonStart], ); const todayEntries = useMemo( () => state.today ? state.entries.filter( (e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === state.today, ) : [], [state.entries, state.today], ); const todayTotal = todayEntries.reduce((s, e) => s + e.measurement, 0); const lastEntryByHeadgate = useMemo(() => { const map = new Map(); for (const e of state.entries) { const existing = map.get(e.headgate_id); if (!existing || e.logged_at > existing.logged_at) { map.set(e.headgate_id, e); } } return map; }, [state.entries]); const filteredEntries = useMemo(() => { return state.entries.filter((e) => { if (state.filters.dateFrom && e.logged_at < state.filters.dateFrom) return false; if (state.filters.dateTo && e.logged_at > state.filters.dateTo + "T23:59:59.999Z") return false; if (state.filters.headgate && e.headgate_id !== state.filters.headgate) return false; if (state.filters.user && e.user_id !== state.filters.user) return false; if (state.filters.via && e.submitted_via !== state.filters.via) return false; return true; }); }, [ state.entries, state.filters.dateFrom, state.filters.dateTo, state.filters.headgate, state.filters.user, state.filters.via, ]); // ── Toast helper ────────────────────────────────────────────────── function notify(msg: string, kind: "ok" | "err" = "ok") { dispatch({ type: "SET_TOAST", toast: { msg, kind } }); setTimeout(() => dispatch({ type: "CLEAR_TOAST" }), 2800); } // ── Handlers are defined in their respective section components ───── function persistSeason(s: IrrigationSeasonSettings) { dispatch({ type: "SET_SEASON_START", value: s }); try { localStorage.setItem("wl_season_settings:v1", JSON.stringify(s)); } catch {} } function persistRecipientName(v: string) { dispatch({ type: "SET_RECIPIENT_NAME", value: v }); try { localStorage.setItem("wl_recipient_name", v); } catch {} } // ── Render ──────────────────────────────────────────────────────── return (
dispatch({ type: "TOGGLE_REPORT_SETTINGS" })} onSeasonChange={persistSeason} onRecipientNameChange={persistRecipientName} /> router.push(`/admin/water-log/headgates/${h.id}`)} /> router.push(`/admin/water-log/entries/${e.id}`)} />
); } // ── Sub-components ────────────────────────────────────────────────────────── function HeaderNav() { return (
}> QR Codes }> Settings }> Field Admin ↗
); } type TodaySummarySectionProps = { brandId: string; today: string; todayLabel: string; todayEntries: AdminEntry[]; todayTotal: number; headgates: AdminHeadgate[]; users: AdminIrrigator[]; inSeason: boolean; showReportSettings: boolean; seasonStart: IrrigationSeasonSettings; recipientName: string; notify: (msg: string, kind?: "ok" | "err") => void; onToggleReportSettings: () => void; onSeasonChange: (s: IrrigationSeasonSettings) => void; onRecipientNameChange: (v: string) => void; }; function TodaySummarySection({ brandId, today, todayLabel, todayEntries, todayTotal, headgates, users, inSeason, showReportSettings, seasonStart, recipientName, notify, onToggleReportSettings, onSeasonChange, onRecipientNameChange, }: 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}`); } return (
{/* 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={onToggleReportSettings} > Report Settings
{showReportSettings && ( )}
); } type HeadgatesSectionProps = { brandId: string; headgates: AdminHeadgate[]; lastEntryByHeadgate: Map; canManage: boolean; showAddHg: boolean; newHg: HgForm; savingHg: boolean; notify: (msg: string, kind?: "ok" | "err") => void; dispatch: React.Dispatch; onEdit: (h: AdminHeadgate) => void; }; function HeadgatesSection({ brandId, headgates, lastEntryByHeadgate, canManage, showAddHg, newHg, savingHg, notify, dispatch, onEdit, }: HeadgatesSectionProps) { async function handleAdd(e: React.FormEvent) { e.preventDefault(); const trimmedName = newHg.name.trim(); if (!trimmedName) return; dispatch({ type: "SET_SAVING_HG", value: true }); const res = await createWaterHeadgate(brandId, trimmedName, newHg.unit, { notes: newHg.notes.trim() || null, }); if (res.success && res.headgate) { dispatch({ type: "ADD_HEADGATE", headgate: res.headgate }); dispatch({ type: "RESET_NEW_HG" }); notify("Headgate added"); } else { notify(res.error ?? "Failed", "err"); } dispatch({ type: "SET_SAVING_HG", value: false }); } async function handleDelete(h: AdminHeadgate) { if (!window.confirm(`Delete "${h.name}"? Existing log entries will be preserved.`)) return; const res = await deleteWaterHeadgate(h.id); if (res.success) { dispatch({ type: "REMOVE_HEADGATE", 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) { dispatch({ type: "UPDATE_HEADGATE_TOKEN", id: h.id, token: res.token }); notify("Token regenerated"); } else { notify(res.error ?? "Failed", "err"); } } return ( <> dispatch({ type: "TOGGLE_ADD_HG" })}> {showAddHg ? "Cancel" : "+ Add Headgate"} ) : null } /> {showAddHg && canManage && (
dispatch({ type: "SET_NEW_HG_NAME", value: v })} placeholder="e.g. North Field Gate 1" required /> dispatch({ type: "SET_NEW_HG_NOTES", value: 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 && (
    ·
    )}
  • ); })}
)} ); } type WaterUsersSectionProps = { brandId: string; users: AdminIrrigator[]; canManage: boolean; showAddUser: boolean; newUser: UserForm; savingUser: boolean; newPin: PinInfo | null; resetPin: PinInfo | null; notify: (msg: string, kind?: "ok" | "err") => void; dispatch: React.Dispatch; }; function WaterUsersSection({ brandId, users, canManage, showAddUser, newUser, savingUser, newPin, resetPin, notify, dispatch, }: WaterUsersSectionProps) { async function handleAdd(e: React.FormEvent) { e.preventDefault(); const trimmedName = newUser.name.trim(); if (!trimmedName) return; dispatch({ type: "SET_SAVING_USER", value: true }); const res = await createWaterUser( brandId, trimmedName, newUser.role, newUser.lang, newUser.phone.trim() || null, ); if (res.success && res.user && res.pin) { dispatch({ type: "ADD_USER", user: res.user }); dispatch({ type: "SET_NEW_PIN", pin: { name: res.user.name, pin: res.pin } }); dispatch({ type: "RESET_NEW_USER" }); notify("User created"); } else { notify(res.error ?? "Failed", "err"); } dispatch({ type: "SET_SAVING_USER", value: 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) { dispatch({ type: "SET_RESET_PIN", pin: { name: u.name, pin: res.pin } }); notify("PIN reset"); } else { notify(res.error ?? "Failed", "err"); } } async function handleDelete(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) { dispatch({ type: "DEACTIVATE_USER", id: u.id }); notify("User deactivated"); } else { notify(res.error ?? "Failed", "err"); } } return ( <> dispatch({ type: "TOGGLE_ADD_USER" })}> {showAddUser ? "Cancel" : "+ Add User"} ) : null } /> {newPin && ( dispatch({ type: "CLEAR_NEW_PIN" })} /> )} {resetPin && ( dispatch({ type: "CLEAR_RESET_PIN" })} /> )} {showAddUser && canManage && (
dispatch({ type: "SET_NEW_USER_NAME", value: v })} placeholder="Full name" required /> dispatch({ type: "SET_NEW_USER_LANG", value: v })} options={[ { value: "en", label: "English" }, { value: "es", label: "Español" }, ]} /> dispatch({ type: "SET_NEW_USER_PHONE", value: 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 && (
    ·
    )}
  • ))}
)} ); } type EntriesSectionProps = { brandId: string; entries: AdminEntry[]; filteredEntries: AdminEntry[]; headgates: AdminHeadgate[]; users: AdminIrrigator[]; filters: { dateFrom: string; dateTo: string; headgate: string; user: string; via: string; }; csvLoading: boolean; today: string; notify: (msg: string, kind?: "ok" | "err") => void; dispatch: React.Dispatch; onEditEntry: (e: AdminEntry) => void; }; function EntriesSection({ brandId, entries, filteredEntries, headgates, users, filters, csvLoading, today, notify, dispatch, onEditEntry, }: EntriesSectionProps) { async function handleExportCSV() { dispatch({ type: "SET_CSV_LOADING", value: true }); try { const all = await getWaterEntries(brandId, 5000); const shaped: WaterLogReportRow[] = all.map(shapeWaterLogEntry); const exportFilters: WaterLogFilter = { dateFrom: filters.dateFrom || undefined, dateTo: filters.dateTo || undefined, headgateId: filters.headgate || undefined, userId: filters.user || undefined, submittedVia: filters.via || undefined, }; const filtered = filterWaterLogEntries(shaped, exportFilters); downloadWaterLogCSV(`water-log-${today}.csv`, filtered); notify(`Exported ${filtered.length} entries`); } finally { dispatch({ type: "SET_CSV_LOADING", value: false }); } } return ( <> } > Export CSV } />
dispatch({ type: "SET_FILTER_DATE_FROM", value: e.target.value })} className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none" aria-label="Filter: from date" /> dispatch({ type: "SET_FILTER_DATE_TO", value: e.target.value })} className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none" aria-label="Filter: to date" /> {(filters.dateFrom || filters.dateTo || filters.headgate || filters.user || filters.via) && ( )} {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}
)} ); } function ToastNotification({ toast }: { toast: Toast | null }) { if (!toast) return null; return (
{toast.msg}
); } // ─── Helper sub-components (unchanged) ────────────────────────────────────── 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} )}
); } const statusPillMap = { 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; function StatusPill({ kind }: { kind: "active" | "inactive" | "closed" | "warn" }) { const s = statusPillMap[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, type = "text", }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; required?: 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+/) .flatMap((n) => (n[0] ? [n[0]] : [])) .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 ( ); }