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:
parent
7d755961f4
commit
e0c07cbf5f
11 changed files with 322 additions and 5 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
35
src/app/api/clock/vehicle-reading-remove/route.test.ts
Normal file
35
src/app/api/clock/vehicle-reading-remove/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
47
src/app/api/clock/vehicle-reading-remove/route.ts
Normal file
47
src/app/api/clock/vehicle-reading-remove/route.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
54
src/components/delete-reading-button.tsx
Normal file
54
src/components/delete-reading-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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></>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue