Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
@@ -0,0 +1,130 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getWaterAdminSession } from "@/actions/water-log/field";
import { getWaterEntryById } from "@/actions/water-log/admin";
import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm";
import Link from "next/link";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
type EntryPageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ from?: string }>;
};
export default async function WaterLogEntryPage({ params, searchParams }: EntryPageProps) {
const { id } = await params;
const { from } = await searchParams;
const adminUser = await getAdminUser();
const waterSession = await getWaterAdminSession();
const isSiteAdmin =
adminUser?.role === "platform_admin" ||
(adminUser?.role === "brand_admin" &&
adminUser?.brand_id === TUXEDO_BRAND_ID &&
adminUser?.can_manage_water_log);
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
if (!isSiteAdmin && !isWaterAdmin) {
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-400">Access Denied</h1>
<p className="mt-2 text-zinc-400">You do not have permission to edit water log entries.</p>
<a href={from ?? "/admin/water-log"} className="mt-4 inline-block text-zinc-400 hover:text-zinc-100">
Back
</a>
</div>
</main>
);
}
const entry = await getWaterEntryById(id);
if (!entry) {
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-400">Entry not found</h1>
<p className="mt-2 text-zinc-400">This entry may have been deleted.</p>
<a href={from ?? "/admin/water-log"} className="mt-4 inline-block text-zinc-400 hover:text-zinc-100">
Back
</a>
</div>
</main>
);
}
const backHref = from ?? "/admin/water-log";
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12">
<div className="mx-auto max-w-4xl">
<Link
href={backHref}
className="text-sm text-zinc-500 hover:text-zinc-300"
>
Back
</Link>
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-zinc-500">Water Log Entry</p>
<h1 className="mt-2 text-3xl font-bold text-zinc-100">{entry.headgate_name}</h1>
<p className="mt-1 text-zinc-400">
Submitted by {entry.user_name}
</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
entry.submitted_via === "field"
? "bg-blue-900/40 text-blue-700"
: "bg-zinc-950 text-zinc-400"
}`}
>
{entry.submitted_via}
</span>
</div>
<div className="mt-6 grid grid-cols-3 gap-6">
<div>
<p className="text-sm font-medium text-zinc-500">Measurement</p>
<p className="mt-1 text-2xl font-bold text-zinc-100">
{entry.measurement} {entry.unit}
</p>
</div>
<div>
<p className="text-sm font-medium text-zinc-500">When</p>
<p className="mt-1 text-lg font-semibold text-zinc-100">
{new Date(entry.logged_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</p>
</div>
<div>
<p className="text-sm font-medium text-zinc-500">Notes</p>
<p className="mt-1 text-zinc-100">{entry.notes ?? "—"}</p>
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
<h2 className="text-2xl font-bold text-zinc-100">Edit Entry</h2>
<p className="mt-1 text-zinc-400">
Update measurement or notes. User, headgate, and timestamp cannot be changed.
</p>
<div className="mt-6">
<WaterLogEntryEditForm entry={entry} brandId={TUXEDO_BRAND_ID} backHref={backHref} />
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,537 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
getWaterHeadgatesAdmin,
createWaterHeadgate,
regenerateHeadgateToken,
updateWaterHeadgate,
} from "@/actions/water-log/admin";
type Headgate = {
id: string;
name: string;
active: boolean;
unit: string;
created_at: string;
deleted_at?: string | null;
headgate_token?: string | null;
last_used_at?: string | null;
high_threshold?: number | null;
low_threshold?: number | null;
};
type Props = {
initialHeadgates: Headgate[];
brandId: string;
};
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
const router = useRouter();
const [headgates, setHeadgates] = useState<Headgate[]>(initialHeadgates);
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState("");
const [newUnit, setNewUnit] = useState("CFS");
const [saving, setSaving] = useState(false);
const [qrModal, setQrModal] = useState<Headgate | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [printLoading, setPrintLoading] = useState(false);
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
// Edit modal state
const [editHg, setEditHg] = useState<Headgate | null>(null);
const [editName, setEditName] = useState("");
const [editUnit, setEditUnit] = useState("CFS");
const [editActive, setEditActive] = useState(true);
const [editHigh, setEditHigh] = useState("");
const [editLow, setEditLow] = useState("");
const [editSaving, setEditSaving] = useState(false);
function showToast(msg: string, ok = true) {
setToast({ msg, ok });
setTimeout(() => setToast(null), 2500);
}
function openEdit(hg: Headgate) {
setEditHg(hg);
setEditName(hg.name);
setEditUnit(hg.unit);
setEditActive(hg.active);
setEditHigh(hg.high_threshold != null ? String(hg.high_threshold) : "");
setEditLow(hg.low_threshold != null ? String(hg.low_threshold) : "");
}
async function handleEditSave(e: React.FormEvent) {
e.preventDefault();
if (!editHg) return;
setEditSaving(true);
const res = await updateWaterHeadgate(
editHg.id,
editName,
editActive,
editUnit,
editHigh ? parseFloat(editHigh) : null,
editLow ? parseFloat(editLow) : null
);
if (res.success) {
const refreshed = await getWaterHeadgatesAdmin(brandId);
setHeadgates(refreshed);
setEditHg(null);
showToast("Headgate updated");
} else {
showToast(res.error ?? "Failed", false);
}
setEditSaving(false);
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
setSaving(true);
const res = await createWaterHeadgate(brandId, newName.trim(), newUnit);
if (res.success) {
const refreshed = await getWaterHeadgatesAdmin(brandId);
setHeadgates(refreshed);
setNewName("");
setShowAdd(false);
showToast("Headgate added");
} else {
showToast(res.error ?? "Failed", false);
}
setSaving(false);
}
async function handleRegenerate(hg: Headgate) {
if (!confirm(`Regenerate token for "${hg.name}"? Existing QR codes will stop working.`)) return;
const res = await regenerateHeadgateToken(hg.id);
if (res.success) {
setHeadgates((prev) =>
prev.map((h) => (h.id === hg.id ? { ...h, headgate_token: res.token! } : h))
);
showToast("Token regenerated");
} else {
showToast(res.error ?? "Failed", false);
}
}
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleAll() {
if (selected.size === headgates.length) {
setSelected(new Set());
} else {
setSelected(new Set(headgates.map((h) => h.id)));
}
}
async function printSelected() {
if (selected.size === 0) return;
setPrintLoading(true);
const hgs = headgates.filter((h) => selected.has(h.id));
try {
const res = await fetch("/api/water-qr-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ headgates: hgs, baseUrl: BASE_URL }),
});
const html = await res.text();
openPrintWindow(html);
} finally {
setPrintLoading(false);
}
}
async function openPrintWindow(html: string) {
const w = window.open("", "_blank", "width=700,height=700");
if (w) {
w.document.write(html);
w.document.close();
}
}
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
<div className="mx-auto max-w-4xl flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<button onClick={() => router.back()} className="text-stone-400 hover:text-stone-600 text-sm"> Back</button>
<h1 className="text-2xl font-bold text-zinc-100">Headgates & QR Codes</h1>
</div>
<p className="text-xs text-stone-500 mt-0.5">Manage headgates and generate QR codes for field access</p>
</div>
<button onClick={() => setShowAdd(true)} className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">
+ Add Headgate
</button>
</div>
</div>
<div className="mx-auto max-w-4xl px-6 py-6 space-y-6">
{/* Bulk actions bar */}
{headgates.length > 0 && (
<div className="flex items-center gap-3 rounded-xl bg-zinc-900 ring-1 ring-stone-200 px-4 py-3">
<button onClick={toggleAll} className="text-sm text-stone-600 hover:text-zinc-100">
{selected.size === headgates.length ? "Deselect all" : "Select all"}
</button>
<span className="text-xs text-stone-400">|</span>
<span className="text-xs text-stone-500">{selected.size} selected</span>
<div className="flex-1" />
<button
onClick={printSelected}
disabled={selected.size === 0 || printLoading}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40 hover:bg-blue-700"
>
{printLoading ? "..." : `Print ${selected.size > 0 ? `(${selected.size})` : ""} QR Codes`}
</button>
</div>
)}
{/* Add form */}
{showAdd && (
<div className="rounded-xl bg-zinc-900 ring-1 ring-stone-200 p-5">
<form onSubmit={handleAdd} className="flex gap-3 items-end">
<div className="flex-1">
<label className="block text-xs font-semibold text-stone-600 mb-1">Headgate Name</label>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. North Field Gate 1"
className="w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-stone-900"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1">Unit</label>
<select
value={newUnit}
onChange={(e) => setNewUnit(e.target.value)}
className="rounded-xl border border-stone-300 px-3 py-3 text-sm outline-none focus:border-stone-900"
>
{["CFS", "GPM", "Inches", "AF/Day"].map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
<button type="submit" disabled={saving} className="rounded-xl bg-green-600 px-5 py-3 text-sm font-bold text-white disabled:opacity-50">
{saving ? "..." : "Create"}
</button>
<button type="button" onClick={() => setShowAdd(false)} className="rounded-xl border border-stone-300 px-4 py-3 text-sm text-stone-600">Cancel</button>
</form>
</div>
)}
{/* Table */}
{headgates.length === 0 ? (
<div className="rounded-xl bg-zinc-900 py-16 text-center text-stone-400 ring-1 ring-stone-200">
No headgates yet. Create one to get started.
</div>
) : (
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-stone-200 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-100 bg-zinc-950 text-left">
<th className="px-4 py-3 w-8">
<input type="checkbox" className="rounded" onChange={toggleAll} checked={selected.size === headgates.length} />
</th>
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Name</th>
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Token</th>
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Unit</th>
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Last Used</th>
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Status</th>
<th className="px-4 py-3 text-xs font-semibold text-stone-500 text-right">QR Code</th>
</tr>
</thead>
<tbody>
{headgates.map((hg) => (
<tr key={hg.id} className={`border-b border-stone-50 last:border-0 hover:bg-zinc-950 ${selected.has(hg.id) ? "bg-blue-900/30" : ""}`}>
<td className="px-4 py-3">
<input type="checkbox" className="rounded" checked={selected.has(hg.id)} onChange={() => toggleSelect(hg.id)} />
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-zinc-100">{hg.name}</span>
{hg.high_threshold != null && (
<span className="inline-flex items-center rounded-full bg-red-900/30 border border-red-200 px-1.5 py-0.5 text-xs font-semibold text-red-400">
High: {hg.high_threshold}
</span>
)}
{hg.low_threshold != null && (
<span className="inline-flex items-center rounded-full bg-blue-900/30 border border-blue-200 px-1.5 py-0.5 text-xs font-semibold text-blue-700">
Low: {hg.low_threshold}
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<code className="text-xs text-stone-500">{hg.headgate_token?.slice(0, 8)}</code>
</td>
<td className="px-4 py-3">
<span className="text-xs text-stone-500">{hg.unit}</span>
</td>
<td className="px-4 py-3">
<span className="text-xs text-stone-500">
{hg.last_used_at
? new Date(hg.last_used_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })
: "Never"}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${hg.active ? "bg-green-900/40 text-green-400" : "bg-stone-100 text-stone-500"}`}>
{hg.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEdit(hg)} className="rounded-lg border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs font-medium text-stone-600 hover:bg-zinc-950">
Edit
</button>
{/* QR preview thumbnail */}
<button onClick={() => setQrModal(hg)} className="hover:opacity-80" title="View / Print QR">
<img
src={`/api/water-qr?token=${hg.headgate_token}&size=80`}
alt={`QR for ${hg.name}`}
className="w-10 h-10 rounded border border-zinc-800"
/>
</button>
<button
onClick={() => setQrModal(hg)}
className="rounded-lg border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs font-medium text-stone-600 hover:bg-zinc-950"
>
Print
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Toast */}
{toast && (
<div className={`fixed bottom-6 right-6 rounded-xl px-4 py-3 text-sm font-semibold shadow-lg ${toast.ok ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}>
{toast.msg}
</div>
)}
{/* Edit Modal */}
{editHg && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setEditHg(null)}>
<div className="bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800">
<p className="font-bold text-zinc-100">Edit Headgate</p>
<button onClick={() => setEditHg(null)} className="text-stone-400 hover:text-stone-600 text-xl leading-none"></button>
</div>
<form onSubmit={handleEditSave} className="p-5 space-y-4">
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1">Name</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-stone-900"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1">Unit</label>
<select
value={editUnit}
onChange={(e) => setEditUnit(e.target.value)}
className="w-full rounded-xl border border-stone-300 px-3 py-3 text-sm outline-none focus:border-stone-900"
>
{["CFS", "GPM", "Inches", "AF/Day"].map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
<div className="flex items-center">
<label className="flex items-center gap-2 text-sm text-stone-700 cursor-pointer">
<input type="checkbox" checked={editActive} onChange={(e) => setEditActive(e.target.checked)} className="rounded" />
Active
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1">High Alert Threshold</label>
<input
type="number"
step="any"
value={editHigh}
onChange={(e) => setEditHigh(e.target.value)}
placeholder="e.g. 15.0"
className="w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
<div>
<label className="block text-xs font-semibold text-stone-600 mb-1">Low Alert Threshold</label>
<input
type="number"
step="any"
value={editLow}
onChange={(e) => setEditLow(e.target.value)}
placeholder="e.g. 5.0"
className="w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-stone-900"
/>
</div>
</div>
<p className="text-xs text-stone-400">Leave thresholds blank to disable alerts for this headgate.</p>
<div className="flex gap-2">
<button type="submit" disabled={editSaving} className="flex-1 rounded-xl bg-green-600 py-3 text-sm font-bold text-white disabled:opacity-50">
{editSaving ? "..." : "Save Changes"}
</button>
<button type="button" onClick={() => setEditHg(null)} className="rounded-xl border border-stone-300 px-4 py-3 text-sm text-stone-600">Cancel</button>
</div>
</form>
</div>
</div>
)}
{/* QR Modal */}
{qrModal && (
<QRModal hg={qrModal} onClose={() => setQrModal(null)} onRegenerate={handleRegenerate} />
)}
</div>
);
}
function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => void; onRegenerate: (hg: Headgate) => void }) {
const [tab, setTab] = useState<"preview" | "print" | "download">("preview");
const [loading, setLoading] = useState(false);
const code = hg.name.replace(/\s+/g, "-").toUpperCase().slice(0, 6) + "-1";
const qrUrl = `/api/water-qr?token=${hg.headgate_token}&size=360`;
const waterUrl = `${process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"}/water?h=${hg.headgate_token}`;
async function handlePrintLabel() {
setLoading(true);
try {
const res = await fetch("/api/water-qr-label", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: hg.headgate_token, name: hg.name }),
});
const html = await res.text();
const w = window.open("", "_blank", "width=500,height=600");
if (w) { w.document.write(html); w.document.close(); }
} finally {
setLoading(false);
}
}
async function handleDownload() {
setLoading(true);
try {
const res = await fetch(`/api/water-qr?token=${hg.headgate_token}&size=400`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${hg.name.replace(/\s+/g, "-").toLowerCase()}-qr.png`;
a.click();
URL.revokeObjectURL(url);
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<div>
<p className="font-bold text-zinc-100">{hg.name}</p>
<p className="text-xs text-slate-400 mt-0.5">QR Label Preview</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-zinc-400 text-xl leading-none"></button>
</div>
{/* ── Simplified label preview ── */}
<div className="flex justify-center bg-zinc-900 py-6 px-4">
{/* Mini version of the new label design */}
<div className="w-40 border-2 border-black rounded-lg p-3 text-center bg-zinc-900 shadow-black/20">
<div className="text-xl font-black text-black leading-tight tracking-tight">{hg.name}</div>
<div className="text-[9px] font-bold text-gray-500 uppercase tracking-widest mt-0.5 mb-3">{code}</div>
<div className="flex justify-center mb-2">
<img
src={`/api/water-qr?token=${hg.headgate_token}&size=160`}
alt="QR"
className="w-28 h-28 rounded"
/>
</div>
<div className="text-[5.5px] text-gray-300 leading-tight break-all">{waterUrl}</div>
</div>
</div>
{/* Tab toggle */}
<div className="flex border-b border-slate-100">
{(["preview", "print", "download"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${tab === t ? "text-zinc-100 border-b-2 border-slate-900" : "text-slate-400"}`}
>
{t === "preview" ? "Preview" : t === "print" ? "🖨️ Print" : "💾 Download"}
</button>
))}
</div>
<div className="p-5 space-y-3">
{tab === "preview" ? (
<div className="space-y-2 text-sm text-zinc-400">
<p>This label is optimized for physical signs and thermal printing:</p>
<ul className="list-disc list-inside space-y-1 text-xs text-zinc-500">
<li><strong>Large name</strong> (24pt bold) scannable from distance</li>
<li><strong>Short code</strong> (e.g. <code className="font-mono bg-zinc-950 px-1 rounded">{code}</code>) field reference</li>
<li><strong>High-contrast QR</strong> error correction H for outdoor use</li>
<li><strong>Tiny URL</strong> at bottom reference only</li>
</ul>
<button onClick={() => setTab("print")} className="w-full rounded-xl bg-slate-900 py-3 text-base font-bold text-white hover:bg-slate-800 transition-colors mt-2">
🖨 Print This Label
</button>
</div>
) : tab === "print" ? (
<button onClick={handlePrintLabel} disabled={loading} className="w-full rounded-xl bg-slate-900 py-3 text-base font-bold text-white disabled:opacity-50">
{loading ? "..." : "🖨️ Print Label"}
</button>
) : (
<button onClick={handleDownload} disabled={loading} className="w-full rounded-xl bg-blue-600 py-3 text-base font-bold text-white disabled:opacity-50">
{loading ? "..." : "💾 Download PNG"}
</button>
)}
<button
onClick={() => { onRegenerate(hg); onClose(); }}
className="w-full rounded-xl border border-zinc-800 py-2.5 text-sm font-medium text-zinc-400 hover:bg-zinc-800"
>
🔄 Regenerate Token
</button>
<div className="rounded-lg bg-zinc-900 p-3">
<p className="text-xs text-zinc-500 mb-1">Token</p>
<code className="text-xs text-zinc-300 font-mono break-all">{hg.headgate_token}</code>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import { getWaterAdminSession } from "@/actions/water-log/field";
import { getWaterHeadgatesAdmin } from "@/actions/water-log/admin";
import HeadgateEditForm from "@/components/admin/HeadgateEditForm";
import Link from "next/link";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
type HeadgatePageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ from?: string }>;
};
export default async function WaterLogHeadgatePage({ params, searchParams }: HeadgatePageProps) {
const { id } = await params;
const { from } = await searchParams;
const adminUser = await getAdminUser();
const waterSession = await getWaterAdminSession();
const isSiteAdmin =
adminUser?.role === "platform_admin" ||
(adminUser?.role === "brand_admin" &&
adminUser?.brand_id === TUXEDO_BRAND_ID &&
adminUser?.can_manage_water_log);
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
const headgates = await getWaterHeadgatesAdmin(TUXEDO_BRAND_ID);
const headgate = headgates.find((h) => h.id === id);
if (!headgate) {
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-400">Headgate not found</h1>
<p className="mt-2 text-zinc-400">This headgate may have been deleted.</p>
<a href={from ?? "/admin/water-log"} className="mt-4 inline-block text-zinc-400 hover:text-zinc-100">
Back
</a>
</div>
</main>
);
}
const backHref = from ?? "/admin/water-log";
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12">
<div className="mx-auto max-w-4xl">
<Link
href={backHref}
className="text-sm text-zinc-500 hover:text-zinc-300"
>
Back
</Link>
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-zinc-500">Water Headgate</p>
<h1 className="mt-2 text-3xl font-bold text-zinc-100">{headgate.name}</h1>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
headgate.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"
}`}
>
{headgate.active ? "Active" : "Inactive"}
</span>
</div>
<div className="mt-6 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-zinc-500">Default Unit</p>
<p className="mt-1 text-2xl font-bold text-zinc-100">{headgate.unit}</p>
</div>
<div>
<p className="text-sm font-medium text-zinc-500">Created</p>
<p className="mt-1 text-lg font-semibold text-zinc-100">
{new Date(headgate.created_at).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</p>
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
<h2 className="text-2xl font-bold text-zinc-100">Edit Headgate</h2>
<p className="mt-1 text-zinc-400">
Update the headgate name, status, and default measurement unit.
</p>
<div className="mt-6">
<HeadgateEditForm headgate={headgate} backHref={backHref} />
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getWaterHeadgatesAdmin, regenerateHeadgateToken } from "@/actions/water-log/admin";
import HeadgatesManager from "./HeadgatesManager";
export const dynamic = "force-dynamic";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default async function HeadgatesPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const isAuthorized =
adminUser?.role === "platform_admin" ||
(adminUser?.brand_id === TUXEDO_BRAND_ID && adminUser?.can_manage_water_log);
if (!isAuthorized) redirect("/admin/water-log");
const headgates = await getWaterHeadgatesAdmin(TUXEDO_BRAND_ID);
return <HeadgatesManager initialHeadgates={headgates} brandId={TUXEDO_BRAND_ID} />;
}
+40
View File
@@ -0,0 +1,40 @@
import WaterLogAdminPanel from "@/components/admin/WaterLogAdminPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin";
import { redirect } from "next/navigation";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
export default async function AdminWaterLogPage() {
const adminUser = await getAdminUser();
const isAuthorized =
adminUser?.role === "platform_admin" ||
(adminUser?.role === "brand_admin" &&
adminUser?.brand_id === TUXEDO_BRAND_ID &&
adminUser?.can_manage_water_log);
if (!isAuthorized) {
redirect("/admin/pickup");
}
const brandId = TUXEDO_BRAND_ID;
const [users, headgates, entries] = await Promise.all([
getWaterIrrigators(brandId),
getWaterHeadgatesAdmin(brandId),
getWaterEntries(brandId, 50),
]);
return (
<WaterLogAdminPanel
initialUsers={users}
initialHeadgates={headgates}
initialEntries={entries}
brandId={brandId}
canManage={isAuthorized}
/>
);
}
+261
View File
@@ -0,0 +1,261 @@
"use client";
import { useState, useEffect } from "react";
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
import { useRouter } from "next/navigation";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default function WaterLogSettingsPage() {
const router = useRouter();
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Form state
const [enabled, setEnabled] = useState(false);
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [sessionDuration, setSessionDuration] = useState(12);
const [canEdit, setCanEdit] = useState(true);
const [canDelete, setCanDelete] = useState(false);
const [canExport, setCanExport] = useState(true);
const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false);
useEffect(() => {
loadSettings();
}, []);
async function loadSettings() {
setLoading(true);
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
if (data) {
setSettings(data);
setEnabled(data.enabled);
setSessionDuration(data.session_duration_hours);
setCanEdit(data.can_edit_entries);
setCanDelete(data.can_delete_entries);
setCanExport(data.can_export_csv);
setAlertPhone(data.alert_phone ?? "");
setAlertsEnabled(data.alerts_enabled ?? false);
}
setLoading(false);
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (enabled && pin && pin !== confirmPin) {
setMessage({ type: "error", text: "PINs do not match" });
return;
}
if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) {
setMessage({ type: "error", text: "PIN must be exactly 4 digits" });
return;
}
setSaving(true);
setMessage(null);
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
enabled,
pin: pin || undefined,
session_duration_hours: sessionDuration,
can_edit_entries: canEdit,
can_delete_entries: canDelete,
can_export_csv: canExport,
alert_phone: alertPhone || null,
alerts_enabled: alertsEnabled,
});
if (result.success) {
setMessage({ type: "success", text: "Settings saved" });
setPin("");
setConfirmPin("");
} else {
setMessage({ type: "error", text: result.error ?? "Save failed" });
}
setSaving(false);
}
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<span className="text-zinc-500">Loading...</span>
</div>
);
}
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
<div className="mx-auto max-w-lg">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm"> Back</button>
<div>
<h1 className="text-xl font-bold text-zinc-100">Water Log Admin Portal</h1>
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-lg px-6 py-6">
<form onSubmit={handleSave} className="space-y-6">
{message && (
<div className={`rounded-xl px-4 py-3 text-sm font-semibold ${message.type === "success" ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}>
{message.text}
</div>
)}
{/* Enable toggle */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-zinc-100">Enable Admin Portal</p>
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p>
</div>
<button
type="button"
onClick={() => setEnabled((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
</div>
{enabled && (
<>
{/* PIN */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
<p className="font-semibold text-zinc-100">4-Digit PIN</p>
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
<input
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="••••"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
style={{ letterSpacing: "0.4em" }}
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
<input
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={confirmPin}
onChange={(e) => setConfirmPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="••••"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
style={{ letterSpacing: "0.4em" }}
/>
</div>
</div>
</div>
{/* Session Duration */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
<p className="font-semibold text-zinc-100">Session Duration</p>
<p className="text-xs text-zinc-500">How long the admin session lasts after login (172 hours).</p>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={72}
value={sessionDuration}
onChange={(e) => setSessionDuration(parseInt(e.target.value))}
className="flex-1"
/>
<span className="w-16 text-center text-sm font-bold text-zinc-100">{sessionDuration}h</span>
</div>
</div>
{/* Permissions */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
<p className="font-semibold text-zinc-100">Permissions</p>
{[
{ key: "canEdit", label: "Edit entries", desc: "Allow modifying existing log entries", value: canEdit, setter: setCanEdit },
{ key: "canDelete", label: "Delete entries", desc: "Allow removing log entries", value: canDelete, setter: setCanDelete },
{ key: "canExport", label: "Export CSV", desc: "Allow exporting water log data", value: canExport, setter: setCanExport },
].map(({ key, label, desc, value, setter }) => (
<div key={key} className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-zinc-200">{label}</p>
<p className="text-xs text-zinc-500">{desc}</p>
</div>
<button
type="button"
onClick={() => setter((v: boolean) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${value ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
))}
</div>
{/* High/Low Alerts */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
<p className="font-semibold text-zinc-100">High/Low Alerts</p>
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate's thresholds.</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-zinc-200">Enable Alerts</p>
<p className="text-xs text-zinc-500">SMS on threshold breach</p>
</div>
<button
type="button"
onClick={() => setAlertsEnabled((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${alertsEnabled ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${alertsEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
{alertsEnabled && (
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label>
<input
type="tel"
value={alertPhone}
onChange={(e) => setAlertPhone(e.target.value)}
placeholder="+1234567890"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
/>
<p className="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p>
</div>
)}
</div>
{/* QR hint */}
<div className="rounded-xl bg-amber-50 border border-amber-100 p-4">
<p className="text-xs text-amber-700">
<strong>QR Lock:</strong> Irrigators can lock a headgate by visiting <code className="bg-amber-900/40 px-1 rounded">/water?h={'{headgate_token}'}</code>.
</p>
</div>
</>
)}
<button
type="submit"
disabled={saving}
className="w-full rounded-xl bg-stone-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</form>
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import { getWaterAdminSession } from "@/actions/water-log/field";
import { getWaterIrrigators } from "@/actions/water-log/admin";
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
import Link from "next/link";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
type UserPageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ from?: string }>;
};
export default async function WaterLogUserPage({ params, searchParams }: UserPageProps) {
const { id } = await params;
const { from } = await searchParams;
const adminUser = await getAdminUser();
const waterSession = await getWaterAdminSession();
const isSiteAdmin =
adminUser?.role === "platform_admin" ||
(adminUser?.role === "brand_admin" &&
adminUser?.brand_id === TUXEDO_BRAND_ID &&
adminUser?.can_manage_water_log);
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
const users = await getWaterIrrigators(TUXEDO_BRAND_ID);
const waterUser = users.find((u) => u.id === id);
if (!waterUser) {
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-400">User not found</h1>
<p className="mt-2 text-zinc-400">This user may have been deleted.</p>
<a href={from ?? "/admin/water-log"} className="mt-4 inline-block text-zinc-400 hover:text-zinc-100">
Back
</a>
</div>
</main>
);
}
const backHref = from ?? "/admin/water-log";
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12">
<div className="mx-auto max-w-4xl">
<Link
href={backHref}
className="text-sm text-zinc-500 hover:text-zinc-300"
>
Back
</Link>
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-zinc-500">Water User</p>
<h1 className="mt-2 text-3xl font-bold text-zinc-100">{waterUser.name}</h1>
<p className="mt-1 text-zinc-400">
{waterUser.role === "water_admin" ? "Admin" : "Irrigator"}
</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
waterUser.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"
}`}
>
{waterUser.active ? "Active" : "Inactive"}
</span>
</div>
<div className="mt-6 grid grid-cols-3 gap-6">
<div>
<p className="text-sm font-medium text-zinc-500">Role</p>
<p className="mt-1 text-lg font-semibold text-zinc-100">
{waterUser.role === "water_admin" ? "Admin" : "Irrigator"}
</p>
</div>
<div>
<p className="text-sm font-medium text-zinc-500">Language</p>
<p className="mt-1 text-lg font-semibold text-zinc-100">
{waterUser.language_preference === "es" ? "Español" : "English"}
</p>
</div>
<div>
<p className="text-sm font-medium text-zinc-500">Last Used</p>
<p className="mt-1 text-lg font-semibold text-zinc-100">
{waterUser.last_used_at
? new Date(waterUser.last_used_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
: "Never"}
</p>
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
<h2 className="text-2xl font-bold text-zinc-100">Edit User</h2>
<p className="mt-1 text-zinc-400">
Update name, role, language, and active status. Use Reset PIN to generate a new PIN.
</p>
<div className="mt-6">
<WaterUserEditForm waterUser={waterUser} backHref={backHref} />
</div>
</div>
</div>
</main>
);
}