diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index cf71768..08ed384 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2647,3 +2647,28 @@ evidence yet." error or traceback appears in post-deployment logs. - Exact next action: none for the selected scope. Optional secret rotation and new n8n execution evidence remain intentionally deferred by the user. + +## M29 — recruiter-facing product polish and engineering evidence (2026-08-10) + +- Added a passwordless **90-second Highlights** entry that presents three directly + verifiable engineering stories: transactional vehicle returns, human-controlled data + correction and citation-bound knowledge. Rebuilt the Engineering page around domain + ownership, commit-before-orchestration, AI evidence, architecture and honest live/demo + integration states. +- Return completion now carries its persisted correlation ID into a dedicated audit + processing trace, linking the return, vehicle state, transactional outbox and delivery + evidence. Data-quality detail pages lead with the human rule name instead of a raw + identifier; Knowledge Hub source titles, staged progress and provider diagnostics are + clearer without overstating RAGcore index evidence. +- Improved recruiter entry metadata and social preview, route skeletons, 360 px touch + targets, reduced-motion behaviour, trilingual content and deterministic guide fallbacks. + Added automated recruiter and mobile-overflow coverage plus reproducible evidence + screenshot tooling. Refocused README, architecture and portfolio documentation on + inspectable product and reliability claims. +- Validation: isolated backend **239 passed**; Ruff clean; mypy clean across 58 files; + frontend lint/build passed; full Playwright **150 passed in 5.2 minutes**; full and + production npm audits report zero vulnerabilities; screenshot capture **1 passed** and + desktop/mobile output was visually inspected. +- Exact next action: commit and push M29, create and verify a live database backup, + deploy the committed archive to Unraid, then run live health, migration, browser and + acceptance checks before recording the final production evidence. diff --git a/README.md b/README.md index 3296269..425d830 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,94 @@ # Fleet Ops -**Connected operations for vehicle rental and service teams.** +**A recruiter-ready operations platform for vehicle rental and service teams.** -Fleet Ops is a working proof of concept for a fictitious mobility company. It combines vehicle and booking operations, a controlled vehicle-return workflow, data-quality review, RAGcore-backed internal knowledge, n8n orchestration and read-only tools published through ITWorx MCP Hub. +[Open the live demo](http://192.168.10.150:1236) · no password required · choose **Highlights in 90 seconds** for the shortest tour. -**Naming:** "Fleet Ops" is the product's visible name everywhere in the UI, the demo -knowledge base, and this documentation. "MobilityOps" remains the technical -identifier only — the repository name, local directory, package/module names, Docker -Compose project, deployment directory, and database names. The UI is fully trilingual -(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization -architecture, the vehicle-status decision table, and the correction evidence, and -`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining -NL/FR translation gaps, centralized API-error localization, the time-dependent -Europe/Brussels dashboard greeting). +Fleet Ops turns fragmented vehicle, booking and procedure data into one controlled operational workspace. It is a complete synthetic-data product demo: the company and records are fictional, while the workflows, persistence, validation, authorization, audit trail and integration boundaries are implemented. -The web application uses the premium responsive **Control Rail** interface: a compact -operations-first workspace with persisted readiness metrics, evidence-led exceptions, -review-before-commit return handling and mobile navigation designed down to 390 px. See -`docs/design/design-directions.md` and `docs/design/implementation-validation.md` for the -design decision and visual evidence. +![Fleet Ops engineering overview](frontend/public/og-fleet-ops.svg) -All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real. +## The 90-second tour -## Demo +1. Open **Highlights** from the login screen. +2. Follow a vehicle return from review to atomic commit, quality issue, outbox and correlated audit trace. +3. Compare and merge a duplicate customer with explicit human confirmation. +4. Ask the Knowledge Hub a damage question and inspect its cited procedure evidence. +5. Open **Engineering** for the architecture, reliability guarantees, test evidence and honest scope boundary. -The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van -rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app -guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page -all make the fictional context, synthetic-data status, and real-vs-simulated boundaries -explicit without any verbal explanation. See `docs/demo-release/` for the full demo -concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour -design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy, -rollback). +## What makes it more than a mock-up -## Scope +- **Transactional operations:** a return writes the inspection, vehicle/booking state, audit events and outbox record atomically. n8n downtime never rolls back the local business transaction. +- **Explainable data quality:** five persisted rule types, SLA deadlines, assignment, bulk queue controls and bounded resolution flows—not decorative warning cards. +- **Grounded knowledge:** the live deployment uses RAGcore; insufficient or unavailable evidence produces no invented answer. Citations and provider provenance remain inspectable. +- **Safe AI exposure:** four tenant-bound, service-authenticated, read-only Fleet Ops tools are published through ITWorx MCP Hub and audited with correlation IDs. +- **Operational reliability:** bounded retries, delivery leases, health/readiness, Prometheus metrics, Grafana, scheduled verified backups and graceful external-dependency degradation. +- **Real product ergonomics:** nl-BE, en-GB and fr-BE; responsive from 360 px; keyboard-accessible navigation; role-aware global search; route-level lazy loading; server-enforced permissions. -The PoC implements: +## Architecture -- operations dashboard with a truthful aggregate n8n/MCP integration-status card; -- vehicle and booking workspaces with planning windows, location filters, service/next- - booking context, operational ordering and pagination; -- server-backed session lifecycle (refresh-safe, central 401 handling); -- a role matrix enforced server-side and mirrored in the UI (see - `docs/12-security-and-audit.md`); -- vehicle return capture → authoritative server-evaluated review → commit → result; -- five deterministic data-quality checks with SLA deadlines, assignment, overdue/bulk - queue controls, bounded resolution flows and a manual scan action; -- human review and customer merge; -- audit trail with human-readable before/after evidence and safe entity links; -- role-aware global search across vehicles, bookings and (Operations Manager) issues; -- safe, confirmed demo reset; -- RAGcore-backed knowledge assistant with citations; -- four canonical n8n workflows: return processing, scheduled data-quality scanning, - RAGcore procedure sync and centralized workflow-error handling, with explicit - heartbeat evidence and crash-recoverable outbox delivery leases; -- four read-only MCP tools through ITWorx MCP Hub; -- deterministic demo reset and five-minute showcase. -- audited operational user creation, role/name editing, password reset and activation. +```mermaid +flowchart LR + UI["React + TypeScript\nresponsive operations UI"] -->|session cookie| API["FastAPI\nbusiness rules + RBAC"] + API --> DB[(PostgreSQL)] + API -->|grounded retrieval| RAG[RAGcore] + DB --> OUT["Transactional outbox"] + OUT -->|bounded retry| N8N["Existing central n8n"] + N8N -->|authenticated callback| API + HUB["ITWorx MCP Hub"] -->|4 read-only tools| API +``` -It is not an ERP, CRM, accounting package, public booking site, payment system or autonomous agent. +Fleet Ops owns operational truth. RAGcore owns retrieval, n8n performs post-commit orchestration, and MCP Hub owns tool transport/publication. Neither RAGcore nor MCP Hub accesses the Fleet Ops database directly. See [the as-built architecture](artifacts/evidence/architecture.md). -## Integration status +## Demonstrable scope -- **n8n**: fully implemented and verified against a real n8n instance, including - degraded mode (n8n stopped mid-flow → return still commits, event stays `pending` - with backoff, self-heals once n8n returns), the failed-delivery manual-retry path, - stale-delivery-lease recovery after a simulated crash, and a second (scheduled - quality-scan) workflow live-verified end to end against a real n8n instance. - `GET /api/v1/integrations/status` reports a truthful aggregate state from outbox - delivery counts, not just the most recent event. -- **RAGcore**: the live deployment uses `KNOWLEDGE_PROVIDER=ragcore`. Readiness and real - grounded retrieval are verified end to end; when RAGcore's generated-answer endpoint - is unavailable, Fleet Ops falls back only to cited extractive search results and never - invents an answer. The deterministic demo provider remains available for clean-checkout - acceptance and local development. -- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and - directly `curl`-verified with correct auth enforcement and audit logging. - `MCP_HUB_REGISTRATION_ENABLED` is actually wired into `Settings` and reported honestly - by the integration-status endpoint (evidence-based: real tool-call audit history, not - just the flag). The Fleet Ops connector is confirmed live in the ITWorx MCP Hub's own - production deployment (Tower), with a real contract fix already applied there - (`vehicle.get`'s wire parameter normalized to `vehicleRef`). +- dashboard, vehicle fleet, booking lifecycle and controlled returns; +- data-quality queue, assignment, review, merge and resolution; +- correlated human-readable audit history; +- cited Knowledge Hub with honest provider state; +- n8n delivery monitoring and manual retry; +- user administration, privacy export/anonymisation and retention guards; +- deterministic reset with 2 users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance records, 33 quality issues and 20 workflow runs. -See `artifacts/functional-completion/final-summary.md` for the functional-completion -audit evidence (supersedes the design-validation summary below for integration status), -and `artifacts/final-acceptance/summary.md` for the original M0–M7 acceptance evidence. +This is deliberately not accounting, payments, a public reservation site, generic CRM, inventory, HR or an autonomous write agent. -## Repository map +## Stack -- `CLAUDE.md` — binding implementation rules. -- `MASTER_BUILD_PROMPT.md` — prompt to start an autonomous Claude run. -- `PROJECT_STATE.md` — short persistent project memory. -- `docs/` — product, architecture, UX and acceptance specification. -- `contracts/` — OpenAPI, event and MCP contracts. -- `knowledge/` — fictitious source documents for the MobilityOps RAGcore workspace. -- `seed/` — deterministic synthetic dataset and generator. -- `n8n/` — importable workflow definitions. -- `backend/` — FastAPI/SQLAlchemy/Alembic API. -- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`). -- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`). -- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures. -- `docs/functional-completion/` — the functional-completion audit and pre-work server baseline. -- `artifacts/functional-completion/` — functional-completion acceptance evidence. -- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided - tour, and runbook. -- `artifacts/demo-release/` — demo-productization acceptance evidence. +React, TypeScript, Vite, FastAPI, SQLAlchemy 2, PostgreSQL, Alembic, n8n, RAGcore, ITWorx MCP Hub, Docker Compose, Prometheus, Grafana and Playwright. -## Quickstart +## Run locally ```bash cp .env.example .env make demo ``` -This builds and starts the full stack (migrations run automatically) and loads the -deterministic demo dataset. See `docs/17-runbook.md` for the one-time n8n workflow setup -required for the automation demo, and the full operational runbook. - -Endpoints: - - Web: `http://localhost:1228` -- API readiness: `http://localhost:8128/health/ready` (liveness: `/health/live`) -- n8n: `http://localhost:5678` +- API readiness: `http://localhost:8128/health/ready` +- Existing n8n server: configure `N8N_BASE_URL`; Fleet Ops does not create a second n8n instance. -All defaults are configurable via `.env` (see `.env.example`). +The deterministic local knowledge provider supports clean-checkout acceptance without pretending to be the live RAGcore integration. Configuration is documented in `.env.example`; operations and recovery are in [docs/17-runbook.md](docs/17-runbook.md). ## Quality gates ```bash -make test # backend: isolated PostgreSQL Compose project; never the live database -make lint # backend: ruff + mypy (strict, zero errors) -make e2e # frontend: complete Playwright acceptance (live stack required) +make test # isolated PostgreSQL backend suite +make lint # Ruff + strict mypy +make e2e # complete Playwright browser acceptance +cd frontend && npm run build ``` -Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`). +Current verified results and production evidence are recorded in [artifacts/evidence/final-summary.md](artifacts/evidence/final-summary.md) and [PROJECT_STATE.md](PROJECT_STATE.md). + +## Repository map + +- `backend/` — FastAPI domain, API, migrations and tests +- `frontend/` — React app and Playwright acceptance suite +- `contracts/` — OpenAPI, event and MCP contracts +- `knowledge/` — versioned fictional procedures +- `n8n/` — importable workflow definitions for the existing server +- `seed/` — deterministic synthetic dataset +- `docs/` — architecture, security, UX, testing and runbooks +- `artifacts/evidence/` — current acceptance evidence and screenshots + +“MobilityOps” remains the repository/deployment identifier; **Fleet Ops** is the product name shown to users. diff --git a/artifacts/evidence/architecture.md b/artifacts/evidence/architecture.md index 398ae32..abea803 100644 --- a/artifacts/evidence/architecture.md +++ b/artifacts/evidence/architecture.md @@ -1,63 +1,68 @@ -# MobilityOps — as-built architecture +# Fleet Ops — as-built architecture ```mermaid flowchart TB - subgraph Browser - UI["MobilityOps Web
React + TypeScript"] - end + UI["Fleet Ops Web\nReact + TypeScript + Vite"] - subgraph MobilityOps["MobilityOps (this repo)"] - API["FastAPI backend
/api/v1/*"] - DISPATCH["Outbox dispatcher
background thread"] + subgraph CORE["Fleet Ops — this repository"] + API["FastAPI /api/v1\nRBAC + domain rules"] + OUT["Outbox dispatcher\nleases + bounded retry"] DB[(PostgreSQL)] + OBS["Prometheus metrics\nGrafana dashboards"] API --> DB - DISPATCH --> DB + OUT --> DB + API --> OBS end - subgraph External["External central services"] - N8N["n8n
return-processing workflow"] - RAGDEMO["Demo KnowledgeProvider
TF-IDF extractive, local files"] - RAGCORE["RAGcore
(adapter built, no live instance)"] - HUB["ITWorx MCP Hub
(endpoints built, no live instance)"] + subgraph EXT["Existing external platforms"] + N8N["Central n8n\nsecondary orchestration"] + RAG["RAGcore\ngrounded procedure retrieval"] + HUB["ITWorx MCP Hub\ntool transport + publication"] end - UI -->|session cookie| API - API -->|GroundedAnswer| RAGDEMO - API -.->|configurable, unavailable-safe| RAGCORE - DISPATCH -->|POST vehicle.returned.v1| N8N - N8N -->|callback, X-Service-Token| API - HUB -.->|X-Service-Token, read-only| API - - classDef unverified stroke-dasharray: 5 5; - class RAGCORE,HUB unverified; + UI -->|secure session cookie| API + API -->|tenant/workspace adapter| RAG + OUT -->|vehicle.returned.v1| N8N + N8N -->|service-authenticated callback| API + HUB -->|service-authenticated read-only tools| API ``` -Dashed boxes/arrows are implemented and unit/contract-tested but were never exercised -against a live instance in this environment (no reachable RAGcore or ITWorx MCP Hub). -Solid boxes were verified end-to-end, including a real n8n instance. +## Ownership and trust boundaries -## Component responsibility (unchanged from `docs/03-architecture.md`) +| Component | Owns | Explicitly does not own | +|---|---|---| +| Fleet Ops | vehicles, customers, bookings, inspections, quality issues, audit, permissions, outbox state | external workflow execution or procedure retrieval | +| RAGcore | indexing/retrieval and grounded procedure evidence | Fleet Ops database or business state | +| ITWorx MCP Hub | MCP transport, connector publication and central tool-call audit | Fleet Ops database or write actions | +| n8n | post-commit workflow orchestration | critical business rules or the source-of-truth transaction | -| Component | Owns | -|---|---| -| MobilityOps | vehicles, customers, bookings, inspections, data-quality issues, audit, outbox/delivery state | -| RAGcore | procedure retrieval and grounded answers (demo provider substitutes locally) | -| ITWorx MCP Hub | MCP transport, tool publication, central tool-call audit | -| n8n | post-commit secondary orchestration only — never the source of truth for vehicle state | +## End-to-end return trace -## Reliability boundaries verified in this build +```mermaid +sequenceDiagram + actor Operator + participant Web + participant API + participant DB + participant n8n + Operator->>Web: Review and confirm return + Web->>API: POST return with idempotency key + API->>DB: Lock booking and validate invariants + API->>DB: Commit inspection, state, audit and outbox atomically + API-->>Web: Result + correlation ID + Web-->>Operator: Human result and full processing trace + API->>n8n: Deliver persisted outbox event + n8n->>API: Authenticated status callback + API->>DB: Persist delivery/audit evidence +``` -1. **Return commits atomically with its outbox event** — `app/services/returns.py`, one - transaction; verified by `test_concurrent_returns_only_one_succeeds` (real Postgres row - locking, not mocked). -2. **Outbox delivery is at-least-once, idempotent by event ID** — verified live: the n8n - callback checks for an existing `AuditEvent` by event ID before recording a second time. -3. **RAGcore failure disables knowledge answers only** — `RAGcoreKnowledgeProvider` degrades - to `unavailable`; the rest of the app is unaffected because the knowledge router is the - only consumer. -4. **MCP Hub failure does not affect the web application** — the four MCP provider - endpoints are a separate authenticated surface (`X-Service-Token`), invisible to the - browser-facing API/UI. -5. **n8n failure leaves events pending with bounded retries** — verified live: a seeded - `failed` event, retried through the UI, was picked up by the background dispatcher and - delivered through the real n8n instance within one poll cycle. +## Verified reliability properties + +1. Concurrent returns serialize through PostgreSQL row locking; only one can commit. +2. Local return success is independent of n8n availability. Pending delivery remains persisted and retryable. +3. Outbox delivery is at-least-once and idempotent by event ID, with crash-recoverable leases and bounded backoff. +4. RAGcore failure affects knowledge answers only. The UI reports unavailable/insufficient evidence and does not invent an answer. +5. MCP endpoints are a separate tenant-bound, client-identity-validated, read-only surface; every call is audited with a correlation ID. +6. Browser authorization is enforced again on the API. Hiding a navigation item is never the security boundary. + +The live deployment has exercised all three external boundaries. Local clean-checkout acceptance can use the deterministic extractive knowledge provider while reporting that mode honestly. diff --git a/artifacts/evidence/screenshots/1-login.png b/artifacts/evidence/screenshots/1-login.png index c513487..56d0d33 100644 Binary files a/artifacts/evidence/screenshots/1-login.png and b/artifacts/evidence/screenshots/1-login.png differ diff --git a/artifacts/evidence/screenshots/2-highlights.png b/artifacts/evidence/screenshots/2-highlights.png new file mode 100644 index 0000000..6a21149 Binary files /dev/null and b/artifacts/evidence/screenshots/2-highlights.png differ diff --git a/artifacts/evidence/screenshots/3-engineering-story.png b/artifacts/evidence/screenshots/3-engineering-story.png new file mode 100644 index 0000000..a267269 Binary files /dev/null and b/artifacts/evidence/screenshots/3-engineering-story.png differ diff --git a/artifacts/evidence/screenshots/4-dashboard.png b/artifacts/evidence/screenshots/4-dashboard.png new file mode 100644 index 0000000..c038ca4 Binary files /dev/null and b/artifacts/evidence/screenshots/4-dashboard.png differ diff --git a/artifacts/evidence/screenshots/5-knowledge-evidence.png b/artifacts/evidence/screenshots/5-knowledge-evidence.png new file mode 100644 index 0000000..8898872 Binary files /dev/null and b/artifacts/evidence/screenshots/5-knowledge-evidence.png differ diff --git a/artifacts/evidence/screenshots/6-highlights-mobile.png b/artifacts/evidence/screenshots/6-highlights-mobile.png new file mode 100644 index 0000000..77a9b64 Binary files /dev/null and b/artifacts/evidence/screenshots/6-highlights-mobile.png differ diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 7d39a91..2ee13e5 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -164,6 +164,7 @@ class RegisterReturnResult(BaseModel): odometer_regression: bool quality_issue_ref: str | None workflow_event_id: str + correlation_id: str next_booking_risk: NextBookingRisk | None diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py index a63771a..a42b870 100644 --- a/backend/app/services/demo_manifest.py +++ b/backend/app/services/demo_manifest.py @@ -143,14 +143,21 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: ), DemoIntegrationSummaryOut( key="ragcore", - status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode", - detail_code="ragcoreDetail", + status_code=( + "operational" + if knowledge_health.provider == "ragcore" and knowledge_health.available + else "demoMode" + if knowledge_health.provider == "demo" and knowledge_health.available + else "unavailable" + ), + detail_code=( + "ragcoreDetail" + if knowledge_health.document_count is not None + else "ragcoreSourceDetail" + ), detail_params={ - "count": ( - knowledge_health.document_count - if knowledge_health.document_count is not None - else "unknown" - ), + "count": knowledge_health.document_count or 0, + "source_count": knowledge_health.source_document_count, "collection": knowledge_health.collection, }, ), @@ -162,7 +169,9 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: # "operational" -- same evidence rule the integration status page uses. status_code="operational" if mcp_hub.state == "operational" else "notConnected", detail_code=( - "mcpDetailEnabled" if mcp_hub.state == "operational" else "mcpDetailNotConnected" + "mcpDetailOperational" + if mcp_hub.state == "operational" + else "mcpDetailPrepared" ), detail_params={}, ), diff --git a/backend/app/services/returns.py b/backend/app/services/returns.py index 6095113..5196b69 100644 --- a/backend/app/services/returns.py +++ b/backend/app/services/returns.py @@ -328,6 +328,7 @@ def register_vehicle_return( "odometer_regression": evaluation.odometer_regression, "quality_issue_ref": quality_issue_ref, "workflow_event_id": str(event.event_id), + "correlation_id": str(correlation_id), "next_booking_risk": evaluation.next_booking_risk, } diff --git a/backend/tests/test_return.py b/backend/tests/test_return.py index 53b103d..6d2f1c6 100644 --- a/backend/tests/test_return.py +++ b/backend/tests/test_return.py @@ -120,6 +120,8 @@ def test_preview_performs_no_writes_and_matches_commit(ops_client): assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"] assert commit_body["odometer_regression"] == preview_body["odometer_regression"] assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"] + assert commit_body["correlation_id"] + assert len(commit_body["correlation_id"]) == 36 def test_preview_detects_odometer_regression(ops_client): diff --git a/docs/16-portfolio-case-study.md b/docs/16-portfolio-case-study.md index 18dee71..071fe3d 100644 --- a/docs/16-portfolio-case-study.md +++ b/docs/16-portfolio-case-study.md @@ -1,43 +1,47 @@ -# Portfolio case-study template +# Fleet Ops — portfolio case study -## Title +## Product -MobilityOps — connected operations for vehicle rental and service teams - -## One-sentence summary - -A working synthetic-data PoC that unifies vehicle and booking operations, automates vehicle returns, surfaces data-quality problems and exposes grounded internal knowledge through reusable RAG and MCP platforms. +Fleet Ops is a working operations platform for a fictional Belgian mobility company. It unifies vehicle and booking work, makes data defects resolvable, automates post-return follow-up and turns internal procedures into cited answers. ## Problem -Operational information was modelled as fragmented across booking, vehicle and procedure sources, causing manual checks, inconsistent states and slow access to instructions. +Rental operations often span booking records, vehicle state, inspections and separate procedure documents. The difficult part is not another dashboard; it is preserving one trustworthy state while people, automation and AI consume the same workflow. -## Approach +## Engineering approach -- MobilityOps owns canonical operational data and business rules. -- A transactional return workflow persists inspections, derives status and queues post-commit orchestration. -- Explainable rules detect duplicates, mileage regressions, overlaps, missing fields and status conflicts. -- RAGcore provides source-grounded procedure answers. -- ITWorx MCP Hub exposes narrowly scoped read-only AI tools. -- n8n orchestrates secondary processing without becoming the business-rule engine. +- Keep business invariants in a FastAPI/SQLAlchemy domain layer backed by PostgreSQL constraints and row locks. +- Commit vehicle returns, audit evidence and an outbox message in one transaction. +- Let the existing central n8n execute secondary work asynchronously, with leases, retries and authenticated callbacks. +- Detect five classes of data-quality issue and require bounded, explicit human resolution. +- Integrate RAGcore through a provider adapter that refuses unsupported answers and preserves citation provenance. +- Publish only four narrow read-only tools through ITWorx MCP Hub; never give external AI systems database access. +- Expose correlation-based evidence in the product so architecture claims can be inspected, not merely read in a diagram. -## Demonstrable results +## Demonstrable result -Use only facts actually measured in the final build, such as: +- 50 vehicles, 180 customers and 254 bookings in a deterministic synthetic dataset; +- one complete controlled return workflow and five persisted quality-rule types; +- 11 authoritative Dutch procedure sources with localized grounded retrieval; +- four tenant-bound, service-authenticated read-only MCP tools; +- trilingual responsive UI, server-backed roles, global search, privacy controls and audit; +- degraded-mode coverage for n8n and RAGcore, production health/metrics and verified backups; +- complete backend and browser acceptance recorded in `artifacts/evidence/final-summary.md`. -- 50 deterministic synthetic vehicles and 245 bookings; -- five automated data-quality rule types; -- one end-to-end return workflow; -- ten versioned source procedures; -- four read-only MCP tools; -- tested degraded modes for RAGcore and n8n. +## Product decisions worth discussing -Do not claim real customer adoption, production use or percentage time savings. +**Why an outbox?** A return must remain committed when n8n is unavailable. Persisting the event in the same database transaction closes the gap between business success and later orchestration. + +**Why no generative fallback?** A fluent procedure answer without evidence is worse than a clear unavailable state. Knowledge answers require cited provider evidence. + +**Why read-only MCP tools?** Recruiter demos should show credible AI integration without granting autonomous mutation rights. Operational writes remain explicit product workflows with authorization, validation and audit. + +**Why synthetic but production-shaped data?** The public demo can remain safely open while still exercising realistic state transitions, failure recovery and resettable scenarios. ## Stack -React, TypeScript, FastAPI, PostgreSQL, n8n, RAGcore, ITWorx MCP Hub, Docker Compose and Playwright. +React, TypeScript, Vite, FastAPI, SQLAlchemy 2, PostgreSQL, Alembic, n8n, RAGcore, ITWorx MCP Hub, Docker Compose, Prometheus, Grafana and Playwright. -## Demo disclosure +## Honest boundary -All data and the represented company are fictitious. The software behaviour, integration contracts, audit trail and validation are implemented as a real proof of concept. +Fleet Ops is a completed locked PoC, not claimed customer adoption or a general ERP. Accounting, payments, public reservations, CRM, inventory, HR and autonomous write agents are intentionally out of scope. A real personal-data rollout would additionally require the adopting organisation's identity provider, retention approvals, secrets lifecycle, alert ownership and disaster-recovery process. diff --git a/frontend/e2e/_capture-recruiter-screenshots.spec.ts b/frontend/e2e/_capture-recruiter-screenshots.spec.ts new file mode 100644 index 0000000..ead1a70 --- /dev/null +++ b/frontend/e2e/_capture-recruiter-screenshots.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; + +const OUT = "../artifacts/evidence/screenshots"; + +test("capture current recruiter-facing evidence", async ({ page, request }) => { + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + await request.post("/api/v1/demo/reset"); + + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Elke overdracht. Eén helder overzicht." })).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/1-login.png`, fullPage: true }); + + await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click(); + await expect(page).toHaveURL(/\/highlights$/); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/2-highlights.png`, fullPage: true }); + + await page.goto("/about"); + await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/3-engineering-story.png`, fullPage: true }); + + await page.goto("/dashboard"); + await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/4-dashboard.png`, fullPage: true }); + + await page.goto("/knowledge"); + await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click(); + await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/5-knowledge-evidence.png`, fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/highlights"); + await expect(page.locator(".highlight-card").first()).toBeVisible(); + await page.waitForTimeout(500); + await page.screenshot({ path: `${OUT}/6-highlights-mobile.png` }); +}); diff --git a/frontend/e2e/demo-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts index 4a1b51f..0af6b33 100644 --- a/frontend/e2e/demo-entry.spec.ts +++ b/frontend/e2e/demo-entry.spec.ts @@ -5,7 +5,7 @@ test.describe.configure({ mode: "serial" }); test("demo entry screen names the fictional org and never shows a password", async ({ page }) => { await page.goto("/login"); await expect(page.getByText(/Northstar Mobility/)).toBeVisible(); - await expect(page.getByText(/Synthetische demo/)).toBeVisible(); + await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible(); await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible(); await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible(); await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible(); diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts index 6011e6d..e08c9cf 100644 --- a/frontend/e2e/demo-legibility.spec.ts +++ b/frontend/e2e/demo-legibility.spec.ts @@ -27,7 +27,7 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a await page.getByRole("button", { name: "Retour bevestigen" }).click(); await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible(); await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Auditgeschiedenis bekijken" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Volledige verwerking volgen" })).toBeVisible(); }); test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => { diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts index 7e5fd91..7bd1085 100644 --- a/frontend/e2e/demo.spec.ts +++ b/frontend/e2e/demo.spec.ts @@ -18,7 +18,7 @@ test("five-minute demo script end to end", async ({ page, request }) => { await test.step("1. login as Operations Manager", async () => { await page.goto("/login"); - await expect(page.getByText(/Synthetische demo/)).toBeVisible(); + await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible(); await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -90,7 +90,7 @@ test("five-minute demo script end to end", async ({ page, request }) => { await test.step("9. verify responsive navigation at mobile width", async () => { await page.setViewportSize({ width: 360, height: 800 }); await page.goto("/dashboard"); - await expect(page.getByText(/Synthetische demo/).first()).toBeVisible(); + await expect(page.getByRole("button", { name: /Synthetische demo/ })).toBeVisible(); await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible(); const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); diff --git a/frontend/e2e/fleet-ops-correction.spec.ts b/frontend/e2e/fleet-ops-correction.spec.ts index 1396505..b1467ca 100644 --- a/frontend/e2e/fleet-ops-correction.spec.ts +++ b/frontend/e2e/fleet-ops-correction.spec.ts @@ -107,7 +107,7 @@ test.describe("status-recommendation flow", () => { await resetDemoData(request); await loginAsOpsManager(page, "nl-BE"); await page.goto("/data-quality/DQ-DEMO-STATUS"); - await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible(); + await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible(); await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click(); await expect(page.getByText("Aanbevolen status")).toBeVisible(); diff --git a/frontend/e2e/interactive-elements.spec.ts b/frontend/e2e/interactive-elements.spec.ts index 4eb8b73..a32b54c 100644 --- a/frontend/e2e/interactive-elements.spec.ts +++ b/frontend/e2e/interactive-elements.spec.ts @@ -185,7 +185,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page, expect(ref).not.toBe(""); await firstLink.click(); await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`)); - await expect(page.getByRole("heading", { name: ref })).toBeVisible(); + await expect(page.getByText(ref, { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Defer" }).click(); await expect(page.locator(".badge.status-deferred")).toBeVisible(); }); @@ -193,7 +193,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page, test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => { await resetDemoData(request); await page.goto("/data-quality/DQ-DEMO-ATTENTION"); - await expect(page.getByRole("heading", { name: "DQ-DEMO-ATTENTION" })).toBeVisible(); + await expect(page.getByText("DQ-DEMO-ATTENTION", { exact: true })).toBeVisible(); await page.getByLabel("Registration number").fill("TST-777"); await page.getByLabel("Make").fill("TestMake"); @@ -207,7 +207,7 @@ test("data quality: providing missing fields resolves a vehicle issue", async ({ test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => { await resetDemoData(request); await page.goto("/data-quality/DQ-DEMO-OVERLAP"); - await expect(page.getByRole("heading", { name: "DQ-DEMO-OVERLAP" })).toBeVisible(); + await expect(page.getByText("DQ-DEMO-OVERLAP", { exact: true })).toBeVisible(); await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check(); await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click(); @@ -227,7 +227,7 @@ test("data quality: applying the recommended status resolves a vehicle conflict" }) => { await resetDemoData(request); await page.goto("/data-quality/DQ-DEMO-STATUS"); - await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible(); + await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Review recommendation" }).click(); await page.getByRole("button", { name: /^Change status to/ }).click(); @@ -248,7 +248,7 @@ test("data quality: retaining canonical resolves an odometer regression issue", const target = issues[0]; await page.goto(`/data-quality/${target.public_ref}`); - await expect(page.getByRole("heading", { name: target.public_ref })).toBeVisible(); + await expect(page.getByText(target.public_ref, { exact: true })).toBeVisible(); await page.getByRole("radio", { name: /Retain canonical/ }).check(); await page.getByRole("button", { name: "Resolve issue" }).click(); diff --git a/frontend/e2e/recruiter-polish.spec.ts b/frontend/e2e/recruiter-polish.spec.ts new file mode 100644 index 0000000..6e2502f --- /dev/null +++ b/frontend/e2e/recruiter-polish.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +test("90-second recruiter entry exposes three verifiable engineering highlights", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click(); + + await expect(page).toHaveURL(/\/highlights$/); + await expect(page.getByRole("heading", { name: "De sterkste keuzes in 90 seconden" })).toBeVisible(); + await expect(page.locator(".highlight-card")).toHaveCount(3); + await expect(page.getByText("Transactie · idempotency · outbox · degradatie")).toBeVisible(); + await expect(page.getByText("RAGcore · citations · veilige degradatie")).toBeVisible(); +}); + +test("Engineering Story exposes architecture, reliability and honest integration evidence", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await page.goto("/about"); + + await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible(); + await expect(page.getByText("Commit eerst, orkestreer daarna")).toBeVisible(); + await expect(page.getByText("AI moet bewijs tonen")).toBeVisible(); + await expect(page.getByText("PostgreSQL + audittrail")).toBeVisible(); + await expect(page.getByText(/nog niet live gekoppeld/)).toHaveCount(0); + await expect(page.getByText(/unknown procedures/)).toHaveCount(0); +}); + +test("mobile recruiter surfaces remain readable without horizontal overflow", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click(); + await expect(page).toHaveURL(/\/highlights$/); + + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + await expect(page.locator(".highlight-card").first()).toBeVisible(); +}); diff --git a/frontend/index.html b/frontend/index.html index 373547b..919576e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,9 +3,18 @@ - + + + + + + + + + + - Fleet Ops + Fleet Ops — connected vehicle operations
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 4b78c66..f8a79f2 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ testDir: "./e2e", // _capture-screenshots.spec.ts is evidence-generation tooling, not a regression test — // it mutates demo data via a reset and is run explicitly, not as part of the suite. - testIgnore: "**/_*.spec.ts", + testIgnore: process.env.CAPTURE_EVIDENCE === "1" ? undefined : "**/_*.spec.ts", timeout: 30_000, fullyParallel: false, workers: 1, diff --git a/frontend/public/og-fleet-ops.svg b/frontend/public/og-fleet-ops.svg new file mode 100644 index 0000000..3b6d594 --- /dev/null +++ b/frontend/public/og-fleet-ops.svg @@ -0,0 +1,24 @@ + + Fleet Ops + Connected vehicle operations with reliable workflows, explainable data quality and grounded knowledge. + + + + + + + + + + + Fleet Ops + + CONNECTED VEHICLE OPERATIONS + Every hand-off. + One clear view. + + Reliable workflows + Explainable quality + Grounded knowledge + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ffa2b4d..9884382 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,9 +21,10 @@ const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ def const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios }))); const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users }))); const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy }))); +const Highlights = lazy(() => import("./pages/Highlights").then((module) => ({ default: module.Highlights }))); function deferredPage(element: ReactNode) { - return }>{element}; + return
}>{element}
; } export function App() { @@ -55,6 +56,7 @@ export function App() { )} /> )} /> )} /> + )} /> } /> } /> diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3c3bb6e..590d658 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -224,6 +224,7 @@ export interface RegisterReturnResult { odometer_regression: boolean; quality_issue_ref: string | null; workflow_event_id: string; + correlation_id: string; next_booking_risk: NextBookingRisk | null; } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 84fca68..5ea36f4 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -71,6 +71,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [ { to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] }, { to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] }, { to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] }, + { to: "/about", labelKey: "items.engineering", icon: "spark" }, ], }, ]; diff --git a/frontend/src/components/ReturnForm.tsx b/frontend/src/components/ReturnForm.tsx index 217abcc..8c99058 100644 --- a/frontend/src/components/ReturnForm.tsx +++ b/frontend/src/components/ReturnForm.tsx @@ -75,7 +75,7 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
{t("result.viewVehicle", { ref: result.vehicle_ref })} {t("result.viewAutomation")} - {t("result.viewAudit")} + {t("result.viewTrace")} {guideOpen && ( - - )} - -
-

