diff --git a/docs/workforce-employers.md b/docs/workforce-employers.md index 62207c9..765f508 100644 --- a/docs/workforce-employers.md +++ b/docs/workforce-employers.md @@ -1,15 +1,21 @@ # Workforce employers and brokers -On **Workforce → Workers**, use **Add Employer/Broker** to open the site-based list. Choose **Add new**, enter a name, select a permitted site, and save. Names are unique within each site. Editing an employer changes its name; its site stays fixed to preserve assignment history. +On **Workforce → Workers**, open **Actions → Manage employers/brokers** to see the site-based list. Choose **Add new**, enter a name, select a permitted site, and save. Names are unique within each site. Editing an employer changes its name; its site stays fixed to preserve assignment history. Worker assignments are read-only in the roster and worker profile. Use **Edit** to open the editing sheet, choose an employer/broker or **Unassigned**, set **Effective from**, and explicitly save. Cancel and closing the sheet discard changes. The first assignment can be backdated to when the worker started with that employer. Later assignments must start on or after the most recent assignment date. Dates before a worker's first assignment remain unassigned. A same-day correction changes attribution from that date, while retaining the previous revision in assignment history. -Workers and Timesheets have **All employers**, **Unassigned**, and named employer filters. The timesheet matrix opens with the selected site and employer; preview and CSV/PDF downloads use the employer assigned on each work date. A period spanning a broker change includes only that broker's matching dates. All-employer exports include all workers and hours. +Workers and Timesheets have **All employers**, **Unassigned**, and named employer filters. On Workers, open **Filters** to see employer, type, position and inactive-worker controls. Active filters appear as removable labels; **Clear all** also clears search. CSV and QR badge exports are available under **Actions**. The timesheet matrix opens with the selected site and employer; preview and CSV/PDF downloads use the employer assigned on each work date. A period spanning a broker change includes only that broker's matching dates. All-employer exports include all workers and hours. ## Database setup Run `python3 scripts/pb-add-employers.py` using the configured PocketBase credentials. It creates server-only `employers` and `employee_employer_assignments` collections, verifies their fields and indexes, and leaves existing workers and timesheets untouched. Re-running verifies the existing schema. Deployment must run this before serving the updated app. Assignments append immutable revisions. A unique worker/site/revision index rejects competing writes, and an expected latest assignment ID rejects stale editing sheets. All reads and writes respect workforce and site access. + +## Add a worker + +Choose **Actions → Add new worker**. Enter their full name and unique employee number, choose an allowed site and worker type, and optionally enter a position, hourly rate and employer/broker. The current site is selected automatically. When viewing all sites, choose one explicitly if multiple sites are available. Employer choices follow the selected site. + +If assigning an employer, set its effective date. The worker and initial employer assignment save in one transaction. **Add worker** opens their profile, including their QR badge. New workers start active. Blank hourly rates are stored as zero and can be set later. Cancel and closing the form discard changes. diff --git a/src/app/api/employees/route.test.ts b/src/app/api/employees/route.test.ts new file mode 100644 index 0000000..7c09942 --- /dev/null +++ b/src/app/api/employees/route.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, it, vi } from "vitest"; +const m = vi.hoisted(() => ({ user: vi.fn(), origin: vi.fn(), get: vi.fn(), list: vi.fn(), create: vi.fn(), send: vi.fn(), operations: [] as { collection: string; data: Record }[] })); +vi.mock("@/lib/auth/guard", () => ({ requireApiUser: m.user })); +vi.mock("@/lib/agent/same-origin", () => ({ isSameOrigin: m.origin })); +vi.mock("@/lib/pb", () => ({ pbAdmin: async () => ({ + collection: () => ({ getOne: m.get, getFullList: m.list, create: m.create }), + createBatch: () => ({ collection: (collection: string) => ({ create: (data: Record) => m.operations.push({ collection, data }) }), send: m.send }), +}) })); +import { POST } from "./route"; +const site = "site00000000001"; +const valid = { name: " New Worker ", employeeNo: " new-001 ", site, workerType: "casual", position: "Labourer", ratePerHour: 35 }; +const request = (body: unknown) => new Request("https://app.test/api/employees", { method: "POST", body: JSON.stringify(body) }); +beforeEach(() => { + vi.resetAllMocks(); m.operations.length = 0; + m.origin.mockReturnValue(true); m.user.mockResolvedValue({ role: "site_user", siteIds: [site] }); + m.get.mockResolvedValue({ id: site, site }); m.list.mockResolvedValue([]); + m.create.mockImplementation(async data => data); m.send.mockResolvedValue([]); +}); +it("creates an active worker on the permitted site with normalized details", async () => { + const response = await POST(request(valid)); + expect(response.status).toBe(201); + expect((await response.json()).worker).toMatchObject({ name: "New Worker", employee_no: "NEW-001", site, employment_status: "active", rate_per_hour: 35 }); +}); +it("blocks duplicate employee numbers regardless of casing or whitespace", async () => { + m.list.mockResolvedValue([{ id: "existing", employee_no: " New-001 " }]); + expect((await POST(request(valid))).status).toBe(409); expect(m.create).not.toHaveBeenCalled(); +}); +it("uses the same record id for repeated creation attempts", async () => { + const first = await (await POST(request(valid))).json(); + const second = await (await POST(request({ ...valid, employeeNo: "NEW-001" }))).json(); + expect(first.worker.id).toMatch(/^[a-z0-9]{15}$/); + expect(first.worker.id).toBe(second.worker.id); +}); +it("creates the worker and initial employer assignment in one transaction", async () => { + const response = await POST(request({ ...valid, employer: "broker000000001", effectiveFrom: "2026-10-01" })); + expect(response.status).toBe(201); + const worker = (await response.json()).worker; + expect(m.operations).toEqual([ + { collection: "employees", data: worker }, + { collection: "employee_employer_assignments", data: { employee: worker.id, site, employer: "broker000000001", effective_from: "2026-10-01 00:00:00.000Z", revision: 1 } }, + ]); + expect(m.send).toHaveBeenCalledOnce(); expect(m.create).not.toHaveBeenCalled(); +}); +it("refuses employers and workers outside the authorized site", async () => { + expect((await POST(request({ ...valid, site: "site00000000002" }))).status).toBe(404); + m.get.mockResolvedValue({ site: "site00000000002" }); + expect((await POST(request({ ...valid, employer: "broker000000001", effectiveFrom: "2026-10-01" }))).status).toBe(400); + expect(m.send).not.toHaveBeenCalled(); expect(m.create).not.toHaveBeenCalled(); +}); +it.each([{}, { ...valid, name: " " }, { ...valid, ratePerHour: -1 }, { ...valid, workerType: "other" }, { ...valid, employer: "broker000000001", effectiveFrom: "2026-02-30" }])("rejects invalid worker details", async body => { + expect((await POST(request(body))).status).toBe(400); expect(m.create).not.toHaveBeenCalled(); +}); +it("requires a signed-in workforce user and same-origin requests", async () => { + m.user.mockResolvedValue(new Response(null, { status: 401 })); + expect((await POST(request(valid))).status).toBe(401); + m.origin.mockReturnValue(false); + expect((await POST(request(valid))).status).toBe(403); +}); diff --git a/src/app/api/employees/route.ts b/src/app/api/employees/route.ts new file mode 100644 index 0000000..4c4baf3 --- /dev/null +++ b/src/app/api/employees/route.ts @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +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"; + +export const runtime = "nodejs"; +const recordId = z.string().regex(/^[a-z0-9]{15}$/); +const schema = z.object({ + name: z.string().trim().min(1).max(160), + employeeNo: z.string().trim().min(1).max(80).transform(value => value.toUpperCase()), + site: recordId, + workerType: z.enum(["casual", "permanent"]), + position: z.string().trim().max(160).default(""), + ratePerHour: z.number().finite().min(0).max(1000000).default(0), + employer: z.union([z.literal(""), recordId]).default(""), + effectiveFrom: z.string().optional(), +}); +const fail = (error: string, status: number) => Response.json({ error }, { status }); + +export async function POST(request: Request) { + if (!isSameOrigin(request)) return fail("Request origin is not allowed", 403); + const user = await requireApiUser("workforce"); + if (user instanceof Response) return user; + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return fail("Enter a name, employee number, site, worker type and valid hourly rate.", 400); + const data = parsed.data; + if (!canAccessSite(user, data.site)) return fail("Site unavailable", 404); + if (data.employer) { + const day = data.effectiveFrom ?? ""; + const date = new Date(`${day}T00:00:00.000Z`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== day || day > workDate(new Date().toISOString())) return fail("Choose a valid employer effective date no later than today.", 400); + } + try { + const pb = await pbAdmin(); + await pb.collection("sites").getOne(data.site, { fields: "id" }); + if (data.employer) { + const employer = await pb.collection("employers").getOne(data.employer, { fields: "site" }); + if (employer.site !== data.site) return fail("Choose an employer/broker on this worker's site.", 400); + } + // Employee numbers identify people across all sites and the clock scanner. + const existing = await pb.collection("employees").getFullList({ fields: "id,employee_no" }); + if (existing.some(worker => String(worker.employee_no ?? "").trim().toUpperCase() === data.employeeNo)) return fail("This employee number is already in use. Search for the existing worker first.", 409); + // Repeated/concurrent submissions through this form claim the same ID. + const id = createHash("sha256").update(`worker-number:${data.employeeNo}`).digest("hex").slice(0, 15); + const worker = { + id, name: data.name, employee_no: data.employeeNo, site: data.site, + worker_type: data.workerType, position: data.position, rate_per_hour: data.ratePerHour, + hours_per_day_standard: 9, employment_status: "active", inactive_from: "", + }; + if (data.employer) { + const batch = pb.createBatch(); + batch.collection("employees").create(worker); + batch.collection("employee_employer_assignments").create({ + employee: id, site: data.site, employer: data.employer, + effective_from: `${data.effectiveFrom} 00:00:00.000Z`, revision: 1, + }); + await batch.send(); + } else { + await pb.collection("employees").create(worker); + } + return Response.json({ worker }, { status: 201 }); + } catch (cause) { + const status = (cause as { status?: number }).status; + if (status === 400) return fail("Could not add this worker. The employee number may already be in use; check the details and refresh before retrying.", 409); + return fail("Could not confirm worker creation. Refresh the Workers list before trying again.", status === 404 ? 404 : 503); + } +} diff --git a/src/app/dashboard/workforce/workers/employees-view.test.tsx b/src/app/dashboard/workforce/workers/employees-view.test.tsx index 02c2eac..8404dc5 100644 --- a/src/app/dashboard/workforce/workers/employees-view.test.tsx +++ b/src/app/dashboard/workforce/workers/employees-view.test.tsx @@ -11,7 +11,11 @@ it("keeps worker assignments read-only until the edit sheet is opened", () => { }]} showSiteColumn={false} positions={[]} employers={[{ id: "broker000000001", name: "Labour broker", site: "site00000000001" }]} />); expect(html).toContain("Labour broker"); expect(html).toContain('aria-label="Edit employer/broker for Test worker"'); - expect(html).toContain("Add Employer/Broker"); + expect(html).toContain("Actions"); + expect(html).toContain("Filters"); + expect(html).not.toContain("Add Employer/Broker"); + expect(html).not.toContain("Export CSV"); + expect(html).not.toContain("Include inactive workers"); const roster = html.slice(html.indexOf("")); expect(roster).not.toContain(">(new Set()); + const [filtersOpen, setFiltersOpen] = React.useState(false); + const [addWorkerOpen, setAddWorkerOpen] = React.useState(false); + const [employerListOpen, setEmployerListOpen] = React.useState(false); const [batchDialogOpen, setBatchDialogOpen] = React.useState(false); const filtered = React.useMemo(() => { @@ -98,10 +103,16 @@ export function EmployeesView({ [sorted, page], ); - const filtersActive = - workerFilter !== "all" || employerFilter !== "all" || - positionFilter !== "all" || - search !== ""; + const activeFilters = [ + ...(workerFilter !== "all" ? [{ key: "type", label: workerFilter === "casual" ? "Casual" : "Permanent", clear: () => setWorkerFilter("all") }] : []), + ...(employerFilter !== "all" ? [{ key: "employer", label: employerFilter === "unassigned" ? "Unassigned" : employers.find(e => e.id === employerFilter)?.name ?? "Employer/Broker", clear: () => setEmployerFilter("all") }] : []), + ...(positionFilter !== "all" ? [{ key: "position", label: positionFilter, clear: () => setPositionFilter("all") }] : []), + ...(includeInactive ? [{ key: "inactive", label: "Including inactive", clear: () => setIncludeInactive(false) }] : []), + ]; + function clearFilters() { + setWorkerFilter("all"); setEmployerFilter("all"); setPositionFilter("all"); + setIncludeInactive(false); setSearch(""); setPage(1); + } const allFilteredSelected = sorted.length > 0 && sorted.every((r) => selected.has(r.id)); @@ -126,89 +137,42 @@ export function EmployeesView({ return (
- -
- - { setSearch(e.target.value); setPage(1); }} - placeholder="Search by name or employee number…" - className="pl-8" - aria-label="Search workers" - /> -
- - - { setEmployerFilter(value); setPage(1); }} - options={[{ value: "all", label: "All employers" }, { value: "unassigned", label: "Unassigned" }, - ...employers.filter(e => initialSiteId === "all" || e.site === initialSiteId).map(e => ({ value: e.id, label: `${e.name}${showSiteColumn ? ` · ${sites.find(s => s.id === e.site)?.name ?? ""}` : ""}` }))]} /> - - - { setWorkerFilter(v as WorkerFilter); setPage(1); }} - options={[ - { value: "all", label: "All workers" }, - { value: "permanent", label: "Permanent only" }, - { value: "casual", label: "Casual only" }, - ]} - /> - - {positions.length > 0 && ( - { setPositionFilter(value); setPage(1); }} - options={[ - { value: "all", label: "All positions" }, - ...positions.map((p) => ({ value: p, label: p })), - ]} - /> - )} - - {filtersActive && ( - - )} - - - - + + + { if (addWorkerOpen || employerListOpen || batchDialogOpen) event.preventDefault(); }}> + setAddWorkerOpen(true)}>Add new worker + + exportCsv(sorted)}>Export CSV + setBatchDialogOpen(true)}>Export QR badges + + setEmployerListOpen(true)}>Manage employers/brokers + + +
+ {filtersOpen &&
+ { setEmployerFilter(value); setPage(1); }} + options={[{ value: "all", label: "All employers" }, { value: "unassigned", label: "Unassigned" }, + ...employers.filter(e => initialSiteId === "all" || e.site === initialSiteId).map(e => ({ value: e.id, label: `${e.name}${showSiteColumn ? ` · ${sites.find(site => site.id === e.site)?.name ?? ""}` : ""}` }))]} /> + { setWorkerFilter(value as WorkerFilter); setPage(1); }} + options={[{ value: "all", label: "All workers" }, { value: "casual", label: "Casual only" }, { value: "permanent", label: "Permanent only" }]} /> + {positions.length > 0 && { setPositionFilter(value); setPage(1); }} + options={[{ value: "all", label: "All positions" }, ...positions.map(position => ({ value: position, label: position }))]} />} + +
} +
+ {sorted.length} of {rows.length} workers{selected.size > 0 ? ` · ${selected.size} selected` : ""} + {activeFilters.map(filter => )} + {(activeFilters.length > 0 || search) && } +
@@ -317,6 +281,10 @@ export function EmployeesView({ )} + {addWorkerOpen && { router.push(`/dashboard/workforce/workers/${id}`); router.refresh(); }} />} + {employerListOpen && } + ; label: string; value: string; onChange: (v: string) => void; options: { value: string; label: string }[]; }) { return ( -
- - {label} - +
+ {label} setName(event.target.value)} className="mt-2" /> + + + + + + + {employer && } + + {error &&

{error}

} +
+ + + ; +} diff --git a/src/components/employer-broker-list.tsx b/src/components/employer-broker-list.tsx index 9e4f686..2d69daa 100644 --- a/src/components/employer-broker-list.tsx +++ b/src/components/employer-broker-list.tsx @@ -8,11 +8,14 @@ import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import type { EmployerOption } from "@/lib/employer-assignments"; -export function EmployerBrokerList({ employers, sites, initialSiteId }: { +export function EmployerBrokerList({ employers, sites, initialSiteId, open: controlledOpen, onOpenChange }: { employers: EmployerOption[]; sites: { id: string; name: string }[]; initialSiteId: string; + open?: boolean; onOpenChange?: (open: boolean) => void; }) { const router = useRouter(); - const [open, setOpen] = React.useState(false); + const [internalOpen, setInternalOpen] = React.useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; const [editing, setEditing] = React.useState(null); const [name, setName] = React.useState(""); const [site, setSite] = React.useState(initialSiteId); @@ -36,7 +39,7 @@ export function EmployerBrokerList({ employers, sites, initialSiteId }: { } const visible = employers.filter(row => listSite === "all" || row.site === listSite); return <> - + {controlledOpen === undefined && } { if (!busy) setOpen(value); }}> Employers / BrokersManage the employers and brokers who pay workers on your sites.