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:
@@ -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 ≥ {h.high_threshold} </>
|
||||
)}
|
||||
{h.low_threshold != null && (
|
||||
<>Lo ≤ {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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user