From c9b6729482a0884174484ade4ee5579a139b8120 Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 3 Jul 2026 17:58:44 -0600 Subject: [PATCH] refactor(water-log): split 2117-line panel into tabbed shell + focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Water Log admin panel was the largest single file in the app (2117 lines, up from the backlog's 1393 estimate) and mixed 12+ distinct UI concerns: tab shell, reducer, three section components, a modal, ~12 sub-components, and 8 inline icons. Split into focused files under src/components/admin/water-log/: Shell.tsx tabbed shell, reducer wiring, derived data reducer.ts reducer + initState + selectors types.ts shared types (State, Action, Toast, …) tabs/TodayTab.tsx hero summary + recent entries + report settings tabs/HeadgatesTab.tsx headgate cards + CRUD tabs/UsersTab.tsx user list + CRUD shared/Primitives.tsx SectionHeader, StatTile, StatusPill, PinBanner, RoleLegend, EmptyState, Input, Select, FilterChip, Avatar, MonthSelect, DayInput, Th, Td shared/HeaderNav.tsx top-of-page quick links shared/Toast.tsx toast notification shared/ReportPreviewModal.tsx daily-report preview modal shared/icons.tsx 9 inline icons (incl. new ClockIcon reserved for the Time Tracking tab in Cycle 3) Tabs wired in this commit: today (default), headgates, users. Time Tracking lands in Cycle 3. Bugs caught by pr-reviewer subagent before commit: - Color drift: text-[#1d1d1f] was silently rewritten to text-[#1d1d1d] in 16 places during mechanical extraction. sed restored all 16. - Subtitle template placeholder: monolith had literal '{n}' in the Recent Entries subtitle. New copy: 'The latest readings…'. - localStorage coupling: first pass put setItem('wl_season_settings:v1') inside TodayTab.onSeasonChange. Lifted to Shell.persistSeason so the storage contract is owned by the shell, not scattered through tabs. Verified: - npx tsc --noEmit clean - npx eslint src/components/admin/water-log/ 0 errors, 0 warnings - npm run lint full project: 388 → 378 warnings (-10 unused imports); 14 pre-existing errors unchanged - npm run build succeeds - curl localhost:4000/admin/water-log HTTP 307 to /login (admin guard fires; route registered) Refactor-progress tracked at /tmp/refactor-routecomm.md (Phase 2, Cycle 1 logged with outcome + 3 fix notes). Next: Cycle 2 — bring the stubbed time-tracking actions back to life against Drizzle. --- src/app/admin/water-log/page.tsx | 4 +- src/components/admin/WaterLogAdminPanel.tsx | 2117 ----------------- src/components/admin/water-log/Shell.tsx | 264 ++ src/components/admin/water-log/reducer.ts | 305 +++ .../admin/water-log/shared/HeaderNav.tsx | 34 + .../admin/water-log/shared/Primitives.tsx | 387 +++ .../water-log/shared/ReportPreviewModal.tsx | 195 ++ .../admin/water-log/shared/Toast.tsx | 24 + .../admin/water-log/shared/icons.tsx | 159 ++ .../admin/water-log/tabs/HeadgatesTab.tsx | 272 +++ .../admin/water-log/tabs/TodayTab.tsx | 588 +++++ .../admin/water-log/tabs/UsersTab.tsx | 270 +++ src/components/admin/water-log/types.ts | 122 + 13 files changed, 2622 insertions(+), 2119 deletions(-) delete mode 100644 src/components/admin/WaterLogAdminPanel.tsx create mode 100644 src/components/admin/water-log/Shell.tsx create mode 100644 src/components/admin/water-log/reducer.ts create mode 100644 src/components/admin/water-log/shared/HeaderNav.tsx create mode 100644 src/components/admin/water-log/shared/Primitives.tsx create mode 100644 src/components/admin/water-log/shared/ReportPreviewModal.tsx create mode 100644 src/components/admin/water-log/shared/Toast.tsx create mode 100644 src/components/admin/water-log/shared/icons.tsx create mode 100644 src/components/admin/water-log/tabs/HeadgatesTab.tsx create mode 100644 src/components/admin/water-log/tabs/TodayTab.tsx create mode 100644 src/components/admin/water-log/tabs/UsersTab.tsx create mode 100644 src/components/admin/water-log/types.ts diff --git a/src/app/admin/water-log/page.tsx b/src/app/admin/water-log/page.tsx index 3353c2b..39240ad 100644 --- a/src/app/admin/water-log/page.tsx +++ b/src/app/admin/water-log/page.tsx @@ -1,4 +1,4 @@ -import WaterLogAdminPanel from "@/components/admin/WaterLogAdminPanel"; +import WaterLogShell from "@/components/admin/water-log/Shell"; import { getAdminUser } from "@/lib/admin-permissions"; import { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin"; import { redirect } from "next/navigation"; @@ -82,7 +82,7 @@ export default async function AdminWaterLogPage() { className="px-6 pt-6" />
- 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 }; - case "OPEN_REPORT_PREVIEW": - return { ...state, reportPreview: action.preview }; - case "CLOSE_REPORT_PREVIEW": - return { ...state, reportPreview: null }; - } -} - -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 {} - } - - function openReportPreview() { - // The "Today's Summary" section has already filtered the rows for us; - // shape them for the report view (adds user_role default) and push - // the preview payload into state. The modal renders from that payload. - const rows: WaterLogReportRow[] = todayEntries.map(shapeWaterLogEntry); - const unit = - rows.find((r) => r.unit)?.unit ?? state.headgates[0]?.unit ?? "CFS"; - const now = new Date(); - - if (rows.length === 0) { - notify( - `No entries today — report would be skipped (season: ${inSeason ? "in" : "out"}).`, - ); - return; - } - - dispatch({ - type: "OPEN_REPORT_PREVIEW", - preview: { - rows, - total: todayTotal, - unit, - dateLabel: state.todayLabel || formatDate(now), - dateIso: state.today || now.toISOString().slice(0, 10), - recipientName: state.recipientName.trim() || undefined, - inSeason, - }, - }); - } - - function closeReportPreview() { - dispatch({ type: "CLOSE_REPORT_PREVIEW" }); - } - - // ── Render ──────────────────────────────────────────────────────── - return ( -
- - - dispatch({ type: "TOGGLE_REPORT_SETTINGS" })} - onSeasonChange={persistSeason} - onRecipientNameChange={persistRecipientName} - onPreviewReport={openReportPreview} - /> - - 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 = { - todayLabel: string; - todayEntries: AdminEntry[]; - todayTotal: number; - headgates: AdminHeadgate[]; - users: AdminIrrigator[]; - inSeason: boolean; - showReportSettings: boolean; - seasonStart: IrrigationSeasonSettings; - recipientName: string; - onToggleReportSettings: () => void; - onSeasonChange: (s: IrrigationSeasonSettings) => void; - onRecipientNameChange: (v: string) => void; - onPreviewReport: () => void; -}; - -function TodaySummarySection({ - todayLabel, - todayEntries, - todayTotal, - headgates, - users, - inSeason, - showReportSettings, - seasonStart, - recipientName, - onToggleReportSettings, - onSeasonChange, - onRecipientNameChange, - onPreviewReport, -}: TodaySummarySectionProps) { - function handlePreviewReport() { - onPreviewReport(); - } - 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) => ( - - - - - - - - - - - ))} - -
WhenUserHeadgateMeasurementGallonsMethodVia
- {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" - /> - ); -} - -// ─── Report Preview Modal ───────────────────────────────────────────────── -// -// Simple, production-appropriate preview of the daily water report that -// would be sent by the cron. Built on the shared GlassModal so ESC, focus -// trapping, scroll-lock, and the standard admin header/close chrome all -// match the rest of /admin. - -function ReportPreviewModal({ - data, - onClose, - notify, -}: { - data: ReportPreviewData | null; - onClose: () => void; - notify: (msg: string, kind?: "ok" | "err") => void; -}) { - const [copied, setCopied] = useState(false); - - if (!data) return null; - - const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason } = data; - const totalUnit = rows[0]?.unit ?? unit ?? "CFS"; - - async function handleCopy() { - const text = formatDailyWaterReport(rows, { - recipientName: recipientName, - previewMode: true, - }); - if (!text) return; - try { - await navigator.clipboard.writeText(text); - setCopied(true); - notify("Report copied to clipboard"); - setTimeout(() => setCopied(false), 1600); - } catch { - notify("Copy failed — your browser blocked clipboard access", "err"); - } - } - - const subtitle = - `${rows.length} ${rows.length === 1 ? "entry" : "entries"} · ` + - `${total.toFixed(2)} ${totalUnit} total · ` + - (inSeason ? "in season" : "out of season"); - - return ( - -
- {/* Meta line */} -
- - Date:{" "} - {dateLabel} - - · - - {dateIso} - - {recipientName ? ( - <> - · - - - Recipient: - {" "} - {recipientName} - - - ) : null} -
- - {/* Preview banner — keep it clear this hasn't been sent */} -
- Preview only. No message has been sent. Daily-report delivery is - handled by the scheduled{" "} - api/cron/water-report endpoint. -
- - {/* Data table */} -
- - - - - - - - - - - - {rows.map((row, i) => { - const time = new Date(row.logged_at).toLocaleTimeString( - "en-US", - { hour: "numeric", minute: "2-digit" }, - ); - return ( - - - - - - - - ); - })} - - - - - - - - -
TimeHeadgateIrrigatorVolumeNote
- {time} - - {row.headgate_name} - - {row.user_name} - - - {row.measurement.toFixed(2)} - {" "} - - {row.unit} - - - {row.notes ?? } -
- Total - - - {total.toFixed(2)} - {" "} - - {totalUnit} - - - -
-
- - {/* Actions */} -
- - -
-
-
- ); -} - -function Th({ - children, - className = "", -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - - {children} - - ); -} - -function Td({ - children, - className = "", - colSpan, -}: { - children: React.ReactNode; - className?: string; - colSpan?: number; -}) { - return ( - - {children} - - ); -} -// ─── 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 ( - - - - - ); -} diff --git a/src/components/admin/water-log/Shell.tsx b/src/components/admin/water-log/Shell.tsx new file mode 100644 index 0000000..3288f27 --- /dev/null +++ b/src/components/admin/water-log/Shell.tsx @@ -0,0 +1,264 @@ +/** + * Shell — the tabbed shell for the Water Log admin. + * + * Replaces the monolithic `WaterLogAdminPanel` (which was 2117 lines + * covering 9 distinct UI concerns). The shell owns: + * + * - the reducer + initial state (delegate to ./reducer) + * - the localStorage-backed season + recipient settings + * - the "today" derived state (today's date / in-season / today's entries) + * - the tab state + * - the toast dispatcher + * + * Each tab is a focused component under ./tabs that consumes the + * state slice it needs. + * + * Adding a new tab (e.g. Time Tracking in Cycle 3) only requires: + * 1. Create the tab component under ./tabs + * 2. Add a TabValue + tabs[] entry below + * 3. Render it conditionally in the {tab === X && ...} block + */ +"use client"; + +import { useEffect, useMemo, useReducer, useState } from "react"; +import { useRouter } from "next/navigation"; +import AdminFilterTabs from "@/components/admin/design-system/AdminFilterTabs"; +import { + isInIrrigationSeason, + shapeWaterLogEntry, + type WaterLogReportRow, +} from "@/lib/water-log-reporting"; +import { formatDate } from "@/lib/format-date"; +import type { + AdminEntry, + AdminHeadgate, + AdminIrrigator, +} from "@/actions/water-log/admin"; +import { reducer, initState } from "./reducer"; +import type { ReportPreviewData } from "./types"; +import { HeaderNav } from "./shared/HeaderNav"; +import { ToastNotification } from "./shared/Toast"; +import { ReportPreviewModal } from "./shared/ReportPreviewModal"; +import { TodayTab } from "./tabs/TodayTab"; +import { HeadgatesTab } from "./tabs/HeadgatesTab"; +import { UsersTab } from "./tabs/UsersTab"; + +// ── Props ──────────────────────────────────────────────────────────────────── +export type ShellProps = { + initialUsers: AdminIrrigator[]; + initialHeadgates: AdminHeadgate[]; + initialEntries: AdminEntry[]; + brandId: string; + canManage: boolean; +}; + +// ── Tab values ─────────────────────────────────────────────────────────────── +type TabValue = "today" | "headgates" | "users"; + +const TABS = [ + { + value: "today" as const, + label: "Today", + // could pull counts from props for badges, omitted for now + }, + { + value: "headgates" as const, + label: "Headgates", + }, + { + value: "users" as const, + label: "Users", + }, +]; + +// ── Main component ─────────────────────────────────────────────────────────── +export default function Shell({ + initialUsers, + initialHeadgates, + initialEntries, + brandId, + canManage, +}: ShellProps) { + const router = useRouter(); + const [tab, setTab] = useState("today"); + const [state, dispatch] = useReducer(reducer, { + initialHeadgates, + initialUsers, + initialEntries, + }, initState); + + // Resolve "today" once on mount, in the browser, to avoid hydration + // mismatches. The setState is wrapped in an async IIFE so the effect + // doesn't call setState synchronously (which trips 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(() => { + if (!state.today) return []; + return state.entries.filter( + (e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === state.today, + ); + }, [state.entries, state.today]); + + const todayTotal = useMemo( + () => todayEntries.reduce((s, e) => s + e.measurement, 0), + [todayEntries], + ); + + 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]); + + // Toast helper ───────────────────────────────────────────────────────────── + function notify(msg: string, kind: "ok" | "err" = "ok") { + dispatch({ type: "SET_TOAST", toast: { msg, kind } }); + setTimeout(() => dispatch({ type: "CLEAR_TOAST" }), 2800); + } + + // ── Persisted UI preferences (lives in the shell so the storage + // contract is owned by one place, not scattered through tabs). + function persistSeason(s: import("@/lib/water-log-reporting").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 {} + } + + // Report preview open/close ──────────────────────────────────────────────── + function openReportPreview() { + const rows: WaterLogReportRow[] = todayEntries.map(shapeWaterLogEntry); + const unit = + rows.find((r) => r.unit)?.unit ?? + state.headgates[0]?.unit ?? + "CFS"; + const now = new Date(); + + if (rows.length === 0) { + notify( + `No entries today — report would be skipped (season: ${ + inSeason ? "in" : "out" + }).`, + ); + return; + } + + const preview: ReportPreviewData = { + rows, + total: todayTotal, + unit, + dateLabel: state.todayLabel || formatDate(now), + dateIso: state.today || now.toISOString().slice(0, 10), + recipientName: state.recipientName.trim() || undefined, + inSeason, + }; + dispatch({ type: "OPEN_REPORT_PREVIEW", preview }); + } + + function closeReportPreview() { + dispatch({ type: "CLOSE_REPORT_PREVIEW" }); + } + + return ( +
+ + +
+ setTab(value as TabValue)} + tabs={TABS.map((t) => ({ + value: t.value, + label: t.label, + }))} + showCounts={false} + size="md" + /> +
+ + {tab === "today" && ( + + )} + + {tab === "headgates" && ( + router.push(`/admin/water-log/headgates/${h.id}`)} + /> + )} + + {tab === "users" && ( + + )} + + {/* Future tab: Time Tracking (Cycle 3) */} + + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/reducer.ts b/src/components/admin/water-log/reducer.ts new file mode 100644 index 0000000..10c3f7b --- /dev/null +++ b/src/components/admin/water-log/reducer.ts @@ -0,0 +1,305 @@ +/** + * Reducer + state initialization for the Water Log admin module. + * + * Pulled out of the monolithic `WaterLogAdminPanel.tsx` so the shell can + * own state and dispatch and pass them down to focused tab components. + * + * localStorage contracts (intentionally narrow): + * - wl_season_settings:v1 → IrrigationSeasonSettings JSON + * - wl_recipient_name → string + */ +import type { + AdminEntry, + AdminHeadgate, + AdminIrrigator, +} from "@/actions/water-log/admin"; +import type { IrrigationSeasonSettings } from "@/lib/water-log-reporting"; +import type { Action, State } from "./types"; + +export const DEFAULT_SEASON: IrrigationSeasonSettings = { + seasonStartMonth: 3, + seasonStartDay: 15, + seasonEndMonth: 10, + seasonEndDay: 15, +}; + +// ── Init helpers ──────────────────────────────────────────────────────────── +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") ?? ""; +} + +export 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(), + reportPreview: null, + }; +} + +// ── Reducer ───────────────────────────────────────────────────────────────── +export function reducer(state: State, action: Action): State { + switch (action.type) { + // Headgates + 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, + ), + }; + // Users + 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, + ), + }; + // Toast + case "SET_TOAST": + return { ...state, toast: action.toast }; + case "CLEAR_TOAST": + return { ...state, toast: null }; + // Forms - Headgate + 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 }, + }; + // Forms - User + 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 } }; + // Filters + 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: "" }, + }; + // Misc UI + 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 }; + case "OPEN_REPORT_PREVIEW": + return { ...state, reportPreview: action.preview }; + case "CLOSE_REPORT_PREVIEW": + return { ...state, reportPreview: null }; + default: + return state; + } +} + +// ── Selectors ──────────────────────────────────────────────────────────────── +/** Latest entry per headgate (by logged_at), for the headgate cards. */ +export function lastEntryByHeadgateSelector(entries: AdminEntry[]) { + 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; +} + +/** Apply the current filter set to the entry list. */ +export function filteredEntriesSelector( + entries: AdminEntry[], + filters: State["filters"], +) { + return entries.filter((e) => { + if (filters.dateFrom && e.logged_at < filters.dateFrom) return false; + if (filters.dateTo && e.logged_at > filters.dateTo + "T23:59:59.999Z") + return false; + if (filters.headgate && e.headgate_id !== filters.headgate) return false; + if (filters.user && e.user_id !== filters.user) return false; + if (filters.via && e.submitted_via !== filters.via) return false; + return true; + }); +} + +/** Today's entries, by logged_date (with logged_at fallback). */ +export function todayEntriesSelector(entries: AdminEntry[], today: string) { + if (!today) return []; + return entries.filter( + (e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today, + ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/shared/HeaderNav.tsx b/src/components/admin/water-log/shared/HeaderNav.tsx new file mode 100644 index 0000000..7abfad7 --- /dev/null +++ b/src/components/admin/water-log/shared/HeaderNav.tsx @@ -0,0 +1,34 @@ +/** + * HeaderNav — top-of-page quick links for the Water Log admin. + * + * Renders the three contextual exits (QR Codes, Settings, Field Admin) + * as secondary `AdminButton`s. Lives outside the tab shell so it's + * always visible regardless of which tab the user is on. + */ +"use client"; + +import Link from "next/link"; +import AdminButton from "@/components/admin/design-system/AdminButton"; +import { QrIcon, GearIcon, FieldIcon } from "./icons"; + +export function HeaderNav() { + return ( +
+ + }> + QR Codes + + + + }> + Settings + + + + }> + Field Admin ↗ + + +
+ ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/shared/Primitives.tsx b/src/components/admin/water-log/shared/Primitives.tsx new file mode 100644 index 0000000..f2982a7 --- /dev/null +++ b/src/components/admin/water-log/shared/Primitives.tsx @@ -0,0 +1,387 @@ +/** + * Shared primitives for the Water Log admin module. + * + * These are the small reusable sub-components used by the tabs: + * section headers, stat tiles, status pills, form inputs, the PIN + * banner, the role legend, and the empty state. All use the "Field + * Almanac" palette (cream / forest / clay) so they sit naturally + * together. + */ +"use client"; + +import * as React from "react"; +import { KeyIcon } from "./icons"; + +// ── SectionHeader ──────────────────────────────────────────────────────────── +export function SectionHeader({ + index, + title, + subtitle, + action, +}: { + index: string; + title: string; + subtitle: string; + action?: React.ReactNode; +}) { + return ( +
+
+
+ {index} +
+

