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:
parent
3dfa28f0c6
commit
a3e042cd76
2 changed files with 53 additions and 12 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue