Status: π’ Live & real. The 5th-pillar hub is fully wired: one route
(/portal/team-hub) assembles ~20 data blocks from real services, renders 16
Alpine panes, and every action posts to a live endpoint. Payroll is not a mock β
it computes a real register from clock data, exports CSV/XLSX, and posts a
balanced Journal Entry to QuickBooks Online (shipped + verified against the live
sandbox 2026-06-21,docs/payroll_to_qbo_plan.md:4).
Route: GET /portal/team-hub β app/portal/router.py:4052
View: templates/team_hub.html (single Alpine app, 16 panes)
Context builder: app/services/team_hub.py:22 (assemble())
Purpose: Consolidate the whole workforce β staff, subcontractor crews, and
vendors β plus HR, compliance, time, and payroll into one command center. Leads &
clients deliberately live in Sales, not here (app/services/team_hub.py:5,
app/portal/router.py:4055).
The hub route is guarded require_portal_role(UserRole.pm, UserRole.office) plus
owner (app/portal/router.py:4054). So:
| Role | Team & HR hub | Notes |
|---|---|---|
| owner | Full access | Only role that can edit employee records, HR docs, equipment, and post payroll to QBO (owner-gated in many routes, e.g. router.py:3183, 3230, 3486). |
| pm | Read + most actions | Can manage workload, approve/save timesheets (router.py:3350), manage PINs (pin.py:213). |
| office | Read + most actions | Same guard as pm. |
| field | No access β redirected. The field role is excluded from the hub; field staff live in the Field App (/field). |
Field workers never see this hub. Their world is the mobile PWA at /field
(app/routers/field.py), and they surface inside the hub as data (directory
rows, clock shifts, PTO requests, PINs).
π Workload (team_hub.html:314), pane at team_hub.html:1243.GET /portal/team-hub/workload-datarouter.py:3428); actions reuse the classic planner endpointsPOST /portal/crews/workload/preview (router.py:3082), /commitrouter.py:3098), GET /crews/workload/advice (router.py:3114). Engine:app/services/capacity.py (workload) + app/services/workload_planner.pyeditable_tasks, simulate, commit, quick_fixes)./portal/crews/workload planner (router.py:3060), reusing the same enginedocs/team_hr_native_pages.md:44).πͺͺ Compliance with a red badge when certs are expiringteam_hub.html:319), pane at team_hub.html:980.team_hub.assemble β cred_rows +licenses (team_hub.py:97-121) from SubCredential/License, plus a 30-daypermits.expiry_scan (team_hub.py:74) and COI status viacoi.sub_status/coi.vendor_status. Add: POST /portal/team-hub/credentialrouter.py:3600); delete: POST /portal/team-hub/credential/{id}/deleterouter.py:3644).ok / expiring β€30d / expired); add a credential to a team member, crew, orholder = team:|crew:|vendor:<id>), attach expiry & coverage.π Field access (team_hub.html:326), pane at team_hub.html:1406.team_hub.field_access() (team_hub.py:371), whichpin._member_row (app/routers/pin.py:224) for accurate PIN/phone/lockoutSub has a clock_token share link. Full PIN engine +app/routers/pin.py (also standalone at /portal/team/pins,pin.py:274)./sub/{token} field-portal link. Owner/PMpin.py:359-497). PIN actions are owner/pm only (pin.py:213)./portal/pin (pin.py:67). Three shapes,keystone_device cookie:
pin.py:93).hash_pin), device-bound; 5 wrong tries β 15-minpin.py:33-34, 116-117).π΅ Payroll (team_hub.html:322), pane at team_hub.html:890.app/services/payroll.py β build_register()payroll.py:43) computes a weekly register on the fly from Shift rows Γovertime.split, US/CA provincial daily rules, payroll.py:92). Register isteam_hub.py:146-161.GET /portal/team-hub/payroll/export (router.py:3442):scope=all to includeapp/services/payroll_export.py).POST /portal/team-hub/payroll/post-qborouter.py:3477, owner-only): posts the week's employee gross wages as aTimeActivity push in thedocs/payroll_to_qbo_plan.md:4). Gated on a connected QBO + mapped wages/team_hub.py:154-161); button disabledapp/routers/payroll.py at /portal/payroll + /payroll/printpayroll.py:61, 82): US WH-347 or Canadian provincial fair-wage forms.require_feature("payroll"), payroll.py:27)docs/nav_consolidation_plan.md:54).docs/payroll_to_qbo_plan.md:26-28, 87-89).π§βπΌ Team (team_hub.html:312), pane at team_hub.html:619.StaffProfile) built inteam_hub.assemble (team_hub.py:29-41, staff_rows); per-employee HR-doc vaultteam_hub.py:203-211); org chart from StaffProfile.manager_idteam_hub.py:213-232). Editing: POST /portal/team/{user_id}/profilerouter.py:3168, owner-only router.py:3183) sets job title, department,pay_rate_cents), manager, PTO policy, emergencyexternal_payroll_id. HR docs: upload / e-sign (Documenso) / deleterouter.py:3222, 3246, 3266, all owner-only). Roles & access folded in heredocs/team_hr_native_pages.md:53 β inline role dropdown / activate / remove/portal/team/{id}/role|toggle|remove routes.GET /portal/team-hub/detailrouter.py:3527 β team_hub.detail, team_hub.py:413) β no page jump.β±οΈ Time clock with a pending badge (team_hub.html:315), pane atteam_hub.html:702. Related: Timesheets pane (team_hub.html:828).team_hub.py:65-71) and a count of unapproved timesheetsteam_hub.py:162-164). Approve a shift: POST /portal/team-hub/shift/{id}/approverouter.py:3657); approve all: /team-hub/timesheets/approve-allrouter.py:3676). The Timesheets pane keys a whole week of manual hours:GET /team-hub/timesheets/data (router.py:3325) +POST /team-hub/timesheets/save (router.py:3342, owner/pm), which upsertsShift(source="manual") rows only β clocked punches are never touchedrouter.py:3344-3345, docs/payroll_grid_plan.md:28-34).Shift table that payroll reads:
GET /field/clock (field.py:1328),POST /field/clock/in / /out (field.py:1362, 1400). Browser supplies GPS;haversine_m vs geofence_radius_m), derives afield.py:1343).app/routers/timeclock.py at /clock/{token}timeclock.py:39): a subcontractor opens their per-crew token link, picks apayroll_require_approval gate,payroll.py:58, surfaced as payroll_gate, router.py:4079); or key manualdocs/payroll_grid_plan.md:11).safety railteam_hub.html (rail buttons: team_hub.html:305-326). Safety livesapp/routers/safety.py (prefix /portal) β toolbox talks:POST /portal/jobs/{job_id}/toolbox (safety.py:51); incident reports:POST /portal/jobs/{job_id}/incidents (safety.py:105), with status transitionssafety.py:139) and a country-aware print-to-PDF (WSIB/WCB/OHS or OSHA) atGET /portal/incidents/{id}/print (safety.py:170). Every action redirects back/portal/jobs/{job_id}?view=classic&tab=safety (safety.py:37).safety.py:63-70); incidents can be filed from the office job view.current_portal_user, no extra rolesafety.py routes β office/PM/owner in practice).The rail also carries panes not called out above, all fed by team_hub.assemble:
Dashboard (HR analytics), My HR (myhr, the signed-in user's own record via
my_hr.for_user, router.py:4074), Workforce directory, Crew, Vendors, Messages
(crew-only inbox, router.py:3507-3524), Onboarding (router.py:3538), Time off
(PTO approvals, team_hub.py:169-182), Performance (reviews/cycles,
team_hub.py:184-201), Org chart, and Equipment lending (router.py:3283).
pin.py:359, 450, 474), emailed/texted to the worker./portal/pin (pin.py:67).field.py:1328-1427)./field/hr (field.py:699): view own pay/PTO/certs andPOST /field/hr/timeoff, field.py:716) β lands pendingsafety.py:37). Flag above./portal/payrollpayroll.py), which is separately feature-gated (payroll.py:27) and livespayroll entitlement still gets the hub paneTimeEntry import kept "for future OP reconcile" β app/services/payroll.py:17TimeEntry with a noqa and never uses it; a hook for a not-yet-builtexcept: pass (field.py:1425, timeclock.py:115). Ifpost-qbo maps failure reasons to a query flagnot_connected / accounts_unmapped / no_wages, router.py:3497-3499) rathernav_consolidation_plan.md marks Team & HR "β
already done"docs/nav_consolidation_plan.md:46) while the other four hubs are still being