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 5bd850a..d2510c0 100644
--- a/src/app/dashboard/workforce/on-site/correction-dialog.test.tsx
+++ b/src/app/dashboard/workforce/on-site/correction-dialog.test.tsx
@@ -100,6 +100,6 @@ it.each([
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");
+ expect(html).toContain("Your entered times determine the corrected hours");
}
});
diff --git a/src/app/dashboard/workforce/on-site/correction-dialog.tsx b/src/app/dashboard/workforce/on-site/correction-dialog.tsx
index dbbde57..75d4d83 100644
--- a/src/app/dashboard/workforce/on-site/correction-dialog.tsx
+++ b/src/app/dashboard/workforce/on-site/correction-dialog.tsx
@@ -32,7 +32,6 @@ function CorrectionForm({ shift, onClose }: {
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(() => recordedOut ? workDate(recordedOut.scannedAt) : scanDate);
@@ -109,7 +108,7 @@ function CorrectionForm({ shift, onClose }: {
- An earlier clock-out is already recorded. Moving arrival before it needs a review of those scans. -
- )} + )} {lateScan && ( diff --git a/src/lib/clock-correction.test.ts b/src/lib/clock-correction.test.ts index dcdb223..f42682f 100644 --- a/src/lib/clock-correction.test.ts +++ b/src/lib/clock-correction.test.ts @@ -66,12 +66,12 @@ describe("missed scan-out correction", () => { expect(lockDelete).toHaveBeenCalledWith("lock1"); }); - it("rejects an out after the next IN without writing", async () => { + it("allows a leaving override after the next recorded IN", async () => { const { pb, send } = fixture(); - await expect(correctMissedClockOut(pb as never, "oldin", { + await correctMissedClockOut(pb as never, "oldin", { actualOutLocal: "2026-09-25T09:00", reason: "Missed exit scan", now, - })).rejects.toMatchObject({ status: 409 }); - expect(send).not.toHaveBeenCalled(); + }); + expect(send).toHaveBeenCalledOnce(); }); it("rejects a duplicate correction and leaves imported time untouched", async () => { @@ -127,18 +127,21 @@ describe("corrected arrival when the morning scan was missed", () => { const now = new Date("2026-09-25T08:00:00Z"); // The 17:05 scan was really the clock-out; the worker arrived at 07:00. - it("measures hours from the corrected arrival when leaving equals the scan time", async () => { + it.each([ + { leaving: "16:00", hours: 8.5 }, + { leaving: "15:00", hours: 7.5 }, + ])("uses corrected times when leaving $leaving is at or before the original scan", async ({ leaving, hours }) => { const { pb, created } = fixture(); const result = await correctMissedClockOut(pb as never, "oldin", { actualInLocal: "2026-09-23T07:00", - actualOutLocal: "2026-09-23T16:00", + actualOutLocal: `2026-09-23T${leaving}`, reason: "Nobody scanned him in this morning", now, }); - // 07:00 -> 16:00 SAST is 9h raw; paid hours subtract the 30-minute lunch. - expect(result.sessionHours).toBe(8.5); + // Paid hours use the entered interval and subtract the 30-minute lunch. + expect(result.sessionHours).toBe(hours); expect(created.find((row) => row.collection === "clock_missed_out_corrections")?.data) - .toMatchObject({ actual_in_at: "2026-09-23T05:00:00.000Z", actual_out_at: "2026-09-23T14:00:00.000Z" }); + .toMatchObject({ actual_in_at: "2026-09-23T05:00:00.000Z", actual_out_at: leaving === "16:00" ? "2026-09-23T14:00:00.000Z" : "2026-09-23T13:00:00.000Z" }); }); it("rejects an unparseable arrival", async () => { @@ -157,32 +160,27 @@ describe("corrected arrival when the morning scan was missed", () => { expect(send).not.toHaveBeenCalled(); }); - it("rejects an arrival at or before the worker's earlier clock-out that day", async () => { + it("allows an arrival at or before the worker's earlier clock-out that day", async () => { const earlyOut = { id: "earlyout", employee: "emp1", site: "site1", direction: "out", scanned_at: "2026-09-23T10:00:00Z" }; // 12:00 SAST const { pb, send } = fixture({ events: [newIn, oldIn, earlyOut] }); await expect(correctMissedClockOut(pb as never, "oldin", { actualInLocal: "2026-09-23T07:00", actualOutLocal: "2026-09-23T16:00", reason: "No morning scan", now, - })).rejects.toMatchObject({ - status: 409, message: "Arrival overlaps the worker's earlier clock-out that day", - }); - expect(send).not.toHaveBeenCalled(); + })).resolves.toMatchObject({ sessionHours: 8.5 }); + expect(send).toHaveBeenCalledOnce(); }); - it("rejects an arrival that lands before the LAST clock-out scanned that day", async () => { + it("allows an arrival that lands before the LAST clock-out scanned that day", async () => { const outMorning = { id: "outam", employee: "emp1", site: "site1", direction: "out", scanned_at: "2026-09-23T08:00:00Z" }; // 10:00 SAST const outMidday = { id: "outpm", employee: "emp1", site: "site1", direction: "out", scanned_at: "2026-09-23T11:00:00Z" }; // 13:00 SAST - // The 16:00 scan is the unresolved IN. The nearest earlier exit is - // 13:00, so an 11:00 arrival overlaps it. + // The manual arrival wins even when multiple earlier exits were scanned. const { pb, send } = fixture({ events: [newIn, oldIn, outMorning, outMidday] }); await expect(correctMissedClockOut(pb as never, "oldin", { actualInLocal: "2026-09-23T11:00", actualOutLocal: "2026-09-23T16:00", reason: "No morning scan", now, - })).rejects.toMatchObject({ - status: 409, message: "Arrival overlaps the worker's earlier clock-out that day", - }); - expect(send).not.toHaveBeenCalled(); + })).resolves.toMatchObject({ sessionHours: 5 }); + expect(send).toHaveBeenCalledOnce(); // After that 13:00 exit the arrival is fine. const allowed = fixture({ events: [newIn, oldIn, outMorning, outMidday] }); diff --git a/src/lib/clock-correction.ts b/src/lib/clock-correction.ts index 7b6b38a..e0ba0cd 100644 --- a/src/lib/clock-correction.ts +++ b/src/lib/clock-correction.ts @@ -116,23 +116,8 @@ export async function correctMissedClockOut( if (!unresolvedClockIns(events, now, corrected).some((event) => event.id === clockInEventId)) { throw new ClockCorrectionError("This scan-out has already been resolved or is no longer open", 409); } - const nextIn = events.filter((event) => event.direction === "in" && - Date.parse(event.scanned_at) > scanAt) - .sort((a, b) => a.scanned_at.localeCompare(b.scanned_at))[0]; - if (nextIn && actualOut.getTime() >= Date.parse(nextIn.scanned_at)) { - throw new ClockCorrectionError("Leaving time overlaps the worker's next clock-in", 409); - } - // A corrected arrival sits inside the same shift as this scan, so it - // must not rewind past the LAST clock-out the worker scanned that day. - // Descending: the nearest earlier exit is the one that would be - // overwritten by an arrival before it. - const previousOut = events.filter((event) => event.direction === "out" && - workDate(event.scanned_at) === scanWorkDate && - Date.parse(event.scanned_at) <= scanAt) - .sort((a, b) => b.scanned_at.localeCompare(a.scanned_at))[0]; - if (actualIn && previousOut && start <= Date.parse(previousOut.scanned_at)) { - throw new ClockCorrectionError("Arrival overlaps the worker's earlier clock-out that day", 409); - } + // Manual times are authoritative; QR scans remain audit evidence and + // do not constrain the corrected arrival or leaving time. const entries = fetchedEntries.filter((entry) => !entry.timesheet); if (entries.length > 1) { throw new ClockCorrectionError("More than one direct time entry exists for this day. Review it manually.", 409);