{t("about.problemTitle")}

-

{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}

-
- -
-

{t("about.scopeTitle")}

-

{t("about.scopeBody", { productName: PRODUCT_NAME })}

-
- -
-
-

{t("about.realTitle")}

-

{t("about.realBody")}

-
- -
-

{t("about.syntheticTitle")}

-

{t("about.syntheticBody", { testDomain: ".test" })}

-
- -
- {t("about.architectureTitle")} -

{t("about.architectureBody")}

-
- -
- {t("about.securityTitle")} -

{t("about.securityBody")}

-
- -
- {t("about.testingTitle")} -

{t("about.testingBody")}

-
+ {manifest && <> +
+
+

{t("about.proofEyebrow")}

+

{t("about.proofTitle")}

+

{t("about.proofDescription")}

+
+ {t("about.quickTourButton")} + {user?.role === "operations_manager" && } + {t("about.scenarioButton")} +
+
-
- -
- {manifest.integrations.map((integration) => ( -
- -
- - {t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)} - -

{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}

-

{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}

-
-
- ))} -
-
+
+
+ {PROOFS.map((proof) =>

{t(`about.proof${proof.id}Title`)}

{t(`about.proof${proof.id}Body`)}

)} +
+
-
-

{t("about.resetTitle")}

-

- }} - /> -

