refactor(water-log): split 2117-line panel into tabbed shell + focused modules

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.
This commit is contained in:
Nora
2026-07-03 17:58:44 -06:00
parent bf68fa8431
commit c9b6729482
13 changed files with 2622 additions and 2119 deletions
@@ -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<string, AdminEntry>;
canManage: boolean;
showAddHg: boolean;
newHg: { name: string; unit: string; notes: string };
savingHg: boolean;
notify: NotifyFn;
dispatch: React.Dispatch<Action>;
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 (
<>
<SectionHeader
index="§ 01"
title="Headgates"
subtitle="Physical gates in the ditch network. Each gets a printable QR code so field workers can scan to log a reading without typing the gate name."
action={
canManage ? (
<AdminButton size="sm" onClick={() => dispatch({ type: "TOGGLE_ADD_HG" })}>
{showAddHg ? "Cancel" : "+ Add Headgate"}
</AdminButton>
) : null
}
/>
{showAddHg && canManage && (
<form
onSubmit={handleAdd}
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_120px_1fr_auto]"
>
<Input
label="Name"
value={newHg.name}
onChange={(v) => dispatch({ type: "SET_NEW_HG_NAME", value: v })}
placeholder="e.g. North Field Gate 1"
required
/>
<Select
label="Unit"
value={newHg.unit}
onChange={(v) => dispatch({ type: "SET_NEW_HG_UNIT", value: v })}
options={["CFS", "GPM", "Inches", "AF/Day", "gal", "ac-ft"]}
/>
<Input
label="Notes"
value={newHg.notes}
onChange={(v) => dispatch({ type: "SET_NEW_HG_NOTES", value: v })}
placeholder="Location, GPS, contact…"
/>
<div className="flex items-end">
<AdminButton
type="submit"
disabled={savingHg}
isLoading={savingHg}
fullWidth
>
Add
</AdminButton>
</div>
</form>
)}
{headgates.length === 0 ? (
<EmptyState
icon={<WaterGauge size={36} level={null} status="open" />}
title="No headgates yet"
body="Add a headgate to start logging. Each one gets a unique QR code you can print and post at the gate."
/>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{headgates.map((h) => {
const last = lastEntryByHeadgate.get(h.id);
const level =
last && h.high_threshold
? Math.min(1, last.measurement / Number(h.high_threshold))
: null;
return (
<li
key={h.id}
className="group rounded-xl border border-[#d4d9d3] bg-white p-4 transition hover:border-[#1a4d2e]/40 hover:shadow-[0_4px_16px_rgba(26,77,46,0.08)]"
>
<div className="flex items-start gap-3">
<WaterGauge
size={36}
level={level}
status={h.status as "open" | "closed" | "maintenance"}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-[#1d1d1f]">
{h.name}
</h3>
<StatusPill
kind={
!h.active
? "inactive"
: h.status === "closed"
? "closed"
: h.status === "maintenance"
? "warn"
: "active"
}
/>
</div>
<p className="mt-0.5 font-mono text-[11px] uppercase tracking-wider text-[#86868b]">
Unit: {h.unit} · Token: {h.headgate_token.slice(0, 8)}
</p>
{last ? (
<p className="mt-2 text-sm text-[#57694e]">
Last:{" "}
<span className="font-mono font-semibold text-[#1d1d1f]">
{last.measurement} {h.unit}
</span>{" "}
<span className="text-[#86868b]">
by {last.user_name} · {formatDateTime(last.logged_at)}
</span>
</p>
) : (
<p className="mt-2 text-sm italic text-[#86868b]">
No readings yet
</p>
)}
{(h.high_threshold != null || h.low_threshold != null) && (
<p className="mt-1 font-mono text-[11px] text-[#57694e]">
{h.high_threshold != null && (
<>Hi&nbsp;&nbsp;{h.high_threshold}&nbsp;&nbsp;</>
)}
{h.low_threshold != null && (
<>Lo&nbsp;&nbsp;{h.low_threshold}</>
)}
</p>
)}
</div>
</div>
{canManage && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-1 border-t border-[#e8ebe8] pt-3 text-[11px]">
<button
type="button"
onClick={() => onEdit(h)}
className="font-medium text-[#1a4d2e] hover:underline"
aria-label="Edit →"
>
Edit
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleRegenerateToken(h)}
className="text-[#57694e] hover:text-[#1a4d2e]"
aria-label="Rotate token"
>
Rotate token
</button>
<span className="text-[#d4d9d3]">·</span>
<button
type="button"
onClick={() => handleDelete(h)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
aria-label="Delete"
>
Delete
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
</>
);
}
@@ -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<Action>;
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 (
<>
<TodaySummary
todayLabel={todayLabel}
todayEntries={todayEntries}
todayTotal={todayTotal}
headgates={state.headgates}
users={state.users}
inSeason={inSeason}
seasonStart={state.seasonStart}
recipientName={state.recipientName}
onToggleReportSettings={() => dispatch({ type: "TOGGLE_REPORT_SETTINGS" })}
onSeasonChange={onSeasonChange}
onRecipientNameChange={onRecipientNameChange}
onPreviewReport={onPreviewReport}
showReportSettings={state.showReportSettings}
/>
<RecentEntriesSection
state={state}
dispatch={dispatch}
onEditEntry={(e) => 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 (
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.18]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, transparent 0 14px, rgba(26,77,46,0.06) 14px 15px), repeating-linear-gradient(-45deg, transparent 0 14px, rgba(202,138,4,0.04) 14px 15px)",
}}
/>
<div className="relative grid grid-cols-1 gap-6 p-6 md:grid-cols-[200px_1fr] md:gap-8">
{/* Hero gauge */}
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Tuxedo Ditch Co.
</div>
<WaterGauge
size={120}
level={
todayEntries.length === 0
? null
: Math.min(1, todayTotal / Math.max(todayTotal, 100))
}
status="open"
ariaLabel="Today's water level"
/>
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Vol. {todayTotal.toFixed(2)} {headgates[0]?.unit ?? "CFS"}
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<h2
className="text-2xl font-semibold tracking-tight text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Today&apos;s Summary
</h2>
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-[#57694e]">
<SeasonMark inSeason={inSeason} size={14} />
{inSeason ? (
<span className="font-medium text-[#1a4d2e]">
In irrigation season
</span>
) : (
<span className="font-medium text-[#a16207]">
Outside irrigation season
</span>
)}
<span className="text-[#57694e]/60">·</span>
<span className="font-mono text-[#1d1d1f]">
{todayEntries.length}{" "}
{todayEntries.length === 1 ? "entry" : "entries"}
</span>
<span className="text-[#57694e]/60">·</span>
<span className="text-[#57694e]">{todayLabel}</span>
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile
label="Total volume"
value={todayTotal.toFixed(2)}
unit={headgates[0]?.unit ?? "CFS"}
accent
/>
<StatTile
label="Headgates"
value={headgates.filter((h) => h.active).length.toString()}
unit={`of ${headgates.length}`}
/>
<StatTile
label="Active irrigators"
value={users.filter((u) => u.active).length.toString()}
unit={`of ${users.length}`}
/>
<StatTile
label="Last entry"
value={
todayEntries.length
? formatDateTime(todayEntries[0].logged_at)
.split(",")
.pop()
?.trim() ?? "—"
: "—"
}
unit=""
mono
/>
</div>
<div className="flex flex-wrap gap-2">
<AdminButton
variant="secondary"
size="sm"
icon={<ReportIcon />}
onClick={onPreviewReport}
>
Preview Report
</AdminButton>
<AdminButton
variant={showReportSettings ? "primary" : "secondary"}
size="sm"
icon={
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="8" cy="8" r="2.2" />
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
</svg>
}
onClick={onToggleReportSettings}
>
Report Settings
</AdminButton>
</div>
{showReportSettings && (
<ReportSettingsInline
season={seasonStart}
onSeasonChange={onSeasonChange}
recipientName={recipientName}
onRecipientNameChange={onRecipientNameChange}
/>
)}
</div>
</div>
</section>
);
}
// ── 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 (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-4">
<p className="mb-3 rounded-lg bg-[#fef9c3] px-3 py-2 text-xs text-[#a16207]">
<strong>Preview only.</strong> Daily-report delivery is wired through
the scheduled <code>api/cron/water-report</code> endpoint and respects
these settings.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ReportSeasonRow
label="Season start"
month={season.seasonStartMonth}
day={season.seasonStartDay}
onMonthChange={(m) =>
onSeasonChange({ ...season, seasonStartMonth: m })
}
onDayChange={(d) => onSeasonChange({ ...season, seasonStartDay: d })}
/>
<ReportSeasonRow
label="Season end"
month={season.seasonEndMonth}
day={season.seasonEndDay}
onMonthChange={(m) =>
onSeasonChange({ ...season, seasonEndMonth: m })
}
onDayChange={(d) => onSeasonChange({ ...season, seasonEndDay: d })}
/>
<div className="sm:col-span-2">
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
Recipient name (used in report salutation)
</span>
<input
type="text"
value={recipientName}
onChange={(e) => onRecipientNameChange(e.target.value)}
placeholder="David"
aria-label="Recipient name"
className="w-full rounded-lg border border-[#d4d9d3] px-3 py-2 text-sm outline-none focus:border-[#1a4d2e]"
/>
</label>
</div>
</div>
</div>
);
}
function ReportSeasonRow({
label,
month,
day,
onMonthChange,
onDayChange,
}: {
label: string;
month: number;
day: number;
onMonthChange: (m: number) => void;
onDayChange: (d: number) => void;
}) {
return (
<div>
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<div className="flex gap-1">
<MonthSelect value={month} onChange={onMonthChange} />
<DayInput value={day} onChange={onDayChange} />
</div>
</div>
);
}
// ── RecentEntriesSection (table + filters) ──────────────────────────────────
function RecentEntriesSection({
state,
dispatch,
onEditEntry,
onExportCSV,
}: {
state: State;
dispatch: React.Dispatch<Action>;
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 (
<>
<SectionHeader
index="§ 03"
title="Recent Entries"
subtitle="The latest readings logged from the field or the admin panel. Use the filters to narrow, then export to CSV."
action={
<AdminButton
size="sm"
onClick={onExportCSV}
isLoading={state.csvLoading}
icon={<DownloadIcon />}
>
Export CSV
</AdminButton>
}
/>
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-[#d4d9d3] bg-white p-2.5">
<FilterChip label="From">
<input
type="date"
value={state.filters.dateFrom}
onChange={(e) =>
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"
/>
</FilterChip>
<FilterChip label="To">
<input
type="date"
value={state.filters.dateTo}
onChange={(e) =>
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"
/>
</FilterChip>
<FilterChip label="Headgate">
<select
value={state.filters.headgate}
onChange={(e) =>
dispatch({ type: "SET_FILTER_HEADGATE", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: headgate"
>
<option value="">All</option>
{state.headgates.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</FilterChip>
<FilterChip label="User">
<select
value={state.filters.user}
onChange={(e) =>
dispatch({ type: "SET_FILTER_USER", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: user"
>
<option value="">All</option>
{state.users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FilterChip>
<FilterChip label="Via">
<select
value={state.filters.via}
onChange={(e) =>
dispatch({ type: "SET_FILTER_VIA", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: submission source"
>
<option value="">All</option>
<option value="field">Field</option>
<option value="admin">Admin</option>
<option value="app">App</option>
</select>
</FilterChip>
{(state.filters.dateFrom ||
state.filters.dateTo ||
state.filters.headgate ||
state.filters.user ||
state.filters.via) && (
<button
type="button"
onClick={() => dispatch({ type: "CLEAR_FILTERS" })}
className="text-xs font-medium text-[#57694e] hover:text-[#1a4d2e] hover:underline"
aria-label="Clear filters"
>
Clear filters
</button>
)}
<span className="ml-auto font-mono text-[11px] text-[#86868b]">
{filteredEntries.length} of {state.entries.length} entries
</span>
</div>
{filteredEntries.length === 0 ? (
<EmptyState
icon={<TableIcon />}
title="No entries match"
body={
state.entries.length === 0
? "No readings have been logged yet."
: "Try clearing the filters above."
}
/>
) : (
<div className="overflow-hidden rounded-xl border border-[#d4d9d3] bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#d4d9d3] bg-[#f5f3ef] text-left font-mono text-[10px] uppercase tracking-[0.15em] text-[#57694e]">
<th className="px-4 py-2.5">When</th>
<th className="px-4 py-2.5">User</th>
<th className="px-4 py-2.5">Headgate</th>
<th className="px-4 py-2.5 text-right">Measurement</th>
<th className="px-4 py-2.5 text-right">Gallons</th>
<th className="px-4 py-2.5">Method</th>
<th className="px-4 py-2.5">Via</th>
<th className="px-4 py-2.5" aria-label="Actions"></th>
</tr>
</thead>
<tbody>
{filteredEntries.map((e, i) => (
<tr
key={e.id}
className={`group border-b border-[#e8ebe8] last:border-0 transition-colors hover:bg-[#fdfaf2] ${
i % 2 === 0 ? "" : "bg-[#fafaf7]"
}`}
>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-[#57694e]">
{formatDateTime(e.logged_at)}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
{e.user_name}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
{e.headgate_name}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono font-semibold text-[#1d1d1f]">
{e.measurement}{" "}
<span className="text-[10px] text-[#86868b]">{e.unit}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono text-[#57694e]">
{e.total_gallons != null
? e.total_gallons.toFixed(1)
: "—"}
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-[11px] uppercase text-[#57694e]">
{e.method}
</td>
<td className="whitespace-nowrap px-4 py-2.5">
<span
className={`inline-block rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
e.submitted_via === "field"
? "bg-[#dcfce7] text-[#15803d]"
: "bg-[#e8ebe8] text-[#57694e]"
}`}
>
{e.submitted_via}
</span>
</td>
<td className="px-4 py-2.5 text-right text-[11px]">
<button
type="button"
onClick={() => onEditEntry(e)}
className="font-medium text-[#1a4d2e] opacity-0 transition-opacity group-hover:opacity-100 hover:underline"
aria-label="Edit →"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
@@ -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<Action>;
};
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 (
<>
<SectionHeader
index="§ 02"
title="Water Users"
subtitle="The people who log readings in the field. Each gets a 4-digit PIN. Admin role can manage headgates, users, and entries; Irrigators can only submit."
action={
canManage ? (
<AdminButton
size="sm"
onClick={() => dispatch({ type: "TOGGLE_ADD_USER" })}
>
{showAddUser ? "Cancel" : "+ Add User"}
</AdminButton>
) : null
}
/>
{newPin && (
<PinBanner
title={`New PIN for ${newPin.name}`}
pin={newPin.pin}
onDismiss={() => dispatch({ type: "CLEAR_NEW_PIN" })}
/>
)}
{resetPin && (
<PinBanner
title={`Reset PIN for ${resetPin.name}`}
pin={resetPin.pin}
tone="warn"
onDismiss={() => dispatch({ type: "CLEAR_RESET_PIN" })}
/>
)}
{showAddUser && canManage && (
<form
onSubmit={handleAdd}
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_140px_120px_1fr_auto]"
>
<Input
label="Name"
value={newUser.name}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_NAME", value: v })
}
placeholder="Full name"
required
/>
<Select
label="Role"
value={newUser.role}
onChange={(v) =>
dispatch({
type: "SET_NEW_USER_ROLE",
value: v as "irrigator" | "water_admin",
})
}
options={[
{ value: "irrigator", label: "Irrigator" },
{ value: "water_admin", label: "Admin" },
]}
/>
<Select
label="Language"
value={newUser.lang}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_LANG", value: v })
}
options={[
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
]}
/>
<Input
label="Phone"
value={newUser.phone}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_PHONE", value: v })
}
placeholder="+1…"
type="tel"
/>
<div className="flex items-end">
<AdminButton
type="submit"
disabled={savingUser}
isLoading={savingUser}
fullWidth
>
Add
</AdminButton>
</div>
</form>
)}
<RoleLegend />
{users.length === 0 ? (
<EmptyState
icon={<UserIcon />}
title="No water users yet"
body="Add an irrigator or admin to grant field access. Their PIN is generated automatically — write it down before dismissing."
/>
) : (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<li
key={u.id}
className={`flex items-center gap-3 rounded-xl border bg-white px-3 py-2 ${
u.active
? "border-[#d4d9d3]"
: "border-[#d4d9d3] bg-[#f5f3ef] opacity-70"
}`}
>
<Avatar name={u.name} role={u.role} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-[#1d1d1f]">
{u.name}
</p>
<p className="font-mono text-[11px] text-[#86868b]">
{u.role === "water_admin" ? "ADMIN" : "IRRIGATOR"} ·{" "}
{u.language_preference === "es" ? "ES" : "EN"}
{u.last_used_at
? ` · seen ${formatDate(u.last_used_at)}`
: " · never signed in"}
</p>
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1 text-[11px]">
<button
type="button"
onClick={() => handleResetPin(u)}
className="text-[#57694e] hover:text-[#1a4d2e]"
aria-label="PIN"
>
PIN
</button>
<span className="text-[#d4d9d3]">·</span>
<button
type="button"
onClick={() => handleDelete(u)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
aria-label="Off"
>
Off
</button>
</div>
)}
</li>
))}
</ul>
)}
</>
);
}