Commit graph

53 commits

Author SHA1 Message Date
95da5ea237 Add AI cost insights dashboard; rota-informed forecast method
AI Insights: a daily Claude-generated wage cost briefing covering
month-to-date pace vs budget, prior-month/prior-year comparison,
rota-vs-actual variance by department, a rota-informed forecast to
month-end, employee-level anomalies, and wage cost as a % of revenue.
Runs on a configurable daily schedule or on demand, gated by a manual
5-minute rate limit and a daily token budget. Uses the Anthropic key
configured centrally in Portal → Settings → Integrations.

Also includes the rota-vs-repeat-pattern forecast method (published/
draft rota tiers with same-weekday fallback) already built into the
Weekly/Monthly views, and adds a .gitignore for node_modules/dist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 20:43:18 +00:00
11e5a2b78a Fix session-expiry redirect breaking standalone PWA out of its shell
Same fix as maintenance/reports/room-planner: manifest scope was the
app's own base path instead of "/", and AuthGate unconditionally
hard-navigated window.top to the central /login on session expiry
even when not embedded in the portal iframe — together these dropped
an installed/standalone wages PWA into the portal's framed browser
view instead of staying in its own window.

AuthGate now only bounces to central login when actually embedded
(passing ?from= so it returns here afterwards); standalone or
directly-opened tabs get an in-app login form and never navigate
away. Also wired up the previously-dead inactivity auto-logout timer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 17:07:04 +00:00
ae10646d32 Add user/logout section to sidebar footer
Sidebar was missing the account info + sign-out control that every
other app's sidebar has; it showed a static hotel-name label instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:47:01 +00:00
2114e60fce Fix chart legend order and tooltip stacking on wage graphs
Legend was sorted to mirror bar stack order, making it hard to tell
segments apart at a glance; reverse it. Tooltip also rendered behind
the legend div when they overlapped — raise its z-index above it.
2026-07-24 15:45:00 +00:00
158d8f1676 Fix stale employee rows in wage breakdown after shift edits/reassignment
syncActuals() recomputes the full date range from Workforce on every run
but only ever upserted rows, so a shift that moved department or was
edited/deleted since the last sync left its old wage_actuals_detail row
in place — showing a "ghost" employee in the dept breakdown whose cost
no longer matched the (correctly overwritten) department total.

Now deletes existing wage_actuals/wage_actuals_detail rows for the
synced date range before reinserting, so removed/reassigned shifts
don't linger.
2026-07-24 12:23:58 +00:00
a1a8900196 Fix current-period % Budget bugs on Rolling 12 Weeks/Months tables
Rolling12Months: current month's budget wasn't prorated at all, so
%Budget compared MTD actual against the FULL monthly budget — always
looked artificially favorable. Now prorates by elapsed PY DOW-matched
sales share (flat day-count fallback), reusing already-fetched data.

