/plant and /plant/map import Fuel Logs CSVs by exact QR and show each vehicle's current and previous distinct site with the dates they were observed. - plant_site_observations is durable, QR-keyed history. It is never wiped and a previous site is never manufactured. - An older or repeated snapshot must not move a vehicle backwards; untimed conflicting sites on one day are held for review. - A manual site change shares the same history and the same lock. - Current/previous metadata and the sticker QR are captured BEFORE the asset rows are deleted in the seed, so a re-seed cannot wipe them. Explicit empty values are preserved too: clearing a site is a deliberate manual observation, not permission to restore an old sheet value. - Imports change location metadata only. They never touch income, cost or fuel measurements. - A new named site is created only for a matched Plant vehicle. Missing coordinates mean "awaiting a pin", never a headquarters fallback. Owner-confirmed 2026-11-04: TLB06's QR is 1089, not the old meter reading 4704. lib/plant-site-policy.json holds the confirmed aliases (Equestria Bastion Job -> Equestria 215, Cosmo City / Lion Park -> Cosmo City); no other identity is guessed.
94 lines
8.5 KiB
TypeScript
94 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useId, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Loader2, Upload, X } from "lucide-react";
|
|
import { siteSeenDate } from "./VehicleSiteHistory";
|
|
|
|
type Change = {
|
|
assetId: string; code: string; qr: string; currentSite: string; previousSite: string;
|
|
lastSeen: string; previousSeen: string; status: string; needsCoordinates: boolean;
|
|
};
|
|
type Preview = {
|
|
ok: boolean; fileHash: string; totalRows: number; matchedVehicles: number; observationCount: number;
|
|
ignoredRows: number; pendingObservations?: number; unknownQrs: { qr: string; name: string; rows: number }[];
|
|
issues: { qr: string; reason: string }[]; changes: Change[]; newSites: string[]; canApply: boolean;
|
|
appliedVehicles?: number; insertedObservations?: number; unchangedVehicles?: number; heldVehicles?: number;
|
|
};
|
|
const control = "inline-flex min-h-[44px] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium disabled:opacity-40";
|
|
|
|
export function FuelSiteImport() {
|
|
const router = useRouter();
|
|
const inputId = useId();
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [preview, setPreview] = useState<Preview | null>(null);
|
|
const [busy, setBusy] = useState<"preview" | "apply" | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [result, setResult] = useState("");
|
|
|
|
function reset() {
|
|
setFile(null); setPreview(null); setError("");
|
|
if (fileInput.current) fileInput.current.value = "";
|
|
}
|
|
|
|
async function submit(mode: "preview" | "apply") {
|
|
if (!file || busy || (mode === "apply" && !preview?.canApply)) return;
|
|
setBusy(mode); setError(""); setResult("");
|
|
try {
|
|
const form = new FormData();
|
|
form.set("file", file); form.set("mode", mode);
|
|
if (mode === "apply") form.set("fileHash", preview!.fileHash);
|
|
const response = await fetch("/api/plant/site-import", { method: "POST", body: form });
|
|
const data = await response.json();
|
|
if (!response.ok || !data.ok) throw new Error(data.error || "Could not read the fuel log");
|
|
if (mode === "preview") setPreview(data);
|
|
else {
|
|
setResult(`Fuel log imported: ${data.appliedVehicles ?? 0} vehicles updated, ${data.unchangedVehicles ?? 0} unchanged, ${data.heldVehicles ?? 0} held for review. ${data.insertedObservations ?? 0} observations saved.`);
|
|
reset(); setOpen(false); router.refresh();
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Could not reach the server");
|
|
if (mode === "preview") setPreview(null);
|
|
} finally { setBusy(null); }
|
|
}
|
|
|
|
return (
|
|
<section className="min-w-0 mb-5">
|
|
<button type="button" onClick={() => { setOpen(v => !v); setResult(""); }} aria-expanded={open} aria-controls={`${inputId}-panel`} className={`${control} bg-[#30638E] text-white hover:bg-[#003D5B]`}>
|
|
<Upload className="h-4 w-4" /> Upload fuel log
|
|
</button>
|
|
{result && <p role="status" className="mt-3 text-sm text-[#00798C]">{result}</p>}
|
|
{open && <div id={`${inputId}-panel`} className="mt-3 min-w-0 rounded-2xl border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-surface">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0"><h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Update vehicle sites from a fuel log</h2><p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Choose the exported CSV, review the latest logged site for each matched QR, then apply. Previous site means the last different site recorded.</p></div>
|
|
<button type="button" disabled={!!busy} onClick={() => { reset(); setOpen(false); }} aria-label="Close fuel log import" className={`${control} min-w-[44px] shrink-0 text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5`}><X className="h-4 w-4" /></button>
|
|
</div>
|
|
<div className="mt-4 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end">
|
|
<div className="min-w-0 flex-1"><label htmlFor={inputId} className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Fuel log CSV</label><input ref={fileInput} id={inputId} type="file" accept=".csv,text/csv" disabled={!!busy} onChange={e => { setFile(e.target.files?.[0] ?? null); setPreview(null); setError(""); }} className="w-full min-w-0 min-h-[44px] rounded-lg border border-gray-200 p-2 text-sm file:mr-2 file:rounded file:border-0 file:bg-gray-100 file:px-2 file:py-1 file:text-xs dark:border-white/10 dark:file:bg-white/10 dark:file:text-white" /></div>
|
|
<button type="button" disabled={!file || !!busy} onClick={() => submit("preview")} className={`${control} bg-[#30638E] text-white`}>{busy === "preview" && <Loader2 className="h-4 w-4 animate-spin" />}Preview changes</button>
|
|
</div>
|
|
{error && <p role="alert" className="mt-3 break-words text-sm text-[#D1495B]">{error}</p>}
|
|
{preview && <div className="mt-4 min-w-0 space-y-4">
|
|
<p className="text-sm text-gray-600 dark:text-gray-300">{preview.totalRows} rows · {preview.matchedVehicles} matched vehicles · {preview.observationCount} observations · {preview.ignoredRows} ignored rows</p>
|
|
{preview.newSites.length > 0 && <p className="break-words text-xs text-gray-600 dark:text-gray-300">New sites: {preview.newSites.join(", ")}. Add their coordinates in the map's site panel after import.</p>}
|
|
{(preview.unknownQrs.length > 0 || preview.issues.length > 0) && <div className="rounded-lg border border-[#EDAE49]/40 bg-[#EDAE49]/10 p-3 text-xs text-gray-700 dark:text-gray-200">
|
|
{preview.unknownQrs.length > 0 && <details><summary className="flex min-h-[44px] cursor-pointer items-center font-medium">{preview.unknownQrs.length} unmatched QR codes — excluded</summary><ul className="space-y-1 break-words">{preview.unknownQrs.map((q, i) => <li key={`${q.qr}-${i}`}>{q.qr || "Empty QR"}{q.name ? ` · ${q.name}` : ""} · {q.rows} rows</li>)}</ul></details>}
|
|
{preview.issues.length > 0 && <details><summary className="flex min-h-[44px] cursor-pointer items-center font-medium">{preview.issues.length} issues to review</summary><ul className="space-y-1 break-words">{preview.issues.map((issue, i) => <li key={i}>{issue.qr && `${issue.qr}: `}{issue.reason}</li>)}</ul></details>}
|
|
</div>}
|
|
<div className="max-h-[400px] space-y-2 overflow-y-auto">
|
|
{preview.changes.map(change => <article key={change.assetId} className="rounded-lg border border-gray-100 p-3 dark:border-white/10">
|
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm"><span className="min-w-0 max-w-full break-all font-semibold text-[#003D5B] dark:text-white">{change.code || change.qr}</span><span className="max-w-full break-words text-xs text-gray-500 dark:text-gray-400">{change.status.replaceAll("-", " ").replaceAll("_", " ")}</span></div>
|
|
<dl className="mt-2 grid min-w-0 grid-cols-1 gap-2 text-xs sm:grid-cols-3 [&>div]:min-w-0"><div><dt className="text-gray-500 dark:text-gray-400">Current site</dt><dd className="break-words font-medium text-[#003D5B] dark:text-white">{change.currentSite || "Unknown"}</dd></div><div><dt className="text-gray-500 dark:text-gray-400">Previous site</dt><dd className="break-words">{change.previousSite || "Unknown"}</dd>{change.previousSeen && <dd className="text-gray-500 dark:text-gray-400">{siteSeenDate(change.previousSeen)}</dd>}</div><div><dt className="text-gray-500 dark:text-gray-400">Last seen</dt><dd>{siteSeenDate(change.lastSeen)}</dd></div></dl>
|
|
{change.needsCoordinates && <p className="mt-2 text-xs text-[#30638E] dark:text-gray-300">Site coordinates needed to show this vehicle on the map.</p>}
|
|
</article>)}
|
|
{preview.changes.length === 0 && <p className="text-sm text-gray-500">No matched vehicles to update.</p>}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2"><button type="button" disabled={!preview.canApply || !!busy} onClick={() => submit("apply")} className={`${control} bg-[#00798C] text-white`}>{busy === "apply" && <Loader2 className="h-4 w-4 animate-spin" />}Apply fuel log</button></div>
|
|
{!preview.canApply && <p className="text-xs text-gray-600 dark:text-gray-300">{preview.changes.length > 0 && preview.issues.length === 0 ? "This fuel log is already up to date. No new observations or site changes to apply." : "No updates can be applied from this file. Review unmatched QR codes and any issues above."}</p>}
|
|
</div>}
|
|
</div>}
|
|
</section>
|
|
);
|
|
}
|