-
+
+ +
+ {["Frontend", "Api", "Database", "Outbox", "External"].map((step, index) =>
{index + 1}{t(`about.architecture${step}`)}{index < 4 && }
)} +
+
-
- -

- {t("about.limitationsTitle")} - {t("about.limitationsBody")} -

-
- - )} +
+

{t("about.problemTitle")}

{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}

+

{t("about.scopeTitle")}

{t("about.scopeBody", { productName: PRODUCT_NAME })}

+

{t("about.realTitle")}

{t("about.realBody")}

+

{t("about.syntheticTitle")}

{t("about.syntheticBody", { testDomain: ".test" })}

+

{t("about.verificationTitle")}

{t("about.verificationBody")}

+

{t("about.securityTitle")}

{t("about.securityBody")}

+
+ +
+ +
{manifest.integrations.map((integration) =>
{t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)}

{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}

{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}

)}
+
+ +

{t("about.resetTitle")}

}} />

+

{t("about.limitationsTitle")}{t("about.limitationsBody")}

+ }
); } diff --git a/frontend/src/pages/Audit.tsx b/frontend/src/pages/Audit.tsx index 36c4f4b..ca302d5 100644 --- a/frontend/src/pages/Audit.tsx +++ b/frontend/src/pages/Audit.tsx @@ -7,6 +7,7 @@ import { useAuth } from "../context/AuthContext"; import { useLocaleFormat } from "../i18n/format"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { Pagination } from "../components/Pagination"; +import { Icon } from "../components/Icons"; function humanizeField(field: string): string { const spaced = field.replace(/_/g, " "); @@ -39,6 +40,22 @@ function ChangeDiff({ before, after, t }: { before: Record | nu interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; } +function OperationTrace({ events, t }: { events: AuditEvent[]; t: (key: string, options?: Record) => string }) { + const actions = new Set(events.map((event) => event.action)); + const isReturn = actions.has("return_registered"); + if (!isReturn) return null; + const steps = [ + { key: "return", complete: isReturn }, + { key: "vehicle", complete: actions.has("vehicle_status_changed") }, + { key: "outbox", complete: isReturn }, + { key: "delivery", complete: actions.has("n8n_return_followup_recorded") }, + ]; + return
+

{t("trace.eyebrow")}

{t("trace.title")}

{t("trace.description")}

+
    {steps.map((step, index) =>
  1. {step.complete ? : index + 1}
    {t(`trace.steps.${step.key}.title`)}{t(`trace.steps.${step.key}.${step.complete ? "complete" : "pending"}`)}
  2. )}
+
; +} + export function Audit() { const { t } = useTranslation("audit"); const { formatDateTime } = useLocaleFormat(); @@ -95,6 +112,7 @@ export function Audit() { + {correlationId && events && } {correlationId &&

{t("relatedFilterActive", { count: events?.total ?? 0 })}

} {error && } {!error && !events && } diff --git a/frontend/src/pages/DataQualityIssueDetail.tsx b/frontend/src/pages/DataQualityIssueDetail.tsx index 434bf4a..c79e177 100644 --- a/frontend/src/pages/DataQualityIssueDetail.tsx +++ b/frontend/src/pages/DataQualityIssueDetail.tsx @@ -774,7 +774,7 @@ export function DataQualityIssueDetail() { return (
{t("detail.backLink")} -
} /> +
} />
{t("detail.summary.rule")}
{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}
{t("detail.summary.entity")}
{issue.entity_type === "vehicle" ? {issue.entity_ref} : issue.entity_ref}
diff --git a/frontend/src/pages/Highlights.tsx b/frontend/src/pages/Highlights.tsx new file mode 100644 index 0000000..43aafbe --- /dev/null +++ b/frontend/src/pages/Highlights.tsx @@ -0,0 +1,60 @@ +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Icon, type IconName } from "../components/Icons"; +import { PageHeader } from "../components/PageChrome"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { PRODUCT_NAME } from "../product"; + +const HIGHLIGHTS: Array<{ id: "return" | "quality" | "knowledge"; to: string; icon: IconName }> = [ + { id: "return", to: "/bookings/BK-DEMO-RETURN", icon: "bookings" }, + { id: "quality", to: "/data-quality/DQ-DEMO-DUPLICATE", icon: "quality" }, + { id: "knowledge", to: "/knowledge", icon: "knowledge" }, +]; + +export function Highlights() { + const { t } = useTranslation("demo"); + const { openGuide } = useDemoGuide(); + + return ( +
+ + +
    + {HIGHLIGHTS.map((highlight, index) => ( +
  1. +
    + 0{index + 1} + {t("highlights.duration")} +
    + +

    {t(`highlights.${highlight.id}.title`)}

    +

    {t(`highlights.${highlight.id}.body`)}

    +
    + {t("highlights.proofLabel")} + {t(`highlights.${highlight.id}.proof`)} +
    + + {t("highlights.open")} + +
  2. + ))} +
+ +
+
+

{t("highlights.proofLabel")}

+

{t("highlights.storyTitle")}

+

{t("highlights.storyBody")}

+
+
+ {t("highlights.storyButton")} + +
+
+
+ ); +} diff --git a/frontend/src/pages/Knowledge.tsx b/frontend/src/pages/Knowledge.tsx index b143b1d..875c687 100644 --- a/frontend/src/pages/Knowledge.tsx +++ b/frontend/src/pages/Knowledge.tsx @@ -20,6 +20,12 @@ function isOpaqueVersion(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value); } +function sourceDisplayTitle(title: string, t: (key: string, options?: Record) => string): string { + const base = title.replace(/\.md$/i, ""); + const fallback = base.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); + return t(`sourceTitles.${base}`, { defaultValue: fallback }); +} + export function Knowledge() { const { t, i18n } = useTranslation(["knowledge", "errors"]); const { formatDateTime } = useLocaleFormat(); @@ -27,6 +33,7 @@ export function Knowledge() { const [statusSettled, setStatusSettled] = useState(false); const [question, setQuestion] = useState(""); const [submitting, setSubmitting] = useState(false); + const [loadingStage, setLoadingStage] = useState(0); const [error, setError] = useState(null); const [exchanges, setExchanges] = useState([]); @@ -44,6 +51,9 @@ export function Knowledge() { async function ask(questionText: string) { setError(null); setSubmitting(true); + setLoadingStage(0); + const sourceTimer = window.setTimeout(() => setLoadingStage(1), 900); + const evidenceTimer = window.setTimeout(() => setLoadingStage(2), 2600); const startedAt = performance.now(); try { const answer = await api.post("/api/v1/knowledge/questions", { @@ -61,6 +71,8 @@ export function Knowledge() { } catch (err) { setError(describeApiError(t, err, "askFailed")); } finally { + window.clearTimeout(sourceTimer); + window.clearTimeout(evidenceTimer); setSubmitting(false); } } @@ -124,11 +136,11 @@ export function Knowledge() { {status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "} {status.document_count === null - ? t("proceduresIndexUnknown") + ? t("proceduresAvailable", { count: status.source_document_count }) : t("proceduresIndexed", { count: status.document_count })} - {status.collection} +
{t("technicalDetails")}{status.collection} · {status.detail}
)} {statusSettled && !status && ( @@ -181,6 +193,9 @@ export function Knowledge() { + {submitting &&
+ {["retrieving", "checking", "composing"].map((stage, index) => {index < loadingStage ? : }{t(`progress.${stage}`)})} +
}
{t("suggestedLabel")} {suggestedQuestions.map((suggestion) => ( @@ -230,7 +245,7 @@ export function Knowledge() {
{sourceIndex + 1} - {source.title} + {sourceDisplayTitle(source.title, t)} {source.section && {source.section}}

{source.excerpt}

diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 5f684f6..41b6abe 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -17,11 +17,11 @@ export function Login() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - async function handleLogin(role: Role, guided = false) { + async function handleLogin(role: Role, destination = "/dashboard") { setError(null); try { await loginAs(role); - navigate(guided ? "/dashboard?guide=start" : "/dashboard"); + navigate(destination); } catch { setError(t("loginFailed")); } @@ -78,9 +78,13 @@ export function Login() { type="button" className="button button-primary login-guide-cta" disabled={loading} - onClick={() => handleLogin("operations_manager", true)} + onClick={() => handleLogin("operations_manager", "/highlights")} > + {t("startRecruiterTour")} + + + diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0bf4bf0..5ac6705 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -435,6 +435,25 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); } .route-loading { min-height: 40vh; display: grid; place-items: center; } +.route-skeleton { width: min(760px, calc(100% - 40px)); display: grid; gap: 14px; } +.route-skeleton span { display: block; height: 18px; background: linear-gradient(90deg, #edf1f5 25%, #f8fafc 50%, #edf1f5 75%); background-size: 220% 100%; border-radius: var(--radius); animation: skeleton-shift 1.25s ease-in-out infinite; } +.route-skeleton span:first-child { width: 42%; height: 34px; }.route-skeleton span:nth-child(2) { width: 76%; }.route-skeleton span:last-child { height: 180px; } +@keyframes skeleton-shift { to { background-position: -220% 0; } } + +/* Recruiter-facing product story */ +.engineering-hero { display: flex; justify-content: space-between; align-items: center; gap: 28px; margin-bottom: 26px; padding: 28px; background: linear-gradient(135deg, var(--petrol), #18344a); color: white; overflow: hidden; } +.engineering-hero h2 { max-width: 720px; margin: 0; font-size: clamp(1.6rem, 3vw, 2.35rem); line-height: 1.1; letter-spacing: -.035em; }.engineering-hero p:not(.page-eyebrow) { max-width: 680px; margin: 10px 0 0; color: #c4d0dc; line-height: 1.6; }.engineering-hero .page-eyebrow { color: #5eead4; } +.engineering-hero-actions { min-width: 220px; display: grid; gap: 9px; }.engineering-hero-actions .button { width: 100%; } +.standalone-heading { padding-inline: 0; border: 0; }.proof-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 26px; }.proof-card { padding: 22px; }.proof-card > span, .highlight-icon { width: 42px; height: 42px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.proof-card svg, .highlight-icon svg { width: 21px; }.proof-card h3 { margin: 18px 0 8px; font-size: 1rem; }.proof-card p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.6; } +.architecture-story { margin-bottom: 26px; overflow: hidden; }.architecture-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 22px; }.architecture-step { position: relative; min-height: 104px; display: flex; flex-direction: column; justify-content: center; gap: 9px; padding: 16px; background: var(--surface-subtle); border: 1px solid var(--line); }.architecture-step > span { color: var(--teal-dark); font-size: var(--type-meta); font-weight: 800; }.architecture-step > strong { font-size: .82rem; line-height: 1.35; }.architecture-step > svg { position: absolute; z-index: 2; right: -12px; top: calc(50% - 12px); width: 24px; height: 24px; padding: 5px; color: white; background: var(--teal-dark); border-radius: 50%; } +.highlight-grid { list-style: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 0 0 18px; padding: 0; counter-reset: highlight; }.highlight-card { min-height: 440px; display: flex; flex-direction: column; padding: 24px; }.highlight-card-top { display: flex; justify-content: space-between; align-items: center; }.highlight-number { color: var(--teal-dark); font-size: .72rem; font-weight: 800; letter-spacing: .1em; }.highlight-duration { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: var(--type-meta); }.highlight-duration svg { width: 14px; }.highlight-icon { margin-top: 32px; }.highlight-card h2 { margin: 18px 0 10px; font-size: 1.18rem; letter-spacing: -.02em; }.highlight-card > p { margin: 0; color: var(--muted); line-height: 1.65; }.highlight-proof { display: grid; gap: 4px; margin: auto 0 18px; padding: 13px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }.highlight-proof strong { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; letter-spacing: .06em; }.highlight-proof span { font-size: .77rem; font-weight: 700; }.highlights-next { display: flex; justify-content: space-between; align-items: center; gap: 24px; padding: 24px; }.highlights-next h2 { margin: 0; font-size: 1.15rem; }.highlights-next p:not(.page-eyebrow) { margin: 7px 0 0; color: var(--muted); }.highlights-next-actions { display: flex; flex-wrap: wrap; gap: 9px; } +.login-full-demo { width: 100%; min-height: 44px; margin: -8px 0 18px; } + +/* Correlated operation trace */ +.operation-trace { margin-bottom: 14px; padding: 22px; }.operation-trace h2 { margin: 0; font-size: 1.15rem; }.operation-trace > div > p:last-child { margin: 7px 0 0; color: var(--muted); }.operation-trace ol { list-style: none; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin: 20px 0 0; padding: 0; }.operation-trace li { position: relative; display: grid; grid-template-columns: 34px 1fr; gap: 10px; align-items: start; padding-right: 15px; }.operation-trace li::after { content: ""; position: absolute; top: 16px; left: 34px; right: 0; border-top: 1px solid var(--line-strong); }.operation-trace li:last-child::after { display: none; }.operation-trace li > span { position: relative; z-index: 1; width: 34px; height: 34px; display: grid; place-items: center; color: var(--muted); background: white; border: 1px solid var(--line-strong); border-radius: 50%; font-size: .72rem; font-weight: 800; }.operation-trace li.is-complete > span { color: white; background: var(--success); border-color: var(--success); }.operation-trace li svg { width: 17px; }.operation-trace li div { position: relative; z-index: 1; padding-right: 6px; background: white; }.operation-trace li strong { display: block; font-size: .78rem; }.operation-trace li small { display: block; margin-top: 4px; color: var(--muted); font-size: var(--type-meta); line-height: 1.4; } + +/* Knowledge wait and diagnostics */ +.knowledge-diagnostics { max-width: 260px; text-align: right; }.knowledge-diagnostics summary { color: var(--teal-dark); font-size: var(--type-meta); font-weight: 700; cursor: pointer; }.knowledge-diagnostics small { display: block; margin-top: 5px; overflow-wrap: anywhere; }.knowledge-progress { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin: 12px 0; padding: 11px; background: var(--surface-subtle); border: 1px solid var(--line); }.knowledge-progress span { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--type-meta); font-weight: 700; }.knowledge-progress span.is-active { color: var(--teal-dark); }.knowledge-progress i, .knowledge-progress svg { width: 15px; height: 15px; flex: 0 0 auto; }.knowledge-progress i { border: 2px solid var(--line-strong); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; } .error { color: #9f2929; font-size: .74rem; font-weight: 600; } .api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); } .api-error-notice strong { display: block; font-size: .78rem; } @@ -540,6 +559,19 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; } } +@media (max-width: 700px) { + :root { --type-body: .875rem; --type-meta: .75rem; --type-label: .75rem; --type-badge: .75rem; } + .icon-button, .language-switcher select, .demo-badge-trigger, .demo-guide-trigger, .back-link, .section-heading > a, .data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button, .toggle-matching-fields, .button-tertiary, .bulk-toolbar select, .bulk-toolbar input { min-width: 44px; min-height: 44px; } + .demo-guide-trigger { width: 44px; justify-content: center; }.demo-badge-trigger { width: 44px; height: 44px; padding: 0; justify-content: center; font-size: 0; }.demo-badge-trigger svg { width: 16px; height: 16px; color: #48566a; }.language-switcher select { height: 44px; } + .mobile-nav span { max-width: 54px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .engineering-hero, .highlights-next { align-items: stretch; flex-direction: column; padding: 20px; }.engineering-hero-actions { min-width: 0; }.proof-grid, .highlight-grid { grid-template-columns: 1fr; }.highlight-card { min-height: 0; }.highlight-proof { margin-top: 22px; }.architecture-flow { grid-template-columns: 1fr; gap: 8px; padding: 14px; }.architecture-step { min-height: 72px; }.architecture-step > svg { right: calc(50% - 12px); top: auto; bottom: -16px; transform: rotate(90deg); }.highlights-next-actions { display: grid; }.operation-trace ol { grid-template-columns: 1fr; gap: 13px; }.operation-trace li { min-height: 54px; }.operation-trace li::after { top: 34px; bottom: -13px; left: 16px; right: auto; border-top: 0; border-left: 1px solid var(--line-strong); }.operation-trace li div { padding-bottom: 5px; }.knowledge-status { align-items: flex-start; }.knowledge-diagnostics { max-width: none; text-align: left; }.knowledge-progress { grid-template-columns: 1fr; }.retrieval-flow span { font-size: var(--type-meta); }.return-progress span { font-size: var(--type-meta); }.duplicate-compare > button { position: sticky; bottom: 78px; z-index: 6; width: 100%; box-shadow: var(--shadow-float); } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .route-skeleton span, .knowledge-progress i { animation: none; } +} + @media (max-width: 700px) { .page-actions { display: flex; min-height: 24px; } }