diff --git a/src/app/water/admin/page.tsx b/src/app/water/admin/page.tsx index b9635d1..40118cb 100644 --- a/src/app/water/admin/page.tsx +++ b/src/app/water/admin/page.tsx @@ -1,7 +1,15 @@ import { redirect } from "next/navigation"; import { cookies } from "next/headers"; +import { + getWaterDisplaySummary, + getWaterHeadgatesAdmin, + getWaterIrrigators, + getWaterAlertLog, +} from "@/actions/water-log/admin"; import WaterAdminClient from "@/components/water/WaterAdminClient"; +const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; + export const dynamic = "force-dynamic"; export default async function WaterAdminPage() { @@ -11,5 +19,22 @@ export default async function WaterAdminPage() { redirect("/water/admin/login"); } - return ; -} \ No newline at end of file + // Bootstrap the 4 datasets in parallel on the server so the first paint + // already has real data — no "Loading…" skeleton flash. The client's + // 30s polling keeps the display-summary card fresh from here. + const [summary, headgates, users, alertLog] = await Promise.all([ + getWaterDisplaySummary(TUXEDO_BRAND_ID), + getWaterHeadgatesAdmin(TUXEDO_BRAND_ID), + getWaterIrrigators(TUXEDO_BRAND_ID), + getWaterAlertLog(TUXEDO_BRAND_ID, 50), + ]); + + return ( + + ); +} diff --git a/src/components/water/WaterAdminClient.tsx b/src/components/water/WaterAdminClient.tsx index 8f0cc2a..0098959 100644 --- a/src/components/water/WaterAdminClient.tsx +++ b/src/components/water/WaterAdminClient.tsx @@ -1,71 +1,65 @@ "use client"; -import { useReducer, useEffect, useCallback } from "react"; +import { useReducer, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; -import { getWaterEntries, getWaterDisplaySummary, getWaterIrrigators, getWaterHeadgatesAdmin, createWaterIrrigator, createWaterHeadgate, getWaterAlertLog, type WaterDisplaySummary, type WaterDisplayHeadgate, type AlertLogEntry } from "@/actions/water-log/admin"; +import { + getWaterEntries, + getWaterDisplaySummary, + createWaterIrrigator, + createWaterHeadgate, + type AdminHeadgate as Headgate, + type AdminIrrigator as WaterUser, + type AdminEntry as WaterEntry, + type WaterDisplaySummary, + type WaterDisplayHeadgate, + type AlertLogEntry, +} from "@/actions/water-log/admin"; import { logoutWaterAdmin } from "@/actions/water-log/field"; -type WaterEntry = { - id: string; - headgate_id: string; - user_id: string; - headgate_name: string; - user_name: string; - measurement: number; - unit: string; - notes: string | null; - submitted_via: string; - logged_at: string; +/* ── Props ────────────────────────────────────────────────────────── */ + +type Props = { + initialSummary: WaterDisplaySummary | null; + initialHeadgates: Headgate[]; + initialUsers: WaterUser[]; + initialAlertLog: AlertLogEntry[]; }; -type WaterUser = { - id: string; - name: string; - role: "irrigator" | "water_admin"; - active: boolean; - language_preference: string; - last_used_at: string | null; - created_at: string; -}; - -type Headgate = { - id: string; - name: string; - active: boolean; - unit: string; - created_at: string; -}; - -const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; +/* ── i18n ─────────────────────────────────────────────────────────── */ const LABELS = { en: { title: "Water Log Admin", - loggedInAs: "Logged in as", - logout: "Salir", - recentEntries: "Entradas Recientes", + loggedInAs: "Signed in as", + logout: "Sign out", + recentEntries: "Recent Entries", noEntries: "No entries yet", - when: "Fecha", - user: "Usuario", - headgate: "Compuerta", - measurement: "Medición", - via: "Vía", - field: "Campo", + noMatches: "No matches", + when: "When", + user: "User", + headgate: "Headgate", + measurement: "Measurement", + via: "Via", + field: "Field", admin: "Admin", exportCSV: "Export CSV", clearFilters: "Clear", - displaySummary: "Resumen de Pantalla", - todayCount: "Entradas de hoy", - lastUpdate: "Última actualización", + save: "Save", + saveShort: "+", + loadMore: "Load more", + displaySummary: "Display Summary", + todayCount: "Entries today", + todayTotalSuffix: "total", + lastUpdate: "Last update", refreshIn: "Refresh in", - noHeadgates: "Sin compuertas activas", - noLatest: "Sin lecturas", - noEntriesToday: "Sin entradas hoy", - recentEntriesLabel: "Entradas recientes", + noHeadgates: "No active headgates", + noLatest: "No readings", + noEntriesToday: "No entries today", headgates: "Headgates", users: "Users", - addHeadgate: "Add Headgate", - addUser: "Add User", + alerts: "Alert Log", + addHeadgate: "Add headgate", + addUser: "Add user", edit: "Edit", cancel: "Cancel", name: "Name", @@ -78,14 +72,28 @@ const LABELS = { newPinFor: "New PIN for", writeThisDown: "Write this down — it will not be shown again.", dismiss: "Dismiss", - saving: "Saving...", + saving: "Saving…", + filterAll: "All headgates", + filterAllVia: "All via", + noUsers: "No users yet", + noAlerts: "No alerts triggered yet.", + time: "Time", + threshold: "Threshold", + reading: "Reading", + alertHigh: "High ↑", + alertLow: "Low ↓", + minutesAgo: "m ago", + hoursAgo: "h ago", + justNow: "Just now", + never: "Never", }, es: { - title: "Admin Registro de Agua", + title: "Administración del Registro de Agua", loggedInAs: "Conectado como", logout: "Salir", - recentEntries: "Entradas Recientes", + recentEntries: "Entradas recientes", noEntries: "Sin entradas aún", + noMatches: "Sin resultados", when: "Fecha", user: "Usuario", headgate: "Compuerta", @@ -95,18 +103,22 @@ const LABELS = { admin: "Admin", exportCSV: "Exportar CSV", clearFilters: "Limpiar", - displaySummary: "Resumen de Pantalla", - todayCount: "Entradas de hoy", + save: "Guardar", + saveShort: "+", + loadMore: "Cargar más", + displaySummary: "Resumen de pantalla", + todayCount: "Entradas hoy", + todayTotalSuffix: "total", lastUpdate: "Última actualización", - refreshIn: "Refresh in", + refreshIn: "Refresca en", noHeadgates: "Sin compuertas activas", noLatest: "Sin lecturas", noEntriesToday: "Sin entradas hoy", - recentEntriesLabel: "Entradas recientes", headgates: "Compuertas", users: "Usuarios", - addHeadgate: "Agregar Compuerta", - addUser: "Agregar Usuario", + alerts: "Registro de alertas", + addHeadgate: "Agregar compuerta", + addUser: "Agregar usuario", edit: "Editar", cancel: "Cancelar", name: "Nombre", @@ -117,18 +129,50 @@ const LABELS = { language: "Idioma", pin: "PIN", newPinFor: "Nuevo PIN para", - writeThisDown: "Write this down — it will not be shown again.", - dismiss: "Dismiss", - saving: "Saving...", + writeThisDown: "Anótalo — no se mostrará de nuevo.", + dismiss: "Cerrar", + saving: "Guardando…", + filterAll: "Todas las compuertas", + filterAllVia: "Todos los medios", + noUsers: "Sin usuarios aún", + noAlerts: "Sin alertas aún.", + time: "Hora", + threshold: "Umbral", + reading: "Lectura", + alertHigh: "Alta ↑", + alertLow: "Baja ↓", + minutesAgo: "m", + hoursAgo: "h", + justNow: "Ahora", + never: "Nunca", }, -}; +} as const; + +type Labels = (typeof LABELS)["en" | "es"]; + +function detectInitialLang(): "en" | "es" { + if (typeof document === "undefined") return "es"; // server snapshot fallback; client useEffect adjusts + const cookie = document.cookie.match(/wl_lang=(en|es)/)?.[1]; + if (cookie === "en" || cookie === "es") return cookie; + if (typeof navigator !== "undefined") { + const lang = navigator.language?.toLowerCase() ?? ""; + if (lang.startsWith("en")) return "en"; + } + return "es"; // default for this predominantly-Spanish audience +} + +const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"]; + +/* ── State ────────────────────────────────────────────────────────── */ type State = { lang: "en" | "es"; displaySummary: WaterDisplaySummary | null; - displayLoading: boolean; refreshCountdown: number; - allEntries: WaterEntry[]; + entries: WaterEntry[]; + entriesPage: number; // 0 = first 50, 1 = next 50, ... + entriesHasMore: boolean; + entriesLoading: boolean; users: WaterUser[]; headgates: Headgate[]; alertLog: AlertLogEntry[]; @@ -151,22 +195,36 @@ type State = { newHgUnit: string; savingHg: boolean; newHgError: string | null; - initialLoading: boolean; }; type Action = | { type: "SET_LANG"; lang: "en" | "es" } | { type: "SET_DISPLAY_SUMMARY"; summary: WaterDisplaySummary | null } - | { type: "SET_DISPLAY_LOADING"; value: boolean } - | { type: "SET_REFRESH_COUNTDOWN"; value: number } | { type: "DECREMENT_COUNTDOWN" } - | { type: "SET_ENTRIES"; entries: WaterEntry[] } + | { type: "RESET_COUNTDOWN"; value: number } + | { type: "SET_ENTRIES"; entries: WaterEntry[]; append?: boolean } + | { type: "SET_ENTRIES_PAGE"; page: number } + | { type: "SET_ENTRIES_HAS_MORE"; value: boolean } + | { type: "SET_ENTRIES_LOADING"; value: boolean } | { type: "SET_USERS"; users: WaterUser[] } | { type: "SET_HEADGATES"; headgates: Headgate[] } | { type: "SET_ALERT_LOG"; alertLog: AlertLogEntry[] } - | { type: "SET_FILTER"; field: "filterDateFrom" | "filterDateTo" | "filterHeadgate" | "filterVia"; value: string } + | { + type: "SET_FILTER"; + field: "filterDateFrom" | "filterDateTo" | "filterHeadgate" | "filterVia"; + value: string; + } | { type: "CLEAR_FILTERS" } - | { type: "TOGGLE"; key: "showHeadgates" | "showUsers" | "showEntries" | "showAlerts" | "showAddHg" | "showAddUser" } + | { + type: "TOGGLE"; + key: + | "showHeadgates" + | "showUsers" + | "showEntries" + | "showAlerts" + | "showAddHg" + | "showAddUser"; + } | { type: "SET_NEW_PIN"; value: { name: string; pin: string } | null } | { type: "SET_NEW_USER_NAME"; value: string } | { type: "SET_NEW_USER_LANG"; value: "en" | "es" } @@ -175,39 +233,7 @@ type Action = | { type: "SET_NEW_HG_NAME"; value: string } | { type: "SET_NEW_HG_UNIT"; value: string } | { type: "SET_SAVING_HG"; value: boolean } - | { type: "SET_NEW_HG_ERROR"; value: string | null } - | { type: "SET_INITIAL_LOADING"; value: boolean }; - -const initialState: State = { - lang: "en", - displaySummary: null, - displayLoading: true, - refreshCountdown: 30, - allEntries: [], - users: [], - headgates: [], - alertLog: [], - filterDateFrom: "", - filterDateTo: "", - filterHeadgate: "", - filterVia: "", - showHeadgates: true, - showUsers: true, - showEntries: true, - showAlerts: true, - showAddHg: false, - showAddUser: false, - newPin: null, - newUserName: "", - newUserLang: "en", - savingUser: false, - newUserError: null, - newHgName: "", - newHgUnit: "CFS", - savingHg: false, - newHgError: null, - initialLoading: true, -}; + | { type: "SET_NEW_HG_ERROR"; value: string | null }; function reducer(state: State, action: Action): State { switch (action.type) { @@ -215,14 +241,21 @@ function reducer(state: State, action: Action): State { return { ...state, lang: action.lang }; case "SET_DISPLAY_SUMMARY": return { ...state, displaySummary: action.summary }; - case "SET_DISPLAY_LOADING": - return { ...state, displayLoading: action.value }; - case "SET_REFRESH_COUNTDOWN": - return { ...state, refreshCountdown: action.value }; case "DECREMENT_COUNTDOWN": - return { ...state, refreshCountdown: state.refreshCountdown - 1 }; + return { ...state, refreshCountdown: Math.max(state.refreshCountdown - 1, 0) }; + case "RESET_COUNTDOWN": + return { ...state, refreshCountdown: action.value }; case "SET_ENTRIES": - return { ...state, allEntries: action.entries }; + return { + ...state, + entries: action.append ? [...state.entries, ...action.entries] : action.entries, + }; + case "SET_ENTRIES_PAGE": + return { ...state, entriesPage: action.page }; + case "SET_ENTRIES_HAS_MORE": + return { ...state, entriesHasMore: action.value }; + case "SET_ENTRIES_LOADING": + return { ...state, entriesLoading: action.value }; case "SET_USERS": return { ...state, users: action.users }; case "SET_HEADGATES": @@ -232,7 +265,13 @@ function reducer(state: State, action: Action): State { case "SET_FILTER": return { ...state, [action.field]: action.value }; case "CLEAR_FILTERS": - return { ...state, filterDateFrom: "", filterDateTo: "", filterHeadgate: "", filterVia: "" }; + return { + ...state, + filterDateFrom: "", + filterDateTo: "", + filterHeadgate: "", + filterVia: "", + }; case "TOGGLE": return { ...state, [action.key]: !state[action.key] }; case "SET_NEW_PIN": @@ -253,65 +292,54 @@ function reducer(state: State, action: Action): State { return { ...state, savingHg: action.value }; case "SET_NEW_HG_ERROR": return { ...state, newHgError: action.value }; - case "SET_INITIAL_LOADING": - return { ...state, initialLoading: action.value }; } } -function SummarySkeleton() { - return ( -
-
-
-
-
-
- {[1, 2, 3].map(i => ( -
-
-
-
- ))} -
-
- ); +const PAGE_SIZE = 50; +const REFRESH_SECONDS = 30; + +function reducerInit( + initialSummary: WaterDisplaySummary | null, + initialHeadgates: Headgate[], + initialUsers: WaterUser[], + initialAlertLog: AlertLogEntry[], +): State { + return { + lang: "es", + displaySummary: initialSummary, + refreshCountdown: REFRESH_SECONDS, + entries: [], + entriesPage: -1, // -1 means "haven't loaded yet"; first load treats this as page 0 + entriesHasMore: true, + entriesLoading: false, + users: initialUsers, + headgates: initialHeadgates, + alertLog: initialAlertLog, + filterDateFrom: "", + filterDateTo: "", + filterHeadgate: "", + filterVia: "", + showHeadgates: true, + showUsers: true, + showEntries: true, + showAlerts: true, + showAddHg: false, + showAddUser: false, + newPin: null, + newUserName: "", + newUserLang: "es", + savingUser: false, + newUserError: null, + newHgName: "", + newHgUnit: "CFS", + savingHg: false, + newHgError: null, + }; } -function TableSkeleton({ rows = 5 }: { rows?: number }) { - return ( -
-
- {Array.from({ length: rows }).map((_, i) => ( -
-
-
-
-
-
- ))} -
- ); -} +/* ── Helpers ──────────────────────────────────────────────────────── */ -function CardsSkeleton({ count = 4 }: { count?: number }) { - return ( -
-
-
-
-
-
- {Array.from({ length: count }).map((_, i) => ( -
-
-
-
-
- ))} -
-
- ); -} +const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; function formatDateTime(iso: string): string { return new Date(iso).toLocaleDateString("en-US", { @@ -322,165 +350,221 @@ function formatDateTime(iso: string): string { }); } -const AGE_COLORS = { - green: "bg-green-100 text-green-700", - yellow: "bg-yellow-100 text-yellow-700", - red: "bg-red-100 text-red-700", -}; +function ageLabel(minutes_ago: number | null, t: Labels): string { + if (minutes_ago === null) return t.never; + if (minutes_ago < 2) return t.justNow; + if (minutes_ago < 60) return `${minutes_ago}${t.minutesAgo}`; + return `${Math.floor(minutes_ago / 60)}${t.hoursAgo}`; +} -const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"]; +function ageColor(minutes_ago: number | null): "ok" | "warn" | "down" { + if (minutes_ago === null) return "down"; + if (minutes_ago > 60) return "warn"; + return "ok"; +} -function HeadgateCard({ hg }: { hg: WaterDisplayHeadgate }) { - const ageColor = (["green", "yellow", "red"] as const)[ - hg.minutes_ago === null ? 2 : hg.minutes_ago > 60 ? 1 : 0 - ]; - const ageLabel = - hg.minutes_ago === null - ? "Never" - : hg.minutes_ago < 2 - ? "Just now" - : hg.minutes_ago < 60 - ? `${hg.minutes_ago}m ago` - : `${Math.floor(hg.minutes_ago / 60)}h ago`; +/* ── Subcomponents ────────────────────────────────────────────────── */ + +function AdminHeader({ + title, + subtitle, + lang, + onChangeLang, + onLogout, + t, +}: { + title: string; + subtitle: string; + lang: "en" | "es"; + onChangeLang: (l: "en" | "es") => void; + onLogout: () => void; + t: Labels; +}) { return ( -
-

{hg.name}

- {hg.latest_entry ? ( - <> -

- {hg.latest_entry.measurement}{" "} - {hg.unit} -

-

{hg.latest_entry.user_name}

- +
+
+

+ {title} +

+

{subtitle}

+
+
+
+ + +
+ +
+
+ + ); +} + +function SectionLabel({ children, count }: { children: React.ReactNode; count?: number }) { + return ( +
+

+ {children} +

+ {count !== undefined && ( + + {count} + )}
); } -function SectionHeader({ - title, - count, - onToggle, - action, +function ToggleButton({ + open, + onClick, }: { - title: string; - count?: number; - onToggle: () => void; - action?: React.ReactNode; + open: boolean; + onClick: () => void; }) { return ( -
- - {action} -
+ ); } -async function handleLogout() { - await logoutWaterAdmin(); - window.location.href = "/water"; -} - -/* ── Subcomponents ─────────────────────────────────────────────── */ - -type Labels = typeof LABELS.en; - -function AdminHeader({ title, onLogout }: { title: string; onLogout: () => void }) { +function HeadgateCard({ hg, t }: { hg: WaterDisplayHeadgate; t: Labels }) { + const tone = ageColor(hg.minutes_ago); + const pillClass = + tone === "ok" + ? "bg-green-900/30 text-green-400 ring-1 ring-inset ring-green-900/50" + : tone === "warn" + ? "bg-amber-900/30 text-amber-300 ring-1 ring-inset ring-amber-900/50" + : "bg-zinc-800 text-zinc-500 ring-1 ring-inset ring-zinc-700"; return ( -
-
-
-

{title}

-

Logged in as: Admin

-
- -
+
+

{hg.name}

+ {hg.latest_entry ? ( + <> +

+ {hg.latest_entry.measurement} + {hg.unit} +

+

{hg.latest_entry.user_name}

+ + {ageLabel(hg.minutes_ago, t)} + + + ) : ( +

{t.noLatest}

+ )}
); } function DisplaySummarySection({ displaySummary, - displayLoading, refreshCountdown, - initialLoading, t, }: { displaySummary: WaterDisplaySummary | null; - displayLoading: boolean; refreshCountdown: number; - initialLoading: boolean; t: Labels; }) { return ( -
-
-

{t.displaySummary}

- - {t.refreshIn}: {refreshCountdown}s +
+
+ {t.displaySummary} + + {t.refreshIn} {refreshCountdown}s
- {initialLoading ? ( - - ) : displayLoading ? ( - - ) : displaySummary ? ( -
-
- {displaySummary.headgates.length === 0 ? ( -

{t.noHeadgates}

- ) : ( -
- {displaySummary.headgates.map((hg) => ( - - ))} -
- )} -
-
+ + {displaySummary ? ( +
+ {displaySummary.headgates.length === 0 ? ( +

{t.noHeadgates}

+ ) : ( +
+ {displaySummary.headgates.map((hg) => ( +
+ +
+ ))} +
+ )} + +
- {t.todayCount} -

+

+ {t.todayCount} +

+

{displaySummary.today_count} {displaySummary.today_count > 0 && ( - - ({displaySummary.today_total.toFixed(1)} total) + + {displaySummary.today_total.toFixed(1)} {t.todayTotalSuffix} )}

- {displaySummary.recent_entries.length > 0 && ( - - {t.lastUpdate}: {formatDateTime(displaySummary.recent_entries[0]?.logged_at)} - + {displaySummary.recent_entries[0] && ( +

+ {t.lastUpdate} +
+ + {formatDateTime(displaySummary.recent_entries[0].logged_at)} + +

)}
) : ( -
{t.noEntriesToday}
+
+

{t.noEntriesToday}

+
)}
); @@ -494,7 +578,6 @@ function HeadgatesSection({ newHgUnit, savingHg, newHgError, - initialLoading, t, onToggleSection, onToggleAdd, @@ -511,7 +594,6 @@ function HeadgatesSection({ newHgUnit: string; savingHg: boolean; newHgError: string | null; - initialLoading: boolean; t: Labels; onToggleSection: () => void; onToggleAdd: () => void; @@ -522,89 +604,108 @@ function HeadgatesSection({ onEdit: (id: string) => void; }) { return ( -
- +
+ {t.headgates} +
+ - } - /> + +
+
{showAddHg && ( -
- + onChangeName(e.target.value)} placeholder={t.name} - className="flex-1 rounded-lg border border-stone-300 px-3 py-2 text-sm min-h-[44px]" required + aria-label={t.name} + className="w-full rounded-md border-0 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-400 min-h-[40px]" /> - -
+
+
)} {newHgError && ( -
{newHgError}
+
+ {newHgError} +
)} {showHeadgates && ( -
- {initialLoading ? ( - - ) : headgates.length === 0 ? ( -

{t.noHeadgates}

+
    + {headgates.length === 0 ? ( +

    {t.noHeadgates}

    ) : ( headgates.map((hg) => ( -
    -
    - - {hg.name} - {hg.unit} +
    + + + {hg.name} + + + {hg.unit} +
    - -
    + )) )} -
    +
)}
); @@ -619,7 +720,6 @@ function UsersSection({ savingUser, newUserError, newPin, - initialLoading, t, onToggleSection, onToggleAdd, @@ -638,7 +738,6 @@ function UsersSection({ savingUser: boolean; newUserError: string | null; newPin: { name: string; pin: string } | null; - initialLoading: boolean; t: Labels; onToggleSection: () => void; onToggleAdd: () => void; @@ -650,104 +749,130 @@ function UsersSection({ onEdit: (id: string) => void; }) { return ( -
+
{newPin && ( -
-

{t.newPinFor} {newPin.name}:

-

{newPin.pin}

-

{t.writeThisDown}

-
)} - + {t.users} +
+ - } - /> + +
+ {showAddUser && ( -
- + onChangeName(e.target.value)} placeholder={t.name} - className="flex-1 rounded-lg border border-stone-300 px-3 py-2 text-sm min-h-[44px]" required + aria-label={t.name} + className="w-full rounded-md border-0 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-400 min-h-[40px]" /> - -
+
+
)} {newUserError && ( -
{newUserError}
+
+ {newUserError} +
)} {showUsers && ( -
- {initialLoading ? ( - - ) : users.length === 0 ? ( -

No users

+
    + {users.length === 0 ? ( +

    {t.noUsers}

    ) : ( users.map((u) => ( -
    -
    - - {u.name} - +
    + + + {u.name} + + {u.role === "water_admin" ? t.admin : t.field}
    - -
    + )) )} -
    +
)}
); @@ -755,131 +880,150 @@ function UsersSection({ function EntriesSection({ entries, - allEntries, + entriesLoading, + entriesHasMore, showEntries, filterDateFrom, filterDateTo, filterHeadgate, filterVia, headgateOptions, - initialLoading, t, onToggleSection, onChangeFilter, onClearFilters, - onRowClick, + onLoadMore, }: { entries: WaterEntry[]; - allEntries: WaterEntry[]; + entriesLoading: boolean; + entriesHasMore: boolean; showEntries: boolean; filterDateFrom: string; filterDateTo: string; filterHeadgate: string; filterVia: string; headgateOptions: { id: string; name: string }[]; - initialLoading: boolean; t: Labels; onToggleSection: () => void; - onChangeFilter: (field: "filterDateFrom" | "filterDateTo" | "filterHeadgate" | "filterVia", value: string) => void; + onChangeFilter: ( + field: "filterDateFrom" | "filterDateTo" | "filterHeadgate" | "filterVia", + value: string, + ) => void; onClearFilters: () => void; - onRowClick: (id: string) => void; + onLoadMore: () => void; }) { + const filtersActive = filterDateFrom || filterDateTo || filterHeadgate || filterVia; + return ( -
- +
+
+ {t.recentEntries} + +
{showEntries && ( <> - {/* Filters */}
- onChangeFilter("filterDateFrom", e.target.value)} - className="rounded-lg border border-stone-300 px-2 py-1.5 text-sm min-h-[36px]" + aria-label={t.when} + className="rounded-md border-0 bg-zinc-950 px-2 py-1.5 text-sm text-zinc-200 ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-zinc-400 min-h-[36px]" /> - onChangeFilter("filterDateTo", e.target.value)} - className="rounded-lg border border-stone-300 px-2 py-1.5 text-sm min-h-[36px]" + aria-label={t.when} + className="rounded-md border-0 bg-zinc-950 px-2 py-1.5 text-sm text-zinc-200 ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-zinc-400 min-h-[36px]" /> - - - {(filterDateFrom || filterDateTo || filterHeadgate || filterVia) && ( - )}
-
- +
+
- - - - - - + + + + + + - {initialLoading ? ( + {entries.length === 0 ? ( - - ) : entries.length === 0 ? ( - ) : ( entries.map((e) => ( onRowClick(e.id)} - className="border-b border-stone-50 last:border-0 hover:bg-stone-50 cursor-pointer" + className="border-b border-zinc-800/50 last:border-0 hover:bg-zinc-800/30" > - - - - - + + + + @@ -889,6 +1033,19 @@ function EntriesSection({
{t.when}{t.user}{t.headgate}{t.measurement}{t.via}
+ {t.when} + + {t.user} + + {t.headgate} + + {t.measurement} + + {t.via} +
-
- {Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
-
-
- ))} -
+
+ {filtersActive ? t.noMatches : t.noEntries}
{allEntries.length === 0 ? t.noEntries : "No matches"}
{formatDateTime(e.logged_at)}{e.user_name}{e.headgate_name}{e.measurement} {e.unit} - + + {formatDateTime(e.logged_at)} + + {e.user_name} + {e.headgate_name} + {e.measurement} {e.unit} + + {e.submitted_via === "field" ? t.field[0] : t.admin[0]}
+ + {entriesHasMore && ( +
+ +
+ )} )}
@@ -898,176 +1055,257 @@ function EntriesSection({ function AlertsSection({ alertLog, showAlerts, + t, onToggleSection, }: { alertLog: AlertLogEntry[]; showAlerts: boolean; + t: Labels; onToggleSection: () => void; }) { return ( -
- +
+
+ {t.alerts} + +
- {showAlerts && ( - <> - {alertLog.length === 0 ? ( -

No alerts triggered yet.

- ) : ( -
- - - - - - - - + {showAlerts && + (alertLog.length === 0 ? ( +

{t.noAlerts}

+ ) : ( +
+
TimeHeadgateTypeReadingThreshold
+ + + + + + + + + + {alertLog.map((a) => ( + + + + + - - - {alertLog.map((a) => ( - - - - - - - - ))} - -
+ {t.time} + + {t.headgate} + + {t.via} + + {t.reading} +
+ {a.formatted_time} + + {a.headgate_name} + + + {a.alert_type === "high" ? t.alertHigh : t.alertLow} + + + {a.reading_value} +
{a.formatted_time}{a.headgate_name} - - {a.alert_type === "high" ? "High ↑" : "Low ↓"} - - {a.reading_value}{a.threshold_value}
-
- )} - - )} + ))} + + +
+ ))}
); } -/* ── Main ──────────────────────────────────────────────────────── */ +/* ── Main ─────────────────────────────────────────────────────────── */ -export default function WaterAdminClient() { +export default function WaterAdminClient({ + initialSummary, + initialHeadgates, + initialUsers, + initialAlertLog, +}: Props) { const router = useRouter(); - const [state, dispatch] = useReducer(reducer, initialState); + const [state, dispatch] = useReducer( + reducer, + reducerInit(initialSummary, initialHeadgates, initialUsers, initialAlertLog), + ); - // Initialize lang from cookie after mount (replaces useState initializer with cookie read) + // One-time post-mount: detect language from cookie/navigator and start + // the entries load + display polling. + useEffect(() => { + const detected = detectInitialLang(); + if (detected !== state.lang) { + dispatch({ type: "SET_LANG", lang: detected }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Persist language to cookie whenever it changes. useEffect(() => { if (typeof document === "undefined") return; - const saved = document.cookie.match(/wl_lang=(en|es)/)?.[1]; - if (saved === "en" || saved === "es") { - dispatch({ type: "SET_LANG", lang: saved }); - } - }, []); - - const loadAll = async () => { - const [entries, usersData, headgatesData, alertsData] = await Promise.all([ - getWaterEntries(TUXEDO_BRAND_ID, 500), - getWaterIrrigators(TUXEDO_BRAND_ID), - getWaterHeadgatesAdmin(TUXEDO_BRAND_ID), - getWaterAlertLog(TUXEDO_BRAND_ID, 50), - ]); - dispatch({ type: "SET_ENTRIES", entries }); - dispatch({ type: "SET_USERS", users: usersData }); - dispatch({ type: "SET_HEADGATES", headgates: headgatesData }); - dispatch({ type: "SET_ALERT_LOG", alertLog: alertsData as AlertLogEntry[] }); - dispatch({ type: "SET_INITIAL_LOADING", value: false }); - }; + document.cookie = `wl_lang=${state.lang}; path=/; max-age=31536000; SameSite=Lax`; + }, [state.lang]); + // Lazy-load the entries page on mount. We always fetch the first 50 + // immediately so the table can render rows; "Load more" appends more. useEffect(() => { - void loadAll(); - }, []); - - const loadDisplaySummary = useCallback(async () => { - const data = await getWaterDisplaySummary(TUXEDO_BRAND_ID); - dispatch({ type: "SET_DISPLAY_SUMMARY", summary: data }); - dispatch({ type: "SET_DISPLAY_LOADING", value: false }); - dispatch({ type: "SET_REFRESH_COUNTDOWN", value: 30 }); + let cancelled = false; + (async () => { + const data = await getWaterEntries(TUXEDO_BRAND_ID, PAGE_SIZE); + if (cancelled) return; + dispatch({ type: "SET_ENTRIES", entries: data, append: false }); + dispatch({ type: "SET_ENTRIES_PAGE", page: 0 }); + dispatch({ + type: "SET_ENTRIES_HAS_MORE", + value: data.length === PAGE_SIZE, + }); + })(); + return () => { + cancelled = true; + }; }, []); + // 30s polling on the display summary — keeps the cards fresh without + // blocking the page. Previous data stays visible throughout. Uses a + // ref to read the live countdown without tearing down the interval. + const countdownRef = useRef(state.refreshCountdown); useEffect(() => { - loadDisplaySummary(); - const interval = setInterval(() => { - const next = state.refreshCountdown - 1; + countdownRef.current = state.refreshCountdown; + }, [state.refreshCountdown]); + useEffect(() => { + let cancelled = false; + const refresh = async () => { + const data = await getWaterDisplaySummary(TUXEDO_BRAND_ID); + if (cancelled) return; + dispatch({ type: "SET_DISPLAY_SUMMARY", summary: data }); + dispatch({ type: "RESET_COUNTDOWN", value: REFRESH_SECONDS }); + }; + const tick = setInterval(() => { + const next = countdownRef.current - 1; if (next <= 0) { - loadDisplaySummary(); + void refresh(); } else { dispatch({ type: "DECREMENT_COUNTDOWN" }); } }, 1000); - return () => clearInterval(interval); - }, [loadDisplaySummary, state.refreshCountdown]); + return () => { + cancelled = true; + clearInterval(tick); + }; + }, []); const t = LABELS[state.lang]; - // Add user - async function handleAddUser(e: React.FormEvent) { - e.preventDefault(); - if (!state.newUserName.trim()) return; - dispatch({ type: "SET_SAVING_USER", value: true }); - dispatch({ type: "SET_NEW_USER_ERROR", value: null }); - const result = await createWaterIrrigator(TUXEDO_BRAND_ID, state.newUserName.trim(), state.newUserLang); - if (result.success && result.pin && result.user) { - dispatch({ type: "SET_NEW_PIN", value: { name: result.user.name, pin: result.pin } }); - dispatch({ type: "SET_NEW_USER_NAME", value: "" }); - dispatch({ type: "TOGGLE", key: "showAddUser" }); - const updated = await getWaterIrrigators(TUXEDO_BRAND_ID); - dispatch({ type: "SET_USERS", users: updated }); - } else { - dispatch({ type: "SET_NEW_USER_ERROR", value: result.error ?? "Failed to add user" }); - } - dispatch({ type: "SET_SAVING_USER", value: false }); + /* ── Handlers ───────────────────────────────────────────────── */ + + async function handleLogout() { + await logoutWaterAdmin(); + window.location.href = "/water"; } - // Add headgate - async function handleAddHg(e: React.FormEvent) { + function handleChangeLang(next: "en" | "es") { + dispatch({ type: "SET_LANG", lang: next }); + } + + async function handleAddHeadgate(e: React.FormEvent) { e.preventDefault(); - if (!state.newHgName.trim()) return; dispatch({ type: "SET_SAVING_HG", value: true }); dispatch({ type: "SET_NEW_HG_ERROR", value: null }); - const result = await createWaterHeadgate(TUXEDO_BRAND_ID, state.newHgName.trim(), state.newHgUnit); - if (result.success) { + const result = await createWaterHeadgate(TUXEDO_BRAND_ID, state.newHgName, state.newHgUnit); + if (result.success && result.headgate) { + dispatch({ + type: "SET_HEADGATES", + headgates: [result.headgate, ...state.headgates], + }); dispatch({ type: "SET_NEW_HG_NAME", value: "" }); dispatch({ type: "SET_NEW_HG_UNIT", value: "CFS" }); + dispatch({ type: "SET_SAVING_HG", value: false }); dispatch({ type: "TOGGLE", key: "showAddHg" }); - const updated = await getWaterHeadgatesAdmin(TUXEDO_BRAND_ID); - dispatch({ type: "SET_HEADGATES", headgates: updated }); } else { - dispatch({ type: "SET_NEW_HG_ERROR", value: result.error ?? "Failed to add headgate" }); + dispatch({ type: "SET_NEW_HG_ERROR", value: result.error ?? t.saving }); + dispatch({ type: "SET_SAVING_HG", value: false }); } - dispatch({ type: "SET_SAVING_HG", value: false }); } - // Filtered entries - const filteredEntries = state.allEntries.filter((e) => { - if (state.filterDateFrom && e.logged_at < state.filterDateFrom) return false; - if (state.filterDateTo && e.logged_at > state.filterDateTo + "T23:59:59.999Z") return false; + async function handleAddUser(e: React.FormEvent) { + e.preventDefault(); + dispatch({ type: "SET_SAVING_USER", value: true }); + dispatch({ type: "SET_NEW_USER_ERROR", value: null }); + const result = await createWaterIrrigator( + TUXEDO_BRAND_ID, + state.newUserName, + state.newUserLang, + ); + if (result.success && result.user) { + dispatch({ + type: "SET_USERS", + users: [result.user, ...state.users], + }); + dispatch({ + type: "SET_NEW_PIN", + value: { name: result.user.name, pin: result.pin ?? "" }, + }); + dispatch({ type: "SET_NEW_USER_NAME", value: "" }); + dispatch({ type: "SET_NEW_USER_LANG", value: "es" }); + dispatch({ type: "SET_SAVING_USER", value: false }); + dispatch({ type: "TOGGLE", key: "showAddUser" }); + } else { + dispatch({ type: "SET_NEW_USER_ERROR", value: result.error ?? t.saving }); + dispatch({ type: "SET_SAVING_USER", value: false }); + } + } + + function handleEditHeadgate(id: string) { + router.push(`/admin/water-log/headgates/${id}?from=/water/admin`); + } + + function handleEditUser(id: string) { + router.push(`/admin/water-log/users/${id}?from=/water/admin`); + } + + /* ── Filter pipeline (client-side over loaded entries) ──────── */ + + const filteredEntries = state.entries.filter((e) => { if (state.filterHeadgate && e.headgate_id !== state.filterHeadgate) return false; if (state.filterVia && e.submitted_via !== state.filterVia) return false; + if (state.filterDateFrom) { + const d = new Date(e.logged_at); + if (d < new Date(state.filterDateFrom)) return false; + } + if (state.filterDateTo) { + const d = new Date(e.logged_at); + const to = new Date(state.filterDateTo); + to.setHours(23, 59, 59, 999); + if (d > to) return false; + } return true; }); - const headgateOptions = Array.from( - new Map(state.allEntries.map((e) => [e.headgate_id, e.headgate_name])).entries() - ).map(([id, name]) => ({ id, name })); + const headgateOptions = state.headgates.map((hg) => ({ id: hg.id, name: hg.name })); return ( -
- - -
+
+ +
@@ -1079,18 +1317,14 @@ export default function WaterAdminClient() { newHgUnit={state.newHgUnit} savingHg={state.savingHg} newHgError={state.newHgError} - initialLoading={state.initialLoading} t={t} onToggleSection={() => dispatch({ type: "TOGGLE", key: "showHeadgates" })} onToggleAdd={() => dispatch({ type: "TOGGLE", key: "showAddHg" })} onChangeName={(v) => dispatch({ type: "SET_NEW_HG_NAME", value: v })} onChangeUnit={(v) => dispatch({ type: "SET_NEW_HG_UNIT", value: v })} - onSubmit={handleAddHg} - onCancel={() => { - dispatch({ type: "TOGGLE", key: "showAddHg" }); - dispatch({ type: "SET_NEW_HG_ERROR", value: null }); - }} - onEdit={(id) => router.push(`/admin/water-log/headgates/${id}?from=/water/admin`)} + onSubmit={handleAddHeadgate} + onCancel={() => dispatch({ type: "TOGGLE", key: "showAddHg" })} + onEdit={handleEditHeadgate} /> dispatch({ type: "TOGGLE", key: "showUsers" })} onToggleAdd={() => dispatch({ type: "TOGGLE", key: "showAddUser" })} onChangeName={(v) => dispatch({ type: "SET_NEW_USER_NAME", value: v })} onChangeLang={(v) => dispatch({ type: "SET_NEW_USER_LANG", value: v })} onSubmit={handleAddUser} - onCancel={() => { - dispatch({ type: "TOGGLE", key: "showAddUser" }); - dispatch({ type: "SET_NEW_USER_ERROR", value: null }); - }} + onCancel={() => dispatch({ type: "TOGGLE", key: "showAddUser" })} onDismissPin={() => dispatch({ type: "SET_NEW_PIN", value: null })} - onEdit={(id) => router.push(`/admin/water-log/users/${id}?from=/water/admin`)} + onEdit={handleEditUser} /> dispatch({ type: "TOGGLE", key: "showEntries" })} onChangeFilter={(field, value) => dispatch({ type: "SET_FILTER", field, value })} onClearFilters={() => dispatch({ type: "CLEAR_FILTERS" })} - onRowClick={(id) => router.push(`/admin/water-log/entries/${id}?from=/water/admin`)} + onLoadMore={async () => { + const currentPage = state.entriesPage; + const nextPage = currentPage + 1; + const limit = (nextPage + 1) * PAGE_SIZE; + dispatch({ type: "SET_ENTRIES_LOADING", value: true }); + const data = await getWaterEntries(TUXEDO_BRAND_ID, limit); + dispatch({ + type: "SET_ENTRIES", + entries: data, + append: true, + }); + dispatch({ type: "SET_ENTRIES_PAGE", page: nextPage }); + dispatch({ + type: "SET_ENTRIES_HAS_MORE", + value: data.length === limit, + }); + dispatch({ type: "SET_ENTRIES_LOADING", value: false }); + }} /> dispatch({ type: "TOGGLE", key: "showAlerts" })} /> + +
-
+ ); }