CSIM — July Update: Role Grid, Timesheets & a New Nav Shell
CSIM's largest release to date. Permissions are now data instead of hard-coded roles, timesheets have shipped end to end, the dashboard is composed per tier rather than hand-written, and the whole navigation shell has been rebuilt on desktop and mobile. Everything listed here is live.
Summary
- Roles are data now. The 3-value role enum is gone. Admins create and edit tiers at
/settings/roleswith an 11-area × 5-action permission grid. Nav, route access, and the database all read that one grid. - Timesheets shipped. Per-day, per-site hours, approved by the foreman of that site. Admin locks the week and exports CSV.
- The dashboard is composable. 12 widgets, arranged per tier at
/settings/dashboards. Nobody gets a hand-written dashboard any more. - New nav shell. Grouped desktop nav, a shift pill that checks you in and out from any page, and a four-tab mobile bottom bar. The hamburger menu is gone.
- Inventory got rewritten. Console on desktop, field view on mobile, undo everywhere.
- Three real security holes closed along the way — see the callouts.
1. Roles & permissions — the big one
Previously "who can do what" lived in three places that disagreed: a 3-value CHECK on profiles.role, ~348 role === 'admin' literals scattered through the code, and an app_config "Function Visibility" matrix that only hid nav links and never blocked the route. That settings page literally admitted it: "Hiding a feature does not block its route."
Now there is one grid.
rolesholds the tier catalogue;role_permissionsholds one row per (tier, area) with view / create / edit / approve / delete plus a scope ofown/all_read/all.- Six tiers ship seeded: Admin (0), Construction Manager (1), Foreman (3), Leading Hand (4), Office (5), Employee (9). Rank 2 is reserved for a future PM tier.
- Rank is display order only. It never decides who can approve what — approval routes by site. There's a test that fails CI if any policy ever references rank.
- Admins can create, duplicate, and delete tiers (delete forces reassignment of members) — no migration needed to add one.
- Every RLS policy, every API guard, and every nav entry reads the same bits. A tier without
contacts.viewgets a 403, not a quietly missing link.
- Self-privilege escalation: any staff user could
UPDATEtheir ownprofilesrow, includingrole. Now blocked by a trigger, and there is a structural test requiring every self-service UPDATE policy to carry an explicitWITH CHECK. - Anonymous audit-log forgery — closed in the same migration.
- Google OAuth: an external-domain sign-in used to leave behind a foreman profile (trigger default), which then passed the forgot-password gate and unlocked password login with foreman access. First sign-ins from allowed domains now auto-provision
employee; rejected external sign-ins get their stray auth row deleted.
Behaviour changes to know about:
- The Function Visibility section on
/settings/generalis gone. Branding (logo) is all that's left there — use/settings/rolesinstead. - Foreman now has
records.view. It was seededfalsefrom the old matrix, which under the new hard-block semantics would have 403'd foremen out of the induction approval screen they work in daily. Caught by e2e. - Leading Hand lost
timesheets.view(see Nav shell — they gained a working path to their own timesheet instead).
Design record: docs/architecture/feature-roles-permissions.md.
2. Timesheets
An employee reports their own hours per site, per day. Each entry is approved by the foreman of that site — so a two-site day has two approvers. Admin is the fallback approver, locks the week for payroll, and exports CSV.
Decisions worth knowing:
- Self-approval is banned in RLS, not in app code. A foreman's own hours route to the admin queue.
- Entries are split per site because a different site means a different approver. That split is also what makes cross-site double-dipping visible, which is why approvers can read a little beyond their own sites.
- Entries are entirely manual. No foreign key, no trigger, no link to
staff_checkins. Check-in times are a one-tap UI suggestion only. - Overlapping entries never block submission, but approving one requires a named confirmation — enforced by a trigger, so the API can't bypass it. Approve-all skips overlaps.
- Locking blocks INSERT/UPDATE but not DELETE — guarding DELETE made any employee with a locked timesheet undeletable (deleting an auth user cascades down to entries).
- Deleting a site with timesheets returns
409 SITE_HAS_TIMESHEETSrather than a bare FK error. Timesheets are payroll evidence; they don't vanish with a site. Archiving a site is unaffected. - The 18:00 auto-checkout cron now tails a daily digest email to each employee whose hours someone changed that day.
Design record: docs/architecture/feature-timesheets.md.
3. Modular dashboard
Ten hand-written {#if} blocks became a 12-widget catalogue composed per tier at /settings/dashboards (tier list · 12-column canvas · widget library).
- Two of those old blocks were asking the wrong permission bit — a tier that could approve timesheets silently saw no timesheet card.
- The loader now takes data by layout instead of running the same fixed batch for everyone: an employee's dashboard went from 9 queries to 3, a foreman's from 17 to 11. Measured, not estimated.
- A tier with no stored layout means "never customised" → it uses the code default. Reset to default is a delete, not a second copy of the defaults.
- If you arrange a layout and later turn that area's view off, the card is marked red in the composer. Without that marker it looks fine to you and silently vanishes for the user — which is the whole reason the page exists.
- The composer validates against the target tier's grid, not yours, so you can't store rows that tier will never render.
Design record: docs/architecture/feature-dashboard.md.
4. Nav shell — and the discovery behind it
The word "check in" meant two unrelated things: attendance (staff_checkins — safety, muster, roll call) and hours (timesheet_entry — payroll). The database decoupled them back in the timesheets phase. The interface hadn't, and it caused two real injuries:
- A Leading Hand is required to log a timesheet and had no way to find it. Their nav entry was gated on
timesheets.view, which was correctly turned off (they can't do anything in an approval queue) — and/timesheets403'd before the page could redirect. Their only remaining path was a dashboard card that an admin could delete. - One "Timesheets" label, three behaviours. Only admin/CM/foreman can actually approve; everyone else holding the view bit got a silent 307 — employee to their own week, office to a dead empty state.
What shipped:
- The nav entry is split in two: Timesheet approvals (gated on
.approve) and My timesheet (gated on the per-personrequires_timesheet, the one entry in that bar the grid doesn't decide). Office and Employee lose the approvals link and lose no capability — that link only ever redirected them away. - Site pill, top-right: says
On site · K3, orOn 3 siteswhen you have several open (being on two sites at once is legal and routine, so it never picks one to name). Lists every open shift with one-tap Check out, hosts the check-in flow, and shows the day's brief after you check in. Attendance only — carries a singleHours are logged separately →signpost. - Grouped desktop nav: Dashboard · Projects · Site Ops ▾ · Safety ▾ · Records · Contacts. Groups are fixed, membership is derived — an entry you can't use is dropped, and a group whose members are all dropped disappears.
- Re-induction has a nav entry for the first time (gated on
inductions.approve). - Mobile: four fixed tabs — Today · On site · Hours · More. The hamburger is gone; More replaced it, and its Admin group exists because the avatar dropdown is desktop-only — without it an admin on a phone couldn't reach the back office at all.
- Nav structure now lives in one file (
nav.ts) read by all three consumers, so "which bit gates Manage Users" has exactly one answer.
Design record: docs/architecture/feature-nav-shell.md.
5. Inventory overhaul
Full rewrite across seven commits: desktop console + mobile field view on /inventory/[siteId] (device-dispatched), a rewritten dashboard, a single-site unit-card board for layouts, and a per-site catalog view for products.
- Destructive actions here use inline arm→confirm (first tap arms a red pill), not modals and not hold-to-confirm — safety is compensated by undo everywhere (6s snackbar, restore-from-snapshot).
- Backend fixes: the status-workflow trigger was blocking all backward moves including the RPC's own revert branch; foreman deletes on items and layouts were silent no-ops (no RLS policy, and the endpoint reported a fake count); a batch PO write happened even when the status transition failed.
- New endpoints: atomic drag-copy, restore, copy-unit-to-level.
- Old URLs 301 to the new pages. Dead components and two superseded endpoints removed.
6. Login & shell polish
- Login-form flash fixed. Returning users saw the landing login form for ~2s because
/had no server load. Now the landing page renders a "Signing you in…" transition card server-side and hands off to the client — plus a?redirectTo=round-trip so deep links survive login. Also closed a pre-existing//hostopen redirect in the OAuth callback. - Dark-mode black screen on sign-in fixed. The dashboard's load now returns immediately and streams; the page keeps last-resolved data during
invalidateAll()instead of flashing a skeleton. Two race conditions surfaced and fixed while doing it. - Fixed layers were sinking to the bottom of the page.
PullToRefreshcarriedtransform+will-changeunconditionally, which establishes a containing block forposition: fixed— so the batch bar, undo snackbar, TrashDock and toasts all anchored to the content wrapper instead of the viewport. Now applied only during a pull. - Admins can edit a user's login email.
/timesheets/mehydration crash fixed: one gap card per site, not per check-in (same site twice in a day is legal — lunch, a trip out and back).
If you hit anything unexpected — a screen you can no longer reach, a nav entry that should be there and isn't, a timesheet that won't approve — reach out.
GZ.