fix(workforce): keep a worker's hours at the site he worked them

Timesheet entries are stamped with the site they were worked at and are
never joined to the worker's current home site, but both the cost loop
and employee_rates were filtered by employee.site. A worker who moved
from A to B therefore lost his A hours when viewing A (he was no longer
on A's roster) and never had them counted when viewing B — the hours
only reappeared under "All sites".

Hours and cost now iterate the in-scope timesheet entries and price each
employee through an unfiltered lookup, so they follow the site the work
happened at. An employee id missing from that map keeps its hours but
counts as unpriced. The roster stays site-filtered and still drives the
figures that are about who works here now: headcount, permanent/casual,
idle 30d, missing rates and the inactive filter on clock events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-10 00:04:03 +02:00
parent 3dfa28f0c6
commit a3e042cd76
2 changed files with 53 additions and 12 deletions

View file

@ -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;

View file

@ -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);