feat(fuel): delete a test or mistaken meter reading with a reason

Clock scanner: double-tap the previous reading to remove the latest
scanner-entered reading. Fuel Logs and vehicle pages: double-click a
meter-only row to delete it. Reason, actor and values are logged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-26 17:16:01 +02:00
parent 7d755961f4
commit e0c07cbf5f
11 changed files with 322 additions and 5 deletions

View file

@ -26,7 +26,7 @@ export async function POST(request:Request) {
const previous=await latestVehicleReading(pb,vehicle.id);
return Response.json({vehicle:{id:vehicle.id,description:vehicle.description,qrCodeId:vehicle.qr_code_id,siteId:vehicle.site,
unitType:vehicle.unit_type,transfer:scanSite!==vehicle.site,fuelLimits:vehicle.fuel_limits,costPerHour:vehicle.cost_per_hour},
hasPrevious:previous!==null,previousReading:previous?.odo_reading ?? null,previousLogId:previous?.id ?? null,
hasPrevious:previous!==null,previousReading:previous?.odo_reading ?? null,previousLogId:previous?.id ?? null,previousRemovable:previous?.source_system==="mega-clock",
workDate:workDate(new Date().toISOString()),
});
} catch (error) {

View file

@ -0,0 +1,35 @@
import "@/test-utils/named-admin-session";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks=vi.hoisted(()=>({vehicles:vi.fn(),logs:vi.fn(),getOne:vi.fn(),del:vi.fn(),release:vi.fn(),lock:vi.fn()}));
vi.mock("@/lib/pb",()=>({pbAdmin:async()=>({
filter:(s:string,args:Record<string,unknown>)=>s.replace(/\{:(\w+)\}/g,(_,key)=>JSON.stringify(args[key])),
collection:(name:string)=>({getList:name==="vehicles"?mocks.vehicles:mocks.logs,getOne:mocks.getOne,delete:mocks.del}),
})}));
vi.mock("@/lib/fuel-import-store",()=>({createFuelImportStore:()=>({acquireLock:mocks.lock})}));
import { POST } from "./route";
const vehicle={id:"veh00000000001",site:"site0000000001",qr_code_id:"QR1",unit_type:"fuel"};
const post=(changes:Record<string,unknown>={})=>POST(new Request("http://x",{method:"POST",body:JSON.stringify({qr_code_id:"QR1",log_id:"test",reason:"test reading",...changes})}));
beforeEach(()=>{
Object.values(mocks).forEach(mock=>mock.mockReset());
mocks.vehicles.mockResolvedValue({items:[vehicle]});
mocks.logs.mockResolvedValue({items:[{id:"test",odo_reading:500,date_logged:"2026-10-26",source_system:"mega-clock"}]});
mocks.getOne.mockResolvedValue({id:"test",vehicle:vehicle.id,site:vehicle.site,odo_reading:500,source_system:"mega-clock"});
mocks.release.mockResolvedValue(undefined);mocks.lock.mockResolvedValue(mocks.release);
});
describe("scanner reading removal",()=>{
it("deletes the latest scanner-entered reading",async()=>{
const res=await post();expect(res.status).toBe(200);
expect(mocks.del).toHaveBeenCalledWith("test");expect(mocks.release).toHaveBeenCalledOnce();
});
it("needs a reason",async()=>{
expect((await post({reason:"x"})).status).toBe(400);expect(mocks.del).not.toHaveBeenCalled();
});
it("refuses a reading that is no longer the latest",async()=>{
expect((await post({log_id:"older"})).status).toBe(409);expect(mocks.del).not.toHaveBeenCalled();
});
it("refuses imported readings",async()=>{
mocks.logs.mockResolvedValue({items:[{id:"test",odo_reading:500,date_logged:"2026-10-26",source_system:"fuel-import"}]});
mocks.getOne.mockResolvedValue({id:"test",vehicle:vehicle.id,site:vehicle.site,odo_reading:500,source_system:"fuel-import"});
expect((await post()).status).toBe(409);expect(mocks.del).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,47 @@
import { z } from "zod";
import { pbAdmin } from "@/lib/pb";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
import { resolveScannableVehicle, latestVehicleReading, VehicleScanError } from "@/lib/vehicle-scan";
import { createFuelImportStore } from "@/lib/fuel-import-store";
export const runtime="nodejs";
const schema=z.object({
qr_code_id:z.string().trim().min(1).max(160),log_id:z.string().min(1),
reason:z.string().trim().min(5).max(500),
});
/** Removes a mistaken or test meter reading entered through the clock scanner.
* Only the unit's latest reading can go, and only if the scanner wrote it, so
* imported and office-reviewed history is never touched from the kiosk. The
* reason, actor and the removed values are written to the server log. */
export async function POST(request:Request) {
const user=await requireApiUser("clock");
if (user instanceof Response) return user;
const parsed=schema.safeParse(await request.json().catch(()=>null));
if (!parsed.success) return Response.json({error:"Give a reason of at least five characters."},{status:400});
const b=parsed.data;
let release:(()=>Promise<void>)|undefined;
try {
const pb=await pbAdmin();
const vehicle=await resolveScannableVehicle(pb,b.qr_code_id);
const denied=requireSiteAccess(user,vehicle.site);
if (denied) return denied;
release=await createFuelImportStore(pb).acquireLock();
const latest=await latestVehicleReading(pb,vehicle.id);
if (!latest || latest.id!==b.log_id) throw new VehicleScanError("Only the latest reading can be removed, and it has changed. Cancel and scan again.");
const log=await pb.collection("fuel_logs").getOne(latest.id);
if (log.vehicle!==vehicle.id) throw new VehicleScanError("That reading belongs to another unit.");
if (log.source_system!=="mega-clock") throw new VehicleScanError("Only readings entered on the clock scanner can be removed here. Ask the office to correct this one.");
await pb.collection("fuel_logs").delete(log.id);
console.warn("[vehicle-reading-remove]",JSON.stringify({vehicle:vehicle.id,qr:vehicle.qr_code_id,log:log.id,odo:log.odo_reading,
dateLogged:log.date_logged,site:log.site,actor:user.id,reason:b.reason,at:new Date().toISOString()}));
const next=await latestVehicleReading(pb,vehicle.id);
return Response.json({removed:log.id,previousReading:next?.odo_reading ?? null,previousLogId:next?.id ?? null,previousRemovable:next?.source_system==="mega-clock"});
} catch(error) {
const busy=error instanceof Error && /already running/.test(error.message);
return Response.json({error:error instanceof VehicleScanError ? error.message : busy ? "A fuel import or fleet update is running. Retry shortly." : "Could not remove the reading. Try again."},
{status:error instanceof VehicleScanError ? error.status : busy ? 409 : 503});
} finally {
if (release) await release().catch(error=>console.error("[vehicle-reading-remove] lock release failed",error));
}
}

View file

@ -66,3 +66,32 @@ async function save(request:Request,ctx:{params:Promise<{id:string}>},create:boo
}
export const PATCH=(request:Request,ctx:{params:Promise<{id:string}>})=>save(request,ctx,false);
export const POST=(request:Request,ctx:{params:Promise<{id:string}>})=>save(request,ctx,true);
/** Removes a mistaken or test reading. The reason and removed values go to the server log. */
export async function DELETE(request:Request,ctx:{params:Promise<{id:string}>}){
const denied=await authorizeOperation(request);if(denied)return denied;
const user=await requireApiUser('dashboard');if(user instanceof Response)return user;
const {id}=await ctx.params;
const b=await request.json().catch(()=>null) as Record<string,unknown>|null;
const bad=(error:string,status=400)=>NextResponse.json({error},{status});
if(!b||typeof b!=='object'||Array.isArray(b))return bad('Body must be JSON');
if(typeof b.reason!=='string'||b.reason.trim().length<5||b.reason.length>2000)return bad('Add a reason of at least five characters.');
if(b.source!=='meter'&&b.source!=='weekly')return bad('Choose a meter reading or weekly sheet.');
if(typeof b.readingId!=='string'||typeof b.expectedUpdated!=='string')return bad('Refresh and reopen the reading.');
const pb=await pbAdmin();let release:(()=>Promise<void>)|undefined;
try{
release=await createFuelImportStore(pb).acquireLock();
const collection=b.source==='meter'?'fuel_logs':'plant_hours_log';
const row=await pb.collection(collection).getOne(b.readingId);
if(row.vehicle!==id)return bad('Not found',404);
const rowDenied=requireSiteAccess(user,row.site);if(rowDenied)return rowDenied;
if(plantReadingRevision(row)!==b.expectedUpdated)return bad('This reading changed. Refresh and reopen it before deleting.',409);
if(b.source==='meter'&&Number(row.litres)>0)return bad('This is a fuel fill, not a meter-only reading. Correct it instead of deleting.',409);
await pb.collection(collection).delete(row.id);
console.warn('[plant-reading-delete]',JSON.stringify({vehicle:id,collection,log:row.id,odo:row.odo_reading,dateLogged:row.date_logged,site:row.site,actor:user.id,reason:b.reason.trim(),at:new Date().toISOString()}));
return NextResponse.json({ok:true});
}catch(error){
console.error('[plant-reading-delete]',error);
return bad('Could not delete. Another fleet update may be running. Refresh before retrying.',409);
}finally{if(release)await release().catch(error=>console.error('[plant-reading-delete] lock release',error));}
}

View file

@ -133,6 +133,8 @@ export function FleetScanner({ onScan, sites = [] }: { onScan?: () => void; site
// silently swap what's written. Refs capture the value at the
// moment submit fires; the state is for UI.
const gpsFixRef = React.useRef<GpsFix | null>(null);
// The last reading on file was entered on this scanner, so it may be removed.
const [previousRemovable, setPreviousRemovable] = React.useState(false);
const lookupGeneration = React.useRef(0);
const gpsGeneration = React.useRef(0);
const confirmation = React.useRef<{requestId:string;previousId:string|null;day:string;payload?:Record<string,unknown>} | null>(null);
@ -214,8 +216,10 @@ export function FleetScanner({ onScan, sites = [] }: { onScan?: () => void; site
hasPrevious: boolean;
previousReading: number | null;
previousLogId: string | null;
previousRemovable?: boolean;
workDate: string;
};
setPreviousRemovable(ok.previousRemovable === true);
confirmation.current={requestId:crypto.randomUUID(),previousId:ok.previousLogId,day:ok.workDate};
setPhase({
kind: "form",
@ -329,6 +333,34 @@ export function FleetScanner({ onScan, sites = [] }: { onScan?: () => void; site
[onScan, setPhase],
);
// Removes the unit's latest scanner-entered reading (a test or a typo) so
// the next one can be entered. Returns an error message, or null on success.
const removePrevious = React.useCallback(
async (reason: string): Promise<string | null> => {
const current = phaseRef.current;
const context = confirmation.current;
if ((current.kind !== "form" && current.kind !== "submitError") || !context?.previousId) return "Nothing to remove";
try {
const res = await fetch("/api/clock/vehicle-reading-remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ qr_code_id: current.qrCodeId, log_id: context.previousId, reason }),
});
const body = await res.json();
if (!res.ok) return (body as { error?: string }).error ?? `Remove failed (${res.status})`;
const next = body as { previousReading: number | null; previousLogId: string | null; previousRemovable: boolean };
confirmation.current = { requestId: crypto.randomUUID(), previousId: next.previousLogId, day: context.day };
setPreviousRemovable(next.previousRemovable === true);
setPhase({ kind: "form", qrCodeId: current.qrCodeId, vehicle: current.vehicle, previousReading: next.previousReading });
onScan?.();
return null;
} catch {
return "Couldn't reach the server — try again";
}
},
[onScan, setPhase],
);
// --- Camera + QR decode loop ----------------------------------------
// Mount once. handleDecoded reads phaseRef.current — no re-binding.
React.useEffect(() => {
@ -485,6 +517,8 @@ export function FleetScanner({ onScan, sites = [] }: { onScan?: () => void; site
phase={phase}
gpsStatus={gpsStatus}
gpsFix={gpsFix}
removable={previousRemovable}
onRemovePrevious={removePrevious}
onSubmit={(reading) => {
if (phase.kind !== "form" && phase.kind !== "submitError") return;
const { qrCodeId, vehicle, previousReading } = phase;
@ -522,11 +556,15 @@ function DialogBody({
phase,
gpsStatus,
gpsFix,
removable,
onRemovePrevious,
onSubmit,
}: {
phase: Phase;
gpsStatus: "pending" | "ok" | "unavailable";
gpsFix: GpsFix | null;
removable: boolean;
onRemovePrevious: (reason: string) => Promise<string | null>;
onSubmit: (reading: number) => void;
}) {
if (phase.kind === "loading") {
@ -571,6 +609,8 @@ function DialogBody({
gpsFix={gpsFix}
initialReading={phase.kind === "submitError" ? phase.reading : ""}
submitError={phase.kind === "submitError" ? phase.message : null}
removable={removable}
onRemovePrevious={onRemovePrevious}
onSubmit={onSubmit}
/>
);
@ -587,6 +627,8 @@ function FormBody({
gpsFix,
submitError,
initialReading,
removable,
onRemovePrevious,
onSubmit,
}: {
qrCodeId: string;
@ -596,6 +638,8 @@ function FormBody({
gpsFix: GpsFix | null;
submitError: string | null;
initialReading: string;
removable: boolean;
onRemovePrevious: (reason: string) => Promise<string | null>;
onSubmit: (reading: number) => void;
}) {
const [value, setValue] = React.useState(initialReading);
@ -640,7 +684,12 @@ function FormBody({
This unit is registered to another site. Saving moves it here and logs its hours and readings against this site from now on.
</div>
)}
<PreviousReadingStrip previousReading={previousReading} suffix={suffix} />
<PreviousReadingStrip
previousReading={previousReading}
suffix={suffix}
removable={removable}
onRemove={onRemovePrevious}
/>
<GpsChip status={gpsStatus} fix={gpsFix} />
</div>
@ -709,10 +758,19 @@ function FormBody({
function PreviousReadingStrip({
previousReading,
suffix,
removable,
onRemove,
}: {
previousReading: number | null;
suffix: string;
removable: boolean;
onRemove: (reason: string) => Promise<string | null>;
}) {
const [removing, setRemoving] = React.useState(false);
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
if (previousReading === null) {
return (
<div className="rounded-lg border border-dashed border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
@ -720,14 +778,61 @@ function PreviousReadingStrip({
</div>
);
}
if (removing) {
const canRemove = reason.trim().length >= 5 && !busy;
return (
<form
className="space-y-2 rounded-lg border border-red-500/40 bg-red-500/5 px-3 py-2"
onSubmit={async (e) => {
e.preventDefault();
if (!canRemove) return;
setBusy(true);
setError(null);
const failure = await onRemove(reason.trim());
setBusy(false);
if (failure) setError(failure);
else { setRemoving(false); setReason(""); }
}}
>
<p className="text-xs font-semibold">
Remove the previous reading ({previousReading.toLocaleString()} {suffix})?
</p>
<Input
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason, e.g. test reading"
aria-label="Reason for removing the reading"
autoFocus
className="h-10 bg-input/60 text-sm"
/>
{error && <p className="text-xs font-medium text-red-600">{error}</p>}
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" className="flex-1" disabled={busy}
onClick={() => { setRemoving(false); setReason(""); setError(null); }}>
Keep it
</Button>
<Button type="submit" variant="destructive" size="sm" className="flex-1" disabled={!canRemove}>
{busy ? "Removing…" : "Remove reading"}
</Button>
</div>
</form>
);
}
return (
<div className="flex items-center justify-between rounded-lg bg-muted/60 px-3 py-2">
<div
className={cn("flex items-center justify-between rounded-lg bg-muted/60 px-3 py-2", removable && "cursor-pointer select-none")}
onDoubleClick={removable ? () => setRemoving(true) : undefined}
title={removable ? "Double-tap to remove this reading" : undefined}
>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Previous reading
</span>
<span className="font-mono text-base font-bold text-foreground">
{previousReading.toLocaleString()} {suffix}
</span>
{removable && <span className="sr-only">Double-tap to remove</span>}
</div>
);
}

View file

@ -27,6 +27,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DeleteReadingDialog } from "@/components/delete-reading-button";
import { TableToolbar } from "@/components/table-toolbar";
import { Pagination } from "@/components/ui/pagination";
import { cn, formatCurrency, formatNumber } from "@/lib/utils";
@ -41,6 +42,8 @@ import { ProjectPeriodBadge } from "@/components/project-period-badge";
export interface FuelLogRow {
id: string;
/** Revision token used when deleting a meter-only reading. */
updated?: string;
dateLogged: string;
vehicleId: string;
vehicleDescription: string;
@ -70,6 +73,8 @@ export function FuelLogsView({
estimatedFuel?: boolean;
sites: { id: string; name: string; project_code: string }[];
}) {
const [deleting, setDeleting] = React.useState<FuelLogRow | null>(null);
const canDelete = (r: FuelLogRow) => r.litres === 0 && !!r.vehicleId && !!r.updated && !r.hasDocument;
const [siteFilter, setSiteFilter] = React.useState<string>("all");
const [unitFilter, setUnitFilter] = React.useState<UnitFilter>("all");
const [sourceFilter, setSourceFilter] = React.useState<SourceFilter>("all");
@ -241,7 +246,10 @@ export function FuelLogsView({
</TableHeader>
<TableBody>
{pageRows.map((r) => (
<TableRow key={r.id}>
<TableRow key={r.id}
onDoubleClick={canDelete(r) ? () => setDeleting(r) : undefined}
title={canDelete(r) ? "Double-click to delete this meter reading" : undefined}
className={canDelete(r) ? "cursor-pointer" : undefined}>
<TableCell className="text-xs tabular-nums">
{r.dateLogged
? format(parseISO(r.dateLogged), "yyyy-MM-dd")
@ -318,6 +326,16 @@ export function FuelLogsView({
</TableBody>
</Table>
)}
{deleting && (
<DeleteReadingDialog
open
onOpenChange={(v) => { if (!v) setDeleting(null); }}
vehicleId={deleting.vehicleId}
readingId={deleting.id}
updated={deleting.updated!}
label={`${deleting.vehicleDescription} · ${deleting.odoReading != null ? formatNumber(deleting.odoReading) : "—"} on ${deleting.dateLogged ? format(parseISO(deleting.dateLogged), "yyyy-MM-dd") : "—"}`}
/>
)}
</CardContent>
{sorted.length > PAGE_SIZE && (
<div className="border-t border-border px-4 py-3">

View file

@ -6,6 +6,7 @@ import { loadFuelPrices } from "@/lib/fuel-prices";
import { filterOpt, siteFilter, siteFilterAllowingUnassigned, type SearchParams } from "@/lib/site-scope";
import { getSiteScope } from "@/lib/site-scope-server";
import { FuelLogsView, type FuelLogRow } from "./fuel-logs-view";
import { plantReadingRevision } from "@/lib/plant-reading-revision";
import { deriveProjectPeriodLabel } from "@/lib/project-calendar";
export const dynamic = "force-dynamic";
@ -59,6 +60,7 @@ export default async function FuelLogsPage({
(l.fuel_sheet ?? "");
return {
id: l.id,
updated: plantReadingRevision(l as unknown as Record<string, unknown>),
dateLogged: l.date_logged ?? "",
vehicleId: l.vehicle ?? "",
vehicleDescription: v?.description ?? "(unknown)",

View file

@ -6,6 +6,7 @@ import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { pbAdmin } from "@/lib/pb";
import { parseFuelLimit } from "@/lib/fuel-variance";
import { plantReadingRevision } from "@/lib/plant-reading-revision";
import { compareMeterLogs } from "@/lib/metrics/plant-hours";
import { fuelFillSeries, averageFuelEfficiency } from "@/lib/metrics/fuel-fill-series";
import { loadFuelPrices } from "@/lib/fuel-prices";
@ -116,6 +117,7 @@ export default async function VehicleDetailPage({
cumulativeCost,
cost,
projectCode: logSiteById[log.site] || vehicleProjectCode,
updated: plantReadingRevision(log as unknown as Record<string, unknown>),
});
}
@ -171,6 +173,7 @@ export default async function VehicleDetailPage({
/>
<VehicleDetailView
vehicleId={vehicleId}
canManage={canManage}
points={points}
kpis={{
totalLitres,

View file

@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { DeleteReadingDialog } from "@/components/delete-reading-button";
import { format, parseISO } from "date-fns";
import { useRouter } from "next/navigation";
import {
@ -40,6 +41,8 @@ export interface VehicleDetailPoint {
cumulativeCost: number;
cost: number;
projectCode: string;
/** Revision token for editing/deleting meter-only readings. */
updated?: string;
}
interface VehicleDetailKpis {
@ -106,7 +109,9 @@ export function VehicleDetailView({
kpis,
vehicle,
vehicleId,
canManage = false,
}: {
canManage?: boolean;
points: VehicleDetailPoint[];
kpis: VehicleDetailKpis;
vehicleId: string;
@ -123,6 +128,7 @@ export function VehicleDetailView({
};
}) {
const [historyPage, setHistoryPage] = React.useState(1);
const [deleting, setDeleting] = React.useState<VehicleDetailPoint | null>(null);
const historyPageCount = Math.max(1,Math.ceil(points.length/25));
const safeHistoryPage = Math.min(historyPage,historyPageCount);
const historyPoints = [...points].reverse().slice((safeHistoryPage-1)*25,safeHistoryPage*25);
@ -446,7 +452,10 @@ export function VehicleDetailView({
{historyPoints.map((p) => {
const km = p.kmPerL !== null ? p.kmPerL * p.litres : null;
return (
<tr key={p.id} className="border-b border-border last:border-0">
<tr key={p.id}
onDoubleClick={canManage && p.litres === 0 && p.updated ? () => setDeleting(p) : undefined}
title={canManage && p.litres === 0 && p.updated ? "Double-click to delete this meter reading" : undefined}
className={cn("border-b border-border last:border-0", canManage && p.litres === 0 && p.updated && "cursor-pointer")}>
<td className="px-3 py-2 text-xs tabular-nums">
{p.date ? format(parseISO(p.date), "yyyy-MM-dd") : "—"}
<span className="block text-muted-foreground">{p.projectCode || "—"}</span>
@ -483,6 +492,16 @@ export function VehicleDetailView({
</table>
</div>
)}
{deleting && (
<DeleteReadingDialog
open
onOpenChange={(v) => { if (!v) setDeleting(null); }}
vehicleId={vehicleId}
readingId={deleting.id}
updated={deleting.updated!}
label={`${formatNumber(deleting.odoReading)} on ${deleting.date ? format(parseISO(deleting.date), "yyyy-MM-dd") : "—"}`}
/>
)}
{points.length > 0 && <Pagination className="border-t border-border p-4" page={safeHistoryPage} pageCount={historyPageCount} pageSize={25} total={points.length} onPageChange={setHistoryPage} label="Newest first" />}
</CardContent>
</Card>

View file

@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
/** Removes a mistaken or test meter reading, with a reason that is logged server-side.
* Opened by double-clicking a meter-only row, so there is no visible delete control. */
export function DeleteReadingDialog({ vehicleId, readingId, updated, label, open, onOpenChange }: {
vehicleId: string; readingId: string; updated: string; label: string;
open: boolean; onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const setOpen = onOpenChange;
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState("");
const canDelete = reason.trim().length >= 5 && !busy;
async function remove(e: React.FormEvent) {
e.preventDefault();
if (!canDelete) return;
setBusy(true); setError("");
try {
const res = await fetch(`/api/vehicles/${vehicleId}/readings`, {
method: "DELETE", headers: { "content-type": "application/json" },
body: JSON.stringify({ source: "meter", readingId, expectedUpdated: updated, reason: reason.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not delete");
setOpen(false); setReason(""); router.refresh();
} catch (err) { setError(err instanceof Error ? err.message : "Could not delete"); }
finally { setBusy(false); }
}
return (
<>
<Dialog open={open} onOpenChange={v => { if (!busy) setOpen(v); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this reading?</DialogTitle>
<DialogDescription>{label}. Use this for a test or mistaken meter reading so the next real one can be recorded.</DialogDescription>
</DialogHeader>
<form onSubmit={remove} className="space-y-3">
<Input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason, e.g. test reading" aria-label="Reason for deleting" maxLength={2000} autoFocus />
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<Button type="submit" variant="destructive" disabled={!canDelete}>{busy ? "Deleting…" : "Delete Reading"}</Button>
</form>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -20,6 +20,10 @@ export function PlantReadingReview({vehicleId,reading,sites,reason,create=false}
const payload={source:reading.source,readingId:reading.id,expectedUpdated:reading.updated,site,reason:note,...(reading.source==='meter'?{date_logged:new Date(date+'+02:00').toISOString(),odo_reading:Number(odo),meter_reset:reset}:{start_date:start,end_date:end,start_hours:Number(startMeter),end_hours:Number(endMeter),cost_per_hour:Number(rate)})};
const res=await fetch(`/api/vehicles/${vehicleId}/readings`,{method:create?'POST':'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});const data=await res.json();if(!res.ok)throw Error(data.error||'Could not save');setOpen(false);router.refresh();
}catch(e){setError(e instanceof Error?e.message:'Could not save');}finally{setSaving(false);}}
async function remove(){if(!window.confirm('Delete this reading? This cannot be undone.'))return;setError('');setSaving(true);try{
const res=await fetch(`/api/vehicles/${vehicleId}/readings`,{method:'DELETE',headers:{'content-type':'application/json'},body:JSON.stringify({source:reading.source,readingId:reading.id,expectedUpdated:reading.updated,reason:note})});const data=await res.json();if(!res.ok)throw Error(data.error||'Could not delete');
setOpen(false);router.refresh();
}catch(e){setError(e instanceof Error?e.message:'Could not delete');}finally{setSaving(false);}}
function begin(){setReset(reading.review_history?.at(-1)?.after?.meter_reset===true);setSite(reading.site);setDate(localTime(reading.source_logged_at || reading.date_logged || ''));setOdo(String(reading.odo_reading??''));setStart(reading.start_date?.slice(0,10)??'');setEnd(reading.end_date?.slice(0,10)??'');setStartMeter(String(reading.start_hours??''));setEndMeter(String(reading.end_hours??''));setRate(String(reading.cost_per_hour??''));setNote('');setError('');setOpen(true);}
return <><Button variant="outline" size="sm" onClick={begin}>{create?'Add Missing Reading':reason?'Review':'Edit Reading'}</Button><Dialog open={open} onOpenChange={v=>{if(!saving)setOpen(v);}}><DialogContent><DialogHeader><DialogTitle>{create?'Add Missing Reading':reading.source==='weekly'?'Review Weekly Sheet':'Review Meter Reading'}</DialogTitle><DialogDescription>Use verified source records. Saving recalculates hours and cost; any remaining issue stays marked for review.</DialogDescription></DialogHeader>
{reason&&<p className="rounded-md border border-border p-3 text-sm">{reason}</p>}
@ -32,6 +36,7 @@ export function PlantReadingReview({vehicleId,reading,sites,reason,create=false}
{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={saving}>{saving?'Saving…':create?'Save Reading':'Save Correction'}</Button>
</form>
{!create&&<div className="space-y-2 border-t border-border pt-3"><p className="text-xs text-muted-foreground">Entered by mistake or as a test? Enter a reason in the note above, then delete it so the next reading can be recorded.</p><Button type="button" variant="destructive" size="sm" disabled={saving||note.trim().length<5} onClick={()=>void remove()}>{saving?'Working…':'Delete Reading'}</Button></div>}
{!!reading.review_history?.length&&<details className="text-xs"><summary className="cursor-pointer">Correction History ({reading.review_history.length})</summary>{reading.review_history.map((h,i)=><div key={i} className="mt-2 border-t border-border pt-2"><p>{h.at.slice(0,10)} · {h.actor}</p><p>{h.reason}</p><pre className="mt-1 whitespace-pre-wrap break-all text-muted-foreground">{JSON.stringify({before:h.before,after:h.after},null,2)}</pre></div>)}</details>}
</DialogContent></Dialog></>;
}