29d9d23a26
- CartContext: lazy initializers replace mount-only useEffect hydration; remove 8 no-initialize-state warnings - Toast/AdminSearchInput: React 19 useContext/use + drop forwardRef (3 no-react19-deprecated-apis) - ProductFormModal: lazy initializers + useSyncExternalStore for mount; parent adds key=editingProduct.id - InstallPrompt: useReducer for prompt state (no-cascading-set-state) - QRScanModal: ref-based latest-callback pattern replaces useEffectEvent deps mistake - OnboardingFlow: functional setState (rerender-functional-setstate) - UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers (rerender-lazy-state-init) - FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic in layout; remove supabase import - LandingPageWrapper: href='#' → href='#top' (anchor-is-valid) - TuxedoVideoHero: replace animate-bounce with ease-out-expo (no-inline-bounce-easing) - ProductTableClient: useCallback for handleDeleted (jsx-no-new-function-as-prop) - excel-parser: pre-compile delimiter regexes (js-hoist-regexp) - water-log/settings: Promise.all for parallel DB calls (async-parallel) - ToastNotification: extract toast store to separate file (only-export-components) - WholesaleClient: inline <WholesaleIcon/> instead of hoisting to const (rendering-hoist-jsx)
1410 lines
48 KiB
TypeScript
1410 lines
48 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* WaterLogAdminPanel — the main Water Log admin dashboard.
|
||
*
|
||
* Visual language: "Field Almanac." A rugged, light-themed almanac of
|
||
* the day's water use. The aesthetic borrows from USDA agricultural
|
||
* bulletins and old USGS survey markers — serif display type (Fraunces)
|
||
* for titles, mono (Fragment Mono) for measurements, and a single
|
||
* custom SVG (WaterGauge) that becomes the visual signature of the
|
||
* whole feature.
|
||
*
|
||
* - No purple-gradient SaaS look.
|
||
* - No glassmorphism. Strong borders, not soft shadows.
|
||
* - Field-notebook tone: precise, agricultural, slightly utilitarian.
|
||
* - Cream + forest + clay palette pulled from the project's existing
|
||
* `forest` / `sage` / `gold` tokens so the panel sits naturally
|
||
* inside the rest of /admin.
|
||
*
|
||
* The component is "use client" because it owns the filters and the
|
||
* entry-adding modals. All DB access happens through server actions in
|
||
* `src/actions/water-log/admin.ts`.
|
||
*/
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
createWaterHeadgate,
|
||
createWaterUser,
|
||
deleteWaterHeadgate,
|
||
deleteWaterUser,
|
||
getWaterEntries,
|
||
regenerateHeadgateToken,
|
||
resetWaterIrrigatorPin,
|
||
type AdminEntry,
|
||
type AdminHeadgate,
|
||
type AdminIrrigator,
|
||
} from "@/actions/water-log/admin";
|
||
import {
|
||
downloadWaterLogCSV,
|
||
filterWaterLogEntries,
|
||
formatDailyWaterReport,
|
||
isInIrrigationSeason,
|
||
shapeWaterLogEntry,
|
||
type IrrigationSeasonSettings,
|
||
type WaterLogFilter,
|
||
type WaterLogReportRow,
|
||
} from "@/lib/water-log-reporting";
|
||
import { WaterGauge, SeasonMark } from "@/components/water/icons";
|
||
import { AdminButton } from "./design-system";
|
||
import { formatDate, formatDateTime } from "@/lib/format-date";
|
||
|
||
type Props = {
|
||
initialUsers: AdminIrrigator[];
|
||
initialHeadgates: AdminHeadgate[];
|
||
initialEntries: AdminEntry[];
|
||
brandId: string;
|
||
canManage: boolean;
|
||
};
|
||
|
||
const DEFAULT_SEASON: IrrigationSeasonSettings = {
|
||
seasonStartMonth: 3,
|
||
seasonStartDay: 15,
|
||
seasonEndMonth: 10,
|
||
seasonEndDay: 15,
|
||
};
|
||
|
||
export default function WaterLogAdminPanel({
|
||
initialUsers,
|
||
initialHeadgates,
|
||
initialEntries,
|
||
brandId,
|
||
canManage,
|
||
}: Props) {
|
||
const router = useRouter();
|
||
const [headgates, setHeadgates] = useState<AdminHeadgate[]>(initialHeadgates);
|
||
const [users, setUsers] = useState<AdminIrrigator[]>(initialUsers);
|
||
const [entries] = useState<AdminEntry[]>(initialEntries);
|
||
const [toast, setToast] = useState<{ msg: string; kind: "ok" | "err" } | null>(null);
|
||
|
||
// ── Forms ──────────────────────────────────────────────────────────
|
||
const [showAddHg, setShowAddHg] = useState(false);
|
||
const [newHg, setNewHg] = useState({ name: "", unit: "CFS", notes: "" });
|
||
const [savingHg, setSavingHg] = useState(false);
|
||
|
||
const [showAddUser, setShowAddUser] = useState(false);
|
||
const [newUser, setNewUser] = useState({
|
||
name: "",
|
||
role: "irrigator" as "irrigator" | "water_admin",
|
||
lang: "en",
|
||
phone: "",
|
||
});
|
||
const [savingUser, setSavingUser] = useState(false);
|
||
const [newPin, setNewPin] = useState<{ name: string; pin: string } | null>(null);
|
||
const [resetPin, setResetPin] = useState<{ name: string; pin: string } | null>(
|
||
null,
|
||
);
|
||
|
||
// ── Filters ────────────────────────────────────────────────────────
|
||
const [filterDateFrom, setFilterDateFrom] = useState("");
|
||
const [filterDateTo, setFilterDateTo] = useState("");
|
||
const [filterHeadgate, setFilterHeadgate] = useState("");
|
||
const [filterUser, setFilterUser] = useState("");
|
||
const [filterVia, setFilterVia] = useState("");
|
||
const [csvLoading, setCsvLoading] = useState(false);
|
||
|
||
// `today` and the human-readable date are read from the clock; compute
|
||
// them in the browser only to avoid hydration mismatches. The setStates
|
||
// are wrapped in an async IIFE so the effect body does not call setState
|
||
// synchronously (avoids the cascading-renders ESLint rule).
|
||
const [today, setToday] = useState<string>("");
|
||
const [todayLabel, setTodayLabel] = useState<string>("");
|
||
useEffect(() => {
|
||
void (async () => {
|
||
const now = new Date();
|
||
setToday(now.toISOString().slice(0, 10));
|
||
setTodayLabel(formatDate(now));
|
||
})();
|
||
}, []);
|
||
|
||
// ── Season + report settings (localStorage) ───────────────────────
|
||
const [showReportSettings, setShowReportSettings] = useState(false);
|
||
const [seasonStart, setSeasonStart] = useState<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;
|
||
}
|
||
});
|
||
const [recipientName, setRecipientName] = useState(() =>
|
||
typeof window !== "undefined" ? localStorage.getItem("wl_recipient_name") ?? "" : "",
|
||
);
|
||
|
||
// ── Derived data ──────────────────────────────────────────────────
|
||
const inSeason = useMemo(
|
||
() => isInIrrigationSeason(new Date(), seasonStart),
|
||
[seasonStart],
|
||
);
|
||
|
||
const todayEntries = useMemo(
|
||
() =>
|
||
today
|
||
? entries.filter((e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today)
|
||
: [],
|
||
[entries, today],
|
||
);
|
||
const todayTotal = todayEntries.reduce((s, e) => s + e.measurement, 0);
|
||
|
||
const lastEntryByHeadgate = useMemo(() => {
|
||
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;
|
||
}, [entries]);
|
||
|
||
const filteredEntries = useMemo(() => {
|
||
return entries.filter((e) => {
|
||
if (filterDateFrom && e.logged_at < filterDateFrom) return false;
|
||
if (filterDateTo && e.logged_at > filterDateTo + "T23:59:59.999Z") return false;
|
||
if (filterHeadgate && e.headgate_id !== filterHeadgate) return false;
|
||
if (filterUser && e.user_id !== filterUser) return false;
|
||
if (filterVia && e.submitted_via !== filterVia) return false;
|
||
return true;
|
||
});
|
||
}, [entries, filterDateFrom, filterDateTo, filterHeadgate, filterUser, filterVia]);
|
||
|
||
// ── Toast helper ──────────────────────────────────────────────────
|
||
function notify(msg: string, kind: "ok" | "err" = "ok") {
|
||
setToast({ msg, kind });
|
||
setTimeout(() => setToast(null), 2800);
|
||
}
|
||
|
||
// ── Handlers ──────────────────────────────────────────────────────
|
||
async function handleAddHg(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!newHg.name.trim()) return;
|
||
setSavingHg(true);
|
||
const res = await createWaterHeadgate(brandId, newHg.name.trim(), newHg.unit, {
|
||
notes: newHg.notes.trim() || null,
|
||
});
|
||
if (res.success && res.headgate) {
|
||
setHeadgates((prev) => [res.headgate!, ...prev]);
|
||
setNewHg({ name: "", unit: "CFS", notes: "" });
|
||
setShowAddHg(false);
|
||
notify("Headgate added");
|
||
} else {
|
||
notify(res.error ?? "Failed", "err");
|
||
}
|
||
setSavingHg(false);
|
||
}
|
||
|
||
async function handleDeleteHg(h: AdminHeadgate) {
|
||
if (!window.confirm(`Delete "${h.name}"? Existing log entries will be preserved.`)) return;
|
||
const res = await deleteWaterHeadgate(h.id);
|
||
if (res.success) {
|
||
setHeadgates((prev) => prev.filter((x) => x.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) {
|
||
setHeadgates((prev) =>
|
||
prev.map((x) => (x.id === h.id ? { ...x, headgate_token: res.token! } : x)),
|
||
);
|
||
notify("Token regenerated");
|
||
} else {
|
||
notify(res.error ?? "Failed", "err");
|
||
}
|
||
}
|
||
|
||
async function handleAddUser(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!newUser.name.trim()) return;
|
||
setSavingUser(true);
|
||
const res = await createWaterUser(
|
||
brandId,
|
||
newUser.name.trim(),
|
||
newUser.role,
|
||
newUser.lang,
|
||
newUser.phone.trim() || null,
|
||
);
|
||
if (res.success && res.user && res.pin) {
|
||
setUsers((prev) => [res.user!, ...prev]);
|
||
setNewPin({ name: res.user.name, pin: res.pin });
|
||
setNewUser({ name: "", role: "irrigator", lang: "en", phone: "" });
|
||
setShowAddUser(false);
|
||
notify("User created");
|
||
} else {
|
||
notify(res.error ?? "Failed", "err");
|
||
}
|
||
setSavingUser(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) {
|
||
setResetPin({ name: u.name, pin: res.pin });
|
||
notify("PIN reset");
|
||
} else {
|
||
notify(res.error ?? "Failed", "err");
|
||
}
|
||
}
|
||
|
||
async function handleDeleteUser(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) {
|
||
setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, active: false } : x)));
|
||
notify("User deactivated");
|
||
} else {
|
||
notify(res.error ?? "Failed", "err");
|
||
}
|
||
}
|
||
|
||
async function handlePreviewReport() {
|
||
const all = await getWaterEntries(brandId, 1000);
|
||
const todayRows: WaterLogReportRow[] = all
|
||
.filter(
|
||
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today,
|
||
)
|
||
.map(shapeWaterLogEntry);
|
||
const report = formatDailyWaterReport(todayRows, {
|
||
recipientName: recipientName || undefined,
|
||
previewMode: true,
|
||
});
|
||
if (!report) {
|
||
notify(`No entries today — report would be skipped (season: ${inSeason ? "in" : "out"}).`);
|
||
return;
|
||
}
|
||
// The browser's `alert` blocks; the modal preview is the better UX in
|
||
// a future iteration, but alert is dependency-free today.
|
||
window.alert(`Daily Report — ${inSeason ? "in season" : "out of season"}\n\n${report}`);
|
||
}
|
||
|
||
async function handleExportCSV() {
|
||
setCsvLoading(true);
|
||
try {
|
||
const all = await getWaterEntries(brandId, 5000);
|
||
const shaped: WaterLogReportRow[] = all.map(shapeWaterLogEntry);
|
||
const filters: WaterLogFilter = {
|
||
dateFrom: filterDateFrom || undefined,
|
||
dateTo: filterDateTo || undefined,
|
||
headgateId: filterHeadgate || undefined,
|
||
userId: filterUser || undefined,
|
||
submittedVia: filterVia || undefined,
|
||
};
|
||
const filtered = filterWaterLogEntries(shaped, filters);
|
||
downloadWaterLogCSV(`water-log-${today}.csv`, filtered);
|
||
notify(`Exported ${filtered.length} entries`);
|
||
} finally {
|
||
setCsvLoading(false);
|
||
}
|
||
}
|
||
|
||
function persistSeason(s: IrrigationSeasonSettings) {
|
||
setSeasonStart(s);
|
||
try {
|
||
localStorage.setItem("wl_season_settings:v1", JSON.stringify(s));
|
||
} catch {}
|
||
}
|
||
|
||
// ── Render ────────────────────────────────────────────────────────
|
||
return (
|
||
<div className="relative">
|
||
{/* Top tabs */}
|
||
<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>
|
||
|
||
{/* ── Today's Summary ─────────────────────────────────────── */}
|
||
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
|
||
{/* Subtle topographic pattern overlay */}
|
||
<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 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={handlePreviewReport}
|
||
>
|
||
Preview Report
|
||
</AdminButton>
|
||
<AdminButton
|
||
variant={showReportSettings ? "primary" : "secondary"}
|
||
size="sm"
|
||
icon={<GearIcon />}
|
||
onClick={() => setShowReportSettings((v) => !v)}
|
||
>
|
||
Report Settings
|
||
</AdminButton>
|
||
</div>
|
||
|
||
{showReportSettings && (
|
||
<ReportSettingsPanel
|
||
season={seasonStart}
|
||
onSeasonChange={persistSeason}
|
||
recipientName={recipientName}
|
||
onRecipientNameChange={(v) => {
|
||
setRecipientName(v);
|
||
try {
|
||
localStorage.setItem("wl_recipient_name", v);
|
||
} catch {}
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ── Headgates ───────────────────────────────────────────── */}
|
||
<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={() => setShowAddHg((v) => !v)}>
|
||
{showAddHg ? "Cancel" : "+ Add Headgate"}
|
||
</AdminButton>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
{showAddHg && canManage && (
|
||
<form
|
||
onSubmit={handleAddHg}
|
||
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) => setNewHg((s) => ({ ...s, name: v }))}
|
||
placeholder="e.g. North Field Gate 1"
|
||
required
|
||
autoFocus
|
||
/>
|
||
<Select
|
||
label="Unit"
|
||
value={newHg.unit}
|
||
onChange={(v) => setNewHg((s) => ({ ...s, unit: v }))}
|
||
options={["CFS", "GPM", "Inches", "AF/Day", "gal", "ac-ft"]}
|
||
/>
|
||
<Input
|
||
label="Notes"
|
||
value={newHg.notes}
|
||
onChange={(v) => setNewHg((s) => ({ ...s, notes: 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={() => router.push(`/admin/water-log/headgates/${h.id}`)}
|
||
className="font-medium text-[#1a4d2e] hover:underline"
|
||
>
|
||
Edit →
|
||
</button>
|
||
<div className="flex items-center gap-2">
|
||
<button type="button"
|
||
onClick={() => handleRegenerateToken(h)}
|
||
className="text-[#57694e] hover:text-[#1a4d2e]"
|
||
>
|
||
Rotate token
|
||
</button>
|
||
<span className="text-[#d4d9d3]">·</span>
|
||
<button type="button"
|
||
onClick={() => handleDeleteHg(h)}
|
||
className="text-[#b91c1c] hover:text-[#7f1d1d]"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
|
||
{/* ── Water Users ──────────────────────────────────────────── */}
|
||
<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={() => setShowAddUser((v) => !v)}>
|
||
{showAddUser ? "Cancel" : "+ Add User"}
|
||
</AdminButton>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
{newPin && (
|
||
<PinBanner
|
||
title={`New PIN for ${newPin.name}`}
|
||
pin={newPin.pin}
|
||
onDismiss={() => setNewPin(null)}
|
||
/>
|
||
)}
|
||
{resetPin && (
|
||
<PinBanner
|
||
title={`Reset PIN for ${resetPin.name}`}
|
||
pin={resetPin.pin}
|
||
tone="warn"
|
||
onDismiss={() => setResetPin(null)}
|
||
/>
|
||
)}
|
||
|
||
{showAddUser && canManage && (
|
||
<form
|
||
onSubmit={handleAddUser}
|
||
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) => setNewUser((s) => ({ ...s, name: v }))}
|
||
placeholder="Full name"
|
||
required
|
||
autoFocus
|
||
/>
|
||
<Select
|
||
label="Role"
|
||
value={newUser.role}
|
||
onChange={(v) =>
|
||
setNewUser((s) => ({ ...s, role: v as "irrigator" | "water_admin" }))
|
||
}
|
||
options={[
|
||
{ value: "irrigator", label: "Irrigator" },
|
||
{ value: "water_admin", label: "Admin" },
|
||
]}
|
||
/>
|
||
<Select
|
||
label="Language"
|
||
value={newUser.lang}
|
||
onChange={(v) => setNewUser((s) => ({ ...s, lang: v }))}
|
||
options={[
|
||
{ value: "en", label: "English" },
|
||
{ value: "es", label: "Español" },
|
||
]}
|
||
/>
|
||
<Input
|
||
label="Phone"
|
||
value={newUser.phone}
|
||
onChange={(v) => setNewUser((s) => ({ ...s, phone: 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]"
|
||
>
|
||
PIN
|
||
</button>
|
||
<span className="text-[#d4d9d3]">·</span>
|
||
<button type="button"
|
||
onClick={() => handleDeleteUser(u)}
|
||
className="text-[#b91c1c] hover:text-[#7f1d1d]"
|
||
>
|
||
Off
|
||
</button>
|
||
</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{/* ── Recent Entries ──────────────────────────────────────── */}
|
||
<SectionHeader
|
||
index="§ 03"
|
||
title="Recent Entries"
|
||
subtitle="The last {n} readings logged from the field or the admin panel. Use the filters to narrow, then export to CSV."
|
||
action={
|
||
<AdminButton
|
||
size="sm"
|
||
onClick={handleExportCSV}
|
||
isLoading={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={filterDateFrom}
|
||
onChange={(e) => setFilterDateFrom(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={filterDateTo}
|
||
onChange={(e) => setFilterDateTo(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={filterHeadgate}
|
||
onChange={(e) => setFilterHeadgate(e.target.value)}
|
||
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||
aria-label="Filter: headgate"
|
||
>
|
||
<option value="">All</option>
|
||
{headgates.map((h) => (
|
||
<option key={h.id} value={h.id}>
|
||
{h.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</FilterChip>
|
||
<FilterChip label="User">
|
||
<select
|
||
value={filterUser}
|
||
onChange={(e) => setFilterUser(e.target.value)}
|
||
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
|
||
aria-label="Filter: user"
|
||
>
|
||
<option value="">All</option>
|
||
{users.map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</FilterChip>
|
||
<FilterChip label="Via">
|
||
<select
|
||
value={filterVia}
|
||
onChange={(e) => setFilterVia(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>
|
||
{(filterDateFrom ||
|
||
filterDateTo ||
|
||
filterHeadgate ||
|
||
filterUser ||
|
||
filterVia) && (
|
||
<button type="button"
|
||
onClick={() => {
|
||
setFilterDateFrom("");
|
||
setFilterDateTo("");
|
||
setFilterHeadgate("");
|
||
setFilterUser("");
|
||
setFilterVia("");
|
||
}}
|
||
className="text-xs font-medium text-[#57694e] hover:text-[#1a4d2e] hover:underline"
|
||
>
|
||
Clear filters
|
||
</button>
|
||
)}
|
||
<span className="ml-auto font-mono text-[11px] text-[#86868b]">
|
||
{filteredEntries.length} of {entries.length} entries
|
||
</span>
|
||
</div>
|
||
|
||
{filteredEntries.length === 0 ? (
|
||
<EmptyState
|
||
icon={<TableIcon />}
|
||
title="No entries match"
|
||
body={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"></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={() =>
|
||
router.push(`/admin/water-log/entries/${e.id}`)
|
||
}
|
||
className="font-medium text-[#1a4d2e] opacity-0 transition-opacity group-hover:opacity-100 hover:underline"
|
||
>
|
||
Edit →
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Toast */}
|
||
{toast && (
|
||
<div
|
||
role="status"
|
||
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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Sub-components ──────────────────────────────────────────────────────
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function StatusPill({ kind }: { kind: "active" | "inactive" | "closed" | "warn" }) {
|
||
const map = {
|
||
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;
|
||
const s = map[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>
|
||
);
|
||
}
|
||
|
||
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"
|
||
>
|
||
Dismiss
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function Input({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
required,
|
||
autoFocus,
|
||
type = "text",
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
required?: boolean;
|
||
autoFocus?: 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}
|
||
autoFocus={autoFocus}
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function Avatar({ name, role }: { name: string; role: string }) {
|
||
const initials = name
|
||
.split(/\s+/)
|
||
.map((n) => n[0])
|
||
.filter(Boolean)
|
||
.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>
|
||
);
|
||
}
|
||
|
||
function ReportSettingsPanel({
|
||
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">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<p className="font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||
Report / Messaging Settings
|
||
</p>
|
||
</div>
|
||
<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">
|
||
<div>
|
||
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||
Season start
|
||
</span>
|
||
<div className="flex gap-1">
|
||
<MonthSelect
|
||
value={season.seasonStartMonth}
|
||
onChange={(m) => onSeasonChange({ ...season, seasonStartMonth: m })}
|
||
/>
|
||
<DayInput
|
||
value={season.seasonStartDay}
|
||
onChange={(d) => onSeasonChange({ ...season, seasonStartDay: d })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||
Season end
|
||
</span>
|
||
<div className="flex gap-1">
|
||
<MonthSelect
|
||
value={season.seasonEndMonth}
|
||
onChange={(m) => onSeasonChange({ ...season, seasonEndMonth: m })}
|
||
/>
|
||
<DayInput
|
||
value={season.seasonEndDay}
|
||
onChange={(d) => onSeasonChange({ ...season, seasonEndDay: d })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
|
||
Recipient name (used in report salutation)
|
||
</span>
|
||
<input aria-label="David"
|
||
type="text"
|
||
value={recipientName}
|
||
onChange={(e) => onRecipientNameChange(e.target.value)}
|
||
placeholder="David"
|
||
className="w-full rounded-lg border border-[#d4d9d3] px-3 py-2 text-sm outline-none focus:border-[#1a4d2e]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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"
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ─── Icons (16–20px, currentColor) ──────────────────────────────────────
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|