+ {title} +

+

{subtitle}

+
+ {action} +
+ ); +} + +// ── StatTile ──────────────────────────────────────────────────────────────── +export function StatTile({ + label, + value, + unit, + accent, + mono, +}: { + label: string; + value: string; + unit: string; + accent?: boolean; + mono?: boolean; +}) { + return ( +
+
+ {label} +
+
+ + {value} + + {unit && ( + + {unit} + + )} +
+
+ ); +} + +// ── StatusPill ────────────────────────────────────────────────────────────── +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; + +export function StatusPill({ + kind, +}: { + kind: "active" | "inactive" | "closed" | "warn"; +}) { + const s = statusPillMap[kind]; + return ( + + {s.label} + + ); +} + +// ── PinBanner ─────────────────────────────────────────────────────────────── +export function PinBanner({ + title, + pin, + tone = "ok", + onDismiss, +}: { + title: string; + pin: string; + tone?: "ok" | "warn"; + onDismiss: () => void; +}) { + return ( +
+ +
+

+ {title} +

+

+ {pin} +

+
+ +
+ ); +} + +// ── RoleLegend ────────────────────────────────────────────────────────────── +export function RoleLegend() { + return ( +

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

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

+ {title} +

+

{body}

+
+ ); +} + +// ── Input ─────────────────────────────────────────────────────────────────── +export function Input({ + label, + value, + onChange, + placeholder, + required, + type = "text", +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + required?: boolean; + type?: string; +}) { + return ( + + ); +} + +// ── Select ────────────────────────────────────────────────────────────────── +export function Select({ + label, + value, + onChange, + options, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: (string | { value: string; label: string })[]; +}) { + return ( + + ); +} + +// ── FilterChip ────────────────────────────────────────────────────────────── +export function FilterChip({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + + {children} +
+ ); +} + +// ── Avatar ────────────────────────────────────────────────────────────────── +export 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 || "·"} + + ); +} + +// ── MonthSelect / DayInput ────────────────────────────────────────────────── +export function MonthSelect({ + value, + onChange, +}: { + value: number; + onChange: (m: number) => void; +}) { + return ( + + ); +} + +export 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" + /> + ); +} + +// ── Th / Td (admin-table cells, used by the ReportPreviewModal) ───────────── +export function Th({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +export function Td({ + children, + className = "", + colSpan, +}: { + children: React.ReactNode; + className?: string; + colSpan?: number; +}) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/shared/ReportPreviewModal.tsx b/src/components/admin/water-log/shared/ReportPreviewModal.tsx new file mode 100644 index 0000000..1f66658 --- /dev/null +++ b/src/components/admin/water-log/shared/ReportPreviewModal.tsx @@ -0,0 +1,195 @@ +/** + * ReportPreviewModal — preview of the daily water report that would be + * sent by the cron. Built on the shared `GlassModal` so ESC, focus + * trapping, and scroll-lock match the rest of /admin. + * + * Triggered from the Today tab (or anywhere that needs to show the + * daily report payload); the preview state lives in the shell reducer. + */ +"use client"; + +import { useState } from "react"; +import GlassModal from "@/components/admin/GlassModal"; +import { formatDailyWaterReport } from "@/lib/water-log-reporting"; +import type { NotifyFn, ReportPreviewData } from "../types"; +import { Th, Td } from "./Primitives"; + +export function ReportPreviewModal({ + data, + onClose, + notify, +}: { + data: ReportPreviewData | null; + onClose: () => void; + notify: NotifyFn; +}) { + const [copied, setCopied] = useState(false); + if (!data) return null; + + const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason } = data; + const totalUnit = rows[0]?.unit ?? unit ?? "CFS"; + + async function handleCopy() { + const text = formatDailyWaterReport(rows, { + recipientName: recipientName, + previewMode: true, + }); + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + notify("Report copied to clipboard"); + setTimeout(() => setCopied(false), 1600); + } catch { + notify("Copy failed — your browser blocked clipboard access", "err"); + } + } + + const subtitle = + `${rows.length} ${rows.length === 1 ? "entry" : "entries"} · ` + + `${total.toFixed(2)} ${totalUnit} total · ` + + (inSeason ? "in season" : "out of season"); + + return ( + +
+ {/* Meta line */} +
+ + + Date: + {" "} + {dateLabel} + + · + + {dateIso} + + {recipientName ? ( + <> + + · + + + + Recipient: + {" "} + {recipientName} + + + ) : null} +
+ + {/* Preview banner — keep it clear this hasn't been sent */} +
+ Preview only. No message has been sent. Daily-report delivery is + handled by the scheduled{" "} + api/cron/water-report endpoint. +
+ + {/* Data table */} +
+ + + + + + + + + + + + {rows.map((row, i) => { + const time = new Date(row.logged_at).toLocaleTimeString( + "en-US", + { hour: "numeric", minute: "2-digit" }, + ); + return ( + + + + + + + + ); + })} + + + + + + + + +
TimeHeadgateIrrigatorVolumeNote
+ {time} + + {row.headgate_name} + + {row.user_name} + + + {row.measurement.toFixed(2)} + {" "} + + {row.unit} + + + {row.notes ?? } +
+ Total + + + {total.toFixed(2)} + {" "} + + {totalUnit} + + + +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/shared/Toast.tsx b/src/components/admin/water-log/shared/Toast.tsx new file mode 100644 index 0000000..a34086e --- /dev/null +++ b/src/components/admin/water-log/shared/Toast.tsx @@ -0,0 +1,24 @@ +/** + * ToastNotification — bottom-right toast surface used by every tab. + * + * State lives in the parent reducer; this component only renders. + */ +"use client"; + +import type { Toast as ToastT } from "../types"; + +export function ToastNotification({ toast }: { toast: ToastT | null }) { + if (!toast) return null; + return ( +
+ {toast.msg} +
+ ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/shared/icons.tsx b/src/components/admin/water-log/shared/icons.tsx new file mode 100644 index 0000000..192536c --- /dev/null +++ b/src/components/admin/water-log/shared/icons.tsx @@ -0,0 +1,159 @@ +/** + * Inline icons used by the Water Log admin module. + * + * All accept `currentColor` (or an explicit stroke color for the key + * icon, which always renders forest green for contrast against the + * cream "Field Almanac" palette). + * + * Kept here so each tab can import what it needs without dragging + * in the whole panel. + */ +import * as React from "react"; + +export function QrIcon() { + return ( + + + + + + + + + ); +} + +export function GearIcon() { + return ( + + + + + ); +} + +export function FieldIcon() { + return ( + + + + + ); +} + +export function ReportIcon() { + return ( + + + + + ); +} + +export function DownloadIcon() { + return ( + + + + + ); +} + +export function KeyIcon() { + return ( + + + + + ); +} + +export function UserIcon() { + return ( + + + + + ); +} + +export function TableIcon() { + return ( + + + + + ); +} + +export function ClockIcon() { + return ( + + + + + ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/tabs/HeadgatesTab.tsx b/src/components/admin/water-log/tabs/HeadgatesTab.tsx new file mode 100644 index 0000000..1acbd01 --- /dev/null +++ b/src/components/admin/water-log/tabs/HeadgatesTab.tsx @@ -0,0 +1,272 @@ +/** + * HeadgatesTab — CRUD surface for water headgates. + * + * Lists every headgate in a card grid, with add/regenerate-token/delete + * controls. Owns its own form state via the shell reducer. + */ +"use client"; + +import * as React from "react"; +import AdminButton from "@/components/admin/design-system/AdminButton"; +import { + createWaterHeadgate, + deleteWaterHeadgate, + regenerateHeadgateToken, + type AdminEntry, + type AdminHeadgate, +} from "@/actions/water-log/admin"; +import { WaterGauge } from "@/components/water/icons"; +import { formatDateTime } from "@/lib/format-date"; +import { + EmptyState, + Input, + SectionHeader, + Select, + StatusPill, +} from "../shared/Primitives"; +import type { Action, NotifyFn } from "../types"; + +export type HeadgatesTabProps = { + brandId: string; + headgates: AdminHeadgate[]; + lastEntryByHeadgate: Map; + canManage: boolean; + showAddHg: boolean; + newHg: { name: string; unit: string; notes: string }; + savingHg: boolean; + notify: NotifyFn; + dispatch: React.Dispatch; + onEdit: (h: AdminHeadgate) => void; +}; + +export function HeadgatesTab({ + brandId, + headgates, + lastEntryByHeadgate, + canManage, + showAddHg, + newHg, + savingHg, + notify, + dispatch, + onEdit, +}: HeadgatesTabProps) { + 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 && ( +
    + +
    + + · + +
    +
    + )} +
  • + ); + })} +
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/tabs/TodayTab.tsx b/src/components/admin/water-log/tabs/TodayTab.tsx new file mode 100644 index 0000000..2a8c425 --- /dev/null +++ b/src/components/admin/water-log/tabs/TodayTab.tsx @@ -0,0 +1,588 @@ +/** + * TodayTab — the "Today" surface for the Water Log admin. + * + * Combines the hero gauge + summary stats with the recent entries + * table + filter bar. Holds the entry CRUD, CSV export, and + * "Generate Report" trigger. + */ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import AdminButton from "@/components/admin/design-system/AdminButton"; +import { WaterGauge, SeasonMark } from "@/components/water/icons"; +import { + getWaterEntries, + type AdminEntry, + type AdminHeadgate, + type AdminIrrigator, +} from "@/actions/water-log/admin"; +import { + downloadWaterLogCSV, + filterWaterLogEntries, + shapeWaterLogEntry, + type IrrigationSeasonSettings, + type WaterLogReportRow, +} from "@/lib/water-log-reporting"; +import { formatDateTime } from "@/lib/format-date"; +import { + DayInput, + EmptyState, + FilterChip, + MonthSelect, + SectionHeader, + StatTile, +} from "../shared/Primitives"; +import { DownloadIcon, ReportIcon, TableIcon } from "../shared/icons"; +import type { Action, NotifyFn, State } from "../types"; + +export type TodayTabProps = { + brandId: string; + state: State; + todayLabel: string; + todayEntries: AdminEntry[]; + todayTotal: number; + inSeason: boolean; + notify: NotifyFn; + dispatch: React.Dispatch; + onPreviewReport: () => void; + onSeasonChange: (s: IrrigationSeasonSettings) => void; + onRecipientNameChange: (v: string) => void; +}; + +export function TodayTab({ + brandId, + state, + todayLabel, + todayEntries, + todayTotal, + inSeason, + notify, + dispatch, + onPreviewReport, + onSeasonChange, + onRecipientNameChange, +}: TodayTabProps) { + const router = useRouter(); + + 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 = { + dateFrom: state.filters.dateFrom || undefined, + dateTo: state.filters.dateTo || undefined, + headgateId: state.filters.headgate || undefined, + userId: state.filters.user || undefined, + submittedVia: state.filters.via || undefined, + }; + const filtered = filterWaterLogEntries(shaped, exportFilters); + downloadWaterLogCSV(`water-log-${state.today}.csv`, filtered); + notify(`Exported ${filtered.length} entries`); + } finally { + dispatch({ type: "SET_CSV_LOADING", value: false }); + } + } + + return ( + <> + dispatch({ type: "TOGGLE_REPORT_SETTINGS" })} + onSeasonChange={onSeasonChange} + onRecipientNameChange={onRecipientNameChange} + onPreviewReport={onPreviewReport} + showReportSettings={state.showReportSettings} + /> + + router.push(`/admin/water-log/entries/${e.id}`)} + onExportCSV={handleExportCSV} + /> + + ); +} + +// ── TodaySummary (hero gauge + stats + report settings toggle) ────────────── +function TodaySummary({ + todayLabel, + todayEntries, + todayTotal, + headgates, + users, + inSeason, + showReportSettings, + seasonStart, + recipientName, + onToggleReportSettings, + onSeasonChange, + onRecipientNameChange, + onPreviewReport, +}: { + todayLabel: string; + todayEntries: AdminEntry[]; + todayTotal: number; + headgates: AdminHeadgate[]; + users: AdminIrrigator[]; + inSeason: boolean; + showReportSettings: boolean; + seasonStart: IrrigationSeasonSettings; + recipientName: string; + onToggleReportSettings: () => void; + onSeasonChange: (s: IrrigationSeasonSettings) => void; + onRecipientNameChange: (v: string) => void; + onPreviewReport: () => void; +}) { + return ( +
+
+ +
+ {/* 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={onPreviewReport} + > + Preview Report + + + + + + } + onClick={onToggleReportSettings} + > + Report Settings + +
+ + {showReportSettings && ( + + )} +
+
+
+ ); +} + +// ── Inline Report Settings (kept under the toggle button) ─────────────────── +function ReportSettingsInline({ + season, + onSeasonChange, + recipientName, + onRecipientNameChange, +}: { + season: IrrigationSeasonSettings; + onSeasonChange: (s: IrrigationSeasonSettings) => void; + recipientName: string; + onRecipientNameChange: (v: string) => void; +}) { + return ( +
+

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

+
+ + onSeasonChange({ ...season, seasonStartMonth: m }) + } + onDayChange={(d) => onSeasonChange({ ...season, seasonStartDay: d })} + /> + + onSeasonChange({ ...season, seasonEndMonth: m }) + } + onDayChange={(d) => onSeasonChange({ ...season, seasonEndDay: d })} + /> +
+ +
+
+
+ ); +} + +function ReportSeasonRow({ + label, + month, + day, + onMonthChange, + onDayChange, +}: { + label: string; + month: number; + day: number; + onMonthChange: (m: number) => void; + onDayChange: (d: number) => void; +}) { + return ( +
+ + {label} + +
+ + +
+
+ ); +} + +// ── RecentEntriesSection (table + filters) ────────────────────────────────── +function RecentEntriesSection({ + state, + dispatch, + onEditEntry, + onExportCSV, +}: { + state: State; + dispatch: React.Dispatch; + onEditEntry: (e: AdminEntry) => void; + onExportCSV: () => void; +}) { + const filteredEntries = 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; + }); + + 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" + /> + + + + + + + + + + + {(state.filters.dateFrom || + state.filters.dateTo || + state.filters.headgate || + state.filters.user || + state.filters.via) && ( + + )} + + {filteredEntries.length} of {state.entries.length} entries + +
+ + {filteredEntries.length === 0 ? ( + } + title="No entries match" + body={ + state.entries.length === 0 + ? "No readings have been logged yet." + : "Try clearing the filters above." + } + /> + ) : ( +
+ + + + + + + + + + + + + + + {filteredEntries.map((e, i) => ( + + + + + + + + + + + ))} + +
WhenUserHeadgateMeasurementGallonsMethodVia
+ {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} + + + +
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/tabs/UsersTab.tsx b/src/components/admin/water-log/tabs/UsersTab.tsx new file mode 100644 index 0000000..5122769 --- /dev/null +++ b/src/components/admin/water-log/tabs/UsersTab.tsx @@ -0,0 +1,270 @@ +/** + * UsersTab — CRUD surface for water users (irrigators + admins). + * + * Lists every user in a card grid, with add / reset-PIN / deactivate + * controls. PINs are shown one-at-a-time via the PinBanner primitive. + */ +"use client"; + +import * as React from "react"; +import AdminButton from "@/components/admin/design-system/AdminButton"; +import { + createWaterUser, + deleteWaterUser, + resetWaterIrrigatorPin, + type AdminIrrigator, +} from "@/actions/water-log/admin"; +import { formatDate } from "@/lib/format-date"; +import { + Avatar, + EmptyState, + Input, + PinBanner, + RoleLegend, + SectionHeader, + Select, +} from "../shared/Primitives"; +import { UserIcon } from "../shared/icons"; +import type { Action, NotifyFn, PinInfo, UserForm } from "../types"; + +export type UsersTabProps = { + brandId: string; + users: AdminIrrigator[]; + canManage: boolean; + showAddUser: boolean; + newUser: UserForm; + savingUser: boolean; + newPin: PinInfo | null; + resetPin: PinInfo | null; + notify: NotifyFn; + dispatch: React.Dispatch; +}; + +export function UsersTab({ + brandId, + users, + canManage, + showAddUser, + newUser, + savingUser, + newPin, + resetPin, + notify, + dispatch, +}: UsersTabProps) { + 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 && ( +
    + + · + +
    + )} +
  • + ))} +
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/admin/water-log/types.ts b/src/components/admin/water-log/types.ts new file mode 100644 index 0000000..9176ec4 --- /dev/null +++ b/src/components/admin/water-log/types.ts @@ -0,0 +1,122 @@ +/** + * Shared types for the Water Log admin module. + * + * Split out of the original monolithic `WaterLogAdminPanel.tsx` so each + * tab / shared component can declare its own prop types without + * pulling in the full reducer surface. + * + * @see ./reducer.ts for State + Action. + */ +import type { + AdminEntry, + AdminHeadgate, + AdminIrrigator, +} from "@/actions/water-log/admin"; +import type { IrrigationSeasonSettings } from "@/lib/water-log-reporting"; + +// ── Toast ──────────────────────────────────────────────────────────────────── +export type Toast = { msg: string; kind: "ok" | "err" }; + +// ── Forms ──────────────────────────────────────────────────────────────────── +export type HgForm = { name: string; unit: string; notes: string }; +export type UserForm = { + name: string; + role: "irrigator" | "water_admin"; + lang: string; + phone: string; +}; + +// ── PIN disclosure ─────────────────────────────────────────────────────────── +export type PinInfo = { name: string; pin: string }; + +// ── Report preview payload ─────────────────────────────────────────────────── +export type ReportPreviewData = { + rows: import("@/lib/water-log-reporting").WaterLogReportRow[]; + total: number; + unit: string; + dateLabel: string; + dateIso: string; + recipientName?: string; + inSeason: boolean; +}; + +// ── Reducer surface (mirrored here so callers don't import reducer internals) +export 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; + reportPreview: ReportPreviewData | null; +}; + +export 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 } + | { type: "OPEN_REPORT_PREVIEW"; preview: ReportPreviewData } + | { type: "CLOSE_REPORT_PREVIEW" }; + +// ── Convenience ────────────────────────────────────────────────────────────── +export type NotifyFn = (msg: string, kind?: "ok" | "err") => void; \ No newline at end of file