feat(workforce): allow any effective date when editing a worker's employer/broker

Backdating before the latest assignment and future dates were refused, so a
wrong start date could never be corrected. Corrections still append a revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-29 23:38:38 +02:00
parent e9edc1988e
commit 6ba4f5be6c
3 changed files with 12 additions and 7 deletions

View file

@ -33,9 +33,15 @@ it("refuses a worker outside the user's sites", async () => {
m.user.mockResolvedValue({ role: "site_user", siteIds: [] });
expect((await POST(request(valid), context)).status).toBe(404); expect(m.create).not.toHaveBeenCalled();
});
it.each(["2026-02-30", "2099-01-01", "bad"])("rejects invalid or future date %s", async effectiveFrom => {
it.each(["2026-02-30", "bad"])("rejects invalid date %s", async effectiveFrom => {
expect((await POST(request({ ...valid, effectiveFrom }), context)).status).toBe(400);
});
it("allows backdating before the most recent assignment", async () => {
m.list.mockResolvedValue([{ id: "today", effective_from: "2026-10-29 00:00:00.000Z", employer: broker, revision: 1 }]);
const response = await POST(request({ ...valid, effectiveFrom: "2026-07-01", expectedAssignmentId: "today" }), context);
expect(response.status).toBe(200);
expect((await response.json()).assignment).toMatchObject({ effective_from: "2026-07-01 00:00:00.000Z", revision: 2 });
});
it("refuses stale edits instead of silently replacing a newer assignment", async () => {
m.list.mockResolvedValue([{ id: "newer", effective_from: "2026-10-02", employer: broker }]);
expect((await POST(request(valid), context)).status).toBe(409); expect(m.create).not.toHaveBeenCalled();

View file

@ -3,7 +3,6 @@ import { pbAdmin } from "@/lib/pb";
import { requireApiUser } from "@/lib/auth/guard";
import { canAccessSite } from "@/lib/auth/access";
import { isSameOrigin } from "@/lib/agent/same-origin";
import { workDate } from "@/lib/clock-day";
import type { EmployerAssignment } from "@/lib/employer-assignments";
export const runtime = "nodejs";
@ -23,7 +22,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
if (!/^[a-z0-9]{15}$/.test(id) || !parsed.success) return fail("Choose an employer/broker and an effective date.", 400);
const { employer, effectiveFrom, expectedAssignmentId } = parsed.data;
const date = new Date(`${effectiveFrom}T00:00:00.000Z`);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== effectiveFrom || effectiveFrom > workDate(new Date().toISOString())) return fail("Choose a valid date no later than today.", 400);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== effectiveFrom) return fail("Choose a valid date.", 400);
try {
const pb = await pbAdmin();
const employee = await pb.collection("employees").getOne(id);
@ -37,10 +36,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
});
const latest = history[0];
if ((latest?.id ?? "") !== expectedAssignmentId) return fail("This worker's assignment changed. Refresh the page and reopen Edit.", 409);
if (latest && effectiveFrom < latest.effective_from.slice(0, 10)) return fail("Choose a date on or after the most recent assignment to preserve its history.", 409);
const payload = { employee: id, site: employee.site, employer, effective_from: `${effectiveFrom} 00:00:00.000Z`, revision: (latest?.revision ?? 0) + 1 };
// The unique (employee, site, revision) index makes concurrent appends atomic:
// only one editor can claim the next revision. Same-day corrections never overwrite.
// only one editor can claim the next revision. Corrections never overwrite: a
// backdated row covers the earlier days, later-dated rows still win from their date.
const assignment = await pb.collection("employee_employer_assignments").create(payload);
return Response.json({ assignment });
} catch (cause) {

View file

@ -55,9 +55,9 @@ export function WorkerEmployerEdit({ worker, employers, history = [], onSaved }:
</label>
{!siteEmployers.length && <p className="text-sm text-muted-foreground">No employers/brokers on this site yet. Use Add Employer/Broker on the Workers page to create one.</p>}
<label className="block text-sm font-medium">Effective from
<input type="date" required min={latest?.effective_from.slice(0, 10)} max={today} value={date} disabled={busy} onChange={event => setDate(event.target.value)} className="mt-2 block w-full rounded-md border bg-background px-3 py-2" />
<input type="date" required value={date} disabled={busy} onChange={event => setDate(event.target.value)} className="mt-2 block w-full rounded-md border bg-background px-3 py-2" />
</label>
<p className="text-sm text-muted-foreground">Earlier dates keep their previous employer/broker. For a first assignment, choose the date this worker started with the employer. Saving again on the same date corrects that day’s assignment.</p>
<p className="text-sm text-muted-foreground">Any date can be chosen. Days before it keep their previous employer/broker; a later dated change still applies from its own date. To fix a wrong start date, pick the correct one and save.</p>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => setOpen(false)}>Cancel</Button>