Rolling12Weeks: budget was prorated, but the elapsed-day count mixed a
real timestamp with Math.ceil(...)+1, always overstating elapsed days
by one and inflating the budget denominator. Rewrote to sum per-day
budgets by date string (no fractional-time bug), and applied the same
PY DOW-weighting as the Weekly page, sourcing each day from its own
calendar month so weeks spanning a month boundary split correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:18:30 +00:00
47e899f636 Weight WTD/MTD budget split by PY DOW-matched sales instead of flat day count
Distributes the monthly wage budget across days proportionally to each
day's share of last year's same-day-of-week sales, so pacing on Weekly/
Monthly views reflects real demand shape (e.g. weekend-heavy) rather than
an even calendar split. Falls back to the old flat day-count split when
PY sales data is unavailable. Weekly also now sources each day's budget
from its own calendar month, so weeks spanning a month boundary split
correctly across both months' budgets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:57:09 +00:00
baba818693 Mobile nav: replace bottom icon bar with burger menu for consistency
Matches the slide-in sidebar + top-bar burger pattern used by other apps (maintenance, cashup) instead of the bottom tab bar.
2026-07-23 17:43:13 +00:00
69b4c50e14 Net sales: use forecast (otb + pickup) instead of otb-only for future dates
The forecasting API's total.otb is booked-to-date only; total.forecast adds
expected pickup and equals otb for past dates anyway. Using otb-only for
future days understated expected net sales in the current-week/month
forecast sections. Relabelled "(OTB)" cards to "(Forecast)" to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 17:39:10 +00:00
320a0ce1ec Add Net Sales £ cards with PY comparison to Weekly and Monthly views
Both views already showed % Net Sales with a PY % sub-line, but lacked a
raw-£ Net Sales card with PY value + delta% — the style already used for
past weeks/months. Adds it consistently across current-period, forecast,
and past-period sections in both views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 17:34:12 +00:00
9e7f4030d2 Monthly forecast: repeat last full actual week to end of month instead of single 7-day lookback
Fixes an edge case where days more than 7 days past the actual cutoff had no
valid prior-week source day (itself a forecast day), silently contributing
£0. Now walks back in 7-day steps until landing on an actual day, so the
last complete actual week tiles forward for the rest of the month.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 17:19:10 +00:00
84f5d38b10 Rolling views: show most recent week/month first in table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 16:15:02 +00:00
34b17b495e syncActuals: switch to timesheets endpoint to capture National Insurance oncosts
The shifts endpoint omits employer NI from cost_with_oncosts for some employees.
The timesheets/on/{date}?include_oncosts=true endpoint includes NI, matching
WF's native 'Cost by Location and Team' Timesheet exc. leave figure exactly.

June 2026 verification: B.shiftLevel.oncostTotal = £51,005.72 = WF target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 15:51:24 +00:00
91bdb63a90 Compare: add salaried staff diagnostic (C) using users?show_wages=true
Fetches all active users with salary field set whose report_department_id
is in a No 4 department. Reports raw salary field shape, prorated period
cost, and combined (shifts oncostExLeave + prorated salary) to test whether
salaried staff account for the gap vs WF native report.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 15:42:26 +00:00
2543e09387 Add include_oncosts=true to timesheets/on/{date} fetch
Without this parameter, nested shifts[] only return shift.cost with no
cost_with_oncosts field. The FD's PBI uses show_costs=true on timesheets
and may also require include_oncosts=true to get employer oncost fields.
This is the missing parameter that may explain the B.oncostTotal == baseCost issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 15:14:17 +00:00
be1ef7f99e Fix timesheetLevel cost field — use t.total_cost not t.cost
The timesheets/on/{date} response has total_cost at the timesheet level,
not cost or cost_with_oncosts. Previous code was reading undefined and
summing zeros.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 15:06:39 +00:00
241524c9ce Compare: add timesheet-level cost fields + A2/A3 filter variants to diagnose £4.7k gap
B now also sums cost/cost_with_oncosts at the timesheet parent object level (not just
nested shifts[]) — reveals whether PBI is using a timesheet-level field we're missing.

compareEndpoints now returns:
- A  (existing): shifts API + report_location_id + client dept filter by location_id
- A2 (new): same shifts, client filter broadened to location_id OR report_location_id
- A3 (new): same shifts, no client-side dept filter (trust API filter only)
- extra_depts_via_report_location_id: departments in A2 but not A
- B: per-day timesheets with both shiftLevel and timesheetLevel cost sums

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 15:03:17 +00:00
a2b46178a5 Return to shifts endpoint with leave filter + cost_with_oncosts
The timesheets/on/{date} endpoint nested shifts don't return cost_with_oncosts
— oncostTotal == baseCost in all results. The shifts endpoint does return it.

Hypothesis: shifts endpoint, leave filtered, cost_with_oncosts ≈ £51k:
- A (shifts, report_location_id, all): £47,607 (shift.cost, includes leave)
- B (timesheets, dept filter, ex-leave): £40,845 (shift.cost only, no oncosts)
- Estimated A_ex_leave × cost_with_oncosts: ~£44.7k × 1.14 ≈ £51,005 ✓

