Prefill clock correction times from recorded daily scans
This commit is contained in:
parent
921e906d55
commit
d21303f3af
4 changed files with 116 additions and 6 deletions
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in a new issue