Before you start: seed a demo tenant
No admin or sample data ships by default (credentials are never committed). Run this once against the running stack — it builds one full location branch (portfolio → room), an asset in that room, and your login:
docker compose exec php php artisan tinker --execute="
\$org = App\Models\Organization::factory()->create(['name' => 'Demo Org']);
\$admin = App\Models\User::factory()->create([
'name' => 'Demo Admin',
'email' => 'demo@facilities-manager.localhost',
'password' => 'demo-password-123',
'is_admin' => true,
]);
App\Models\Membership::factory()->for(\$admin)->for(\$org)->create();
\$portfolio = App\Models\Portfolio::factory()->for(\$org)->create(['name' => 'EMEA Portfolio']);
\$continent = App\Models\Continent::factory()->create(['portfolio_id' => \$portfolio->id]);
\$country = App\Models\Country::factory()->create(['continent_id' => \$continent->id]);
\$city = App\Models\City::factory()->create(['country_id' => \$country->id]);
\$facility = App\Models\Facility::factory()->create(['city_id' => \$city->id]);
\$building = App\Models\Building::factory()->create(['facility_id' => \$facility->id]);
\$floor = App\Models\Floor::factory()->create(['building_id' => \$building->id]);
\$room = App\Models\Room::factory()->create(['floor_id' => \$floor->id, 'name' => 'Room 301', 'kind' => 'room']);
\$category = App\Models\AssetCategory::factory()->create(['organization_id' => \$org->id]);
\$asset = App\Models\Asset::factory()->create([
'organization_id' => \$org->id,
'location_node_id' => \$room->locationNode()->firstOrFail()->id,
'asset_category_id' => \$category->id,
'name' => 'Rooftop AHU Unit 1',
]);
echo 'Login: '.\$admin->email.' / demo-password-123'.PHP_EOL;
echo 'Asset: '.\$asset->name.' (id '.\$asset->id.')'.PHP_EOL;
echo 'Room path: '.\$room->locationNode()->firstOrFail()->path.PHP_EOL;
"
Panel login: http://facilities-manager.localhost/operations/login with the printed email/password. Everyone — requesters, technicians, inspectors — signs in at this same URL/session; there's no separate portal login. What differs is which pages they're pointed at.
Part A — live in the browser
- Marketing home page
- Facilities location hierarchy
- Assets
- Work orders (CRUD)
- Requester portal
- Dispatch & inspection workspaces
- Inspection report
- Tenancy & access administration
- Secure response headers
- Rate limiting
- Multi-factor authentication
- Audit trail (store, viewer, export)
- Structured (JSON) logging
- Application metrics endpoint
- Mobile app (Flutter)
Part B — backend-verified, no UI yet
Part A — live in the browser
Everything below is a real page or route today. Click through it in this order for a coherent demo.
Marketing home page
LivePublic landing page: hero, feature grid, "how it works" flow, stats band, closing CTA — all routing back to the panel.
- Open
http://facilities-manager.localhost/. - Scroll through the feature grid (Locations & assets, Work orders, Preventive maintenance, Inspections, Fleet, Inventory) and the 4-step "how it works" band (Submit → Triage & approve → Assign → Complete & close).
- Click "Open the dashboard →" (hero, CTA band, or footer) or Sign in in the nav.
/operations, which redirects to /operations/login since you're not authenticated yet.Facilities location hierarchy
LiveTasks 66-82Full portfolio → continent → country → city → facility → building → floor → room/zone chain, plus a lazy-loading tree and cross-level search — all under nav group Facilities administration.
- Log in, then visit each resource in turn:
/operations/portfolios,/operations/continents,/operations/countries,/operations/cities,/operations/facilities,/operations/buildings,/operations/floors,/operations/rooms(labelled "Rooms and zones"). The seed script already created one full branch — open each to see its one row, or create a second sibling to show the picker. - Go to Facilities administration → Hierarchy (
/operations/hierarchy-tree) and expand nodes down to Room 301. - In the same page, type
Room 301(orEMEA) into the search box.
Assets
LiveTasks 83-93, 97-98Asset register with category/location/condition/status, archive lifecycle, and a printable QR label.
- Go to
/operations/assets— the seeded "Rooftop AHU Unit 1" is listed. - Click New asset, fill code/tag/name/category/location/condition/status, save.
- Use the filters (Location, Category, Status, Condition, Operational status, Criticality, Vendor, purchase date).
- Open a record, click Archive, then filter by Archived status and Reactivate it.
- In a new tab, go to
/assets/{id}/label(swap in the asset's id) to show the printable QR label.
/asset-scan/{token}, which redirects back to the asset's panel page.Work orders (CRUD)
LiveTasks 105-106, 110Nav group Maintenance. Only list/create/view/edit exist in the panel today — the full lifecycle (assign, start, complete, approve, close) is real and tested, but backend-only; see Part B.
- Go to
/operations/work-orders→ New work order. - Fill Summary, Type, Source, Priority, Status, and under Scope pick the seeded location (search "Room 301") and the seeded asset.
- Save, then open it and use Edit to change status manually.
Requester portal
LiveTasks 186-193Plain web routes under /portal (not Filament) — same login/session as the panel, just a different landing area for non-admin requesters.
- Go to
/portal— the portal home. - Click through to
/portal/requests/createand submit: pick a location, category (electrical/plumbing/hvac/cleaning/security/accessibility/general), a description of 20+ characters, and a priority. - From
/portal, open the new request at/portal/requests/{id}and use the Follow action. - Visit
/portal/suggestionsto show the issue-suggestion feature.
Dispatch & inspection workspaces
LiveTasks 194-198, 209-217 (roadmap numbering)Role-specific Filament pages under /operations, plus one plain route for the technician's paper-style job sheet.
/operations/supervisor-dispatch— "Supervisor dispatch"./dispatch— "Assignment board"./operations/technician-workbench— technician's personal queue./technician/job-sheet— plain route (not Filament), the printable daily job sheet./operations/inspector-workspace,/operations/guided-inspection-execution("Perform inspection"),/operations/compliance-evidence("Compliance evidence workspace"),/operations/auditor-workspace,/operations/corrective-verification,/operations/failed-item-triage.
Inspection report
LiveTask 140A single inspection's printable report — plain route, not Filament.
- Requires an existing
Inspectionrecord (there's no create-inspection UI yet — seed one via tinker, see Part B). - Visit
/inspections/{id}/report.
Tenancy & access administration
LiveTasks 226-235Nav group Tenancy and access: Organizations, Users, Memberships, Roles, Departments, Teams.
- Organizations (
/operations/organizations) — create one, filter by status, edit status to Suspended. - Users (
/operations/users) — open a user, flip the Administrator toggle. No password field is ever shown. - Memberships (
/operations/memberships) — new membership: pick a user + one or more roles in one screen. Try a duplicate user+org to see the uniqueness constraint reject it. - Roles (
/operations/roles) — new role: name it, multi-select permissions from the catalogue, save, reopen to confirm they persisted. Try a duplicate name in the same org. - Departments (
/operations/departments) — create, Archive, filter by status, Reactivate. - Teams (
/operations/teams) — create, multi-select members, reopen and remove one, check the Members count column.
Secure response headers
LiveTask 209Every response carries a hardened header set (CSP, X-Frame-Options, nosniff, referrer policy, permissions policy, HSTS over HTTPS).
curl -sI http://facilities-manager.localhost/health
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy, a Content-Security-Policy line. No Strict-Transport-Security over plain HTTP.Rate limiting
LiveTask 210Named limiters guard login, search, uploads, exports, and API mutations. Easiest to demo on mobile API login (5/min).
for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" \ -X POST http://facilities-manager.localhost/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"nobody@example.com","password":"wrong","device_id":"demo"}' done
422, the 6th returns 429 with a Retry-After header and X-RateLimit-Remaining: 0.Multi-factor authentication
LiveTask 211TOTP app-based MFA available to every panel user via the profile page: enroll, get challenged, recover, or remove. Not mandatory yet.
- Log in, open the user menu → Profile.
- Under Multi-factor authentication, click Set up, scan the QR with an authenticator app, enter the code, save a recovery code.
- Log out, log back in with email/password, enter the current 6-digit code at the prompt.
- Log out again, log back in, click "use a recovery code instead", paste the saved code.
- Back on Profile, click Disable (requires a valid code) to remove it.
Audit trail: event store, viewer, export
LiveTasks 212-213Critical actions write an immutable, redacted audit event, browsable and exportable from the panel.
- Toggle another user's Administrator switch (Users resource, above) to generate an event.
- Go to Security → Audit events.
- Confirm the
user.privilege_changedrow, open it, check Before/After showis_admin: false → true. - Use the actor/date filters, then click Export CSV.
- Reopen the list — a new
audit.exportedrow appears (the export logs itself).
Structured (JSON) logging
LiveTask 214A JSON log channel stamps every line with a correlation ID, tenant, and actor — shared between a web request and any job it triggers.
curl -sI http://facilities-manager.localhost/health | grep -i x-correlation-iddocker compose exec php tail -5 storage/logs/structured.log
extra.correlation_id, alongside organization_id and actor_id.Application metrics endpoint
LiveTask 215A private, token-gated /metrics endpoint: request latency/errors, mobile sync failures, queue lag/failures, scheduler runs, disk usage.
curl -i http://facilities-manager.localhost/metricscurl -s http://facilities-manager.localhost/metrics \ -H "X-Metrics-Token: $(grep METRICS_TOKEN .env | cut -d= -f2)"
401. Second → 200, Prometheus text format, including http_requests_total, http_request_duration_seconds_*.Mobile app (Flutter)
LiveTasks 194-207Flutter 3.44 / Dart 3.12, Android + iOS. Offline outbox, conflict resolution, evidence upload.
- Start the backend:
docker compose up -d nginx(from repo root). cd mobile && flutter pub get- iOS Simulator:
flutter run -d "iPhone <sim name>"(default base URLhttp://localhostworks as-is).
Android emulator:flutter run -d emulator-5554(default base URL is alreadyhttp://10.0.2.2).
Real device:flutter run --dart-define=API_BASE_URL=http://facilities-manager.localhost - Sign in, land on the 4-tab shell: Work orders, Scan, Conflicts, Evidence.
- To demo offline: actually toggle airplane mode / disable network on the simulator/device (there's no in-app simulated toggle), open a work order, attempt an action — a banner reading "Showing cached data — offline" appears and the action queues locally.
- Re-enable network — the queued action syncs; any conflicts surface on the Conflicts tab, queued photos/signatures on the Evidence tab.
Part B — backend-verified, no UI yet
These are fully built and covered by passing feature tests, but nothing in the panel or a web route surfaces them yet. Demo by referencing the test file or running one tinker snippet — don't hunt for a button.
Service-request triage & work-order lifecycle
Backend onlyTasks 104, 108-124| Capability | Backend class | Reference test |
|---|---|---|
| Triage / approve a request | ServiceRequestTriageEvent | ServiceRequestTriageTest.php |
| Convert request → work order | (service action) | ConvertServiceRequestTest.php |
| Assign supervisor/technician/team/vendor | App\Actions\Maintenance\* | AssignWorkOrder{Supervisor,Technician,Team,Vendor}Test.php |
| Start / pause / resume | App\Actions\Maintenance\* | StartWorkOrderTest.php, PauseAndResumeWorkOrderTest.php |
| Tasks / labor / photos / notes | respective models | WorkOrderTaskTest.php, WorkOrderLaborEntryTest.php, WorkOrderPhotoTest.php, WorkOrderNoteTest.php |
| Complete / approve / close / reopen | App\Actions\Maintenance\CloseAndReopenWorkOrder + others | CompleteWorkOrderTest.php, ApproveWorkOrderCompletionTest.php, CloseAndReopenWorkOrderTest.php |
docker compose exec php php artisan test --filter=CloseAndReopenWorkOrderTest — it drives the full close/reopen flow (with immutable history) end-to-end and prints green.Checklists, PM schedules, inspection workflow
Backend onlyTasks 125-143| Capability | Backend class | Reference test |
|---|---|---|
| Checklist templates + versioning | ChecklistTemplate, ChecklistTemplateVersion | ChecklistTemplateTest.php, PublishChecklistTemplateTest.php |
| PM schedule recurrence + generation | PmSchedule, App\Actions\Reliability\* | AdvancePmScheduleTest.php, GeneratePmWorkOrderTest.php (generates a real WorkOrder, visible in the panel afterward) |
| Schedule / perform / complete an inspection | Inspection | ScheduleInspectionTest.php, PerformInspectionTest.php, CompleteInspectionTest.php |
| Corrective work from a failed item | InspectionCorrectiveWork | CreateCorrectiveWorkFromInspectionTest.php |
Inspection row you can view at /inspections/{id}/report (Part A), run/read ScheduleInspectionTest.php for the exact factory chain, or seed one directly via Inspection::factory()->create([...]) in tinker.Asset photos, documents, movement, CSV import
Backend only (API-reachable)Tasks 90-91, 94-96, 99-100| Capability | How to exercise | Reference test |
|---|---|---|
| Photo upload | POST /assets/{asset}/photos (multipart, auth session, throttle:uploads) — curl/Postman only, no panel widget | AssetPhotoTest.php |
| Document upload | POST /assets/{asset}/documents | AssetDocumentTest.php |
| Movement request/approval | no route at all — App\Actions\Assets\DecideAssetMovement in tinker only | AssetMovementTest.php, DecideAssetMovementTest.php |
| CSV import preview + commit | POST /asset-imports/preview then POST /asset-imports/{token}/commit — no GET form, multipart POST only | AssetImportPreviewTest.php, CommitAssetImportTest.php |
Fleet operations
Backend onlyTasks 144-153, 169No Filament resource or route for any of this yet — vehicles, drivers, readings, maintenance triggers, accidents, and the fleet dashboard all live in App\Models\FleetAsset/App\Actions\Fleet\*/App\Support\FleetDashboard.php, exercised only by tests/Feature/FleetCoordinatorWorkspaceTest.php and VehicleCheckoutServiceTest.php.
docker compose exec php php artisan test --filter=FleetCoordinatorWorkspaceTest, or open the model/action classes directly if asked "show me the code."Inventory & stock ledger
Backend onlyTasks 154-164, 170Stores, items, and the receive/reserve/issue/return/transfer/adjust ledger operations are all App\Actions\Inventory\* classes with no Filament resource — exercised by tests/Feature/StocktakeWorkspaceTest.php. LowStockQuery.php and InventoryDashboard.php compute real numbers but nothing renders them yet.
docker compose exec php php artisan test --filter=StocktakeWorkspaceTest.Vendors, vendor portal, manager approvals
Backend onlyTasks 165-168, 186-225 (vendor-approvals phase)No vendor login, no vendor-prefixed route, no manager-approval-inbox page exists yet. Vendor/VendorContract models, VendorPortalShell, VendorAssignmentResponder, VendorCommercialWorkflow, VendorComplianceWorkspace, VendorVisitCompletionFlow, ManagerApprovalInbox, and ApprovalDelegationService are all plain service objects with no controller/guard/view. The one real HTTP surface touching "approval" anywhere in the app is the JSON API POST /api/v1/work-orders/{workOrder}/approve.
docker compose exec php php artisan test --filter=VendorPortalShellTest (or the equivalent workflow test), or call the API approve endpoint directly with a Sanctum token.