syncActuals reverted to shifts endpoint, adds leave_request_id filter,
uses cost_with_oncosts as total_cost (was using shift.cost before).

compareEndpoints A now shows both baseCostExLeave and oncostExLeave so
we can confirm the £51k match directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 14:31:07 +00:00
fcea24b46a Fix timesheet sync: per-day fetch + shift.id dedup + dept_id filter
Key findings from diagnostic runs:
- timesheets/on/{date} returns only that DATE's shifts (not the whole
  week), so weekly-step fetch only samples 6 days of a 30-day month
- user.report_department_id filter too strict: only 32 staff have it
  set, missing many valid No 4 employees → £15k instead of £51k
- Correct approach: per-day fetch (all 30 days), dedup by shift.id
  (robust against any duplicate timesheet records), filter by
  shift.department_id → location_id (matches WF "by Location and Team")

Also includes inactive users (show_inactive=true) to catch staff who
left mid-month but still have timesheets in the date range.

Compare endpoint now shows baseCost (shift.cost) and oncostTotal
(shift.cost_with_oncosts) so we can confirm which matches WF's £51k.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 14:25:01 +00:00
dd04b50fda Fix two root causes of timesheet figure overcount
1. Weekly fetch step (not per-day): timesheets/on/{date} returns the
   whole weekly period for any date in that week, so fetching 30 days
   counted each weekly timesheet ~7x. Now fetches one date per 7-day
   step from 'to' backwards (PBI pattern), deduplicates by timesheet.id,
   and filters shifts by sh.date to the requested window.

2. Filter by user.report_department_id not shift.department_id: shared
   departments (HR, Management, etc.) have a single location assignment
   (No 4) but staff from all hotels clock into them. Filtering by the
   employee's HOME department (report_department_id) correctly isolates
   No 4 staff. Mirrors the PBI model join: timesheet → user →
   report_department_id → department.location_id → hotel.

Both fixes applied to syncActuals and compareEndpoints diagnostic.
Target: B.cost = WF "Timesheet exc. leave inc. allowances" (~£51k June).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 14:11:14 +00:00
2690ee149f Switch syncActuals to timesheets endpoint — PBI-matched method
Uses /api/v2/timesheets/on/{date} per day (no location param), expands
nested shifts[], skips leave (leave_request_id!=null), and post-filters
to this hotel's departments via location_id match on the departments
list. This mirrors exactly what the FD's Power BI query does and should
give the correct £51k timesheet figure for June.

Also simplifies compareEndpoints to two variants: A (old shifts method)
vs B (new timesheets method) so the discrepancy is immediately visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 14:03:43 +00:00
74024f554f Inspect cost field names on timesheets + shifts for compare diagnostic
Timesheet per-day fetch returns 3,269 records with £0 — cost fields are
named differently. Adds tsBaseCost()/tsTotalCost() helpers that try multiple
field names (cost, timesheet_cost, base_cost, employee_cost, etc.). Adds
sampleFields to E result so we can see the actual field names on a live
record. Also samples a shift record (variant A) for confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 13:56:22 +00:00
265df96a63 Parallelize per-day timesheet fetch; extend compare route timeout to 120s
Sequential day-by-day caused 504 on month-long ranges. Switching to
Promise.all over all dates so 30 concurrent calls complete in ~3-5s.
Route timeout bumped to 120s to give the parallel calls room to land.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 13:53:30 +00:00
4cba6a2ffa Expand compare diagnostic: location filter variants + per-day timesheets fallback
Adds four endpoint variants (A-D) to isolate the location filter discrepancy:
report_location_id vs location_id vs no filter vs timesheets range. If the
timesheets range 404s, auto-falls back to per-day /timesheets/on/{date} fetch
(variant E) so we can see timesheet totals regardless of API version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 13:48:42 +00:00
c15147b298 Add endpoint comparison diagnostic for cost figure discrepancies
GET /wages/api/sync/compare?from=YYYY-MM-DD&to=YYYY-MM-DD returns cost totals
from four Workforce endpoint variants side-by-side: shifts (all statuses),
shifts (APPROVED only), timesheets (all statuses), timesheets (APPROVED only).
Useful for identifying which endpoint matches the expected payroll figure.
Also adds data.timesheets to wfFetchPaged response unwrap chain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 13:43:19 +00:00
3a068ece23 Add vs Prev column to monthly table; weekly two-row cards + vs PY + vs Prev Wk
Monthly: fetches previous month actuals; adds % delta vs prior month full
alongside existing vs PY column in the dept breakdown table.

