diff --git a/src/app/dashboard/workforce/page.test.tsx b/src/app/dashboard/workforce/page.test.tsx index ab7e9c1..aaede11 100644 --- a/src/app/dashboard/workforce/page.test.tsx +++ b/src/app/dashboard/workforce/page.test.tsx @@ -53,7 +53,6 @@ const mover = { site: "site2", employment_status: "", }; -void mover; beforeEach(() => { vi.useFakeTimers(); @@ -112,6 +111,25 @@ it("flags unpriced hours this month instead of reporting an all-time total", asy expect(tileSub(html, "Cost \\(this month\\)")).toBe("8 hrs this month still need rates"); }); +it("keeps a worker who moved sites in the hours and cost of the site he worked", async () => { + getFullList.mockImplementation(async (name: string, options?: { filter?: string }) => { + if (name === "clock_events") return events; + if (name === "clock_missed_out_corrections") return []; + if (name === "employees") return options?.filter ? [ada] : [ada, mover]; + if (name === "timesheet_entries") return [ + { id: "t1", employee: "w1", site: "site1", date_logged: "2026-10-01 00:00:00.000Z", hours_worked: 8 }, + // Bo worked 6h at A before moving to B; his entries carry A. + { id: "t2", employee: "w2", site: "site1", date_logged: "2026-10-02 00:00:00.000Z", hours_worked: 6 }, + ]; + return []; + }); + const html = renderToStaticMarkup(await WorkforceLanding({ searchParams: Promise.resolve({ site: site.id }) })); + expect(tileValue(html, "Hours \\(this month\\)")).toBe("14"); + expect(tileValue(html, "Cost \\(this month\\)")).toBe("R 1 400"); + // Headcount is about who works here NOW, so Bo (home site B) is not counted. + expect(tileValue(html, "Headcount")).toBe("1"); +}); + it("removes the attention summary once all earlier clock-outs are corrected", async () => { getFullList.mockImplementation(async (name: string) => { if (name === "clock_events") return events; diff --git a/src/app/dashboard/workforce/page.tsx b/src/app/dashboard/workforce/page.tsx index 2afd0d8..a47a3db 100644 --- a/src/app/dashboard/workforce/page.tsx +++ b/src/app/dashboard/workforce/page.tsx @@ -34,8 +34,16 @@ async function loadWorkforceSummary(scope: SiteScope) { const last30Start = todayUtc - 30 * MS_PER_DAY; const weekStartUtc = startOfIsoWeekUtc(now); - const [employees, timesheets, sites, clockEvents, correctedIds, rateHistory] = await Promise.all([ + const [employees, allEmployees, timesheets, sites, clockEvents, correctedIds, rateHistory] = await Promise.all([ pb.collection("employees").getFullList({ sort: "name", ...filterOpt(siteF) }), + // Unfiltered lookup for pricing the in-scope timesheet entries. Entries + // are stamped with the site they were WORKED at, never joined to the + // worker's current home site (see TimesheetEntryRecord.site), so a + // worker who has since moved elsewhere is missing from `employees` + // here — his hours still happened at this site and must still be costed. + pb.collection("employees").getFullList({ + fields: "id,name,employee_no,worker_type,rate_per_hour,site,employment_status,inactive_from", + }), pb.collection("timesheet_entries").getFullList({ ...filterOpt(siteF) }), // Sites is lookup data (worker/site join via employeeRates); kept // unfiltered so the import-timesheets dialog always sees every site. @@ -46,11 +54,13 @@ async function loadWorkforceSummary(scope: SiteScope) { ...filterOpt(siteF), }), loadClockCorrectionIds(pb, scope), - pb.collection("employee_rates").getFullList({ - ...filterOpt(siteF.replace(/\bsite=/g, "employee.site=")), - }), + // Rate history is joined on the employee, not the entry's site — a + // worker priced at this site yesterday is priced here now too. + pb.collection("employee_rates").getFullList(), ]); + const employeeById = new Map(allEmployees.map((e) => [e.id, e])); + const activeEmployees = employees.filter(isWorkerActive); const inactiveIds = new Set(employees.filter((e) => !isWorkerActive(e)).map((e) => e.id)); const activeClockEvents = clockEvents.filter((event) => !inactiveIds.has(event.employee) && isWorkerActive(event.expand?.employee)); @@ -96,12 +106,17 @@ async function loadWorkforceSummary(scope: SiteScope) { let monthUnpricedHours = 0; let idle = 0; const topByCost: { id: string; name: string; type: string; cost: number; hours: number }[] = []; - for (const e of employees) { - const list = timesheetsByEmployee.get(e.id) ?? []; + for (const [id, list] of timesheetsByEmployee) { + // Hours and cost follow the TIMESHEET entries, not the roster: entries + // carry the site they were worked at, so a worker who has since moved + // away still contributes the hours he put in here. An employee id we + // cannot resolve keeps its hours but has no rate. + const e = employeeById.get(id); const hours = list.reduce((s, t) => s + t.hours, 0); - const history = historyByEmployee.get(e.id) ?? []; + const history = historyByEmployee.get(id) ?? []; + const legacyRate = e?.rate_per_hour || 0; const cost = list.reduce((sum, entry) => { - const rate = resolveEmployeeRate(entry.date, history, e.rate_per_hour || 0); + const rate = resolveEmployeeRate(entry.date, history, legacyRate); if (rate === null) unpricedHours += entry.hours; if (String(entry.date ?? "").slice(0, 7) === monthKey) { monthHours += entry.hours; @@ -110,12 +125,20 @@ async function loadWorkforceSummary(scope: SiteScope) { } return sum + entry.hours * (rate ?? 0); }, 0); - const missingSince = missingRateSince(today, list.filter((entry) => entry.hours > 0).map((entry) => entry.date), history, e.rate_per_hour || 0); - if (missingSince && isWorkerActive(e)) missingRates.push({ id: e.id, name: e.name, employeeNo: e.employee_no, siteName: siteNames.get(e.site) ?? "", missingSince }); totalHours += hours; totalCost += cost; + if (e && cost > 0) topByCost.push({ id, name: e.name, type: e.worker_type, cost, hours }); + } + + // Roster-driven figures: headcount, idle and missing rates are all + // about who works at THIS site now, so they follow the site-filtered + // employees rather than the timesheet history. + for (const e of employees) { + const list = timesheetsByEmployee.get(e.id) ?? []; + const history = historyByEmployee.get(e.id) ?? []; + const missingSince = missingRateSince(today, list.filter((entry) => entry.hours > 0).map((entry) => entry.date), history, e.rate_per_hour || 0); + if (missingSince && isWorkerActive(e)) missingRates.push({ id: e.id, name: e.name, employeeNo: e.employee_no, siteName: siteNames.get(e.site) ?? "", missingSince }); if (isWorkerActive(e) && sumHoursInWindow(list, last30Start, todayUtc + MS_PER_DAY) === 0) idle += 1; - if (cost > 0) topByCost.push({ id: e.id, name: e.name, type: e.worker_type, cost, hours }); } topByCost.sort((a, b) => b.cost - a.cost);