M29: polish recruiter engineering story
@@ -2647,3 +2647,28 @@ evidence yet."
|
|||||||
error or traceback appears in post-deployment logs.
|
error or traceback appears in post-deployment logs.
|
||||||
- Exact next action: none for the selected scope. Optional secret rotation and new n8n
|
- Exact next action: none for the selected scope. Optional secret rotation and new n8n
|
||||||
execution evidence remain intentionally deferred by the user.
|
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.
|
||||||
|
|||||||
@@ -1,136 +1,94 @@
|
|||||||
# Fleet Ops
|
# 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
|
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.
|
||||||
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).
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
## What makes it more than a mock-up
|
||||||
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).
|
|
||||||
|
|
||||||
## 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;
|
```mermaid
|
||||||
- vehicle and booking workspaces with planning windows, location filters, service/next-
|
flowchart LR
|
||||||
booking context, operational ordering and pagination;
|
UI["React + TypeScript\nresponsive operations UI"] -->|session cookie| API["FastAPI\nbusiness rules + RBAC"]
|
||||||
- server-backed session lifecycle (refresh-safe, central 401 handling);
|
API --> DB[(PostgreSQL)]
|
||||||
- a role matrix enforced server-side and mirrored in the UI (see
|
API -->|grounded retrieval| RAG[RAGcore]
|
||||||
`docs/12-security-and-audit.md`);
|
DB --> OUT["Transactional outbox"]
|
||||||
- vehicle return capture → authoritative server-evaluated review → commit → result;
|
OUT -->|bounded retry| N8N["Existing central n8n"]
|
||||||
- five deterministic data-quality checks with SLA deadlines, assignment, overdue/bulk
|
N8N -->|authenticated callback| API
|
||||||
queue controls, bounded resolution flows and a manual scan action;
|
HUB["ITWorx MCP Hub"] -->|4 read-only tools| API
|
||||||
- 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.
|
|
||||||
|
|
||||||
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
|
- dashboard, vehicle fleet, booking lifecycle and controlled returns;
|
||||||
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending`
|
- data-quality queue, assignment, review, merge and resolution;
|
||||||
with backoff, self-heals once n8n returns), the failed-delivery manual-retry path,
|
- correlated human-readable audit history;
|
||||||
stale-delivery-lease recovery after a simulated crash, and a second (scheduled
|
- cited Knowledge Hub with honest provider state;
|
||||||
quality-scan) workflow live-verified end to end against a real n8n instance.
|
- n8n delivery monitoring and manual retry;
|
||||||
`GET /api/v1/integrations/status` reports a truthful aggregate state from outbox
|
- user administration, privacy export/anonymisation and retention guards;
|
||||||
delivery counts, not just the most recent event.
|
- deterministic reset with 2 users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance records, 33 quality issues and 20 workflow runs.
|
||||||
- **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`).
|
|
||||||
|
|
||||||
See `artifacts/functional-completion/final-summary.md` for the functional-completion
|
This is deliberately not accounting, payments, a public reservation site, generic CRM, inventory, HR or an autonomous write agent.
|
||||||
audit evidence (supersedes the design-validation summary below for integration status),
|
|
||||||
and `artifacts/final-acceptance/summary.md` for the original M0–M7 acceptance evidence.
|
|
||||||
|
|
||||||
## Repository map
|
## Stack
|
||||||
|
|
||||||
- `CLAUDE.md` — binding implementation rules.
|
React, TypeScript, Vite, FastAPI, SQLAlchemy 2, PostgreSQL, Alembic, n8n, RAGcore, ITWorx MCP Hub, Docker Compose, Prometheus, Grafana and Playwright.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Quickstart
|
## Run locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
make demo
|
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`
|
- Web: `http://localhost:1228`
|
||||||
- API readiness: `http://localhost:8128/health/ready` (liveness: `/health/live`)
|
- API readiness: `http://localhost:8128/health/ready`
|
||||||
- n8n: `http://localhost:5678`
|
- 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
|
## Quality gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # backend: isolated PostgreSQL Compose project; never the live database
|
make test # isolated PostgreSQL backend suite
|
||||||
make lint # backend: ruff + mypy (strict, zero errors)
|
make lint # Ruff + strict mypy
|
||||||
make e2e # frontend: complete Playwright acceptance (live stack required)
|
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.
|
||||||
|
|||||||
@@ -1,63 +1,68 @@
|
|||||||
# MobilityOps — as-built architecture
|
# Fleet Ops — as-built architecture
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph Browser
|
UI["Fleet Ops Web\nReact + TypeScript + Vite"]
|
||||||
UI["MobilityOps Web<br/>React + TypeScript"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph MobilityOps["MobilityOps (this repo)"]
|
subgraph CORE["Fleet Ops — this repository"]
|
||||||
API["FastAPI backend<br/>/api/v1/*"]
|
API["FastAPI /api/v1\nRBAC + domain rules"]
|
||||||
DISPATCH["Outbox dispatcher<br/>background thread"]
|
OUT["Outbox dispatcher\nleases + bounded retry"]
|
||||||
DB[(PostgreSQL)]
|
DB[(PostgreSQL)]
|
||||||
|
OBS["Prometheus metrics\nGrafana dashboards"]
|
||||||
API --> DB
|
API --> DB
|
||||||
DISPATCH --> DB
|
OUT --> DB
|
||||||
|
API --> OBS
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["External central services"]
|
subgraph EXT["Existing external platforms"]
|
||||||
N8N["n8n<br/>return-processing workflow"]
|
N8N["Central n8n\nsecondary orchestration"]
|
||||||
RAGDEMO["Demo KnowledgeProvider<br/>TF-IDF extractive, local files"]
|
RAG["RAGcore\ngrounded procedure retrieval"]
|
||||||
RAGCORE["RAGcore<br/>(adapter built, no live instance)"]
|
HUB["ITWorx MCP Hub\ntool transport + publication"]
|
||||||
HUB["ITWorx MCP Hub<br/>(endpoints built, no live instance)"]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
UI -->|session cookie| API
|
UI -->|secure session cookie| API
|
||||||
API -->|GroundedAnswer| RAGDEMO
|
API -->|tenant/workspace adapter| RAG
|
||||||
API -.->|configurable, unavailable-safe| RAGCORE
|
OUT -->|vehicle.returned.v1| N8N
|
||||||
DISPATCH -->|POST vehicle.returned.v1| N8N
|
N8N -->|service-authenticated callback| API
|
||||||
N8N -->|callback, X-Service-Token| API
|
HUB -->|service-authenticated read-only tools| API
|
||||||
HUB -.->|X-Service-Token, read-only| API
|
|
||||||
|
|
||||||
classDef unverified stroke-dasharray: 5 5;
|
|
||||||
class RAGCORE,HUB unverified;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Dashed boxes/arrows are implemented and unit/contract-tested but were never exercised
|
## Ownership and trust boundaries
|
||||||
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.
|
|
||||||
|
|
||||||
## 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 |
|
## End-to-end return trace
|
||||||
|---|---|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## 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
|
## Verified reliability properties
|
||||||
transaction; verified by `test_concurrent_returns_only_one_succeeds` (real Postgres row
|
|
||||||
locking, not mocked).
|
1. Concurrent returns serialize through PostgreSQL row locking; only one can commit.
|
||||||
2. **Outbox delivery is at-least-once, idempotent by event ID** — verified live: the n8n
|
2. Local return success is independent of n8n availability. Pending delivery remains persisted and retryable.
|
||||||
callback checks for an existing `AuditEvent` by event ID before recording a second time.
|
3. Outbox delivery is at-least-once and idempotent by event ID, with crash-recoverable leases and bounded backoff.
|
||||||
3. **RAGcore failure disables knowledge answers only** — `RAGcoreKnowledgeProvider` degrades
|
4. RAGcore failure affects knowledge answers only. The UI reports unavailable/insufficient evidence and does not invent an answer.
|
||||||
to `unavailable`; the rest of the app is unaffected because the knowledge router is the
|
5. MCP endpoints are a separate tenant-bound, client-identity-validated, read-only surface; every call is audited with a correlation ID.
|
||||||
only consumer.
|
6. Browser authorization is enforced again on the API. Hiding a navigation item is never the security boundary.
|
||||||
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
|
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.
|
||||||
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.
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -164,6 +164,7 @@ class RegisterReturnResult(BaseModel):
|
|||||||
odometer_regression: bool
|
odometer_regression: bool
|
||||||
quality_issue_ref: str | None
|
quality_issue_ref: str | None
|
||||||
workflow_event_id: str
|
workflow_event_id: str
|
||||||
|
correlation_id: str
|
||||||
next_booking_risk: NextBookingRisk | None
|
next_booking_risk: NextBookingRisk | None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -143,14 +143,21 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
|||||||
),
|
),
|
||||||
DemoIntegrationSummaryOut(
|
DemoIntegrationSummaryOut(
|
||||||
key="ragcore",
|
key="ragcore",
|
||||||
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
status_code=(
|
||||||
detail_code="ragcoreDetail",
|
"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={
|
detail_params={
|
||||||
"count": (
|
"count": knowledge_health.document_count or 0,
|
||||||
knowledge_health.document_count
|
"source_count": knowledge_health.source_document_count,
|
||||||
if knowledge_health.document_count is not None
|
|
||||||
else "unknown"
|
|
||||||
),
|
|
||||||
"collection": knowledge_health.collection,
|
"collection": knowledge_health.collection,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -162,7 +169,9 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
|||||||
# "operational" -- same evidence rule the integration status page uses.
|
# "operational" -- same evidence rule the integration status page uses.
|
||||||
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
|
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
|
||||||
detail_code=(
|
detail_code=(
|
||||||
"mcpDetailEnabled" if mcp_hub.state == "operational" else "mcpDetailNotConnected"
|
"mcpDetailOperational"
|
||||||
|
if mcp_hub.state == "operational"
|
||||||
|
else "mcpDetailPrepared"
|
||||||
),
|
),
|
||||||
detail_params={},
|
detail_params={},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ def register_vehicle_return(
|
|||||||
"odometer_regression": evaluation.odometer_regression,
|
"odometer_regression": evaluation.odometer_regression,
|
||||||
"quality_issue_ref": quality_issue_ref,
|
"quality_issue_ref": quality_issue_ref,
|
||||||
"workflow_event_id": str(event.event_id),
|
"workflow_event_id": str(event.event_id),
|
||||||
|
"correlation_id": str(correlation_id),
|
||||||
"next_booking_risk": evaluation.next_booking_risk,
|
"next_booking_risk": evaluation.next_booking_risk,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"]
|
||||||
assert commit_body["odometer_regression"] == preview_body["odometer_regression"]
|
assert commit_body["odometer_regression"] == preview_body["odometer_regression"]
|
||||||
assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"]
|
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):
|
def test_preview_detects_odometer_regression(ops_client):
|
||||||
|
|||||||
@@ -1,43 +1,47 @@
|
|||||||
# Portfolio case-study template
|
# Fleet Ops — portfolio case study
|
||||||
|
|
||||||
## Title
|
## Product
|
||||||
|
|
||||||
MobilityOps — connected operations for vehicle rental and service teams
|
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.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Problem
|
## 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.
|
- Keep business invariants in a FastAPI/SQLAlchemy domain layer backed by PostgreSQL constraints and row locks.
|
||||||
- A transactional return workflow persists inspections, derives status and queues post-commit orchestration.
|
- Commit vehicle returns, audit evidence and an outbox message in one transaction.
|
||||||
- Explainable rules detect duplicates, mileage regressions, overlaps, missing fields and status conflicts.
|
- Let the existing central n8n execute secondary work asynchronously, with leases, retries and authenticated callbacks.
|
||||||
- RAGcore provides source-grounded procedure answers.
|
- Detect five classes of data-quality issue and require bounded, explicit human resolution.
|
||||||
- ITWorx MCP Hub exposes narrowly scoped read-only AI tools.
|
- Integrate RAGcore through a provider adapter that refuses unsupported answers and preserves citation provenance.
|
||||||
- n8n orchestrates secondary processing without becoming the business-rule engine.
|
- 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;
|
## Product decisions worth discussing
|
||||||
- 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.
|
|
||||||
|
|
||||||
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
|
## 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.
|
||||||
|
|||||||
@@ -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` });
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ test.describe.configure({ mode: "serial" });
|
|||||||
test("demo entry screen names the fictional org and never shows a password", async ({ page }) => {
|
test("demo entry screen names the fictional org and never shows a password", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
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: "Start begeleide demo" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
|
||||||
|
|||||||
@@ -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 page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).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 }) => {
|
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
||||||
|
|||||||
@@ -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 test.step("1. login as Operations Manager", async () => {
|
||||||
await page.goto("/login");
|
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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
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 test.step("9. verify responsive navigation at mobile width", async () => {
|
||||||
await page.setViewportSize({ width: 360, height: 800 });
|
await page.setViewportSize({ width: 360, height: 800 });
|
||||||
await page.goto("/dashboard");
|
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();
|
await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible();
|
||||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ test.describe("status-recommendation flow", () => {
|
|||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await loginAsOpsManager(page, "nl-BE");
|
await loginAsOpsManager(page, "nl-BE");
|
||||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
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 page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||||
await expect(page.getByText("Aanbevolen status")).toBeVisible();
|
await expect(page.getByText("Aanbevolen status")).toBeVisible();
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
|
|||||||
expect(ref).not.toBe("");
|
expect(ref).not.toBe("");
|
||||||
await firstLink.click();
|
await firstLink.click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
|
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 page.getByRole("button", { name: "Defer" }).click();
|
||||||
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
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 }) => {
|
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/data-quality/DQ-DEMO-ATTENTION");
|
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("Registration number").fill("TST-777");
|
||||||
await page.getByLabel("Make").fill("TestMake");
|
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 }) => {
|
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
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("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
|
||||||
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
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 resetDemoData(request);
|
||||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
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: "Review recommendation" }).click();
|
||||||
await page.getByRole("button", { name: /^Change status to/ }).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];
|
const target = issues[0];
|
||||||
|
|
||||||
await page.goto(`/data-quality/${target.public_ref}`);
|
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("radio", { name: /Retain canonical/ }).check();
|
||||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -3,9 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
<meta name="description" content="Fleet Ops is a production-minded vehicle operations demo with reliable workflows, explainable data quality and source-grounded AI." />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Fleet Ops — connected vehicle operations" />
|
||||||
|
<meta property="og:description" content="Explore reliable vehicle returns, controlled data-quality resolution and source-grounded operational knowledge." />
|
||||||
|
<meta property="og:image" content="/og-fleet-ops.svg" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Fleet Ops — connected vehicle operations" />
|
||||||
|
<meta name="twitter:description" content="A working synthetic-data platform for reliable mobility operations." />
|
||||||
|
<meta name="twitter:image" content="/og-fleet-ops.svg" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Fleet Ops</title>
|
<title>Fleet Ops — connected vehicle operations</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export default defineConfig({
|
|||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
// _capture-screenshots.spec.ts is evidence-generation tooling, not a regression test —
|
// _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.
|
// 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,
|
timeout: 30_000,
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Fleet Ops</title>
|
||||||
|
<desc id="desc">Connected vehicle operations with reliable workflows, explainable data quality and grounded knowledge.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0f172a"/><stop offset="1" stop-color="#17394d"/></linearGradient>
|
||||||
|
<radialGradient id="glow"><stop stop-color="#2dd4bf" stop-opacity=".34"/><stop offset="1" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||||
|
<circle cx="1000" cy="92" r="360" fill="url(#glow)"/>
|
||||||
|
<g transform="translate(78 72)">
|
||||||
|
<path d="M42 72C16 42 7 24 7 8a35 35 0 1 1 70 0c0 16-9 34-35 64Z" fill="#fff" transform="translate(0 6) scale(.72)"/>
|
||||||
|
<circle cx="30" cy="28" r="6" fill="#2dd4bf"/>
|
||||||
|
<rect x="0" y="58" width="62" height="6" rx="3" fill="#2dd4bf"/>
|
||||||
|
<text x="84" y="51" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="36" font-weight="750">Fleet Ops</text>
|
||||||
|
</g>
|
||||||
|
<text x="78" y="265" fill="#5eead4" font-family="Inter,Segoe UI,sans-serif" font-size="24" font-weight="750" letter-spacing="3">CONNECTED VEHICLE OPERATIONS</text>
|
||||||
|
<text x="78" y="345" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="60" font-weight="760">Every hand-off.</text>
|
||||||
|
<text x="78" y="413" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="60" font-weight="760">One clear view.</text>
|
||||||
|
<g transform="translate(78 500)" font-family="Inter,Segoe UI,sans-serif" font-size="22" font-weight="650">
|
||||||
|
<rect width="272" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="27" y="35" fill="#dbeafe">Reliable workflows</text>
|
||||||
|
<rect x="292" width="288" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="319" y="35" fill="#dbeafe">Explainable quality</text>
|
||||||
|
<rect x="600" width="290" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="627" y="35" fill="#dbeafe">Grounded knowledge</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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 Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
|
||||||
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
|
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
|
||||||
const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy })));
|
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) {
|
function deferredPage(element: ReactNode) {
|
||||||
return <Suspense fallback={<div className="route-loading" role="status"><span className="spinner" /></div>}>{element}</Suspense>;
|
return <Suspense fallback={<div className="route-loading" role="status" aria-label="Loading"><div className="route-skeleton"><span /><span /><span /></div></div>}>{element}</Suspense>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -55,6 +56,7 @@ export function App() {
|
|||||||
<Route path="/privacy" element={deferredPage(<Privacy />)} />
|
<Route path="/privacy" element={deferredPage(<Privacy />)} />
|
||||||
<Route path="/about" element={deferredPage(<AboutDemo />)} />
|
<Route path="/about" element={deferredPage(<AboutDemo />)} />
|
||||||
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
|
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
|
||||||
|
<Route path="/highlights" element={deferredPage(<Highlights />)} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export interface RegisterReturnResult {
|
|||||||
odometer_regression: boolean;
|
odometer_regression: boolean;
|
||||||
quality_issue_ref: string | null;
|
quality_issue_ref: string | null;
|
||||||
workflow_event_id: string;
|
workflow_event_id: string;
|
||||||
|
correlation_id: string;
|
||||||
next_booking_risk: NextBookingRisk | null;
|
next_booking_risk: NextBookingRisk | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
|
|||||||
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
|
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
|
||||||
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
|
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
|
||||||
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
|
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
|
||||||
|
{ to: "/about", labelKey: "items.engineering", icon: "spark" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
|
|||||||
<div className="result-links">
|
<div className="result-links">
|
||||||
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
|
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
|
||||||
<Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>
|
<Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>
|
||||||
<Link className="button button-secondary" to="/audit">{t("result.viewAudit")}<Icon name="chevron" /></Link>
|
<Link className="button button-secondary" to={`/audit?correlation_id=${result.correlation_id}`}>{t("result.viewTrace")}<Icon name="chevron" /></Link>
|
||||||
{guideOpen && (
|
{guideOpen && (
|
||||||
<button type="button" className="button button-primary" onClick={continueDemo}>
|
<button type="button" className="button button-primary" onClick={continueDemo}>
|
||||||
{t("result.continueDemo")} <Icon name="chevron" />
|
{t("result.continueDemo")} <Icon name="chevron" />
|
||||||
|
|||||||
@@ -17,20 +17,20 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
|
|||||||
{
|
{
|
||||||
id: "open-booking",
|
id: "open-booking",
|
||||||
route: (manifest) =>
|
route: (manifest) =>
|
||||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
|
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings/BK-DEMO-RETURN",
|
||||||
target: ".record-surface",
|
target: ".record-surface",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "process-return",
|
id: "process-return",
|
||||||
route: (manifest) =>
|
route: (manifest) =>
|
||||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
|
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings/BK-DEMO-RETURN",
|
||||||
target: "#return-form-heading",
|
target: "#return-form-heading",
|
||||||
},
|
},
|
||||||
{ id: "handle-quality-issue", route: () => "/data-quality", target: ".table-shell" },
|
{ id: "handle-quality-issue", route: () => "/data-quality", target: ".table-shell" },
|
||||||
{
|
{
|
||||||
id: "merge-duplicate",
|
id: "merge-duplicate",
|
||||||
route: (manifest) =>
|
route: (manifest) =>
|
||||||
manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality",
|
manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality/DQ-DEMO-DUPLICATE",
|
||||||
target: "#compare-heading",
|
target: "#compare-heading",
|
||||||
},
|
},
|
||||||
{ id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" },
|
{ id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" },
|
||||||
|
|||||||
@@ -69,6 +69,17 @@
|
|||||||
"survivor": "Retained profile",
|
"survivor": "Retained profile",
|
||||||
"loser": "Merged profile"
|
"loser": "Merged profile"
|
||||||
},
|
},
|
||||||
|
"trace": {
|
||||||
|
"eyebrow": "End-to-end trace",
|
||||||
|
"title": "From return to automation",
|
||||||
|
"description": "This view connects events sharing the same correlation ID into one verifiable operation.",
|
||||||
|
"steps": {
|
||||||
|
"return": { "title": "Return persisted", "complete": "Booking and inspection were processed atomically.", "pending": "Not processed yet." },
|
||||||
|
"vehicle": { "title": "Fleet state derived", "complete": "The server persisted the resulting vehicle state.", "pending": "No state change found yet." },
|
||||||
|
"outbox": { "title": "Automation queued", "complete": "The outbox job was created in the same transaction.", "pending": "No outbox job found." },
|
||||||
|
"delivery": { "title": "External follow-up", "complete": "n8n confirmed the follow-up.", "pending": "Delivery is asynchronous and not yet confirmed in this audit trace." }
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Logged in",
|
"demo_login": "Logged in",
|
||||||
"demo_logout": "Logged out",
|
"demo_logout": "Logged out",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"accessHeading": "Choose how to start",
|
"accessHeading": "Choose how to start",
|
||||||
"accessIntro": "No password needed. Each role opens a scoped synthetic environment — all workflows and controls are really implemented.",
|
"accessIntro": "No password needed. Each role opens a scoped synthetic environment — all workflows and controls are really implemented.",
|
||||||
"startGuidedDemo": "Start guided demo",
|
"startGuidedDemo": "Start guided demo",
|
||||||
|
"startRecruiterTour": "See the highlights in 90 seconds",
|
||||||
"exploreAsOperationsManager": "Explore as Operations Manager",
|
"exploreAsOperationsManager": "Explore as Operations Manager",
|
||||||
"exploreAsOperationsManagerDetail": "Full overview, quality resolution and retries",
|
"exploreAsOperationsManagerDetail": "Full overview, quality resolution and retries",
|
||||||
"exploreAsRentalEmployee": "Explore as Rental Employee",
|
"exploreAsRentalEmployee": "Explore as Rental Employee",
|
||||||
|
|||||||
@@ -87,6 +87,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"highlights": {
|
||||||
|
"eyebrow": "Recruiter highlights",
|
||||||
|
"title": "The strongest choices in 90 seconds",
|
||||||
|
"description": "Three short views show how {{productName}} combines operational reliability, controlled corrections and source-grounded AI.",
|
||||||
|
"duration": "about 30 sec",
|
||||||
|
"open": "View in the platform",
|
||||||
|
"proofLabel": "Engineering proof",
|
||||||
|
"return": { "title": "A return that cannot fail silently", "body": "See server-side impact validation, atomic persistence and an outbox that decouples external automation from the rental desk.", "proof": "Transaction · idempotency · outbox · degradation" },
|
||||||
|
"quality": { "title": "Data quality with human control", "body": "Compare duplicate profiles, deliberately choose the surviving record and preserve booking and audit history.", "proof": "Explainable rules · preview · audit · rollback" },
|
||||||
|
"knowledge": { "title": "AI that must show its sources", "body": "Ask a procedure question and inspect the answer, source excerpts and honest insufficient-evidence state.", "proof": "RAGcore · citations · safe degradation" },
|
||||||
|
"storyTitle": "Want to see the choices behind the system?",
|
||||||
|
"storyBody": "The Engineering page connects product flows to architecture, reliability and honest integration status.",
|
||||||
|
"storyButton": "Open Engineering Story",
|
||||||
|
"fullTourButton": "Start the complete demo"
|
||||||
|
},
|
||||||
"scenarios": {
|
"scenarios": {
|
||||||
"eyebrow": "Demonstration scenarios",
|
"eyebrow": "Demonstration scenarios",
|
||||||
"title": "Try a demonstration scenario",
|
"title": "Try a demonstration scenario",
|
||||||
@@ -149,9 +164,29 @@
|
|||||||
"title": "What {{productName}} is and isn't",
|
"title": "What {{productName}} is and isn't",
|
||||||
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
|
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
|
||||||
"loading": "Loading demo information…",
|
"loading": "Loading demo information…",
|
||||||
"ctaTitle": "Prefer to jump right in?",
|
"ctaTitle": "Choose how much time you have",
|
||||||
"ctaBody": "The guided demo walks through all eight steps above in practice.",
|
"ctaBody": "See the three strongest engineering moments in 90 seconds, or take the complete operational tour.",
|
||||||
"ctaButton": "Start guided demo",
|
"ctaButton": "Start full demo",
|
||||||
|
"quickTourButton": "See highlights · 90 sec",
|
||||||
|
"scenarioButton": "Open scenario lab",
|
||||||
|
"proofEyebrow": "Engineering evidence",
|
||||||
|
"proofTitle": "Control, reliability and explainability",
|
||||||
|
"proofDescription": "Every technical choice supports a concrete operational action.",
|
||||||
|
"proofRulesTitle": "Rules stay with the domain",
|
||||||
|
"proofRulesBody": "FastAPI governs returns, statuses and data-quality resolutions. n8n and prompts never make authoritative decisions.",
|
||||||
|
"proofReliabilityTitle": "Commit first, orchestrate second",
|
||||||
|
"proofReliabilityBody": "The operation commits atomically; an outbox then delivers to n8n with bounded retries. An outage never blocks the rental desk.",
|
||||||
|
"proofAiTitle": "AI must show evidence",
|
||||||
|
"proofAiBody": "RAGcore answers include sources. Without sufficient evidence the assistant does not invent an answer.",
|
||||||
|
"architectureFlowTitle": "From user action to controlled follow-up",
|
||||||
|
"architectureFlowDescription": "The system of record stays separate from external orchestration and AI.",
|
||||||
|
"architectureFrontend": "Accessible React interface",
|
||||||
|
"architectureApi": "FastAPI domain rules",
|
||||||
|
"architectureDatabase": "PostgreSQL + audit trail",
|
||||||
|
"architectureOutbox": "Reliable outbox",
|
||||||
|
"architectureExternal": "External services: n8n, RAGcore and MCP Hub",
|
||||||
|
"verificationTitle": "Built to be verified",
|
||||||
|
"verificationBody": "Domain rules, API contracts, degraded modes and the complete demo are tested automatically. The repository contains the exact acceptance commands and evidence bundle.",
|
||||||
"problemTitle": "The fictional problem",
|
"problemTitle": "The fictional problem",
|
||||||
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. {{productName}} shows how one connected system flags these problems early and lets them be resolved under control.",
|
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. {{productName}} shows how one connected system flags these problems early and lets them be resolved under control.",
|
||||||
"scopeTitle": "Who it's for and its scope",
|
"scopeTitle": "Who it's for and its scope",
|
||||||
@@ -172,7 +207,7 @@
|
|||||||
"resetBodyManager": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. Use <strong>Reset demo data</strong> in the sidebar to start over.",
|
"resetBodyManager": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. Use <strong>Reset demo data</strong> in the sidebar to start over.",
|
||||||
"resetBodyEmployee": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. An Operations Manager can reset the demo environment via the sidebar.",
|
"resetBodyEmployee": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. An Operations Manager can reset the demo environment via the sidebar.",
|
||||||
"limitationsTitle": "Limitations",
|
"limitationsTitle": "Limitations",
|
||||||
"limitationsBody": "This is a focused proof of concept, not a full ERP. RAGcore and the ITWorx MCP Hub are not yet live-connected; the knowledge assistant uses a local, scoped demo knowledge base instead of a live RAGcore environment.",
|
"limitationsBody": "This is a focused operational platform, not a full ERP. Accounting, payments, public reservations and a generic CRM are deliberately out of scope. External integrations are assessed separately and honestly using recent evidence.",
|
||||||
"unknown": "unknown"
|
"unknown": "unknown"
|
||||||
},
|
},
|
||||||
"integrationSummary": {
|
"integrationSummary": {
|
||||||
@@ -183,7 +218,8 @@
|
|||||||
},
|
},
|
||||||
"n8nDetail": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending.",
|
"n8nDetail": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending.",
|
||||||
"ragcoreDetail": "{{count}} procedures indexed in {{collection}}.",
|
"ragcoreDetail": "{{count}} procedures indexed in {{collection}}.",
|
||||||
"mcpDetailEnabled": "Prepared for future, controlled tool calls from the Hub.",
|
"ragcoreSourceDetail": "{{source_count}} managed source procedures available; RAGcore does not report a reliable index count.",
|
||||||
"mcpDetailNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub."
|
"mcpDetailOperational": "Live controlled tool calls from the Hub are confirmed in the audit trail.",
|
||||||
|
"mcpDetailPrepared": "Four read-only tools are contract-ready; no recent Hub call has been recorded."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@
|
|||||||
"statusUnavailable": "Unavailable",
|
"statusUnavailable": "Unavailable",
|
||||||
"proceduresIndexed": "{{count}} procedures indexed",
|
"proceduresIndexed": "{{count}} procedures indexed",
|
||||||
"proceduresIndexUnknown": "Index size is not available through this connection",
|
"proceduresIndexUnknown": "Index size is not available through this connection",
|
||||||
|
"proceduresAvailable": "{{count}} managed source procedures available",
|
||||||
|
"technicalDetails": "Technical provider details",
|
||||||
|
"progress": { "retrieving": "Selecting sources", "checking": "Checking evidence", "composing": "Building answer" },
|
||||||
|
"sourceTitles": {
|
||||||
|
"damage-procedure": "Vehicle return damage",
|
||||||
|
"vehicle-availability-procedure": "Restoring vehicle availability",
|
||||||
|
"vehicle-return-procedure": "Registering a vehicle return",
|
||||||
|
"odometer-anomaly-procedure": "Reviewing an odometer anomaly",
|
||||||
|
"checkout-procedure": "Required checkout controls"
|
||||||
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"sourceDocuments": "source documents in this language",
|
"sourceDocuments": "source documents in this language",
|
||||||
"reportedSynced": "sync reported by n8n",
|
"reportedSynced": "sync reported by n8n",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"audit": "Audit trail",
|
"audit": "Audit trail",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"privacy": "Privacy"
|
"privacy": "Privacy",
|
||||||
|
"engineering": "Engineering"
|
||||||
},
|
},
|
||||||
"primaryNavLabel": "Primary navigation",
|
"primaryNavLabel": "Primary navigation",
|
||||||
"mobileNavLabel": "Mobile navigation",
|
"mobileNavLabel": "Mobile navigation",
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
"showMatchingFields": "Also show matching fields",
|
"showMatchingFields": "Also show matching fields",
|
||||||
"hideMatchingFields": "Hide matching fields",
|
"hideMatchingFields": "Hide matching fields",
|
||||||
"previewTitle": "Preview of the merged record ({{ref}})",
|
"previewTitle": "Preview of the merged record ({{ref}})",
|
||||||
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
|
"mergePreview": "{{loser}} will be safely archived and linked to {{survivor}}; its booking history moves with it.",
|
||||||
"mergeInto": "Merge into {{ref}}",
|
"mergeInto": "Merge into {{ref}}",
|
||||||
"confirmMergeTitle": "Confirm merge",
|
"confirmMergeTitle": "Confirm merge",
|
||||||
"confirmMergeBody": "Merge {{loser}} into {{survivor}}? This cannot be undone.",
|
"confirmMergeBody": "Merge {{loser}} into {{survivor}}? This cannot be undone.",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"viewVehicle": "View vehicle {{ref}}",
|
"viewVehicle": "View vehicle {{ref}}",
|
||||||
"viewAutomation": "View automation status",
|
"viewAutomation": "View automation status",
|
||||||
"viewAudit": "View audit trail",
|
"viewAudit": "View audit trail",
|
||||||
|
"viewTrace": "Trace the complete operation",
|
||||||
"continueDemo": "Continue the demo"
|
"continueDemo": "Continue the demo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
|||||||
@@ -69,6 +69,17 @@
|
|||||||
"survivor": "Profil conservé",
|
"survivor": "Profil conservé",
|
||||||
"loser": "Profil fusionné"
|
"loser": "Profil fusionné"
|
||||||
},
|
},
|
||||||
|
"trace": {
|
||||||
|
"eyebrow": "Trace de bout en bout",
|
||||||
|
"title": "Du retour à l'automatisation",
|
||||||
|
"description": "Cette vue relie les événements partageant le même identifiant de corrélation en une opération vérifiable.",
|
||||||
|
"steps": {
|
||||||
|
"return": { "title": "Retour enregistré", "complete": "La réservation et l'inspection ont été traitées atomiquement.", "pending": "Pas encore traité." },
|
||||||
|
"vehicle": { "title": "État de flotte dérivé", "complete": "Le serveur a enregistré l'état résultant du véhicule.", "pending": "Aucun changement d'état trouvé." },
|
||||||
|
"outbox": { "title": "Automatisation planifiée", "complete": "La tâche outbox a été créée dans la même transaction.", "pending": "Aucune tâche outbox trouvée." },
|
||||||
|
"delivery": { "title": "Suivi externe", "complete": "n8n a confirmé le suivi.", "pending": "La livraison est asynchrone et n'est pas encore confirmée dans cette trace." }
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Connecté",
|
"demo_login": "Connecté",
|
||||||
"demo_logout": "Déconnecté",
|
"demo_logout": "Déconnecté",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"accessHeading": "Choisissez comment démarrer",
|
"accessHeading": "Choisissez comment démarrer",
|
||||||
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
|
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
|
||||||
"startGuidedDemo": "Démarrer la démo guidée",
|
"startGuidedDemo": "Démarrer la démo guidée",
|
||||||
|
"startRecruiterTour": "Voir les points forts en 90 secondes",
|
||||||
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
|
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
|
||||||
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
||||||
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
|
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
|
||||||
|
|||||||
@@ -87,6 +87,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"highlights": {
|
||||||
|
"eyebrow": "Points forts pour recruteurs",
|
||||||
|
"title": "Les choix les plus forts en 90 secondes",
|
||||||
|
"description": "Trois vues courtes montrent comment {{productName}} combine fiabilité opérationnelle, corrections contrôlées et IA fondée sur des sources.",
|
||||||
|
"duration": "± 30 s",
|
||||||
|
"open": "Voir dans la plateforme",
|
||||||
|
"proofLabel": "Preuve technique",
|
||||||
|
"return": { "title": "Un retour qui ne peut pas échouer silencieusement", "body": "Découvrez la validation d'impact côté serveur, la persistance atomique et une outbox qui découple l'automatisation externe du comptoir.", "proof": "Transaction · idempotence · outbox · dégradation" },
|
||||||
|
"quality": { "title": "Qualité des données sous contrôle humain", "body": "Comparez les profils en double, choisissez délibérément la fiche survivante et conservez l'historique des réservations et de l'audit.", "proof": "Règles explicables · aperçu · audit · rollback" },
|
||||||
|
"knowledge": { "title": "Une IA qui doit montrer ses sources", "body": "Posez une question de procédure et inspectez la réponse, les extraits sources et l'état honnête de preuves insuffisantes.", "proof": "RAGcore · citations · dégradation sûre" },
|
||||||
|
"storyTitle": "Voir les choix derrière le système ?",
|
||||||
|
"storyBody": "La page Ingénierie relie les flux produit à l'architecture, la fiabilité et l'état honnête des intégrations.",
|
||||||
|
"storyButton": "Ouvrir l'histoire d'ingénierie",
|
||||||
|
"fullTourButton": "Démarrer la démo complète"
|
||||||
|
},
|
||||||
"scenarios": {
|
"scenarios": {
|
||||||
"eyebrow": "Scénarios de démonstration",
|
"eyebrow": "Scénarios de démonstration",
|
||||||
"title": "Essayer un scénario de démonstration",
|
"title": "Essayer un scénario de démonstration",
|
||||||
@@ -149,9 +164,29 @@
|
|||||||
"title": "Ce que {{productName}} est et n'est pas",
|
"title": "Ce que {{productName}} est et n'est pas",
|
||||||
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
|
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
|
||||||
"loading": "Chargement des informations de démo…",
|
"loading": "Chargement des informations de démo…",
|
||||||
"ctaTitle": "Vous préférez vous lancer directement ?",
|
"ctaTitle": "Choisissez le temps dont vous disposez",
|
||||||
"ctaBody": "La démo guidée parcourt les huit étapes ci-dessus en pratique.",
|
"ctaBody": "Découvrez les trois moments d'ingénierie les plus forts en 90 secondes, ou suivez la démo opérationnelle complète.",
|
||||||
"ctaButton": "Démarrer la démo guidée",
|
"ctaButton": "Démarrer la démo complète",
|
||||||
|
"quickTourButton": "Voir les points forts · 90 s",
|
||||||
|
"scenarioButton": "Ouvrir le laboratoire de scénarios",
|
||||||
|
"proofEyebrow": "Preuves d'ingénierie",
|
||||||
|
"proofTitle": "Contrôle, fiabilité et explicabilité",
|
||||||
|
"proofDescription": "Chaque choix technique soutient une action opérationnelle concrète.",
|
||||||
|
"proofRulesTitle": "Les règles restent dans le domaine",
|
||||||
|
"proofRulesBody": "FastAPI régit les retours, les statuts et les résolutions de qualité. n8n et les prompts ne prennent jamais de décisions faisant autorité.",
|
||||||
|
"proofReliabilityTitle": "Valider d'abord, orchestrer ensuite",
|
||||||
|
"proofReliabilityBody": "L'opération est validée atomiquement ; une outbox livre ensuite à n8n avec des tentatives limitées. Une panne ne bloque jamais le comptoir.",
|
||||||
|
"proofAiTitle": "L'IA doit montrer ses preuves",
|
||||||
|
"proofAiBody": "Les réponses RAGcore incluent des sources. Sans preuves suffisantes, l'assistant n'invente pas de réponse.",
|
||||||
|
"architectureFlowTitle": "De l'action utilisateur au suivi contrôlé",
|
||||||
|
"architectureFlowDescription": "Le système de référence reste séparé de l'orchestration externe et de l'IA.",
|
||||||
|
"architectureFrontend": "Interface React accessible",
|
||||||
|
"architectureApi": "Règles métier FastAPI",
|
||||||
|
"architectureDatabase": "PostgreSQL + piste d'audit",
|
||||||
|
"architectureOutbox": "Outbox fiable",
|
||||||
|
"architectureExternal": "Services externes : n8n, RAGcore et MCP Hub",
|
||||||
|
"verificationTitle": "Construit pour être vérifiable",
|
||||||
|
"verificationBody": "Les règles métier, contrats d'API, modes dégradés et la démo complète sont testés automatiquement. Le dépôt contient les commandes d'acceptation exactes et le dossier de preuves.",
|
||||||
"problemTitle": "Le problème fictif",
|
"problemTitle": "Le problème fictif",
|
||||||
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
||||||
"scopeTitle": "Pour qui et avec quelle portée",
|
"scopeTitle": "Pour qui et avec quelle portée",
|
||||||
@@ -172,7 +207,7 @@
|
|||||||
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
|
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
|
||||||
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.",
|
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.",
|
||||||
"limitationsTitle": "Limitations",
|
"limitationsTitle": "Limitations",
|
||||||
"limitationsBody": "Ceci est une preuve de concept ciblée, pas un ERP complet. RAGcore et l'ITWorx MCP Hub ne sont pas encore connectés en direct ; l'assistant de connaissances utilise une base de connaissances de démo locale et délimitée au lieu d'un environnement RAGcore en direct.",
|
"limitationsBody": "Il s'agit d'une plateforme opérationnelle ciblée, pas d'un ERP complet. La comptabilité, les paiements, les réservations publiques et un CRM générique sont délibérément hors périmètre. Les intégrations externes sont évaluées séparément et honnêtement à partir de preuves récentes.",
|
||||||
"unknown": "inconnu"
|
"unknown": "inconnu"
|
||||||
},
|
},
|
||||||
"integrationSummary": {
|
"integrationSummary": {
|
||||||
@@ -183,7 +218,8 @@
|
|||||||
},
|
},
|
||||||
"n8nDetail": "{{succeeded}} réussi(s) · {{failed}} échoué(s) · {{pending}} en attente.",
|
"n8nDetail": "{{succeeded}} réussi(s) · {{failed}} échoué(s) · {{pending}} en attente.",
|
||||||
"ragcoreDetail": "{{count}} procédures indexées dans {{collection}}.",
|
"ragcoreDetail": "{{count}} procédures indexées dans {{collection}}.",
|
||||||
"mcpDetailEnabled": "Préparé pour de futurs appels d'outils contrôlés depuis le Hub.",
|
"ragcoreSourceDetail": "{{source_count}} procédures sources gérées sont disponibles ; RAGcore ne fournit pas de comptage d'index fiable.",
|
||||||
"mcpDetailNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub."
|
"mcpDetailOperational": "Des appels d'outils contrôlés en direct depuis le Hub sont confirmés dans la piste d'audit.",
|
||||||
|
"mcpDetailPrepared": "Quatre outils en lecture seule sont prêts contractuellement ; aucun appel récent du Hub n'a été enregistré."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,17 @@
|
|||||||
"statusAvailable": "Disponible",
|
"statusAvailable": "Disponible",
|
||||||
"statusUnavailable": "Indisponible",
|
"statusUnavailable": "Indisponible",
|
||||||
"proceduresIndexed": "{{count}} procédures indexées",
|
"proceduresIndexed": "{{count}} procédures indexées",
|
||||||
"proceduresIndexUnknown": "La taille de l’index n’est pas disponible via cette connexion",
|
"proceduresIndexUnknown": "La taille de l'index n'est pas disponible via cette connexion",
|
||||||
|
"proceduresAvailable": "{{count}} procédures sources gérées disponibles",
|
||||||
|
"technicalDetails": "Détails techniques du fournisseur",
|
||||||
|
"progress": { "retrieving": "Sélection des sources", "checking": "Vérification des preuves", "composing": "Construction de la réponse" },
|
||||||
|
"sourceTitles": {
|
||||||
|
"damage-procedure": "Dommages au retour du véhicule",
|
||||||
|
"vehicle-availability-procedure": "Rétablir la disponibilité du véhicule",
|
||||||
|
"vehicle-return-procedure": "Enregistrer un retour de véhicule",
|
||||||
|
"odometer-anomaly-procedure": "Examiner une anomalie kilométrique",
|
||||||
|
"checkout-procedure": "Contrôles obligatoires au départ"
|
||||||
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"sourceDocuments": "documents sources dans cette langue",
|
"sourceDocuments": "documents sources dans cette langue",
|
||||||
"reportedSynced": "synchronisation signalée par n8n",
|
"reportedSynced": "synchronisation signalée par n8n",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"integrations": "Intégrations",
|
"integrations": "Intégrations",
|
||||||
"audit": "Piste d'audit",
|
"audit": "Piste d'audit",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
"privacy": "Confidentialité"
|
"privacy": "Confidentialité",
|
||||||
|
"engineering": "Ingénierie"
|
||||||
},
|
},
|
||||||
"primaryNavLabel": "Navigation principale",
|
"primaryNavLabel": "Navigation principale",
|
||||||
"mobileNavLabel": "Navigation mobile",
|
"mobileNavLabel": "Navigation mobile",
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
"showMatchingFields": "Afficher aussi les champs identiques",
|
"showMatchingFields": "Afficher aussi les champs identiques",
|
||||||
"hideMatchingFields": "Masquer les champs identiques",
|
"hideMatchingFields": "Masquer les champs identiques",
|
||||||
"previewTitle": "Aperçu de la fiche fusionnée ({{ref}})",
|
"previewTitle": "Aperçu de la fiche fusionnée ({{ref}})",
|
||||||
"mergePreview": "{{loser}} deviendra une fiche archivée liée à {{survivor}} ; ses réservations seront transférées.",
|
"mergePreview": "{{loser}} sera archivé en toute sécurité et lié à {{survivor}} ; son historique de réservations sera transféré.",
|
||||||
"mergeInto": "Fusionner avec {{ref}}",
|
"mergeInto": "Fusionner avec {{ref}}",
|
||||||
"confirmMergeTitle": "Confirmer la fusion",
|
"confirmMergeTitle": "Confirmer la fusion",
|
||||||
"confirmMergeBody": "Fusionner {{loser}} avec {{survivor}} ? Cette action est irréversible.",
|
"confirmMergeBody": "Fusionner {{loser}} avec {{survivor}} ? Cette action est irréversible.",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"viewVehicle": "Voir le véhicule {{ref}}",
|
"viewVehicle": "Voir le véhicule {{ref}}",
|
||||||
"viewAutomation": "Voir le statut d'automatisation",
|
"viewAutomation": "Voir le statut d'automatisation",
|
||||||
"viewAudit": "Voir la piste d'audit",
|
"viewAudit": "Voir la piste d'audit",
|
||||||
|
"viewTrace": "Suivre l'opération complète",
|
||||||
"continueDemo": "Poursuivre la démo"
|
"continueDemo": "Poursuivre la démo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
|||||||
@@ -69,6 +69,17 @@
|
|||||||
"survivor": "Behouden profiel",
|
"survivor": "Behouden profiel",
|
||||||
"loser": "Samengevoegd profiel"
|
"loser": "Samengevoegd profiel"
|
||||||
},
|
},
|
||||||
|
"trace": {
|
||||||
|
"eyebrow": "Volledige verwerking",
|
||||||
|
"title": "Van retour tot automatisering",
|
||||||
|
"description": "Deze weergave koppelt de gebeurtenissen met dezelfde correlatiecode tot één controleerbare verwerking.",
|
||||||
|
"steps": {
|
||||||
|
"return": { "title": "Retour opgeslagen", "complete": "Boeking en inspectie zijn atomair verwerkt.", "pending": "Nog niet verwerkt." },
|
||||||
|
"vehicle": { "title": "Wagenparkstatus afgeleid", "complete": "De server heeft de resulterende voertuigstatus vastgelegd.", "pending": "Statuswijziging nog niet gevonden." },
|
||||||
|
"outbox": { "title": "Automatisering klaargezet", "complete": "De outbox-opdracht is in dezelfde transactie aangemaakt.", "pending": "Geen outbox-opdracht gevonden." },
|
||||||
|
"delivery": { "title": "Externe opvolging", "complete": "n8n heeft de opvolging bevestigd.", "pending": "Aflevering is asynchroon en nog niet in deze audittrace bevestigd." }
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Ingelogd",
|
"demo_login": "Ingelogd",
|
||||||
"demo_logout": "Uitgelogd",
|
"demo_logout": "Uitgelogd",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"accessHeading": "Kies hoe je wil starten",
|
"accessHeading": "Kies hoe je wil starten",
|
||||||
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
|
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
|
||||||
"startGuidedDemo": "Start begeleide demo",
|
"startGuidedDemo": "Start begeleide demo",
|
||||||
|
"startRecruiterTour": "Bekijk de highlights in 90 seconden",
|
||||||
"exploreAsOperationsManager": "Verken als Operationsmanager",
|
"exploreAsOperationsManager": "Verken als Operationsmanager",
|
||||||
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
||||||
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
|
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
|
||||||
|
|||||||
@@ -87,6 +87,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"highlights": {
|
||||||
|
"eyebrow": "Highlights voor recruiters",
|
||||||
|
"title": "De sterkste keuzes in 90 seconden",
|
||||||
|
"description": "Drie korte inkijkjes tonen hoe {{productName}} operationele betrouwbaarheid, gecontroleerde correcties en brongebaseerde AI samenbrengt.",
|
||||||
|
"duration": "± 30 sec",
|
||||||
|
"open": "Bekijk in het platform",
|
||||||
|
"proofLabel": "Technisch bewijs",
|
||||||
|
"return": { "title": "Een retour die niet stil kan falen", "body": "Bekijk serverzijdige impactvalidatie, atomaire opslag en een outbox die externe automatisering loskoppelt van de balie.", "proof": "Transactie · idempotency · outbox · degradatie" },
|
||||||
|
"quality": { "title": "Datakwaliteit met menselijke controle", "body": "Vergelijk dubbele profielen, kies bewust het overlevende record en behoud boekings- en auditgeschiedenis.", "proof": "Uitlegbare regels · preview · audit · rollback" },
|
||||||
|
"knowledge": { "title": "AI die zijn bronnen moet tonen", "body": "Stel een procedurevraag en inspecteer het antwoord, de bronfragmenten en de eerlijke insufficient-evidence toestand.", "proof": "RAGcore · citations · veilige degradatie" },
|
||||||
|
"storyTitle": "Wil je de keuzes achter het systeem zien?",
|
||||||
|
"storyBody": "De Engineering-pagina verbindt de productflows met architectuur, betrouwbaarheid en eerlijke integratiestatus.",
|
||||||
|
"storyButton": "Open het engineeringverhaal",
|
||||||
|
"fullTourButton": "Start de volledige demo"
|
||||||
|
},
|
||||||
"scenarios": {
|
"scenarios": {
|
||||||
"eyebrow": "Demonstratiescenario's",
|
"eyebrow": "Demonstratiescenario's",
|
||||||
"title": "Probeer een demonstratiescenario",
|
"title": "Probeer een demonstratiescenario",
|
||||||
@@ -149,9 +164,29 @@
|
|||||||
"title": "Wat {{productName}} wel en niet is",
|
"title": "Wat {{productName}} wel en niet is",
|
||||||
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
|
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
|
||||||
"loading": "Demo-informatie laden…",
|
"loading": "Demo-informatie laden…",
|
||||||
"ctaTitle": "Liever meteen aan de slag?",
|
"ctaTitle": "Kies hoeveel tijd je hebt",
|
||||||
"ctaBody": "De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.",
|
"ctaBody": "Bekijk de drie sterkste engineeringmomenten in 90 seconden, of doorloop de volledige operationele demo.",
|
||||||
"ctaButton": "Start begeleide demo",
|
"ctaButton": "Start volledige demo",
|
||||||
|
"quickTourButton": "Bekijk highlights · 90 sec",
|
||||||
|
"scenarioButton": "Open scenariolab",
|
||||||
|
"proofEyebrow": "Engineeringbewijs",
|
||||||
|
"proofTitle": "Controle, betrouwbaarheid en uitlegbaarheid",
|
||||||
|
"proofDescription": "Elke technische keuze ondersteunt een concrete operationele handeling.",
|
||||||
|
"proofRulesTitle": "Regels blijven bij het domein",
|
||||||
|
"proofRulesBody": "FastAPI bewaakt retouren, statussen en datakwaliteitsoplossingen. n8n en prompts nemen nooit gezaghebbende beslissingen.",
|
||||||
|
"proofReliabilityTitle": "Commit eerst, orkestreer daarna",
|
||||||
|
"proofReliabilityBody": "De operatie commit atomair; een outbox levert nadien met begrensde herpogingen aan n8n. Een storing blokkeert de verhuurbalie niet.",
|
||||||
|
"proofAiTitle": "AI moet bewijs tonen",
|
||||||
|
"proofAiBody": "RAGcore-antwoorden bevatten bronnen. Zonder voldoende evidentie geeft de assistent geen verzonnen antwoord.",
|
||||||
|
"architectureFlowTitle": "Van gebruikersactie tot gecontroleerd vervolg",
|
||||||
|
"architectureFlowDescription": "Het systeem van record blijft gescheiden van externe orkestratie en AI.",
|
||||||
|
"architectureFrontend": "Toegankelijke React-interface",
|
||||||
|
"architectureApi": "FastAPI-domeinregels",
|
||||||
|
"architectureDatabase": "PostgreSQL + audittrail",
|
||||||
|
"architectureOutbox": "Betrouwbare outbox",
|
||||||
|
"architectureExternal": "Externe diensten: n8n, RAGcore en MCP Hub",
|
||||||
|
"verificationTitle": "Verifieerbaar gebouwd",
|
||||||
|
"verificationBody": "Domeinregels, API-contracten, degradatiemodi en de volledige demonstratie worden automatisch getest. De repository bevat de exacte acceptatiecommando's en bewijsbundel.",
|
||||||
"problemTitle": "Het fictieve probleem",
|
"problemTitle": "Het fictieve probleem",
|
||||||
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
|
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
|
||||||
"scopeTitle": "Voor wie en met welke scope",
|
"scopeTitle": "Voor wie en met welke scope",
|
||||||
@@ -172,7 +207,7 @@
|
|||||||
"resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Gebruik <strong>Demogegevens herstellen</strong> in de zijbalk om opnieuw te beginnen.",
|
"resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Gebruik <strong>Demogegevens herstellen</strong> in de zijbalk om opnieuw te beginnen.",
|
||||||
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operationsmanager kan de demo-omgeving herstellen via de zijbalk.",
|
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operationsmanager kan de demo-omgeving herstellen via de zijbalk.",
|
||||||
"limitationsTitle": "Beperkingen",
|
"limitationsTitle": "Beperkingen",
|
||||||
"limitationsBody": "Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale, afgebakende demokennisbank in plaats van een live RAGcore-omgeving.",
|
"limitationsBody": "Dit is een doelgericht operationeel platform, geen volledig ERP. Boekhouding, betalingen, publieke reserveringen en een generieke CRM vallen bewust buiten scope. Externe integraties worden afzonderlijk en eerlijk op recent bewijs beoordeeld.",
|
||||||
"unknown": "onbekend"
|
"unknown": "onbekend"
|
||||||
},
|
},
|
||||||
"integrationSummary": {
|
"integrationSummary": {
|
||||||
@@ -183,7 +218,8 @@
|
|||||||
},
|
},
|
||||||
"n8nDetail": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij.",
|
"n8nDetail": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij.",
|
||||||
"ragcoreDetail": "{{count}} procedures geïndexeerd in {{collection}}.",
|
"ragcoreDetail": "{{count}} procedures geïndexeerd in {{collection}}.",
|
||||||
"mcpDetailEnabled": "Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.",
|
"ragcoreSourceDetail": "{{source_count}} beheerde bronprocedures beschikbaar; RAGcore rapporteert geen betrouwbare indextelling.",
|
||||||
"mcpDetailNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub."
|
"mcpDetailOperational": "Live gecontroleerde tool-aanroepen vanuit de Hub zijn in de auditgeschiedenis bevestigd.",
|
||||||
|
"mcpDetailPrepared": "Vier read-only tools zijn contractueel voorbereid; er is nog geen recente Hub-aanroep geregistreerd."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@
|
|||||||
"statusUnavailable": "Niet beschikbaar",
|
"statusUnavailable": "Niet beschikbaar",
|
||||||
"proceduresIndexed": "{{count}} procedures geïndexeerd",
|
"proceduresIndexed": "{{count}} procedures geïndexeerd",
|
||||||
"proceduresIndexUnknown": "Indexomvang niet beschikbaar via deze koppeling",
|
"proceduresIndexUnknown": "Indexomvang niet beschikbaar via deze koppeling",
|
||||||
|
"proceduresAvailable": "{{count}} beheerde bronprocedures beschikbaar",
|
||||||
|
"technicalDetails": "Technische providerdetails",
|
||||||
|
"progress": { "retrieving": "Bronnen selecteren", "checking": "Evidentie controleren", "composing": "Antwoord opbouwen" },
|
||||||
|
"sourceTitles": {
|
||||||
|
"damage-procedure": "Schade bij voertuigretour",
|
||||||
|
"vehicle-availability-procedure": "Voertuig opnieuw beschikbaar stellen",
|
||||||
|
"vehicle-return-procedure": "Voertuigretour registreren",
|
||||||
|
"odometer-anomaly-procedure": "Afwijkende kilometerstand beoordelen",
|
||||||
|
"checkout-procedure": "Verplichte vertrekcontroles"
|
||||||
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"sourceDocuments": "brondocumenten in deze taal",
|
"sourceDocuments": "brondocumenten in deze taal",
|
||||||
"reportedSynced": "sync door n8n gerapporteerd",
|
"reportedSynced": "sync door n8n gerapporteerd",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"integrations": "Integraties",
|
"integrations": "Integraties",
|
||||||
"audit": "Auditgeschiedenis",
|
"audit": "Auditgeschiedenis",
|
||||||
"users": "Gebruikers",
|
"users": "Gebruikers",
|
||||||
"privacy": "Privacy"
|
"privacy": "Privacy",
|
||||||
|
"engineering": "Techniek"
|
||||||
},
|
},
|
||||||
"primaryNavLabel": "Hoofdnavigatie",
|
"primaryNavLabel": "Hoofdnavigatie",
|
||||||
"mobileNavLabel": "Mobiele navigatie",
|
"mobileNavLabel": "Mobiele navigatie",
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
"showMatchingFields": "Ook overeenkomende velden tonen",
|
"showMatchingFields": "Ook overeenkomende velden tonen",
|
||||||
"hideMatchingFields": "Overeenkomende velden verbergen",
|
"hideMatchingFields": "Overeenkomende velden verbergen",
|
||||||
"previewTitle": "Voorbeeld van het samengevoegde record ({{ref}})",
|
"previewTitle": "Voorbeeld van het samengevoegde record ({{ref}})",
|
||||||
"mergePreview": "{{loser}} wordt een tombstone gekoppeld aan {{survivor}}; de boekingen worden herverbonden.",
|
"mergePreview": "{{loser}} wordt veilig gearchiveerd en gekoppeld aan {{survivor}}; de boekingsgeschiedenis verhuist mee.",
|
||||||
"mergeInto": "Samenvoegen met {{ref}}",
|
"mergeInto": "Samenvoegen met {{ref}}",
|
||||||
"confirmMergeTitle": "Samenvoegen bevestigen",
|
"confirmMergeTitle": "Samenvoegen bevestigen",
|
||||||
"confirmMergeBody": "{{loser}} samenvoegen met {{survivor}}? Dit kan niet ongedaan gemaakt worden.",
|
"confirmMergeBody": "{{loser}} samenvoegen met {{survivor}}? Dit kan niet ongedaan gemaakt worden.",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"viewVehicle": "Voertuig {{ref}} bekijken",
|
"viewVehicle": "Voertuig {{ref}} bekijken",
|
||||||
"viewAutomation": "Automatiseringsstatus bekijken",
|
"viewAutomation": "Automatiseringsstatus bekijken",
|
||||||
"viewAudit": "Auditgeschiedenis bekijken",
|
"viewAudit": "Auditgeschiedenis bekijken",
|
||||||
|
"viewTrace": "Volledige verwerking volgen",
|
||||||
"continueDemo": "Ga verder met de demo"
|
"continueDemo": "Ga verder met de demo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { useLocaleFormat } from "../i18n/format";
|
import { useLocaleFormat } from "../i18n/format";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon, type IconName } from "../components/Icons";
|
||||||
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
import { PRODUCT_NAME } from "../product";
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
|
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = { n8n: "n8n", ragcore: "rag", mcp_hub: "mcp" };
|
||||||
n8n: "n8n",
|
const N8N_STATUS_LABEL_KEY: Record<string, string> = { disabled: "notConnected", unavailable: "deliveryFailed", degraded: "retryAvailable", operational: "operational", no_evidence: "prepared" };
|
||||||
ragcore: "rag",
|
const PROOFS: Array<{ id: "Rules" | "Reliability" | "Ai"; icon: IconName }> = [
|
||||||
mcp_hub: "mcp",
|
{ id: "Rules", icon: "shield" },
|
||||||
};
|
{ id: "Reliability", icon: "integrations" },
|
||||||
|
{ id: "Ai", icon: "knowledge" },
|
||||||
const N8N_STATUS_LABEL_KEY: Record<string, string> = {
|
];
|
||||||
disabled: "notConnected",
|
|
||||||
unavailable: "deliveryFailed",
|
|
||||||
degraded: "retryAvailable",
|
|
||||||
operational: "operational",
|
|
||||||
no_evidence: "prepared",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AboutDemo() {
|
export function AboutDemo() {
|
||||||
const { t } = useTranslation(["demo", "integrations"]);
|
const { t } = useTranslation(["demo", "integrations"]);
|
||||||
@@ -27,112 +22,56 @@ export function AboutDemo() {
|
|||||||
const { manifest, loading } = useDemoManifest();
|
const { manifest, loading } = useDemoManifest();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { openGuide } = useDemoGuide();
|
const { openGuide } = useDemoGuide();
|
||||||
|
const statusLabelKey = (key: string, statusCode: string) => key === "n8n" ? N8N_STATUS_LABEL_KEY[statusCode] ?? statusCode : statusCode;
|
||||||
function statusLabelKey(key: string, statusCode: string): string {
|
|
||||||
if (key === "n8n") return N8N_STATUS_LABEL_KEY[statusCode] ?? statusCode;
|
|
||||||
return statusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page engineering-story">
|
||||||
<PageHeader
|
<PageHeader eyebrow={t("about.eyebrow")} title={t("about.title", { productName: PRODUCT_NAME })} description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined} />
|
||||||
eyebrow={t("about.eyebrow")}
|
|
||||||
title={t("about.title", { productName: PRODUCT_NAME })}
|
|
||||||
description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{loading && <LoadingState label={t("about.loading")} />}
|
{loading && <LoadingState label={t("about.loading")} />}
|
||||||
|
{manifest && <>
|
||||||
{manifest && (
|
<section className="panel engineering-hero">
|
||||||
<>
|
<div>
|
||||||
{user?.role === "operations_manager" && (
|
<p className="page-eyebrow">{t("about.proofEyebrow")}</p>
|
||||||
<section className="record-surface about-card about-cta">
|
<h2>{t("about.proofTitle")}</h2>
|
||||||
<div>
|
<p>{t("about.proofDescription")}</p>
|
||||||
<strong>{t("about.ctaTitle")}</strong>
|
|
||||||
<p>{t("about.ctaBody")}</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="button button-primary" onClick={openGuide}>
|
|
||||||
<Icon name="spark" /> {t("about.ctaButton")}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="record-surface about-card">
|
|
||||||
<h2>{t("about.problemTitle")}</h2>
|
|
||||||
<p>{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="record-surface about-card">
|
|
||||||
<h2>{t("about.scopeTitle")}</h2>
|
|
||||||
<p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="about-grid">
|
|
||||||
<div className="record-surface about-card">
|
|
||||||
<h2>{t("about.realTitle")}</h2>
|
|
||||||
<p>{t("about.realBody")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="record-surface about-card">
|
|
||||||
<h2>{t("about.syntheticTitle")}</h2>
|
|
||||||
<p>{t("about.syntheticBody", { testDomain: ".test" })}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details className="record-surface about-card about-details">
|
|
||||||
<summary>{t("about.architectureTitle")}</summary>
|
|
||||||
<p>{t("about.architectureBody")}</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details className="record-surface about-card about-details">
|
|
||||||
<summary>{t("about.securityTitle")}</summary>
|
|
||||||
<p>{t("about.securityBody")}</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details className="record-surface about-card about-details">
|
|
||||||
<summary>{t("about.testingTitle")}</summary>
|
|
||||||
<p>{t("about.testingBody")}</p>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="engineering-hero-actions">
|
||||||
|
<Link className="button button-primary" to="/highlights"><Icon name="spark" /> {t("about.quickTourButton")}</Link>
|
||||||
|
{user?.role === "operations_manager" && <button type="button" className="button button-secondary" onClick={openGuide}>{t("about.ctaButton")}</button>}
|
||||||
|
<Link className="button button-secondary" to="/scenarios">{t("about.scenarioButton")}</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section aria-label={t("about.integrationsTitle")} className="record-surface">
|
<section aria-label={t("about.proofTitle")}>
|
||||||
<SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
|
<div className="proof-grid">
|
||||||
<div className="integration-cards">
|
{PROOFS.map((proof) => <article className="panel proof-card" key={proof.id}><span><Icon name={proof.icon} /></span><h3>{t(`about.proof${proof.id}Title`)}</h3><p>{t(`about.proof${proof.id}Body`)}</p></article>)}
|
||||||
{manifest.integrations.map((integration) => (
|
</div>
|
||||||
<article key={integration.key}>
|
</section>
|
||||||
<IntegrationMark kind={INTEGRATION_ICON[integration.key]} />
|
|
||||||
<div>
|
|
||||||
<span className="integration-kicker">
|
|
||||||
{t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)}
|
|
||||||
</span>
|
|
||||||
<h2>{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}</h2>
|
|
||||||
<p>{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="record-surface about-card">
|
<section className="panel architecture-story" aria-labelledby="architecture-heading">
|
||||||
<h2>{t("about.resetTitle")}</h2>
|
<SectionHeading title={t("about.architectureFlowTitle")} description={t("about.architectureFlowDescription")} />
|
||||||
<p>
|
<div className="architecture-flow" id="architecture-heading">
|
||||||
<Trans
|
{["Frontend", "Api", "Database", "Outbox", "External"].map((step, index) => <div className="architecture-step" key={step}><span>{index + 1}</span><strong>{t(`about.architecture${step}`)}</strong>{index < 4 && <Icon name="chevron" />}</div>)}
|
||||||
i18nKey={user?.role === "operations_manager" ? "about.resetBodyManager" : "about.resetBodyEmployee"}
|
</div>
|
||||||
t={t}
|
</section>
|
||||||
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("about.unknown") }}
|
|
||||||
components={{ strong: <strong /> }}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="access-note record-surface" aria-label={t("about.limitationsTitle")}>
|
<div className="about-grid">
|
||||||
<Icon name="shield" />
|
<div className="record-surface about-card"><h2>{t("about.problemTitle")}</h2><p>{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}</p></div>
|
||||||
<p>
|
<div className="record-surface about-card"><h2>{t("about.scopeTitle")}</h2><p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p></div>
|
||||||
<strong>{t("about.limitationsTitle")}</strong>
|
<div className="record-surface about-card"><h2>{t("about.realTitle")}</h2><p>{t("about.realBody")}</p></div>
|
||||||
<span>{t("about.limitationsBody")}</span>
|
<div className="record-surface about-card"><h2>{t("about.syntheticTitle")}</h2><p>{t("about.syntheticBody", { testDomain: ".test" })}</p></div>
|
||||||
</p>
|
<div className="record-surface about-card"><h2>{t("about.verificationTitle")}</h2><p>{t("about.verificationBody")}</p></div>
|
||||||
</section>
|
<div className="record-surface about-card"><h2>{t("about.securityTitle")}</h2><p>{t("about.securityBody")}</p></div>
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
|
<section aria-label={t("about.integrationsTitle")} className="record-surface">
|
||||||
|
<SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
|
||||||
|
<div className="integration-cards">{manifest.integrations.map((integration) => <article key={integration.key}><IntegrationMark kind={INTEGRATION_ICON[integration.key]} /><div><span className="integration-kicker">{t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)}</span><h2>{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}</h2><p>{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}</p></div></article>)}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="record-surface about-card"><h2>{t("about.resetTitle")}</h2><p><Trans i18nKey={user?.role === "operations_manager" ? "about.resetBodyManager" : "about.resetBodyEmployee"} t={t} values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("about.unknown") }} components={{ strong: <strong /> }} /></p></section>
|
||||||
|
<section className="access-note record-surface" aria-label={t("about.limitationsTitle")}><Icon name="shield" /><p><strong>{t("about.limitationsTitle")}</strong><span>{t("about.limitationsBody")}</span></p></section>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import { useLocaleFormat } from "../i18n/format";
|
import { useLocaleFormat } from "../i18n/format";
|
||||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
import { Pagination } from "../components/Pagination";
|
import { Pagination } from "../components/Pagination";
|
||||||
|
import { Icon } from "../components/Icons";
|
||||||
|
|
||||||
function humanizeField(field: string): string {
|
function humanizeField(field: string): string {
|
||||||
const spaced = field.replace(/_/g, " ");
|
const spaced = field.replace(/_/g, " ");
|
||||||
@@ -39,6 +40,22 @@ function ChangeDiff({ before, after, t }: { before: Record<string, unknown> | nu
|
|||||||
|
|
||||||
interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; }
|
interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; }
|
||||||
|
|
||||||
|
function OperationTrace({ events, t }: { events: AuditEvent[]; t: (key: string, options?: Record<string, unknown>) => 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 <section className="panel operation-trace" aria-labelledby="operation-trace-title">
|
||||||
|
<div><p className="page-eyebrow">{t("trace.eyebrow")}</p><h2 id="operation-trace-title">{t("trace.title")}</h2><p>{t("trace.description")}</p></div>
|
||||||
|
<ol>{steps.map((step, index) => <li className={step.complete ? "is-complete" : "is-pending"} key={step.key}><span>{step.complete ? <Icon name="check" /> : index + 1}</span><div><strong>{t(`trace.steps.${step.key}.title`)}</strong><small>{t(`trace.steps.${step.key}.${step.complete ? "complete" : "pending"}`)}</small></div></li>)}</ol>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
export function Audit() {
|
export function Audit() {
|
||||||
const { t } = useTranslation("audit");
|
const { t } = useTranslation("audit");
|
||||||
const { formatDateTime } = useLocaleFormat();
|
const { formatDateTime } = useLocaleFormat();
|
||||||
@@ -95,6 +112,7 @@ export function Audit() {
|
|||||||
<label>{t("fromFilterLabel")}<input type="date" value={from} onChange={(e) => updateParams({ from: e.target.value, page: 1 })} /></label>
|
<label>{t("fromFilterLabel")}<input type="date" value={from} onChange={(e) => updateParams({ from: e.target.value, page: 1 })} /></label>
|
||||||
<label>{t("toFilterLabel")}<input type="date" value={to} onChange={(e) => updateParams({ to: e.target.value, page: 1 })} /></label>
|
<label>{t("toFilterLabel")}<input type="date" value={to} onChange={(e) => updateParams({ to: e.target.value, page: 1 })} /></label>
|
||||||
</form>
|
</form>
|
||||||
|
{correlationId && events && <OperationTrace events={events.items} t={t} />}
|
||||||
{correlationId && <p className="quiet-empty" role="status">{t("relatedFilterActive", { count: events?.total ?? 0 })} <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: null, page: 1 })}>{t("clearFilter")}</button></p>}
|
{correlationId && <p className="quiet-empty" role="status">{t("relatedFilterActive", { count: events?.total ?? 0 })} <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: null, page: 1 })}>{t("clearFilter")}</button></p>}
|
||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} />}
|
||||||
{!error && !events && <LoadingState label={t("loading")} />}
|
{!error && !events && <LoadingState label={t("loading")} />}
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ export function DataQualityIssueDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
|
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
|
||||||
<PageHeader eyebrow={t("detail.eyebrow", { rule: t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") }) })} title={issue.public_ref} description={t("detail.title")} actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} label={t(`list.status${issue.status.charAt(0).toUpperCase()}${issue.status.slice(1)}`, { defaultValue: issue.status })} /></div>} />
|
<PageHeader eyebrow={issue.public_ref} title={t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })} description={t("detail.title")} actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} label={t(`list.status${issue.status.charAt(0).toUpperCase()}${issue.status.slice(1)}`, { defaultValue: issue.status })} /></div>} />
|
||||||
<section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid">
|
<section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid">
|
||||||
<div><dt>{t("detail.summary.rule")}</dt><dd>{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}</dd></div>
|
<div><dt>{t("detail.summary.rule")}</dt><dd>{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}</dd></div>
|
||||||
<div><dt>{t("detail.summary.entity")}</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
|
<div><dt>{t("detail.summary.entity")}</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="page recruiter-highlights">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow={t("highlights.eyebrow")}
|
||||||
|
title={t("highlights.title")}
|
||||||
|
description={t("highlights.description", { productName: PRODUCT_NAME })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ol className="highlight-grid">
|
||||||
|
{HIGHLIGHTS.map((highlight, index) => (
|
||||||
|
<li key={highlight.id} className="panel highlight-card">
|
||||||
|
<div className="highlight-card-top">
|
||||||
|
<span className="highlight-number">0{index + 1}</span>
|
||||||
|
<span className="highlight-duration"><Icon name="clock" /> {t("highlights.duration")}</span>
|
||||||
|
</div>
|
||||||
|
<span className="highlight-icon"><Icon name={highlight.icon} /></span>
|
||||||
|
<h2>{t(`highlights.${highlight.id}.title`)}</h2>
|
||||||
|
<p>{t(`highlights.${highlight.id}.body`)}</p>
|
||||||
|
<div className="highlight-proof">
|
||||||
|
<strong>{t("highlights.proofLabel")}</strong>
|
||||||
|
<span>{t(`highlights.${highlight.id}.proof`)}</span>
|
||||||
|
</div>
|
||||||
|
<Link className="button button-primary" to={highlight.to}>
|
||||||
|
{t("highlights.open")} <Icon name="chevron" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<section className="panel highlights-next">
|
||||||
|
<div>
|
||||||
|
<p className="page-eyebrow">{t("highlights.proofLabel")}</p>
|
||||||
|
<h2>{t("highlights.storyTitle")}</h2>
|
||||||
|
<p>{t("highlights.storyBody")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="highlights-next-actions">
|
||||||
|
<Link className="button button-primary" to="/about">{t("highlights.storyButton")} <Icon name="chevron" /></Link>
|
||||||
|
<button type="button" className="button button-secondary" onClick={openGuide}><Icon name="spark" /> {t("highlights.fullTourButton")}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ function isOpaqueVersion(value: string): boolean {
|
|||||||
return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value);
|
return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceDisplayTitle(title: string, t: (key: string, options?: Record<string, unknown>) => 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() {
|
export function Knowledge() {
|
||||||
const { t, i18n } = useTranslation(["knowledge", "errors"]);
|
const { t, i18n } = useTranslation(["knowledge", "errors"]);
|
||||||
const { formatDateTime } = useLocaleFormat();
|
const { formatDateTime } = useLocaleFormat();
|
||||||
@@ -27,6 +33,7 @@ export function Knowledge() {
|
|||||||
const [statusSettled, setStatusSettled] = useState(false);
|
const [statusSettled, setStatusSettled] = useState(false);
|
||||||
const [question, setQuestion] = useState("");
|
const [question, setQuestion] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [loadingStage, setLoadingStage] = useState(0);
|
||||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||||
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
||||||
|
|
||||||
@@ -44,6 +51,9 @@ export function Knowledge() {
|
|||||||
async function ask(questionText: string) {
|
async function ask(questionText: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
setLoadingStage(0);
|
||||||
|
const sourceTimer = window.setTimeout(() => setLoadingStage(1), 900);
|
||||||
|
const evidenceTimer = window.setTimeout(() => setLoadingStage(2), 2600);
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
try {
|
try {
|
||||||
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", {
|
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", {
|
||||||
@@ -61,6 +71,8 @@ export function Knowledge() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(describeApiError(t, err, "askFailed"));
|
setError(describeApiError(t, err, "askFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
|
window.clearTimeout(sourceTimer);
|
||||||
|
window.clearTimeout(evidenceTimer);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,11 +136,11 @@ export function Knowledge() {
|
|||||||
<span>
|
<span>
|
||||||
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
|
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
|
||||||
{status.document_count === null
|
{status.document_count === null
|
||||||
? t("proceduresIndexUnknown")
|
? t("proceduresAvailable", { count: status.source_document_count })
|
||||||
: t("proceduresIndexed", { count: status.document_count })}
|
: t("proceduresIndexed", { count: status.document_count })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<small>{status.collection}</small>
|
<details className="knowledge-diagnostics"><summary>{t("technicalDetails")}</summary><small>{status.collection} · {status.detail}</small></details>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{statusSettled && !status && (
|
{statusSettled && !status && (
|
||||||
@@ -181,6 +193,9 @@ export function Knowledge() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ApiErrorNotice error={error} />
|
<ApiErrorNotice error={error} />
|
||||||
|
{submitting && <div className="knowledge-progress" role="status" aria-live="polite">
|
||||||
|
{["retrieving", "checking", "composing"].map((stage, index) => <span className={index <= loadingStage ? "is-active" : ""} key={stage}>{index < loadingStage ? <Icon name="check" /> : <i />}{t(`progress.${stage}`)}</span>)}
|
||||||
|
</div>}
|
||||||
<div className="knowledge-suggestions">
|
<div className="knowledge-suggestions">
|
||||||
<span>{t("suggestedLabel")}</span>
|
<span>{t("suggestedLabel")}</span>
|
||||||
{suggestedQuestions.map((suggestion) => (
|
{suggestedQuestions.map((suggestion) => (
|
||||||
@@ -230,7 +245,7 @@ export function Knowledge() {
|
|||||||
<details open={sourceIndex === 0}>
|
<details open={sourceIndex === 0}>
|
||||||
<summary>
|
<summary>
|
||||||
<span className="source-number">{sourceIndex + 1}</span>
|
<span className="source-number">{sourceIndex + 1}</span>
|
||||||
<span className="source-title">{source.title}</span>
|
<span className="source-title">{sourceDisplayTitle(source.title, t)}</span>
|
||||||
{source.section && <span className="source-section">{source.section}</span>}
|
{source.section && <span className="source-section">{source.section}</span>}
|
||||||
</summary>
|
</summary>
|
||||||
<p className="source-excerpt">{source.excerpt}</p>
|
<p className="source-excerpt">{source.excerpt}</p>
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ export function Login() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
async function handleLogin(role: Role, guided = false) {
|
async function handleLogin(role: Role, destination = "/dashboard") {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await loginAs(role);
|
await loginAs(role);
|
||||||
navigate(guided ? "/dashboard?guide=start" : "/dashboard");
|
navigate(destination);
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("loginFailed"));
|
setError(t("loginFailed"));
|
||||||
}
|
}
|
||||||
@@ -78,9 +78,13 @@ export function Login() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="button button-primary login-guide-cta"
|
className="button button-primary login-guide-cta"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => handleLogin("operations_manager", true)}
|
onClick={() => handleLogin("operations_manager", "/highlights")}
|
||||||
>
|
>
|
||||||
<Icon name="spark" />
|
<Icon name="spark" />
|
||||||
|
{t("startRecruiterTour")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="button button-secondary login-full-demo" disabled={loading} onClick={() => handleLogin("operations_manager", "/dashboard?guide=start")}>
|
||||||
{t("startGuidedDemo")}
|
{t("startGuidedDemo")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -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); }
|
.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-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; }
|
.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 { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
|
||||||
.api-error-notice strong { display: block; font-size: .78rem; }
|
.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; }
|
.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) {
|
@media (max-width: 700px) {
|
||||||
.page-actions { display: flex; min-height: 24px; }
|
.page-actions { display: flex; min-height: 24px; }
|
||||||
}
|
}
|
||||||
|
|||||||