feat(workforce): simplify toolbar and add workers
This commit is contained in:
parent
bb2d15a39b
commit
757861c293
7 changed files with 265 additions and 100 deletions
|
|
@ -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.
|
||||
|
|
|
|||
58
src/app/api/employees/route.test.ts
Normal file
58
src/app/api/employees/route.test.ts
Normal file
|
|
@ -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<string, unknown> }[] }));
|
||||
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<string, unknown>) => 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);
|
||||
});
|
||||
70
src/app/api/employees/route.ts
Normal file
70
src/app/api/employees/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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("<tbody"), html.indexOf("</tbody>"));
|
||||
expect(roster).not.toContain("<select");
|
||||
expect(html).not.toContain("Save changes");
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Download, Filter, QrCode, Search, Users } from "lucide-react";
|
||||
import { Download, Filter, QrCode, Search, Users, UserPlus, Building2, ChevronDown, X } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -24,6 +24,8 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { AddWorkerDialog } from "@/components/add-worker-dialog";
|
||||
import { WorkerQrBatchDialog } from "@/components/worker-qr-batch-dialog";
|
||||
|
||||
import { EmployerBrokerList } from "@/components/employer-broker-list";
|
||||
|
|
@ -70,6 +72,9 @@ export function EmployeesView({
|
|||
const [search, setSearch] = React.useState("");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [selected, setSelected] = React.useState<Set<string>>(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 (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:flex-wrap">
|
||||
<div className="relative w-full sm:w-64 sm:shrink-0">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
placeholder="Search by name or employee number…"
|
||||
className="pl-8"
|
||||
aria-label="Search workers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmployerBrokerList employers={employers} sites={sites} initialSiteId={initialSiteId} />
|
||||
<SelectFilter icon={Filter} label="Employer/Broker" value={employerFilter} onChange={value => { 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 ?? ""}` : ""}` }))]} />
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={includeInactive} onChange={(event) => { setIncludeInactive(event.target.checked); setPage(1); }} />
|
||||
Include inactive workers
|
||||
</label>
|
||||
<SelectFilter
|
||||
icon={Filter}
|
||||
label="Type"
|
||||
value={workerFilter}
|
||||
onChange={(v) => { 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 && (
|
||||
<SelectFilter
|
||||
icon={Filter}
|
||||
label="Position"
|
||||
value={positionFilter}
|
||||
onChange={value => { setPositionFilter(value); setPage(1); }}
|
||||
options={[
|
||||
{ value: "all", label: "All positions" },
|
||||
...positions.map((p) => ({ value: p, label: p })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filtersActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setWorkerFilter("all");
|
||||
setEmployerFilter("all");
|
||||
setPositionFilter("all");
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
|
||||
<div className="relative w-full sm:min-w-0 sm:flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={search} onChange={event => { setSearch(event.target.value); setPage(1); }} placeholder="Search workers by name or number…" className="pl-8" aria-label="Search workers" />
|
||||
</div>
|
||||
<Button variant="outline" className="h-9 gap-2" aria-expanded={filtersOpen} aria-controls="worker-filters" onClick={() => setFiltersOpen(value => !value)}>
|
||||
<Filter className="h-4 w-4" />Filters{activeFilters.length > 0 && <span className="rounded bg-muted px-1.5 text-xs tabular-nums">{activeFilters.length}</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportCsv(sorted)}
|
||||
disabled={sorted.length === 0}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export CSV
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setBatchDialogOpen(true)}
|
||||
disabled={sorted.length === 0}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5" />
|
||||
Export QR badges
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant="outline" className="h-9 gap-2">Actions<ChevronDown className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60" onCloseAutoFocus={event => { if (addWorkerOpen || employerListOpen || batchDialogOpen) event.preventDefault(); }}>
|
||||
<DropdownMenuItem disabled={!sites.length} onSelect={() => setAddWorkerOpen(true)}><UserPlus className="h-4 w-4" />Add new worker</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!sorted.length} onSelect={() => exportCsv(sorted)}><Download className="h-4 w-4" />Export CSV</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!sorted.length} onSelect={() => setBatchDialogOpen(true)}><QrCode className="h-4 w-4" />Export QR badges</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => setEmployerListOpen(true)}><Building2 className="h-4 w-4" />Manage employers/brokers</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{filtersOpen && <div id="worker-filters" className="grid gap-4 border-t pt-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="Worker filters">
|
||||
<SelectFilter label="Employer/Broker" value={employerFilter} onChange={value => { 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 ?? ""}` : ""}` }))]} />
|
||||
<SelectFilter label="Worker type" value={workerFilter} onChange={value => { 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 && <SelectFilter label="Position" value={positionFilter} onChange={value => { setPositionFilter(value); setPage(1); }}
|
||||
options={[{ value: "all", label: "All positions" }, ...positions.map(position => ({ value: position, label: position }))]} />}
|
||||
<label className="flex min-h-9 items-center gap-2 self-end text-sm"><input type="checkbox" checked={includeInactive} onChange={event => { setIncludeInactive(event.target.checked); setPage(1); }} />Include inactive workers</label>
|
||||
</div>}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="mr-auto text-muted-foreground" role="status">{sorted.length} of {rows.length} workers{selected.size > 0 ? ` · ${selected.size} selected` : ""}</span>
|
||||
{activeFilters.map(filter => <button key={filter.key} type="button" className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={`Remove ${filter.label} filter`} onClick={() => { filter.clear(); setPage(1); }}><span>{filter.label}</span><X className="h-3 w-3" aria-hidden="true" /></button>)}
|
||||
{(activeFilters.length > 0 || search) && <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={clearFilters}>Clear all</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -317,6 +281,10 @@ export function EmployeesView({
|
|||
)}
|
||||
</Card>
|
||||
|
||||
{addWorkerOpen && <AddWorkerDialog sites={sites} employers={employers} initialSiteId={initialSiteId} onOpenChange={setAddWorkerOpen}
|
||||
onCreated={id => { router.push(`/dashboard/workforce/workers/${id}`); router.refresh(); }} />}
|
||||
{employerListOpen && <EmployerBrokerList employers={employers} sites={sites} initialSiteId={initialSiteId} open onOpenChange={setEmployerListOpen} />}
|
||||
|
||||
<WorkerQrBatchDialog
|
||||
open={batchDialogOpen}
|
||||
onOpenChange={setBatchDialogOpen}
|
||||
|
|
@ -328,25 +296,21 @@ export function EmployeesView({
|
|||
}
|
||||
|
||||
function SelectFilter({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" /> {label}
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-9 w-[160px]">
|
||||
<SelectTrigger className="h-9 w-full" aria-label={label}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
60
src/components/add-worker-dialog.tsx
Normal file
60
src/components/add-worker-dialog.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { workDate } from "@/lib/clock-day";
|
||||
import type { EmployerOption } from "@/lib/employer-assignments";
|
||||
|
||||
export function AddWorkerDialog({ sites, employers, initialSiteId, onOpenChange, onCreated }: {
|
||||
sites: { id: string; name: string }[]; employers: EmployerOption[]; initialSiteId: string;
|
||||
onOpenChange: (open: boolean) => void; onCreated: (id: string) => void;
|
||||
}) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [number, setNumber] = React.useState("");
|
||||
const [site, setSite] = React.useState(sites.some(s => s.id === initialSiteId) ? initialSiteId : sites.length === 1 ? sites[0].id : "");
|
||||
const [workerType, setWorkerType] = React.useState("casual");
|
||||
const [position, setPosition] = React.useState("");
|
||||
const [rate, setRate] = React.useState("");
|
||||
const [employer, setEmployer] = React.useState("");
|
||||
const today = workDate(new Date().toISOString());
|
||||
const [effectiveFrom, setEffectiveFrom] = React.useState(today);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
const fieldClass = "mt-2 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault(); if (busy) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const response = await fetch("/api/employees", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, employeeNo: number, site, workerType, position, ratePerHour: rate === "" ? 0 : Number(rate), employer, effectiveFrom }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? "Could not add worker.");
|
||||
onCreated(body.worker.id);
|
||||
onOpenChange(false);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "Could not add worker."); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <Dialog open onOpenChange={value => { if (!busy) onOpenChange(value); }}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader><DialogTitle>Add new worker</DialogTitle><DialogDescription>Create an active worker for your site. Their QR badge will be available on their profile.</DialogDescription></DialogHeader>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<fieldset disabled={busy} className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="text-sm font-medium sm:col-span-2">Full name<Input autoFocus required maxLength={160} autoComplete="off" value={name} onChange={event => setName(event.target.value)} className="mt-2" /></label>
|
||||
<label className="text-sm font-medium">Employee number<Input required maxLength={80} autoComplete="off" value={number} onChange={event => setNumber(event.target.value)} className="mt-2" /></label>
|
||||
<label className="text-sm font-medium">Site<select required value={site} onChange={event => { setSite(event.target.value); setEmployer(""); }} className={fieldClass}><option value="" disabled>Choose a site</option>{sites.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}</select></label>
|
||||
<label className="text-sm font-medium">Worker type<select value={workerType} onChange={event => setWorkerType(event.target.value)} className={fieldClass}><option value="casual">Casual</option><option value="permanent">Permanent</option></select></label>
|
||||
<label className="text-sm font-medium">Position <span className="font-normal text-muted-foreground">(optional)</span><Input maxLength={160} value={position} onChange={event => setPosition(event.target.value)} className="mt-2" /></label>
|
||||
<label className="text-sm font-medium">Hourly rate <span className="font-normal text-muted-foreground">(optional)</span><Input type="number" min="0" max="1000000" step="0.01" placeholder="Set later" value={rate} onChange={event => setRate(event.target.value)} className="mt-2" /></label>
|
||||
<label className="text-sm font-medium">Employer/Broker<select value={employer} disabled={!site} onChange={event => setEmployer(event.target.value)} className={fieldClass}><option value="">Unassigned</option>{employers.filter(e => e.site === site).map(e => <option key={e.id} value={e.id}>{e.name}</option>)}</select></label>
|
||||
{employer && <label className="text-sm font-medium sm:col-span-2">Employer effective from<Input type="date" required max={today} value={effectiveFrom} onChange={event => setEffectiveFrom(event.target.value)} className="mt-2" /></label>}
|
||||
</fieldset>
|
||||
{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={() => onOpenChange(false)}>Cancel</Button><Button type="submit" disabled={busy || !name.trim() || !number.trim() || !site}>{busy ? "Adding…" : "Add worker"}</Button></div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
|
@ -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<string | null>(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 <>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => { setOpen(true); setEditing(null); setListSite(initialSiteId); setNotice(""); }}><Plus className="h-3.5 w-3.5" />Add Employer/Broker</Button>
|
||||
{controlledOpen === undefined && <Button variant="outline" size="sm" className="gap-1.5" onClick={() => { setOpen(true); setEditing(null); setListSite(initialSiteId); setNotice(""); }}><Plus className="h-3.5 w-3.5" />Add Employer/Broker</Button>}
|
||||
<Dialog open={open} onOpenChange={value => { if (!busy) setOpen(value); }}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader><DialogTitle>Employers / Brokers</DialogTitle><DialogDescription>Manage the employers and brokers who pay workers on your sites.</DialogDescription></DialogHeader>
|
||||
|
|
|
|||
Loading…
Reference in a new issue