From d21303f3afee5ba9147fae0bc2334b549a47d455 Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 14 Oct 2026 16:19:10 +0200 Subject: [PATCH] Prefill clock correction times from recorded daily scans --- .../on-site/correction-dialog.test.tsx | 35 ++++++++++++ .../workforce/on-site/correction-dialog.tsx | 53 +++++++++++++++++-- src/lib/attendance.test.ts | 18 +++++++ src/lib/attendance.ts | 16 +++++- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/app/dashboard/workforce/on-site/correction-dialog.test.tsx b/src/app/dashboard/workforce/on-site/correction-dialog.test.tsx index a23e534..5bd850a 100644 --- a/src/app/dashboard/workforce/on-site/correction-dialog.test.tsx +++ b/src/app/dashboard/workforce/on-site/correction-dialog.test.tsx @@ -68,3 +68,38 @@ it.each([ }); expect(onClose).toHaveBeenCalledOnce(); }); + +it.each([ + { name: "matching later exit", scans: [ + { id: "exit", direction: "out" as const, scannedAt: "2026-10-08T13:00:00Z" }, + ], expectedOut: "15:00" }, + { name: "exit before the selected arrival", scans: [ + { id: "exit", direction: "out" as const, scannedAt: "2026-10-08T05:24:23Z" }, + ], expectedOut: "" }, + { name: "exit belonging to the next shift", scans: [ + { id: "next", direction: "in" as const, scannedAt: "2026-10-08T10:00:00Z" }, + { id: "exit", direction: "out" as const, scannedAt: "2026-10-08T13:00:00Z" }, + ], expectedOut: "" }, + { name: "exit on another work day", scans: [ + { id: "exit", direction: "out" as const, scannedAt: "2026-10-08T22:30:00Z" }, + ], expectedOut: "" }, +])("prefills recorded times: $name", ({ scans, expectedOut }) => { + vi.mocked(React.useState).mockImplementation((initial?: unknown) => [ + typeof initial === "function" ? initial() : initial, vi.fn(), + ] as ReturnType); + const shift: AttentionRow = { + clockInEventId: "selected", clockedInAt: "2026-10-08T05:24:31Z", + employeeId: "emp1", employeeName: "Test worker", employeeNo: "SUB0085", + siteId: "site1", siteName: "Test site", lateScan: false, + dayScans: [{ id: "selected", direction: "in", scannedAt: "2026-10-08T05:24:31Z" }, ...scans], + }; + const html = renderToStaticMarkup(); + const timeInputs = [...html.matchAll(/]*value="([^"]*)"/g)].map(match => match[1]); + expect(timeInputs).toEqual(["07:24", expectedOut]); + expect(html).toContain("Recorded scans for this day"); + expect(html).toContain("07:24:31"); + if (scans[0].scannedAt === "2026-10-08T05:24:23Z") { + expect(html).toContain("07:24:23"); + expect(html).toContain("An earlier clock-out is already recorded"); + } +}); diff --git a/src/app/dashboard/workforce/on-site/correction-dialog.tsx b/src/app/dashboard/workforce/on-site/correction-dialog.tsx index 2229273..dbbde57 100644 --- a/src/app/dashboard/workforce/on-site/correction-dialog.tsx +++ b/src/app/dashboard/workforce/on-site/correction-dialog.tsx @@ -22,17 +22,25 @@ function CorrectionForm({ shift, onClose }: { }) { const router = useRouter(); const { mode } = useAuth(); + const scanDate = shift ? workDate(shift.clockedInAt) : ""; + const scanTime = shift ? localTimeHHmm(shift.clockedInAt) : ""; + const scanAt = shift ? Date.parse(shift.clockedInAt) : 0; + const dayScans = (shift?.dayScans ?? []) + .filter(scan => workDate(scan.scannedAt) === scanDate) + .sort((a, b) => Date.parse(a.scannedAt) - Date.parse(b.scannedAt) || a.id.localeCompare(b.id)); + const nextIn = dayScans.find(scan => scan.direction === "in" && Date.parse(scan.scannedAt) > scanAt); + const recordedOut = dayScans.find(scan => scan.direction === "out" && + Date.parse(scan.scannedAt) > scanAt && + (!nextIn || Date.parse(scan.scannedAt) < Date.parse(nextIn.scannedAt))); + const previousOut = dayScans.filter(scan => scan.direction === "out" && Date.parse(scan.scannedAt) <= scanAt).at(-1); const [inDate, setInDate] = React.useState(() => shift ? workDate(shift.clockedInAt) : ""); const [inTime, setInTime] = React.useState(() => shift ? localTimeHHmm(shift.clockedInAt) : ""); - const [outDate, setOutDate] = React.useState(() => shift ? workDate(shift.clockedInAt) : ""); - const [outTime, setOutTime] = React.useState(""); + const [outDate, setOutDate] = React.useState(() => recordedOut ? workDate(recordedOut.scannedAt) : scanDate); + const [outTime, setOutTime] = React.useState(() => recordedOut ? localTimeHHmm(recordedOut.scannedAt) : ""); const [reason, setReason] = React.useState(""); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(""); - const scanDate = shift ? workDate(shift.clockedInAt) : ""; - const scanTime = shift ? localTimeHHmm(shift.clockedInAt) : ""; - let hours: number | null = null; if (shift && inDate && inTime && outDate && outTime) { try { @@ -104,6 +112,41 @@ function CorrectionForm({ shift, onClose }: { The original QR scan stays in the record. + {dayScans.length > 0 && ( +
+

Recorded scans for this day

+
    + {dayScans.map(scan => ( +
  • + + {scan.direction === "in" ? "Clock-in" : "Clock-out"}{" "} + {new Date(scan.scannedAt).toLocaleTimeString("en-GB", { + timeZone: "Africa/Johannesburg", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", + })} + {scan.id === shift?.clockInEventId && " (selected)"} + + +
  • + ))} +
+ {previousOut && ( +

+ An earlier clock-out is already recorded. Moving arrival before it needs a review of those scans. +

+ )} +
+ )} {lateScan && (

Scanned at {scanTime} — late for a clock-in. If nobody scanned them in this morning, diff --git a/src/lib/attendance.test.ts b/src/lib/attendance.test.ts index 8cb39f0..c56eb5c 100644 --- a/src/lib/attendance.test.ts +++ b/src/lib/attendance.test.ts @@ -58,4 +58,22 @@ describe("groupAttendance", () => { })); expect(groupAttendance(inactiveEvents, new Set(), NOW)).toEqual({ onSite: [], clockedOutToday: [], notArrivedToday: [], needsAttention: [] }); }); + + it("includes only that worker's same local day scans in chronological order for correction", () => { + const scans = [ + ev("open", "a", "in", "2026-09-23T05:24:31Z"), + ev("other-worker", "b", "out", "2026-09-23T05:24:25Z"), + ev("exit", "a", "out", "2026-09-23T05:24:23Z"), + ev("first", "a", "in", "2026-09-23T05:24:09Z"), + ev("midnight", "a", "out", "2026-09-22T22:01:00Z"), + ev("yesterday", "a", "out", "2026-09-22T21:59:00Z"), + ]; + const row = groupAttendance(scans, new Set(), NOW).needsAttention.find(r => r.clockInEventId === "open"); + expect(row).toHaveProperty("dayScans", [ + { id: "midnight", direction: "out", scannedAt: "2026-09-22T22:01:00Z" }, + { id: "first", direction: "in", scannedAt: "2026-09-23T05:24:09Z" }, + { id: "exit", direction: "out", scannedAt: "2026-09-23T05:24:23Z" }, + { id: "open", direction: "in", scannedAt: "2026-09-23T05:24:31Z" }, + ]); + }); }); diff --git a/src/lib/attendance.ts b/src/lib/attendance.ts index a735336..9c5ba37 100644 --- a/src/lib/attendance.ts +++ b/src/lib/attendance.ts @@ -6,7 +6,7 @@ * The page still does the PocketBase reads and passes them in. */ import { bucketClockEventsForToday, unresolvedClockIns } from "@/lib/clock-events"; -import { localTimeHHmm } from "@/lib/clock-day"; +import { localTimeHHmm, workDate } from "@/lib/clock-day"; import { MS_PER_DAY } from "@/lib/metrics/constants"; import type { ClockEventRecord } from "@/lib/pb-types"; import { isWorkerActive } from "@/lib/worker-status"; @@ -55,6 +55,8 @@ export interface AttentionRow extends BaseRow { clockedInAt: string; /** Scan landed at or after LATE_SCAN_HOUR — likely really the clock-out. */ lateScan: boolean; + /** Recorded scans for this employee's South African work day. */ + dayScans?: { id: string; direction: "in" | "out"; scannedAt: string }[]; } export interface AttendanceGroups { @@ -154,11 +156,23 @@ export function groupAttendance( // a few days are quieter than the ones we saw yesterday. notArrivedToday.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)); + const scansByEmployeeDay = new Map>(); + for (const event of events) { + const key = `${event.employee}:${workDate(event.scanned_at)}`; + const scans = scansByEmployeeDay.get(key) ?? []; + scans.push({ id: event.id, direction: event.direction, scannedAt: event.scanned_at }); + scansByEmployeeDay.set(key, scans); + } + for (const scans of scansByEmployeeDay.values()) { + scans.sort((a, b) => Date.parse(a.scannedAt) - Date.parse(b.scannedAt) || a.id.localeCompare(b.id)); + } + const needsAttention: AttentionRow[] = unresolved.map((event) => ({ ...toRow(event), clockInEventId: event.id, clockedInAt: event.scanned_at, lateScan: Number(localTimeHHmm(event.scanned_at).slice(0, 2)) >= LATE_SCAN_HOUR, + dayScans: scansByEmployeeDay.get(`${event.employee}:${workDate(event.scanned_at)}`) ?? [], })); return { onSite, clockedOutToday, notArrivedToday, needsAttention };