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:
@@ -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"
|
||||
/>
|
||||
<div className="px-6 pb-8">
|
||||
<WaterLogAdminPanel
|
||||
<WaterLogShell
|
||||
initialUsers={users}
|
||||
initialHeadgates={headgates}
|
||||
initialEntries={entries}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<TabValue>("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<string, AdminEntry>();
|
||||
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 (
|
||||
<div className="relative">
|
||||
<HeaderNav />
|
||||
|
||||
<div className="mb-6">
|
||||
<AdminFilterTabs
|
||||
activeTab={tab}
|
||||
onTabChange={(value) => setTab(value as TabValue)}
|
||||
tabs={TABS.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
}))}
|
||||
showCounts={false}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === "today" && (
|
||||
<TodayTab
|
||||
brandId={brandId}
|
||||
state={state}
|
||||
todayLabel={state.todayLabel}
|
||||
todayEntries={todayEntries}
|
||||
todayTotal={todayTotal}
|
||||
inSeason={inSeason}
|
||||
notify={notify}
|
||||
dispatch={dispatch}
|
||||
onPreviewReport={openReportPreview}
|
||||
onSeasonChange={persistSeason}
|
||||
onRecipientNameChange={persistRecipientName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "headgates" && (
|
||||
<HeadgatesTab
|
||||
brandId={brandId}
|
||||
headgates={state.headgates}
|
||||
lastEntryByHeadgate={lastEntryByHeadgate}
|
||||
canManage={canManage}
|
||||
showAddHg={state.forms.showAddHg}
|
||||
newHg={state.forms.newHg}
|
||||
savingHg={state.forms.savingHg}
|
||||
notify={notify}
|
||||
dispatch={dispatch}
|
||||
onEdit={(h) => router.push(`/admin/water-log/headgates/${h.id}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "users" && (
|
||||
<UsersTab
|
||||
brandId={brandId}
|
||||
users={state.users}
|
||||
canManage={canManage}
|
||||
showAddUser={state.forms.showAddUser}
|
||||
newUser={state.forms.newUser}
|
||||
savingUser={state.forms.savingUser}
|
||||
newPin={state.forms.newPin}
|
||||
resetPin={state.forms.resetPin}
|
||||
notify={notify}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Future tab: Time Tracking (Cycle 3) */}
|
||||
|
||||
<ToastNotification toast={state.toast} />
|
||||
|
||||
<ReportPreviewModal
|
||||
data={state.reportPreview}
|
||||
onClose={closeReportPreview}
|
||||
notify={notify}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, AdminEntry>();
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||
<Link href="/admin/water-log/headgates">
|
||||
<AdminButton variant="secondary" size="sm" icon={<QrIcon />}>
|
||||
QR Codes
|
||||
</AdminButton>
|
||||
</Link>
|
||||
<Link href="/admin/water-log/settings">
|
||||
<AdminButton variant="secondary" size="sm" icon={<GearIcon />}>
|
||||
Settings
|
||||
</AdminButton>
|
||||
</Link>
|
||||
<Link href="/water/admin" target="_blank" rel="noopener noreferrer">
|
||||
<AdminButton variant="secondary" size="sm" icon={<FieldIcon />}>
|
||||
Field Admin ↗
|
||||
</AdminButton>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<header className="mb-3 mt-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3] pb-3">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#1a4d2e]">
|
||||
{index}
|
||||
</div>
|
||||
<h2
|
||||
className="mt-0.5 text-xl font-semibold text-[#1d1d1f]"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-0.5 max-w-xl text-xs text-[#57694e]">{subtitle}</p>
|
||||
</div>
|
||||
{action}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// ── StatTile ────────────────────────────────────────────────────────────────
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
accent,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
accent?: boolean;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-3 ${
|
||||
accent ? "border-[#1a4d2e]/30 bg-[#f0fdf4]" : "border-[#d4d9d3] bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[#57694e]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-baseline gap-1">
|
||||
<span
|
||||
className={`text-lg font-semibold text-[#1a4d2e] ${
|
||||
mono ? "font-mono" : ""
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{unit && (
|
||||
<span className="font-mono text-[10px] uppercase text-[#57694e]">
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${s.bg} ${s.fg}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PinBanner ───────────────────────────────────────────────────────────────
|
||||
export function PinBanner({
|
||||
title,
|
||||
pin,
|
||||
tone = "ok",
|
||||
onDismiss,
|
||||
}: {
|
||||
title: string;
|
||||
pin: string;
|
||||
tone?: "ok" | "warn";
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`mb-4 flex items-center gap-4 rounded-xl border p-4 ${
|
||||
tone === "warn"
|
||||
? "border-[#fde047] bg-[#fef9c3]"
|
||||
: "border-[#15803d]/40 bg-[#dcfce7]"
|
||||
}`}
|
||||
>
|
||||
<KeyIcon />
|
||||
<div className="flex-1">
|
||||
<p className="font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className="font-mono text-2xl font-bold tracking-[0.5em] text-[#1a4d2e]"
|
||||
aria-label={`PIN ${pin.split("").join(" ")}`}
|
||||
>
|
||||
{pin}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="rounded-lg border border-[#1a4d2e]/30 px-3 py-1.5 text-xs font-semibold text-[#1a4d2e] hover:bg-white"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── RoleLegend ──────────────────────────────────────────────────────────────
|
||||
export function RoleLegend() {
|
||||
return (
|
||||
<p className="mb-3 flex items-center gap-3 font-mono text-[11px] text-[#57694e]">
|
||||
<span>
|
||||
<span className="font-bold text-[#1e3a8a]">ADMIN</span>
|
||||
<span className="text-[#86868b]"> — manage headgates, users, entries</span>
|
||||
</span>
|
||||
<span className="text-[#d4d9d3]">·</span>
|
||||
<span>
|
||||
<span className="font-bold text-[#15803d]">IRRIGATOR</span>
|
||||
<span className="text-[#86868b]"> — submit entries only</span>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmptyState ──────────────────────────────────────────────────────────────
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-[#d4d9d3] bg-white px-6 py-10 text-center">
|
||||
<div className="mx-auto mb-3 flex w-12 items-center justify-center text-[#86868b]">
|
||||
{icon}
|
||||
</div>
|
||||
<p
|
||||
className="text-base font-semibold text-[#1d1d1f]"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-[#57694e]">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Select ──────────────────────────────────────────────────────────────────
|
||||
export function Select({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: (string | { value: string; label: string })[];
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||||
{label}
|
||||
</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
|
||||
>
|
||||
{options.map((o) => {
|
||||
if (typeof o === "string") {
|
||||
return (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FilterChip ──────────────────────────────────────────────────────────────
|
||||
export function FilterChip({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-[#d4d9d3] bg-[#fafaf7] px-2 py-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[#86868b]">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-mono text-[11px] font-bold text-white"
|
||||
style={{ background: bg }}
|
||||
aria-hidden
|
||||
>
|
||||
{initials || "·"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MonthSelect / DayInput ──────────────────────────────────────────────────
|
||||
export function MonthSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (m: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className="flex-1 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
|
||||
aria-label="Month"
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<option key={i + 1} value={i + 1}>
|
||||
{new Date(0, i).toLocaleString("en", { month: "short" })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (d: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={value}
|
||||
onChange={(e) => 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 (
|
||||
<th
|
||||
className={`px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)] ${className}`}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({
|
||||
children,
|
||||
className = "",
|
||||
colSpan,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
colSpan?: number;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className={`px-3 py-2 text-sm text-[var(--admin-text-primary)] ${className}`}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<GlassModal
|
||||
title="Daily water report — preview"
|
||||
subtitle={subtitle}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Meta line */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--admin-text-muted)]">
|
||||
<span>
|
||||
<span className="font-semibold text-[var(--admin-text-secondary)]">
|
||||
Date:
|
||||
</span>{" "}
|
||||
{dateLabel}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-[var(--admin-border)]">·</span>
|
||||
<span className="font-mono text-[var(--admin-text-secondary)]">
|
||||
{dateIso}
|
||||
</span>
|
||||
{recipientName ? (
|
||||
<>
|
||||
<span className="hidden sm:inline text-[var(--admin-border)]">
|
||||
·
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold text-[var(--admin-text-secondary)]">
|
||||
Recipient:
|
||||
</span>{" "}
|
||||
{recipientName}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Preview banner — keep it clear this hasn't been sent */}
|
||||
<div
|
||||
className="rounded-lg px-3 py-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-warning-bg, #fef9c3)",
|
||||
color: "var(--admin-warning-text, #a16207)",
|
||||
}}
|
||||
>
|
||||
Preview only. No message has been sent. Daily-report delivery is
|
||||
handled by the scheduled{" "}
|
||||
<code className="font-mono">api/cron/water-report</code> endpoint.
|
||||
</div>
|
||||
|
||||
{/* Data table */}
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--admin-border)]">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: "var(--admin-bg-subtle)" }}>
|
||||
<Th className="w-[72px]">Time</Th>
|
||||
<Th>Headgate</Th>
|
||||
<Th>Irrigator</Th>
|
||||
<Th className="w-[88px] text-right">Volume</Th>
|
||||
<Th>Note</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const time = new Date(row.logged_at).toLocaleTimeString(
|
||||
"en-US",
|
||||
{ hour: "numeric", minute: "2-digit" },
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-t border-[var(--admin-border-light)]"
|
||||
>
|
||||
<Td className="font-mono text-[var(--admin-text-secondary)]">
|
||||
{time}
|
||||
</Td>
|
||||
<Td className="font-medium text-[var(--admin-text-primary)]">
|
||||
{row.headgate_name}
|
||||
</Td>
|
||||
<Td className="text-[var(--admin-text-secondary)]">
|
||||
{row.user_name}
|
||||
</Td>
|
||||
<Td className="text-right">
|
||||
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">
|
||||
{row.measurement.toFixed(2)}
|
||||
</span>{" "}
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{row.unit}
|
||||
</span>
|
||||
</Td>
|
||||
<Td className="text-[var(--admin-text-muted)]">
|
||||
{row.notes ?? <span className="opacity-40">—</span>}
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr
|
||||
className="border-t-2"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
<Td
|
||||
className="!border-0 pt-2 text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]"
|
||||
colSpan={3}
|
||||
>
|
||||
Total
|
||||
</Td>
|
||||
<Td className="!border-0 pt-2 text-right">
|
||||
<span className="font-mono font-bold text-[var(--admin-text-primary)]">
|
||||
{total.toFixed(2)}
|
||||
</span>{" "}
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{totalUnit}
|
||||
</span>
|
||||
</Td>
|
||||
<Td className="!border-0 pt-2">
|
||||
<span className="opacity-40">—</span>
|
||||
</Td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col-reverse items-stretch gap-2 pt-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="rounded-lg bg-[var(--admin-accent)] px-4 py-2 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||
>
|
||||
{copied ? "Copied" : "Copy plain text"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={`fixed bottom-6 right-6 z-50 rounded-xl px-4 py-3 font-medium shadow-lg transition-all ${
|
||||
toast.kind === "ok"
|
||||
? "border border-[#15803d] bg-[#dcfce7] text-[#15803d]"
|
||||
: "border border-[#b91c1c] bg-[#fee2e2] text-[#b91c1c]"
|
||||
}`}
|
||||
>
|
||||
{toast.msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
>
|
||||
<rect x="2" y="2" width="5" height="5" />
|
||||
<rect x="9" y="2" width="5" height="5" />
|
||||
<rect x="2" y="9" width="5" height="5" />
|
||||
<path d="M9 9 H11 V11 H9 Z" />
|
||||
<path d="M12 9 H14 V12" />
|
||||
<path d="M9 12 V14 H14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearIcon() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
>
|
||||
<path d="M2 11 L8 5 L11 8 L14 5" />
|
||||
<path d="M2 14 H14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
>
|
||||
<rect x="3" y="2" width="10" height="12" rx="1" />
|
||||
<path d="M5 5 H11 M5 8 H11 M5 11 H8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
>
|
||||
<path d="M8 2 V10 M5 7 L8 10 L11 7" />
|
||||
<path d="M2 12 V14 H14 V12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#1a4d2e"
|
||||
strokeWidth={1.6}
|
||||
>
|
||||
<circle cx="8" cy="12" r="4" />
|
||||
<path d="M12 12 H22 M18 12 V16 M22 12 V15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={36}
|
||||
height={36}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21 C4 16 8 14 12 14 C16 14 20 16 20 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={36}
|
||||
height={36}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||
<path d="M3 10 H21 M9 4 V20" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClockIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<path d="M8 4 V8 L11 10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user