From e0c07cbf5f1fd86be9d83444aac3a594df69fdfd Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 26 Oct 2026 17:16:01 +0200 Subject: [PATCH] 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 --- src/app/api/clock/vehicle-lookup/route.ts | 2 +- .../vehicle-reading-remove/route.test.ts | 35 ++++++ .../api/clock/vehicle-reading-remove/route.ts | 47 ++++++++ src/app/api/vehicles/[id]/readings/route.ts | 29 +++++ src/app/clock/fleet-scanner.tsx | 109 +++++++++++++++++- .../plant/fuel-logs/fuel-logs-view.tsx | 20 +++- src/app/dashboard/plant/fuel-logs/page.tsx | 2 + .../plant/vehicles/[vehicleId]/page.tsx | 3 + .../[vehicleId]/vehicle-detail-view.tsx | 21 +++- src/components/delete-reading-button.tsx | 54 +++++++++ src/components/plant-reading-review.tsx | 5 + 11 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 src/app/api/clock/vehicle-reading-remove/route.test.ts create mode 100644 src/app/api/clock/vehicle-reading-remove/route.ts create mode 100644 src/components/delete-reading-button.tsx diff --git a/src/app/api/clock/vehicle-lookup/route.ts b/src/app/api/clock/vehicle-lookup/route.ts index 5857808..c373bde 100644 --- a/src/app/api/clock/vehicle-lookup/route.ts +++ b/src/app/api/clock/vehicle-lookup/route.ts @@ -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) { diff --git a/src/app/api/clock/vehicle-reading-remove/route.test.ts b/src/app/api/clock/vehicle-reading-remove/route.test.ts new file mode 100644 index 0000000..012ae23 --- /dev/null +++ b/src/app/api/clock/vehicle-reading-remove/route.test.ts @@ -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)=>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={})=>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(); + }); +}); diff --git a/src/app/api/clock/vehicle-reading-remove/route.ts b/src/app/api/clock/vehicle-reading-remove/route.ts new file mode 100644 index 0000000..99e720c --- /dev/null +++ b/src/app/api/clock/vehicle-reading-remove/route.ts @@ -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)|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)); + } +} diff --git a/src/app/api/vehicles/[id]/readings/route.ts b/src/app/api/vehicles/[id]/readings/route.ts index 023e4f4..40e8edc 100644 --- a/src/app/api/vehicles/[id]/readings/route.ts +++ b/src/app/api/vehicles/[id]/readings/route.ts @@ -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|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)|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));} +} diff --git a/src/app/clock/fleet-scanner.tsx b/src/app/clock/fleet-scanner.tsx index cf15c76..01dd127 100644 --- a/src/app/clock/fleet-scanner.tsx +++ b/src/app/clock/fleet-scanner.tsx @@ -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(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} | 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 => { + 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; 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; 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. )} - + @@ -709,10 +758,19 @@ function FormBody({ function PreviousReadingStrip({ previousReading, suffix, + removable, + onRemove, }: { previousReading: number | null; suffix: string; + removable: boolean; + onRemove: (reason: string) => Promise; }) { + const [removing, setRemoving] = React.useState(false); + const [reason, setReason] = React.useState(""); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + if (previousReading === null) { return (
@@ -720,14 +778,61 @@ function PreviousReadingStrip({
); } + + if (removing) { + const canRemove = reason.trim().length >= 5 && !busy; + return ( +
{ + 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(""); } + }} + > +

+ Remove the previous reading ({previousReading.toLocaleString()} {suffix})? +

+ 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 &&

{error}

} +
+ + +
+
+ ); + } + return ( -
+
setRemoving(true) : undefined} + title={removable ? "Double-tap to remove this reading" : undefined} + > Previous reading {previousReading.toLocaleString()} {suffix} + {removable && Double-tap to remove}
); } diff --git a/src/app/dashboard/plant/fuel-logs/fuel-logs-view.tsx b/src/app/dashboard/plant/fuel-logs/fuel-logs-view.tsx index f36df14..772e5a7 100644 --- a/src/app/dashboard/plant/fuel-logs/fuel-logs-view.tsx +++ b/src/app/dashboard/plant/fuel-logs/fuel-logs-view.tsx @@ -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(null); + const canDelete = (r: FuelLogRow) => r.litres === 0 && !!r.vehicleId && !!r.updated && !r.hasDocument; const [siteFilter, setSiteFilter] = React.useState("all"); const [unitFilter, setUnitFilter] = React.useState("all"); const [sourceFilter, setSourceFilter] = React.useState("all"); @@ -241,7 +246,10 @@ export function FuelLogsView({ {pageRows.map((r) => ( - + setDeleting(r) : undefined} + title={canDelete(r) ? "Double-click to delete this meter reading" : undefined} + className={canDelete(r) ? "cursor-pointer" : undefined}> {r.dateLogged ? format(parseISO(r.dateLogged), "yyyy-MM-dd") @@ -318,6 +326,16 @@ export function FuelLogsView({ )} + {deleting && ( + { 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") : "—"}`} + /> + )} {sorted.length > PAGE_SIZE && (
diff --git a/src/app/dashboard/plant/fuel-logs/page.tsx b/src/app/dashboard/plant/fuel-logs/page.tsx index 353a053..d2e678a 100644 --- a/src/app/dashboard/plant/fuel-logs/page.tsx +++ b/src/app/dashboard/plant/fuel-logs/page.tsx @@ -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), dateLogged: l.date_logged ?? "", vehicleId: l.vehicle ?? "", vehicleDescription: v?.description ?? "(unknown)", diff --git a/src/app/dashboard/plant/vehicles/[vehicleId]/page.tsx b/src/app/dashboard/plant/vehicles/[vehicleId]/page.tsx index 9a1717d..f6e0f14 100644 --- a/src/app/dashboard/plant/vehicles/[vehicleId]/page.tsx +++ b/src/app/dashboard/plant/vehicles/[vehicleId]/page.tsx @@ -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), }); } @@ -171,6 +173,7 @@ export default async function VehicleDetailPage({ /> (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 ( - + 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")}> {p.date ? format(parseISO(p.date), "yyyy-MM-dd") : "—"} {p.projectCode || "—"} @@ -483,6 +492,16 @@ export function VehicleDetailView({
)} + {deleting && ( + { 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 && } diff --git a/src/components/delete-reading-button.tsx b/src/components/delete-reading-button.tsx new file mode 100644 index 0000000..631acb0 --- /dev/null +++ b/src/components/delete-reading-button.tsx @@ -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 ( + <> + { if (!busy) setOpen(v); }}> + + + Delete this reading? + {label}. Use this for a test or mistaken meter reading so the next real one can be recorded. + +
+ setReason(e.target.value)} placeholder="Reason, e.g. test reading" aria-label="Reason for deleting" maxLength={2000} autoFocus /> + {error &&

{error}

} + +
+
+
+ + ); +} diff --git a/src/components/plant-reading-review.tsx b/src/components/plant-reading-review.tsx index b722d85..5a037c8 100644 --- a/src/components/plant-reading-review.tsx +++ b/src/components/plant-reading-review.tsx @@ -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 <>{if(!saving)setOpen(v);}}>{create?'Add Missing Reading':reading.source==='weekly'?'Review Weekly Sheet':'Review Meter Reading'}Use verified source records. Saving recalculates hours and cost; any remaining issue stays marked for review. {reason&&

{reason}

} @@ -32,6 +36,7 @@ export function PlantReadingReview({vehicleId,reading,sites,reason,create=false} {error&&

{error}

} + {!create&&

Entered by mistake or as a test? Enter a reason in the note above, then delete it so the next reading can be recorded.

} {!!reading.review_history?.length&&
Correction History ({reading.review_history.length}){reading.review_history.map((h,i)=>

{h.at.slice(0,10)} · {h.actor}

{h.reason}

{JSON.stringify({before:h.before,after:h.after},null,2)}
)}
}
; }