Weekly: expanded 14-day actuals fetch (prev+current week) so prior-week
per-dept data is available for both forecast and comparison. Two-row summary
cards for current week (WTD actuals + full-week forecast). Past weeks keep
single-row layout. Dept table gains vs PY and vs Prev Wk delta columns;
budget columns use WTD pro-rata for current week, 7-day proportion for past.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:59:14 +00:00
27f9190dc5 Add vs PY % delta column to monthly dept breakdown table
Shows (actual_mtd - PY_MTD) / PY_MTD for current month rows,
(actual - PY_full) / PY_full for past months. Red if wages up vs PY,
green if down. Total row uses same basis. Matches department by ID
against pyDepts so departments with no PY data show —.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:44:17 +00:00
5b243b3ef8 Yesterday cutoff for actuals; two-row monthly summary cards
Both Weekly and Monthly now cut off actuals at yesterday (not today) to avoid
partial clockins and open timesheets skewing the WTD/MTD figures and % net sales.
PY comparison periods also cap at the equivalent yesterday for fair comparison.

Monthly rota forecast removed — all future days use prior-week same-day actuals
from yesterday back, which is more reliable than partially-published future rotas.

Monthly summary cards split into two rows for current month:
  Row 1 (MTD to yesterday): Actual MTD · Budget pro-rata · % Budget MTD · % Net Sales MTD
  Row 2 (Full month):       Forecast EOM · Monthly Budget · % Budget (Forecast) · % Net Sales (OTB)
Net sales fetched for full month so row 2 % net sales uses OTB forecast revenue.
Past months show one row (full month actuals only).

Weekly: budget pro-rata now counts days Monday to yesterday for current week;
dept totals filtered to yesterday for current week.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:36:42 +00:00
d04cd1e82c Monthly view: rota boundary, EOM PY card, % Budget headline; backfill 25 months
Rota forecasting: only trust Workforce rota for the current Mon–Sun week —
beyond that use prior-week actuals to avoid incomplete next-week rotas
deflating the EOM forecast.

% vs Budget cards (Weekly + Monthly): show as coloured headline number
(green/amber/red) instead of small pct-badge chip, matching other cards.

Forecast EOM: PY sub-label shows full prior-year month total + % delta,
replacing the static "rota + prior-week actual" note once PY data exists.
Achieves this by fetching the full PY month rather than MTD-capped range,
then deriving both MTD (for Actual MTD card) and full-month (for EOM card).

Backfill depth: 13 → 25 months so past months in Rolling 12 have PY wage
data available (viewing Dec 2024 needs Dec 2023 = 19 months ago).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:08:13 +00:00
a75aceb6e7 Mobile responsive layout — bottom nav + table scroll
- Hide sidebar on mobile (≤640px), show fixed bottom nav bar instead
- Cards get overflow-x: auto so wide tables scroll horizontally
- Summary grid forces 2-column on mobile
- Reduce content padding to 12px, clear 68px for bottom nav
- Summary card values shrink to 18px on mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:58:14 +00:00
c6166f31d0 Batch net-sales requests in ≤365-day chunks — forecasting API 422s at 396 days 2026-07-23 11:52:05 +00:00
92400dbcec Show budget % as delta (+/-) from 100% across all views 2026-07-23 11:48:38 +00:00
bbc9a23da0 Fix budget CSV import — handle quoted fields, Aug-25 month format, currency symbols 2026-07-23 11:41:12 +00:00
95103280c3 Fix employee name lookup — use name field not first_name/last_name; remove temp logs 2026-07-23 11:33:45 +00:00
6661189127 Fix getUserNameMap location filter; fix detail cost filter to match aggregate 2026-07-23 11:32:28 +00:00
cf0cd0a5e4 Temp: log user fields + shift status cost breakdown 2026-07-23 11:31:03 +00:00
5372111f52 Add per-employee dept breakdown via modal popup
Stores per-employee shift costs in wage_actuals_detail (APPROVED shifts
only). Sync fetches employee name map from /api/v2/users alongside dept
names. Clicking any dept row in Weekly or Monthly opens a modal showing
Employee · Shifts · Cost · % of Dept, fetched on demand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:02:11 +00:00
91936ab131 Remove temp shift sample log 2026-07-23 10:57:50 +00:00
21391d4122 Temp: log raw shift sample for field inspection 2026-07-23 10:55:54 +00:00
f9916cc3fd Add collapsed PY dept breakdown table below weekly/monthly tables
Stores full PY department breakdown from already-fetched pyActRes.
A toggle button (collapsed by default) expands an inline table showing
PY wages per dept, % of PY total, and % of PY net sales. Label adapts
to PY WTD / PY MTD for current partial periods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:25:04 +00:00
a857eb6177 Add PY(TD) sub-label to % of Net Sales summary card
Shows prior year equivalent % (wages/net sales) as a sub-label under the
current period value — labelled PY WTD/MTD for current partial periods,
PY for complete past periods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:16:47 +00:00
44a7caa937 Fair PYTD comparison for current week/month
Weekly: cap pyToStr at elapsed days (not full Mon-Sun), and filter
py_sales to past days only — both give WTD vs WTD. Labels show
'PY WTD' for current week, 'PY' for complete past weeks.

Monthly: pyToStr already capped at same day-of-month; salesTo already
capped at today. Labels now show 'PY MTD' for current month, 'PY' for
complete past months.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:11:09 +00:00
dc1a14a070 PY wages/sales as sub-labels not separate cards
Show PY value + % change as a subtitle line under the period card,
same pattern as the original PY sales sub-label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:06:09 +00:00
1d7cb229e4 Add PY wages/sales cards + dept % of total column
- Weekly/Monthly: PY Wages summary card (fetches equivalent week/month last year)
- Weekly/Monthly: PY Net Sales as its own card (was a sub-label, now prominent)
- Weekly/Monthly: dept table gains '% of Total' column
- Monthly: PY comparison caps at same day-of-month for fair PYTD comparison

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:01:16 +00:00
605feaab4e Batch scheduled sync in 7-day chunks (API limit is 7 days)
Schedules endpoint has same 7-day limit as shifts. The 14-day forward
window was being fetched in one call — split into two 7-day batches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 09:54:09 +00:00
793e97dbbb Fix week date range off-by-one in BST timezone
toISOString() returns UTC — in BST (UTC+1) dates computed from new Date()
land one day early. Replace with local date components via fmt()/localStr().
Affects Weekly, Monthly, Rolling12Weeks, Rolling12Months.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 09:48:46 +00:00
3e1851fa6d Add department budget % allocation
- Budgets page: new dept split section with % inputs, equal-split button, save
- Weekly/Monthly: per-dept budget column using dept pcts from actuals response
- Backend: dept_budget_pcts in ALLOWED_KEYS; actuals returns dept_pcts map;
  budgets route adds GET /dept-config + PUT /dept-pcts endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 09:43:39 +00:00
f97772cb72 Fix Monthly net sales range + chart data; Budgets year nav + CSV upload 2026-07-23 09:25:51 +00:00
4d6e53a1f8 Fix rolling sync: batch actuals in 7-day chunks (API limit 31 days) 2026-07-23 09:18:15 +00:00
46201c6592 Fix Weekly: store week as string to prevent useCallback infinite loop 2026-07-23 09:15:11 +00:00