mega-plant/components/BookedOutTable.tsx
David 29a035a4f2 Clock app: vehicle-QR diesel book-out with meter photo; user access levels
- Scanning a vehicle QR (its register code, e.g. TLB04) books diesel from the main
  tank; litres, optional odometer and a required meter photo (stored protected,
  viewable from the Booked out table)
- Settings: users are Full or Clock-app-only; clock-only users are confined to /clock
- Clock app: tapped tab highlights at once plus a loading screen; remove the
  "scanned on this device" list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:25:15 +02:00

136 lines
6.7 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import type { OilLogRow } from "@/components/OilsBoard";
import { inBounds, presetBounds, saDay, type LogPreset } from "@/lib/oil-log";
const PRESETS: { id: LogPreset; label: string }[] = [
{ id: "today", label: "Today" },
{ id: "week", label: "This week" },
{ id: "month", label: "This month" },
{ id: "all", label: "Everything" },
{ id: "range", label: "Date range" },
];
const fmt = (n: number) => n.toLocaleString("en-ZA", { maximumFractionDigits: 2 });
const when = (stamp: string) =>
!stamp ? "—" : !stamp.includes("T") && stamp.length <= 10
? new Date(stamp + "T12:00:00").toLocaleDateString("en-ZA", { day: "numeric", month: "short", year: "numeric" })
: new Date(stamp.replace(" ", "T")).toLocaleString("en-ZA", {
timeZone: "Africa/Johannesburg", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23",
});
/** Everything booked out of stock, filterable by period. A price per litre column slots in here later. */
export function BookedOutTable({ log }: { log: OilLogRow[] }) {
const [preset, setPreset] = useState<LogPreset>("month");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [product, setProduct] = useState("");
const products = useMemo(() => [...new Set(log.map((r) => r.item))].sort(), [log]);
const rows = useMemo(() => {
const bounds = presetBounds(preset, saDay(new Date().toISOString()), from, to);
return log.filter((r) => inBounds(saDay(r.at), bounds) && (!product || r.item === product));
}, [log, preset, from, to, product]);
const totals = useMemo(() => {
const m = new Map<string, number>();
for (const r of rows) m.set(r.item, (m.get(r.item) || 0) + r.litres);
return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [rows]);
const field = "min-h-[44px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm";
return (
<div className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl p-4 sm:p-5 shadow-sm">
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white mb-3">Booked out</h2>
<div className="flex flex-col lg:flex-row lg:items-center gap-3 mb-3">
{/* Pills from lg, a dropdown below it. */}
<div className="hidden lg:flex gap-1.5" role="group" aria-label="Period">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => setPreset(p.id)}
aria-pressed={preset === p.id}
className={`min-h-[44px] px-4 rounded-lg text-xs font-medium border ${
preset === p.id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border text-gray-600 dark:text-gray-300"
}`}
>
{p.label}
</button>
))}
</div>
<select className={`${field} lg:hidden`} value={preset} onChange={(e) => setPreset(e.target.value as LogPreset)} aria-label="Period">
{PRESETS.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</select>
{preset === "range" && (
<div className="flex items-center gap-2">
<input type="date" className={field} value={from} max={to || undefined} onChange={(e) => setFrom(e.target.value)} aria-label="From" />
<span className="text-xs text-gray-500">to</span>
<input type="date" className={field} value={to} min={from || undefined} onChange={(e) => setTo(e.target.value)} aria-label="To" />
</div>
)}
<select className={`${field} lg:ml-auto`} value={product} onChange={(e) => setProduct(e.target.value)} aria-label="Product">
<option value="">All products</option>
{products.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
{totals.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{totals.map(([name, l]) => (
<span key={name} className="px-3 py-1.5 rounded-lg bg-[#30638E]/10 text-[#30638E] text-xs font-medium">
{name}: <strong className="tabular-nums">{fmt(l)} L</strong>
</span>
))}
</div>
)}
{rows.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400 py-4">
{log.length === 0 ? "Nothing booked out yet. Staff book litres out in the Plant Clock app." : "Nothing booked out in this period."}
</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<table className="w-full text-sm min-w-[760px]">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400">
<th className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 font-medium">When</th>
<th className="py-2 pr-3 font-medium">Product</th>
<th className="py-2 pr-3 font-medium text-right">Litres</th>
<th className="py-2 pr-3 font-medium">Vehicle</th>
<th className="py-2 pr-3 font-medium text-right">Odometer</th>
<th className="py-2 pr-3 font-medium">Booked by</th>
<th className="py-2 font-medium">Meter</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t border-gray-100 dark:border-border">
<td className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 whitespace-nowrap tabular-nums">{when(r.at)}</td>
<td className="py-2 pr-3">{r.item}</td>
<td className="py-2 pr-3 text-right tabular-nums">{fmt(r.litres)}</td>
<td className="py-2 pr-3 font-mono">{r.asset || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums">{r.odometer != null ? fmt(r.odometer) : "—"}</td>
<td className="py-2 pr-3">{r.by || "—"}</td>
<td className="py-2">{r.photo ? <a href={`/api/oils/movements/${r.id}/photo`} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-[44px] items-center text-[#30638E] underline">View photo</a> : "—"}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-200 dark:border-border font-semibold">
<td className="sticky left-0 bg-white dark:bg-surface py-2 pr-3">Total · {rows.length} booking{rows.length !== 1 && "s"}</td>
<td />
<td className="py-2 pr-3 text-right tabular-nums">{fmt(rows.reduce((s, r) => s + r.litres, 0))}</td>
<td colSpan={4} />
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
}