Prefill clock correction times from recorded daily scans

This commit is contained in:
DEls 2026-10-14 16:19:10 +02:00
parent 921e906d55
commit d21303f3af
4 changed files with 116 additions and 6 deletions

View file

@ -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<typeof React.useState>);
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(<CorrectionDialog shift={shift} onClose={vi.fn()} />);
const timeInputs = [...html.matchAll(/<input type="text"[^>]*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");
}
});

View file

@ -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.
</DialogDescription>
</DialogHeader>
{dayScans.length > 0 && (
<div className="rounded-md border border-input px-3 py-2 text-sm">
<p className="font-medium">Recorded scans for this day</p>
<ul className="mt-1 max-h-28 space-y-1 overflow-y-auto text-muted-foreground">
{dayScans.map(scan => (
<li key={scan.id} className="flex items-center justify-between gap-3">
<span>
{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)"}
</span>
<button type="button" disabled={busy} className="shrink-0 text-xs font-medium underline"
onClick={() => {
if (scan.direction === "in") {
setInDate(workDate(scan.scannedAt));
setInTime(localTimeHHmm(scan.scannedAt));
} else {
setOutDate(workDate(scan.scannedAt));
setOutTime(localTimeHHmm(scan.scannedAt));
}
}}>
{scan.direction === "in" ? "Use arrival" : "Use leaving"}
</button>
</li>
))}
</ul>
{previousOut && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
An earlier clock-out is already recorded. Moving arrival before it needs a review of those scans.
</p>
)}
</div>
)}
{lateScan && (
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-300">
Scanned at {scanTime} — late for a clock-in. If nobody scanned them in this morning,

View file

@ -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" },
]);
});
});

View file

@ -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<string, NonNullable<AttentionRow["dayScans"]>>();
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 };