polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul

Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and
knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with
eager-bundled per-namespace resources, a persisted accessible language switcher (topbar
and mobile drawer), locale-aware date/number formatting, and a coverage test that fails
the build on any missing or empty translation key.

Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves
from fixed English/Dutch prose to stable message codes + params so the frontend can
localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus
(11 documents each) with per-language retrieval and localized evidence-state messages.

The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a
floating panel that auto-collapses to a persistent, closable progress chip on standard
desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+
highlight on "go to this step", Escape handling, and reduced-motion support.

The Data Quality Workbench gets accessible choice-card decisions with a clear primary/
secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and
uses meaningful short refs; the Audit trail groups events by correlation id with human
action labels and readable before/after diffs. Attention Queue, Today's movements,
Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern)
with independent secondary links, keyboard support and mobile touch targets.

Fixes a topbar overflow on mobile caused by the new language switcher (moved into the
mobile drawer at <=960px) and two dangling aria-labelledby references introduced this
session. Updates all affected Playwright specs for the new nl-BE default and the new
Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and
clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-03 18:33:22 +02:00
co-authored by Claude Sonnet 5
parent 257a4cf6c0
commit 337f8716bb
127 changed files with 5529 additions and 1287 deletions
+1 -2
View File
@@ -61,12 +61,11 @@ def get_dashboard(
entity = customers_by_id.get(issue.entity_id) entity = customers_by_id.get(issue.entity_id)
link_type = "customer" link_type = "customer"
link_ref = entity.public_ref if entity else "" link_ref = entity.public_ref if entity else ""
title = f"{issue.rule_type.replace('_', ' ').title()}{link_ref}"
attention_items.append( attention_items.append(
AttentionItem( AttentionItem(
kind="quality_issue", kind="quality_issue",
severity=issue.severity, severity=issue.severity,
title=title, rule_type=issue.rule_type,
detail=issue.evidence_json.get("summary", ""), detail=issue.evidence_json.get("summary", ""),
link_type=link_type, link_type=link_type,
link_ref=link_ref, link_ref=link_ref,
+11 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Literal
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -13,9 +14,12 @@ from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledg
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
class AskQuestionRequest(BaseModel): class AskQuestionRequest(BaseModel):
question: str = Field(min_length=3, max_length=1000) question: str = Field(min_length=3, max_length=1000)
language: SupportedLanguage = "en-GB"
@router.post("/questions", response_model=GroundedAnswer) @router.post("/questions", response_model=GroundedAnswer)
@@ -26,7 +30,7 @@ def ask_question(
) -> GroundedAnswer: ) -> GroundedAnswer:
correlation_id = str(uuid.uuid4()) correlation_id = str(uuid.uuid4())
provider = get_knowledge_provider() provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id) answer = provider.ask(body.question, correlation_id, body.language)
record_audit_event( record_audit_event(
db, db,
@@ -40,6 +44,7 @@ def ask_question(
"provider": answer.provider, "provider": answer.provider,
"source_ids": [s.document_id for s in answer.sources], "source_ids": [s.document_id for s in answer.sources],
"question_length": len(body.question), "question_length": len(body.question),
"language": body.language,
}, },
) )
db.commit() db.commit()
@@ -47,5 +52,8 @@ def ask_question(
@router.get("/status", response_model=KnowledgeHealth) @router.get("/status", response_model=KnowledgeHealth)
def knowledge_status(_user: CurrentUser = Depends(get_current_user)) -> KnowledgeHealth: def knowledge_status(
return get_knowledge_provider().health() language: SupportedLanguage = "en-GB",
_user: CurrentUser = Depends(get_current_user),
) -> KnowledgeHealth:
return get_knowledge_provider().health(language)
+6 -9
View File
@@ -199,27 +199,24 @@ class IntegrationStatusOut(BaseModel):
class DemoScenarioOut(BaseModel): class DemoScenarioOut(BaseModel):
id: str id: str
title: str
operational_problem: str
estimated_minutes: int estimated_minutes: int
required_roles: list[Role] required_roles: list[Role]
start_path: str start_path: str
demonstrates: str
ready: bool ready: bool
blocked_reason: str | None = None blocked_reason_code: str | None = None
blocked_reason_params: dict[str, str] = {}
class DemoIntegrationSummaryOut(BaseModel): class DemoIntegrationSummaryOut(BaseModel):
key: Literal["n8n", "ragcore", "mcp_hub"] key: Literal["n8n", "ragcore", "mcp_hub"]
label: str status_code: str
status_label: str detail_code: str
detail: str detail_params: dict[str, str | int] = {}
class DemoManifestOut(BaseModel): class DemoManifestOut(BaseModel):
demo_mode: bool demo_mode: bool
organization_name: str organization_name: str
organization_description: str
timezone: str timezone: str
synthetic_data: bool synthetic_data: bool
allow_reset: bool allow_reset: bool
@@ -251,7 +248,7 @@ class DashboardMetrics(BaseModel):
class AttentionItem(BaseModel): class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"] kind: Literal["quality_issue", "vehicle"]
severity: str severity: str
title: str rule_type: str
detail: str detail: str
link_type: Literal["vehicle", "booking", "customer"] link_type: Literal["vehicle", "booking", "customer"]
link_ref: str link_ref: str
+48 -114
View File
@@ -16,26 +16,8 @@ from app.services.knowledge import get_knowledge_provider
settings = get_settings() settings = get_settings()
# The default name matches the project's locked fictitious tenant (see PROJECT_STATE.md
# "Locked decisions"; the same slug already backs `ragcore_tenant`) — this surfaces that
# existing decision in the UI rather than inventing a new one. Configurable via
# DEMO_ORGANIZATION_NAME so a redeployment can rebrand the fictional org without a code change.
ORGANIZATION_DESCRIPTION = (
"MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, "
"ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en "
"automatiseert gecontroleerde vervolgstappen."
)
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020" _FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
_N8N_STATE_LABELS = {
"disabled": "Niet gekoppeld",
"unavailable": "Verwerking mislukt",
"degraded": "Opnieuw proberen mogelijk",
"operational": "Operationeel",
"no_evidence": "Voorbereid",
}
def _last_reset(db: Session) -> tuple[datetime | None, str | None]: def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
marker = db.scalar( marker = db.scalar(
@@ -61,44 +43,33 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID) select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
) )
knowledge_health = get_knowledge_provider().health() knowledge_health = get_knowledge_provider().health()
reset_hint = "Reset de demo-data om dit scenario opnieuw beschikbaar te maken."
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
# languages. This service only emits stable identifiers and message codes -- never
# display prose -- per the message_code + params architecture used across the app.
return_ready = bool(
booking and booking.status == "active" and booking.end_odometer_km is None
)
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
return [ return [
DemoScenarioOut( DemoScenarioOut(
id="return-anomaly", id="return-anomaly",
title="Retour met afwijkende kilometerstand",
operational_problem=(
"Een voertuig komt terug met een kilometerstand die lager ligt dan de "
"laatst geregistreerde stand — een teken van een foutieve invoer of een "
"verwisseld voertuig."
),
estimated_minutes=3, estimated_minutes=3,
required_roles=["rental_employee", "operations_manager"], required_roles=["rental_employee", "operations_manager"],
start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings", start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings",
demonstrates=( ready=return_ready,
"Retourverwerking, automatische detectie van datakwaliteitsproblemen en de " blocked_reason_code=(
"audit trail die daaruit ontstaat."
),
ready=bool(
booking and booking.status == "active" and booking.end_odometer_km is None
),
blocked_reason=(
None None
if booking and booking.status == "active" and booking.end_odometer_km is None if return_ready
else ( else "bookingNotFound" if booking is None else "bookingAlreadyProcessed"
f"Demoboeking BK-DEMO-RETURN niet gevonden. {reset_hint}"
if booking is None
else f"Deze boeking is al verwerkt sinds de laatste reset. {reset_hint}"
)
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
id="duplicate-customer", id="duplicate-customer",
title="Mogelijke dubbele klant samenvoegen",
operational_problem=(
"Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — "
"waarschijnlijk dezelfde persoon, twee keer geregistreerd."
),
estimated_minutes=3, estimated_minutes=3,
required_roles=["operations_manager"], required_roles=["operations_manager"],
start_path=( start_path=(
@@ -106,87 +77,46 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
if duplicate_issue if duplicate_issue
else "/data-quality" else "/data-quality"
), ),
demonstrates=( ready=duplicate_ready,
"Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail." blocked_reason_code=(
),
ready=bool(duplicate_issue and duplicate_issue.status == "open"),
blocked_reason=(
None None
if duplicate_issue and duplicate_issue.status == "open" if duplicate_ready
else ( else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved"
f"Demo-issue DQ-DEMO-DUPLICATE niet gevonden. {reset_hint}"
if duplicate_issue is None
else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}"
)
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
id="booking-overlap", id="booking-overlap",
title="Overlappende boekingen herstellen",
operational_problem=(
"Eén voertuig staat dubbel gereserveerd voor overlappende periodes — een "
"planningsfout die vóór vertrek moet worden opgelost."
),
estimated_minutes=2, estimated_minutes=2,
required_roles=["operations_manager"], required_roles=["operations_manager"],
start_path=( start_path=(
f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality" f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality"
), ),
demonstrates="Detectie en gecontroleerde oplossing van planningsconflicten.", ready=overlap_ready,
ready=bool(overlap_issue and overlap_issue.status == "open"), blocked_reason_code=(
blocked_reason=(
None None
if overlap_issue and overlap_issue.status == "open" if overlap_ready
else ( else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved"
f"Demo-issue DQ-DEMO-OVERLAP niet gevonden. {reset_hint}"
if overlap_issue is None
else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}"
)
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
id="automation-retry", id="automation-retry",
title="Mislukte automatisering opnieuw proberen",
operational_problem=(
"Eén eerdere gebeurtenis kon niet worden afgeleverd aan de automatisering "
"door een gesimuleerde verbindingsfout."
),
estimated_minutes=2, estimated_minutes=2,
required_roles=["operations_manager"], required_roles=["operations_manager"],
start_path="/automation", start_path="/automation",
demonstrates=( ready=automation_ready,
"Betrouwbare aflevering met begrensde herpogingen en zichtbare foutstatus." blocked_reason_code=(
),
ready=bool(failed_run and failed_run.delivery_status == "failed"),
blocked_reason=(
None None
if failed_run and failed_run.delivery_status == "failed" if automation_ready
else ( else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered"
f"Gesimuleerde mislukte gebeurtenis niet gevonden. {reset_hint}"
if failed_run is None
else f"Deze gebeurtenis is al hersteld sinds de laatste reset. {reset_hint}"
)
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
id="knowledge-question", id="knowledge-question",
title="Een procedurevraag stellen",
operational_problem=(
"Een medewerker weet niet zeker welke procedure van toepassing is bij een "
"specifieke operationele situatie."
),
estimated_minutes=2, estimated_minutes=2,
required_roles=["rental_employee", "operations_manager"], required_roles=["rental_employee", "operations_manager"],
start_path="/knowledge", start_path="/knowledge",
demonstrates=(
"Antwoorden met brongebaseerde onderbouwing uit een afgebakende demokennisbank."
),
ready=knowledge_health.available, ready=knowledge_health.available,
blocked_reason=( blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable",
None
if knowledge_health.available
else "De demokennisbank is momenteel niet beschikbaar."
),
), ),
] ]
@@ -198,27 +128,32 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
return [ return [
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
key="n8n", key="n8n",
label="Automatisering (n8n)", status_code=n8n.state,
status_label=_N8N_STATE_LABELS.get(n8n.state, n8n.state), detail_code="n8nDetail",
detail=f"{n8n.succeeded} geslaagd, {n8n.failed} mislukt, {n8n.pending} in wachtrij.", detail_params={
"succeeded": n8n.succeeded,
"failed": n8n.failed,
"pending": n8n.pending,
},
), ),
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
key="ragcore", key="ragcore",
label="Kennisassistent (RAGcore)", status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
status_label=( detail_code="ragcoreDetail",
"Demomodus — lokale kennisprovider" detail_params={
if knowledge_health.provider != "ragcore" "count": knowledge_health.document_count,
else "Operationeel" "collection": knowledge_health.collection,
), },
detail=knowledge_health.detail,
), ),
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
key="mcp_hub", key="mcp_hub",
label="ITWorx MCP Hub", status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected",
status_label=( detail_code=(
"Operationeel" if settings.mcp_hub_registration_enabled else "Niet gekoppeld" "mcpDetailEnabled"
if settings.mcp_hub_registration_enabled
else "mcpDetailNotConnected"
), ),
detail="Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.", detail_params={},
), ),
] ]
@@ -230,7 +165,7 @@ def scenario_integrity_report(db: Session) -> dict:
overview already use, so this can never drift from what a visitor actually sees.""" overview already use, so this can never drift from what a visitor actually sees."""
scenarios = _scenarios(db) scenarios = _scenarios(db)
not_ready = [ not_ready = [
{"id": s.id, "title": s.title, "reason": s.blocked_reason} {"id": s.id, "reason_code": s.blocked_reason_code}
for s in scenarios for s in scenarios
if not s.ready if not s.ready
] ]
@@ -242,7 +177,6 @@ def build_demo_manifest(db: Session) -> DemoManifestOut:
return DemoManifestOut( return DemoManifestOut(
demo_mode=settings.mobilityops_demo_mode, demo_mode=settings.mobilityops_demo_mode,
organization_name=settings.demo_organization_name, organization_name=settings.demo_organization_name,
organization_description=ORGANIZATION_DESCRIPTION,
timezone=settings.demo_timezone, timezone=settings.demo_timezone,
synthetic_data=True, synthetic_data=True,
allow_reset=settings.demo_allow_reset, allow_reset=settings.demo_allow_reset,
+4 -2
View File
@@ -39,9 +39,11 @@ class KnowledgeHealth(BaseModel):
class KnowledgeProvider(Protocol): class KnowledgeProvider(Protocol):
name: str name: str
def health(self) -> KnowledgeHealth: ... def health(self, language: str = "en-GB") -> KnowledgeHealth: ...
def ask(self, question: str, correlation_id: str) -> GroundedAnswer: ... def ask(
self, question: str, correlation_id: str, language: str = "en-GB"
) -> GroundedAnswer: ...
@lru_cache @lru_cache
+105 -38
View File
@@ -8,12 +8,31 @@ from pathlib import Path
from app.core.config import get_settings from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
STOPWORDS = { SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being", DEFAULT_LANGUAGE = "en-GB"
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
"do", "does", "did", "must", "may", "can", "could", "should", "would", STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how", "en-GB": {
"with", "without", "this", "that", "these", "those", "not", "no", "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
"do", "does", "did", "must", "may", "can", "could", "should", "would",
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how",
"with", "without", "this", "that", "these", "those", "not", "no",
},
"nl-BE": {
"een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt",
"van", "in", "op", "voor", "en", "of", "maar", "als", "dan",
"moet", "mag", "kan", "kunnen", "zou", "zouden",
"ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe",
"met", "zonder", "dit", "dat", "deze", "die", "niet", "geen",
},
"fr-BE": {
"un", "une", "le", "la", "les", "des", "est", "sont", "était", "être",
"de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors",
"doit", "peut", "peuvent", "pourrait", "devrait",
"je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment",
"avec", "sans", "ce", "cette", "ces", "cela", "pas", "non",
},
} }
_WORD_RE = re.compile(r"[a-z0-9]+") _WORD_RE = re.compile(r"[a-z0-9]+")
@@ -28,9 +47,10 @@ def _stem(word: str) -> str:
return word return word
def _tokenize(text: str) -> set[str]: def _tokenize(text: str, language: str) -> set[str]:
stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE])
words = _WORD_RE.findall(text.lower()) words = _WORD_RE.findall(text.lower())
return {_stem(w) for w in words if w not in STOPWORDS and len(w) > 2} return {_stem(w) for w in words if w not in stopwords and len(w) > 2}
@dataclass @dataclass
@@ -86,7 +106,7 @@ def _split_sections(body: str) -> list[tuple[str, str]]:
return sections return sections
def _load_sections(procedures_dir: Path) -> list[ScoredSection]: def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
sections: list[ScoredSection] = [] sections: list[ScoredSection] = []
for path in sorted(procedures_dir.glob("*.md")): for path in sorted(procedures_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8") raw = path.read_text(encoding="utf-8")
@@ -96,7 +116,7 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
document_id=meta.get("document_id", path.stem), document_id=meta.get("document_id", path.stem),
title=title, title=title,
version=meta.get("version", "1.0"), version=meta.get("version", "1.0"),
title_tokens=_tokenize(title), title_tokens=_tokenize(title, language),
) )
for heading, text in _split_sections(body): for heading, text in _split_sections(body):
sections.append( sections.append(
@@ -104,19 +124,49 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
document=doc, document=doc,
heading=heading, heading=heading,
text=text, text=text,
heading_tokens=_tokenize(heading), heading_tokens=_tokenize(heading, language),
body_tokens=_tokenize(text), body_tokens=_tokenize(text, language),
) )
) )
return sections return sections
_NO_MATCH_TEXT = {
"en-GB": "No matching procedure was found for this question.",
"nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.",
"fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.",
}
_LOW_CONFIDENCE_TEXT = {
"en-GB": (
"The available procedures do not clearly answer this question. "
"The closest matches are included below for review."
),
"nl-BE": (
"De beschikbare procedures beantwoorden deze vraag niet duidelijk. "
"De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling."
),
"fr-BE": (
"Les procédures disponibles ne répondent pas clairement à cette question. "
"Les correspondances les plus proches sont indiquées ci-dessous pour examen."
),
}
_LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
"fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}',
}
class DemoKnowledgeProvider: class DemoKnowledgeProvider:
"""Deterministic extractive retrieval over the local procedure Markdown files. """Deterministic extractive retrieval over the local procedure Markdown files.
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap Not a generative model: it scores sections with TF-IDF-weighted keyword overlap
(downweighting terms common across the whole corpus, like "vehicle", in favor of (downweighting terms common across the whole corpus, like "vehicle", in favor of
distinctive ones, like "damage") and returns real excerpts, never invented text. distinctive ones, like "damage") and returns real excerpts, never invented text.
Each supported UI language has its own translated procedure corpus under
knowledge/procedures/<language>/ -- retrieval searches only within the requested
language's corpus so citations always link to a same-language document.
""" """
name = "demo" name = "demo"
@@ -124,10 +174,18 @@ class DemoKnowledgeProvider:
def __init__(self) -> None: def __init__(self) -> None:
settings = get_settings() settings = get_settings()
self._settings = settings self._settings = settings
self._procedures_dir = Path(settings.knowledge_dir) base_dir = Path(settings.knowledge_dir)
self._sections = _load_sections(self._procedures_dir) self._sections_by_language: dict[str, list[ScoredSection]] = {}
self._document_count = len({s.document.document_id for s in self._sections}) self._idf_by_language: dict[str, dict[str, float]] = {}
self._idf = self._build_idf(self._sections) self._document_count_by_language: dict[str, int] = {}
for language in SUPPORTED_LANGUAGES:
lang_dir = base_dir / language
sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else []
self._sections_by_language[language] = sections
self._idf_by_language[language] = self._build_idf(sections)
self._document_count_by_language[language] = len(
{s.document.document_id for s in sections}
)
@staticmethod @staticmethod
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]: def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
@@ -140,7 +198,13 @@ class DemoKnowledgeProvider:
doc_freq[token] = doc_freq.get(token, 0) + 1 doc_freq[token] = doc_freq.get(token, 0) + 1
return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()} return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()}
def health(self) -> KnowledgeHealth: def _normalize_language(self, language: str | None) -> str:
if language in SUPPORTED_LANGUAGES:
return language
return DEFAULT_LANGUAGE
def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth:
language = self._normalize_language(language)
return KnowledgeHealth( return KnowledgeHealth(
provider=self.name, provider=self.name,
available=True, available=True,
@@ -148,36 +212,40 @@ class DemoKnowledgeProvider:
tenant=self._settings.ragcore_tenant, tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace, workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection, collection=self._settings.ragcore_collection,
document_count=self._document_count, document_count=self._document_count_by_language[language],
) )
def _score(self, query_tokens: set[str], section: ScoredSection) -> float: def _score(
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
) -> float:
score = 0.0 score = 0.0
for token in query_tokens: for token in query_tokens:
idf = self._idf.get(token, 0.0) token_idf = idf.get(token, 0.0)
if idf == 0.0: if token_idf == 0.0:
continue continue
if token in section.heading_tokens: if token in section.heading_tokens:
score += 3 * idf score += 3 * token_idf
elif token in section.document.title_tokens: elif token in section.document.title_tokens:
score += 2 * idf score += 2 * token_idf
elif token in section.body_tokens: elif token in section.body_tokens:
score += idf score += token_idf
return score return score
def ask(self, question: str, correlation_id: str) -> GroundedAnswer: def ask(
query_tokens = _tokenize(question) self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE
scored = [ ) -> GroundedAnswer:
(self._score(query_tokens, section), section) language = self._normalize_language(language)
for section in self._sections sections = self._sections_by_language[language]
] idf = self._idf_by_language[language]
query_tokens = _tokenize(question, language)
scored = [(self._score(query_tokens, section, idf), section) for section in sections]
scored = [(score, section) for score, section in scored if score > 0] scored = [(score, section) for score, section in scored if score > 0]
scored.sort(key=lambda item: item[0], reverse=True) scored.sort(key=lambda item: item[0], reverse=True)
top = scored[:3] top = scored[:3]
if not top: if not top:
return GroundedAnswer( return GroundedAnswer(
answer="No matching procedure was found for this question.", answer=_NO_MATCH_TEXT[language],
evidence_state="insufficient", evidence_state="insufficient",
sources=[], sources=[],
provider=self.name, provider=self.name,
@@ -197,10 +265,7 @@ class DemoKnowledgeProvider:
if top[0][0] < 3: if top[0][0] < 3:
return GroundedAnswer( return GroundedAnswer(
answer=( answer=_LOW_CONFIDENCE_TEXT[language],
"The available procedures do not clearly answer this question. "
"The closest matches are included below for review."
),
evidence_state="insufficient", evidence_state="insufficient",
sources=sources, sources=sources,
provider=self.name, provider=self.name,
@@ -208,9 +273,11 @@ class DemoKnowledgeProvider:
) )
lead_section = top[0][1] lead_section = top[0][1]
answer = ( answer = _LEAD_ANSWER_TEMPLATE[language].format(
f'Per "{lead_section.document.title}" (v{lead_section.document.version}), ' title=lead_section.document.title,
f'section "{lead_section.heading}": {lead_section.text.splitlines()[0][:300]}' version=lead_section.document.version,
heading=lead_section.heading,
excerpt=lead_section.text.splitlines()[0][:300],
) )
return GroundedAnswer( return GroundedAnswer(
answer=answer, answer=answer,
+3 -2
View File
@@ -32,7 +32,7 @@ class RAGcoreKnowledgeProvider:
timeout=self._settings.ragcore_http_timeout_seconds, timeout=self._settings.ragcore_http_timeout_seconds,
) )
def health(self) -> KnowledgeHealth: def health(self, language: str = "en-GB") -> KnowledgeHealth:
try: try:
with self._client() as client: with self._client() as client:
response = client.get("/health") response = client.get("/health")
@@ -52,7 +52,7 @@ class RAGcoreKnowledgeProvider:
document_count=0, document_count=0,
) )
def ask(self, question: str, correlation_id: str) -> GroundedAnswer: def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
try: try:
with self._client() as client: with self._client() as client:
response = client.post( response = client.post(
@@ -63,6 +63,7 @@ class RAGcoreKnowledgeProvider:
"collection": self._settings.ragcore_collection, "collection": self._settings.ragcore_collection,
"question": question, "question": question,
"correlation_id": correlation_id, "correlation_id": correlation_id,
"language": language,
}, },
) )
response.raise_for_status() response.raise_for_status()
+2 -3
View File
@@ -44,12 +44,11 @@ def test_demo_manifest_scenarios_ready_after_fresh_reset(client):
scenarios = {s["id"]: s for s in body["scenarios"]} scenarios = {s["id"]: s for s in body["scenarios"]}
for scenario_id, scenario in scenarios.items(): for scenario_id, scenario in scenarios.items():
assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset" assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset"
assert scenario["blocked_reason"] is None assert scenario["blocked_reason_code"] is None
assert scenario["start_path"] assert scenario["start_path"]
def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client): def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client):
body = client.get("/api/v1/demo/manifest").json() body = client.get("/api/v1/demo/manifest").json()
ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore") ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore")
assert "Demomodus" in ragcore["status_label"] assert ragcore["status_code"] == "demoMode"
assert "RAGcore" not in ragcore["status_label"]
+46 -1
View File
@@ -32,7 +32,52 @@ def test_demo_provider_health_reports_document_count():
health = provider.health() health = provider.health()
assert health.provider == "demo" assert health.provider == "demo"
assert health.available is True assert health.available is True
assert health.document_count == 10 assert health.document_count == 11
def test_demo_provider_health_reports_document_count_per_language():
provider = DemoKnowledgeProvider()
for language in ("nl-BE", "en-GB", "fr-BE"):
assert provider.health(language).document_count == 11
def test_demo_provider_grounds_damage_question_in_dutch():
provider = DemoKnowledgeProvider()
answer = provider.ask(
"Wat moet ik doen als een voertuig terugkomt met schade?",
"test-correlation-nl",
"nl-BE",
)
assert answer.evidence_state == "grounded"
document_ids = {s.document_id for s in answer.sources}
assert "damage-procedure" in document_ids
def test_demo_provider_grounds_damage_question_in_french():
provider = DemoKnowledgeProvider()
answer = provider.ask(
"Que dois-je faire quand un véhicule revient avec des dommages ?",
"test-correlation-fr",
"fr-BE",
)
assert answer.evidence_state == "grounded"
document_ids = {s.document_id for s in answer.sources}
assert "damage-procedure" in document_ids
def test_demo_provider_insufficient_evidence_message_is_localized():
provider = DemoKnowledgeProvider()
nl_answer = provider.ask(
"Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE"
)
fr_answer = provider.ask(
"Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE"
)
assert nl_answer.evidence_state == "insufficient"
assert fr_answer.evidence_state == "insufficient"
assert nl_answer.answer != fr_answer.answer
assert "France" not in nl_answer.answer
assert "France" not in fr_answer.answer
def test_ask_question_endpoint_grounded(ops_client): def test_ask_question_endpoint_grounded(ops_client):
+105
View File
@@ -0,0 +1,105 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
}
// Verifies the "stretched link" pattern used across the Attention Queue, Today's movements,
// Vehicles, Bookings and Data Quality tables: the whole row is one activation target, not
// just its title/reference text, while any secondary in-row link stays independently usable.
test.beforeEach(async ({ page, request }) => {
await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login");
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
test("attention queue row opens its record when clicking empty row space, not just the title", async ({ page }) => {
const row = page.locator(".attention-list li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
expect(box).not.toBeNull();
// Click near the far right edge of the row -- empty space, not the title text or badge.
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("attention queue row is keyboard reachable and opens on Enter", async ({ page }) => {
const row = page.locator(".attention-list li.row-clickable").first();
const link = row.locator(".row-link");
await link.focus();
await expect(link).toBeFocused();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("today's movements row opens the correct booking on click", async ({ page }) => {
const row = page.locator(".movement-timeline li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(/\/bookings\/BK-/);
});
test("vehicles table row opens the vehicle detail from empty row space", async ({ page }) => {
await page.goto("/vehicles");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/vehicles/${ref}$`));
});
test("bookings table row opens the booking, and the secondary vehicle link stays independently clickable", async ({ page }) => {
await page.goto("/bookings");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const vehicleLink = row.locator(".cell-link");
const vehicleRef = (await vehicleLink.innerText()).trim();
// Clicking the secondary vehicle-ref link navigates to the vehicle, not the booking --
// it must not be swallowed by the row-spanning overlay link sitting behind it.
await vehicleLink.click();
await expect(page).toHaveURL(new RegExp(`/vehicles/${vehicleRef}$`));
await page.goto("/bookings");
const rowAgain = page.locator(".data-table tbody tr.row-clickable").first();
const bookingRef = (await rowAgain.locator("th").first().innerText()).split("\n")[0].trim();
const box = await rowAgain.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/bookings/${bookingRef}$`));
});
test("data quality table row opens the issue detail from empty row space", async ({ page }) => {
await page.goto("/data-quality");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
});
test("attention queue row opens the correct record on a mobile viewport tap", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
const row = page.locator(".attention-list li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
// A real touch context needs `hasTouch`, which this shared spec file doesn't opt into;
// a mouse click at the same mobile viewport size still exercises the same CSS layout
// and click-target logic, since the app has no touch-specific event handling.
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("data quality table row has a pointer cursor and a visible focus ring covering the whole row", async ({ page }) => {
await page.goto("/data-quality");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toHaveCSS("cursor", "pointer");
await row.locator(".row-link").focus();
await expect(row.locator(".row-link")).toBeFocused();
});
+3 -3
View File
@@ -30,10 +30,10 @@ test("demo guide does not cover the return form's action buttons on desktop", as
await expect(page).toHaveURL(/\/dashboard/); await expect(page).toHaveURL(/\/dashboard/);
await page.goto("/bookings/BK-DEMO-RETURN"); await page.goto("/bookings/BK-DEMO-RETURN");
const reviewButton = page.getByRole("button", { name: "Review return" }); const reviewButton = page.getByRole("button", { name: "Retour nakijken" });
await expect(reviewButton).toBeVisible(); await expect(reviewButton).toBeVisible();
await reviewButton.click({ timeout: 5000 }); await reviewButton.click({ timeout: 5000 });
await expect(page.getByText(/Expected fleet state/)).toBeVisible(); await expect(page.getByText(/Verwachte wagenparkstatus/)).toBeVisible();
}); });
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => { test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
@@ -79,7 +79,7 @@ test("key demo pages load without console errors", async ({ page }) => {
await page.goto("/scenarios"); await page.goto("/scenarios");
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
await page.goto("/about"); await page.goto("/about");
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
await page.getByRole("button", { name: /Demo-gids/ }).click(); await page.getByRole("button", { name: /Demo-gids/ }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
+2 -2
View File
@@ -37,9 +37,9 @@ test("permanent demo badge shows a popover with last reset info and a working Ab
await page.getByRole("link", { name: /Over deze demo/ }).click(); await page.getByRole("link", { name: /Over deze demo/ }).click();
await expect(page).toHaveURL(/\/about$/); await expect(page).toHaveURL(/\/about$/);
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
await expect(page.getByText("Northstar Mobility").first()).toBeVisible(); await expect(page.getByText("Northstar Mobility").first()).toBeVisible();
await expect(page.getByText("Demomodus — lokale kennisprovider")).toBeVisible(); await expect(page.getByText("Demomodus").first()).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible(); await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
}); });
+80 -4
View File
@@ -36,7 +36,10 @@ test("starting a scenario navigates to its fixed record", async ({ page }) => {
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
}); });
test("demo guide: navigating steps, jumping to a step, and closing works", async ({ page }) => { // The default Playwright viewport (1280x720) falls in the "standard desktop/tablet" tier
// (see useViewportTier.ts): the guide is a floating, non-modal panel that auto-collapses
// to a persistent progress chip the moment the visitor acts on "Ga naar deze stap".
test("demo guide: navigating steps, jumping to a step collapses to a chip, and the chip reopens it", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click(); await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
@@ -50,9 +53,26 @@ test("demo guide: navigating steps, jumping to a step, and closing works", async
await page.getByRole("button", { name: "Ga naar deze stap" }).click(); await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/knowledge$/); await expect(page).toHaveURL(/\/knowledge$/);
// Panel auto-collapses to a persistent chip -- it must never sit over the knowledge
// page's primary "Ask" action after navigation.
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
const chip = page.getByRole("button", { name: /Demo-gids · stap 6 van 8/ });
await expect(chip).toBeVisible();
await chip.click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
});
test("the collapsed chip has its own close control, independent of reopening it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
const chip = page.getByRole("button", { name: /Demo-gids · stap/ });
await expect(chip).toBeVisible();
await page.getByRole("button", { name: "Sluiten" }).click(); await page.getByRole("button", { name: "Sluiten" }).click();
await expect(chip).toBeHidden();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
}); });
@@ -61,12 +81,12 @@ test("demo guide progress persists across navigation and the trigger shows it",
await page.getByRole("button", { name: "Start begeleide demo" }).click(); await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Volgende" }).click(); await page.getByRole("button", { name: "Volgende" }).click();
await page.getByRole("button", { name: "Volgende" }).click(); await page.getByRole("button", { name: "Volgende" }).click();
await page.getByRole("button", { name: "Sluiten" }).click(); await page.getByRole("button", { name: "Demo-gids inklappen" }).click();
await expect(page.getByRole("button", { name: /Demo-gids/ })).toContainText("2/8"); await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("2/8");
await page.goto("/vehicles"); await page.goto("/vehicles");
await page.getByRole("button", { name: /Demo-gids/ }).click(); await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible(); await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
}); });
@@ -85,3 +105,59 @@ test("restarting the demo from the guide resets data and returns to login", asyn
await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click(); await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click();
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 }); await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
}); });
test("wide desktop viewport docks the guide as a rail that never collapses to a chip", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
const panel = page.locator(".demo-guide-panel.is-wide");
await expect(panel).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(panel).toBeVisible();
await expect(page.locator(".demo-guide-chip")).toHaveCount(0);
});
test("mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
const panel = page.locator(".demo-guide-panel.is-mobile");
await expect(panel).toBeVisible();
await expect(panel).toHaveClass(/sheet-half/);
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
await page.locator(".demo-guide-sheet-handle").click();
await expect(panel).toHaveClass(/sheet-full/);
await page.locator(".demo-guide-sheet-handle").click();
await expect(panel).toHaveClass(/sheet-collapsed/);
});
test("Escape collapses the standard-tier panel, then closes it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeVisible();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeHidden();
});
test("going to a step scrolls, focuses and highlights the on-page target", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/knowledge$/);
const target = page.locator("#ask-heading");
await expect(target).toBeFocused();
await expect(target).toHaveClass(/demo-guide-highlight/);
});
+15 -15
View File
@@ -17,17 +17,17 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a
await page.goto("/bookings/BK-DEMO-RETURN"); await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible(); await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
const odometerInput = page.getByLabel("End odometer (km)"); const odometerInput = page.getByLabel("Eindkilometerstand (km)");
await expect(odometerInput).not.toHaveValue(""); await expect(odometerInput).not.toHaveValue("");
const prefilled = Number(await odometerInput.inputValue()); const prefilled = Number(await odometerInput.inputValue());
expect(prefilled).toBeGreaterThan(0); expect(prefilled).toBeGreaterThan(0);
await page.getByRole("button", { name: "Review return" }).click(); await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByText(/below.*canonical reading/)).toBeVisible(); await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click(); await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
await expect(page.getByRole("link", { name: "View automation status" })).toBeVisible(); await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
await expect(page.getByRole("link", { name: "View audit trail" })).toBeVisible(); await expect(page.getByRole("link", { name: "Audit trail bekijken" })).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 }) => {
@@ -36,9 +36,9 @@ test("data quality issue detail explains what's wrong and why it matters", async
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByText("What's wrong")).toBeVisible(); await expect(page.getByText("Wat is er mis")).toBeVisible();
await expect(page.getByText("Why it matters")).toBeVisible(); await expect(page.getByText("Waarom dit belangrijk is")).toBeVisible();
await expect(page.getByText(/likely the same person/)).toBeVisible(); await expect(page.getByText(/waarschijnlijk dezelfde persoon/)).toBeVisible();
}); });
test("data quality list can filter to demo scenarios only", async ({ page }) => { test("data quality list can filter to demo scenarios only", async ({ page }) => {
@@ -49,7 +49,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const allRows = await page.locator(".data-table tbody tr").count(); const allRows = await page.locator(".data-table tbody tr").count();
await page.getByRole("checkbox", { name: "Demo scenario's only" }).check(); await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).check();
const filteredRows = await page.locator(".data-table tbody tr").count(); const filteredRows = await page.locator(".data-table tbody tr").count();
expect(filteredRows).toBeGreaterThan(0); expect(filteredRows).toBeGreaterThan(0);
expect(filteredRows).toBeLessThanOrEqual(allRows); expect(filteredRows).toBeLessThanOrEqual(allRows);
@@ -68,10 +68,10 @@ test("knowledge page suggested question returns a grounded, honestly-labelled an
// The status badge and retrieval-flow diagram must name the actual active provider // The status badge and retrieval-flow diagram must name the actual active provider
// honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is // honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
// allowed to mention RAGcore by name when explaining it isn't live yet. // allowed to mention RAGcore by name when explaining it isn't live yet.
await expect(page.locator(".knowledge-status strong")).toHaveText("Demo knowledge base"); await expect(page.locator(".knowledge-status strong")).toHaveText("de demokennisbank");
await expect(page.locator(".retrieval-flow")).toContainText("Demo knowledge base"); await expect(page.locator(".retrieval-flow")).toContainText("de demokennisbank");
await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore"); await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
await page.getByRole("button", { name: "Who reviews an unusual odometer reading?" }).click(); await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
}); });
+24 -25
View File
@@ -24,7 +24,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
}); });
await test.step("2. verify dashboard metrics are loaded", async () => { await test.step("2. verify dashboard metrics are loaded", async () => {
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
const metricValues = page.locator(".metric-cell dd"); const metricValues = page.locator(".metric-cell dd");
await expect(metricValues.first()).toBeVisible(); await expect(metricValues.first()).toBeVisible();
const values = await metricValues.allTextContents(); const values = await metricValues.allTextContents();
@@ -35,62 +35,61 @@ test("five-minute demo script end to end", async ({ page, request }) => {
await test.step("3. open active demo booking", async () => { await test.step("3. open active demo booking", async () => {
await page.goto("/bookings/BK-DEMO-RETURN"); await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible(); await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
await expect(page.getByText("active", { exact: true })).toBeVisible(); await expect(page.getByText("actief", { exact: true })).toBeVisible();
}); });
await test.step("4. register an odometer-regression return (S1)", async () => { await test.step("4. register an odometer-regression return (S1)", async () => {
const vehicleOdometerText = await page const vehicleOdometerText = await page
.locator(".detail-grid div", { hasText: "Start odometer" }) .locator(".detail-grid div", { hasText: "Startkilometerstand" })
.locator("dd") .locator("dd")
.textContent(); .textContent();
const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10); const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10);
const lowReading = Math.max(0, startOdometer - 500); const lowReading = Math.max(0, startOdometer - 500);
await page.getByLabel("End odometer (km)").fill(String(lowReading)); await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading));
await page.getByLabel("Fuel level (%)").fill("55"); await page.getByLabel("Brandstofniveau (%)").fill("55");
await page.getByRole("button", { name: "Review return" }).click(); await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByRole("heading", { name: "Review return impact" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click(); await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
}); });
await test.step("5. verify quality issue and queued automation event", async () => { await test.step("5. verify quality issue and queued automation event", async () => {
await expect(page.getByText(/DQ-RET-|None created/)).toBeVisible(); await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).toBeVisible();
await expect(page.getByText(/Queued for delivery \(/)).toBeVisible(); await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible();
}); });
await test.step("6. resolve the duplicate customer scenario (S2)", async () => { await test.step("6. resolve the duplicate customer scenario (S2)", async () => {
await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible();
await page.getByRole("button", { name: /Merge into CUS-0012/ }).click(); await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click();
await page.getByRole("button", { name: "Yes, merge" }).click(); await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
await expect(page.getByText("resolved", { exact: true })).toBeVisible(); await expect(page.locator(".badge.status-resolved")).toBeVisible();
}); });
await test.step("7. ask the damage question and inspect citations (S6)", async () => { await test.step("7. ask the damage question and inspect citations (S6)", async () => {
await page.goto("/knowledge"); await page.goto("/knowledge");
await page await page
.getByPlaceholder(/What must I do when a vehicle returns with damage/) .getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
.fill("What must I do when a vehicle returns with damage?"); .fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
await page.getByRole("button", { name: "Ask" }).click(); await page.getByRole("button", { name: "Vraag stellen" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
await expect(page.getByText("Damage handling procedure").first()).toBeVisible(); await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible();
await expect(page.getByText("Vehicle return procedure").first()).toBeVisible();
}); });
await test.step("8. inspect audit entries", async () => { await test.step("8. inspect audit entries", async () => {
await page.goto("/audit"); await page.goto("/audit");
await page.getByLabel("Action").fill("return_registered"); await page.getByLabel("Actie").fill("return_registered");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(page.locator(".audit-group-list li").first()).toBeVisible();
await expect(page.getByText("return registered").first()).toBeVisible(); await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible();
}); });
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.getByText(/Synthetische demo/).first()).toBeVisible();
await expect(page.getByRole("link", { name: "Overview" }).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);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
+21 -16
View File
@@ -14,6 +14,11 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
request, request,
}) => { }) => {
await resetDemoData(request); await resetDemoData(request);
// Wide desktop viewport: the guide docks as a rail and never auto-collapses to a chip
// (see useViewportTier.ts), so this walkthrough can keep interacting with the panel
// directly across every step -- the standard-tier auto-collapse behaviour itself is
// covered separately in demo-guide.spec.ts.
await page.setViewportSize({ width: 1600, height: 1000 });
await test.step("start the guided demo from the login screen", async () => { await test.step("start the guided demo from the login screen", async () => {
await page.goto("/login"); await page.goto("/login");
@@ -24,7 +29,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await test.step("step 1: understand the operational state", async () => { await test.step("step 1: understand the operational state", async () => {
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible(); await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
await page.getByRole("button", { name: "Volgende" }).click(); await page.getByRole("button", { name: "Volgende" }).click();
}); });
@@ -38,10 +43,10 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => { await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => {
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible(); await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible(); await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
await page.getByRole("button", { name: "Review return" }).click(); await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByText(/below.*canonical reading/)).toBeVisible(); await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click(); await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click(); await page.getByRole("button", { name: "Ga verder met de demo" }).click();
}); });
@@ -50,28 +55,28 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible(); await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible();
const firstIssueLink = page.locator(".data-table tbody tr").first().locator("a"); const firstIssueLink = page.locator(".data-table tbody tr").first().locator("a");
await firstIssueLink.click(); await firstIssueLink.click();
await expect(page.getByText("What's wrong")).toBeVisible(); await expect(page.getByText("Wat is er mis")).toBeVisible();
// The newest issue is the odometer regression this return just created. // The newest issue is the odometer regression this return just created.
await page.getByRole("radio", { name: /Retain canonical/ }).check(); await page.getByRole("radio", { name: /Laatst bevestigde stand behouden/ }).check();
await page.getByRole("button", { name: "Resolve issue" }).click(); await page.getByRole("button", { name: "Probleem oplossen" }).click();
await expect(page.getByText(/Issue .* resolved/)).toBeVisible(); await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click(); await page.getByRole("button", { name: "Ga verder met de demo" }).click();
}); });
await test.step("step 5: review and merge the possible duplicate customer", async () => { await test.step("step 5: review and merge the possible duplicate customer", async () => {
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible(); await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible();
await page.getByRole("button", { name: /^Merge into/ }).click(); await page.getByRole("button", { name: /^Samenvoegen met/ }).click();
await page.getByRole("button", { name: "Yes, merge" }).click(); await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
await expect(page.getByText(/Issue .* resolved/)).toBeVisible(); await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click(); await page.getByRole("button", { name: "Ga verder met de demo" }).click();
}); });
await test.step("step 6: ask the procedure assistant a question", async () => { await test.step("step 6: ask the procedure assistant a question", async () => {
await expect(page).toHaveURL(/\/knowledge$/); await expect(page).toHaveURL(/\/knowledge$/);
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible(); await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
await page.getByRole("button", { name: "What must I do when a vehicle returns with damage?" }).click(); await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
await page.getByRole("button", { name: "Volgende" }).click(); await page.getByRole("button", { name: "Volgende" }).click();
}); });
@@ -83,7 +88,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await expect(page.locator(".integration-cards")).not.toContainText("degraded"); await expect(page.locator(".integration-cards")).not.toContainText("degraded");
await expect(page.locator(".integration-cards")).not.toContainText("no_evidence"); await expect(page.locator(".integration-cards")).not.toContainText("no_evidence");
await page.goto("/audit"); await page.goto("/audit");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(page.locator(".audit-group-list li").first()).toBeVisible();
await page.getByRole("button", { name: /Demo-gids/ }).click(); await page.getByRole("button", { name: /Demo-gids/ }).click();
await page.getByRole("button", { name: "Volgende" }).click(); await page.getByRole("button", { name: "Volgende" }).click();
}); });
@@ -92,7 +97,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible(); await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click(); await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/about$/); await expect(page).toHaveURL(/\/about$/);
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible(); await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible(); await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
}); });
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Pure Node-context checks (no browser needed): every locale must define exactly the
// same set of translation keys. A missing key would otherwise silently fall back to
// showing the raw key string in production -- this test makes that impossible to ship.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"];
function collectKeyPaths(value: unknown, prefix = ""): string[] {
if (value === null || typeof value !== "object") {
return [prefix];
}
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key),
);
}
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
const namespaces = fs
.readdirSync(path.join(LOCALES_DIR, "nl-BE"))
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
test("every locale defines the same translation keys as nl-BE, for every namespace", () => {
expect(namespaces.length).toBeGreaterThan(0);
for (const namespace of namespaces) {
const referenceKeys = collectKeyPaths(loadNamespace("nl-BE", namespace)).sort();
for (const language of LANGUAGES) {
if (language === "nl-BE") continue;
const keys = collectKeyPaths(loadNamespace(language, namespace)).sort();
const missing = referenceKeys.filter((k) => !keys.includes(k));
const extra = keys.filter((k) => !referenceKeys.includes(k));
expect(
missing,
`${language}/${namespace}.json is missing keys present in nl-BE: ${missing.join(", ")}`,
).toEqual([]);
expect(
extra,
`${language}/${namespace}.json has extra keys not present in nl-BE: ${extra.join(", ")}`,
).toEqual([]);
}
}
});
test("no locale file contains an empty string value", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const data = loadNamespace(language, namespace);
const keys = collectKeyPaths(data);
for (const keyPath of keys) {
const value = keyPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
if (typeof value === "string") {
expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0);
}
}
}
}
});
+27 -23
View File
@@ -7,9 +7,13 @@ async function resetDemoData(request: APIRequestContext) {
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
// This file's assertions were authored against the English UI copy; nl-BE is now the
// app's default for a fresh session, so force English explicitly rather than rewriting
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
}); });
@@ -52,7 +56,7 @@ test("vehicles page: free-text search actually filters the rendered rows", async
const totalRows = await page.locator(".data-table tbody tr").count(); const totalRows = await page.locator(".data-table tbody tr").count();
expect(totalRows).toBeGreaterThan(1); expect(totalRows).toBeGreaterThan(1);
const searchBox = page.getByRole("form", { name: "Filter vehicles" }).getByLabel("Search"); const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search");
await searchBox.fill("MO-001"); await searchBox.fill("MO-001");
await expect(async () => { await expect(async () => {
const rows = await page.locator(".data-table tbody tr").count(); const rows = await page.locator(".data-table tbody tr").count();
@@ -140,7 +144,7 @@ test("data quality page: status and rule-type filters work", async ({ page }) =>
); );
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents(); const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents();
expect(rules.every((r) => r.includes("possible duplicate customer"))).toBeTruthy(); expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy();
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption(""); await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption("");
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved"); await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved");
@@ -159,7 +163,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
await firstLink.click(); await firstLink.click();
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible(); await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
await page.getByRole("button", { name: "Defer" }).click(); await page.getByRole("button", { name: "Defer" }).click();
await expect(page.getByText("deferred", { exact: true })).toBeVisible(); await expect(page.locator(".badge.status-deferred")).toBeVisible();
}); });
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 }) => {
@@ -173,7 +177,7 @@ test("data quality: providing missing fields resolves a vehicle issue", async ({
await page.getByLabel("Location").fill("Depot"); await page.getByLabel("Location").fill("Depot");
await page.getByRole("button", { name: "Save and re-check" }).click(); await page.getByRole("button", { name: "Save and re-check" }).click();
await expect(page.getByText("resolved", { exact: true })).toBeVisible(); await expect(page.getByText("Resolved").first()).toBeVisible();
}); });
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 }) => {
@@ -184,7 +188,7 @@ test("data quality: resolving a booking overlap blocks one booking", async ({ pa
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();
await expect(page.getByText("resolved", { exact: true })).toBeVisible(); await expect(page.getByText("Resolved").first()).toBeVisible();
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A"); const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
expect((await booking.json()).status).toBe("blocked"); expect((await booking.json()).status).toBe("blocked");
}); });
@@ -220,7 +224,7 @@ test("data quality: retaining canonical resolves an odometer regression issue",
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();
await expect(page.getByText("resolved", { exact: true })).toBeVisible(); await expect(page.getByText("Resolved").first()).toBeVisible();
}); });
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => { test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
@@ -255,11 +259,11 @@ test("automation page: status filter and retry button work", async ({ page, requ
test("audit page: action filter works", async ({ page }) => { test("audit page: action filter works", async ({ page }) => {
await page.goto("/audit"); await page.goto("/audit");
await expect(page.locator(".data-table")).toBeVisible(); await expect(page.locator(".audit-group-list")).toBeVisible();
await page.getByLabel("Action").fill("demo_login"); await page.getByLabel("Action").fill("demo_login");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); await expect(page.locator(".audit-group").first()).toBeVisible();
const actions = await page.locator(".data-table tbody tr td:nth-child(3)").allTextContents(); const headings = await page.locator(".audit-group-heading strong").allTextContents();
expect(actions.every((a) => a.includes("demo login"))).toBeTruthy(); expect(headings.every((a) => a === "Logged in")).toBeTruthy();
}); });
test("audit page: shows human-readable before/after and a safe entity link", async ({ test("audit page: shows human-readable before/after and a safe entity link", async ({
@@ -284,14 +288,14 @@ test("audit page: shows human-readable before/after and a safe entity link", asy
await page.goto("/audit"); await page.goto("/audit");
await page.getByLabel("Action").fill("return_registered"); await page.getByLabel("Action").fill("return_registered");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); const firstGroup = page.locator(".audit-group").first();
await expect(firstGroup).toBeVisible();
const changeCell = page.locator(".data-table tbody tr").first().locator("td").nth(4); const changeDiff = firstGroup.locator(".change-diff");
await expect(changeCell).toContainText("status"); await expect(changeDiff).toContainText(/status/i);
await expect(changeCell).toContainText("returned"); await expect(changeDiff).toContainText("returned");
const entityCell = page.locator(".data-table tbody tr").first().locator("td").nth(3); await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/);
await expect(entityCell.locator("a")).toHaveAttribute("href", /\/bookings\/BK-/);
}); });
test("knowledge page: form submits and clears input", async ({ page }) => { test("knowledge page: form submits and clears input", async ({ page }) => {
@@ -340,7 +344,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
page, page,
}) => { }) => {
await page.getByRole("button", { name: "Switch role" }).click(); await page.getByRole("button", { name: "Switch role" }).click();
await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
// Manager-only nav items are not shown at all, not merely disabled. // Manager-only nav items are not shown at all, not merely disabled.
@@ -352,16 +356,16 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
// message as a defense-in-depth measure, not just a hidden button. // message as a defense-in-depth measure, not just a hidden button.
await page.goto("/automation"); await page.goto("/automation");
await expect( await expect(
page.getByText("Automation delivery status is visible to Operations Managers only."), page.getByText("Automation is visible to Operations Managers only.").first(),
).toBeVisible(); ).toBeVisible();
await page.goto("/data-quality"); await page.goto("/data-quality");
await expect( await expect(
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only."), page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(),
).toBeVisible(); ).toBeVisible();
await page.goto("/audit"); await page.goto("/audit");
await expect(page.getByText("Audit history is visible to Operations Managers only.")).toBeVisible(); await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
}); });
@@ -374,7 +378,7 @@ test("operations manager can reset demo data and is returned to login", async ({
await expect(page).toHaveURL(/\/login$/); await expect(page).toHaveURL(/\/login$/);
// The reset must not have affected the ability to log back in against fresh data. // The reset must not have affected the ability to log back in against fresh data.
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
}); });
@@ -392,7 +396,7 @@ test("rental employee direct API access to manager-only endpoints is rejected",
page, page,
}) => { }) => {
await page.getByRole("button", { name: "Switch role" }).click(); await page.getByRole("button", { name: "Switch role" }).click();
await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
// page.request shares the browser context's cookies, and (via the web container's // page.request shares the browser context's cookies, and (via the web container's
+10 -6
View File
@@ -5,10 +5,14 @@ async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/reset"); await request.post("/api/v1/demo/reset");
} }
// This file's assertions were authored against the English UI copy; nl-BE is now the
// app's default for a fresh session, so force English explicitly rather than rewriting
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
test.beforeEach(async ({ page, request }) => { test.beforeEach(async ({ page, request }) => {
await resetDemoData(request); await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
}); });
@@ -22,7 +26,7 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => { test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
await page.keyboard.press("Control+k"); await page.keyboard.press("Control+k");
const search = page.getByRole("combobox", { name: "Search MobilityOps" }); const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await expect(search).toBeFocused(); await expect(search).toBeFocused();
await search.fill("MO-024"); await search.fill("MO-024");
const result = page.getByRole("option", { name: /MO-024/ }); const result = page.getByRole("option", { name: /MO-024/ });
@@ -33,7 +37,7 @@ test("global search supports its keyboard shortcut and finds a vehicle by refere
}); });
test("global search supports arrow-key navigation and Enter to select", async ({ page }) => { test("global search supports arrow-key navigation and Enter to select", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search MobilityOps" }); const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("fleet"); await search.fill("fleet");
await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible(); await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible();
await search.press("ArrowDown"); await search.press("ArrowDown");
@@ -42,7 +46,7 @@ test("global search supports arrow-key navigation and Enter to select", async ({
}); });
test("global search shows a no-results state and closes on Escape", async ({ page }) => { test("global search shows a no-results state and closes on Escape", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search MobilityOps" }); const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("zzz-nothing-matches-zzz"); await search.fill("zzz-nothing-matches-zzz");
await expect(page.getByText(/No matches for/)).toBeVisible(); await expect(page.getByText(/No matches for/)).toBeVisible();
await search.press("Escape"); await search.press("Escape");
@@ -50,7 +54,7 @@ test("global search shows a no-results state and closes on Escape", async ({ pag
}); });
test("global search finds a booking and a data-quality issue by reference", async ({ page }) => { test("global search finds a booking and a data-quality issue by reference", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search MobilityOps" }); const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("BK-DEMO-RETURN"); await search.fill("BK-DEMO-RETURN");
const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ }); const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ });
await expect(bookingResult).toBeVisible(); await expect(bookingResult).toBeVisible();
@@ -83,7 +87,7 @@ test("return review separates capture from irreversible commit", async ({ page }
// The review step is server-evaluated (not client-guessed), so exactly one non-mutating // The review step is server-evaluated (not client-guessed), so exactly one non-mutating
// preview call is expected before any commit. // preview call is expected before any commit.
expect(previewRequests).toBe(1); expect(previewRequests).toBe(1);
await expect(page.getByText("Queue n8n delivery after the local commit")).toBeVisible(); await expect(page.getByText("Queue automation after the local commit")).toBeVisible();
await page.getByRole("button", { name: "Edit details" }).click(); await page.getByRole("button", { name: "Edit details" }).click();
await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000"); await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000");
+3 -3
View File
@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="nl">
<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="MobilityOps synthetic-data operations proof of concept" /> <meta name="description" content="Fleet Ops synthetic-data operations demo" />
<title>MobilityOps</title> <title>Fleet Ops</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+85 -1
View File
@@ -8,8 +8,10 @@
"name": "mobilityops-web", "name": "mobilityops-web",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"i18next": "^26.3.6",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "^17.0.11",
"react-router-dom": "7.18.2" "react-router-dom": "7.18.2"
}, },
"devDependencies": { "devDependencies": {
@@ -255,6 +257,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1463,6 +1474,43 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1661,6 +1709,33 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-i18next": {
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.14.2", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -1794,7 +1869,7 @@
"version": "5.6.3", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -1835,6 +1910,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+2
View File
@@ -11,8 +11,10 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"i18next": "^26.3.6",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "^17.0.11",
"react-router-dom": "7.18.2" "react-router-dom": "7.18.2"
}, },
"devDependencies": { "devDependencies": {
+6 -9
View File
@@ -88,7 +88,7 @@ export interface DashboardMetrics {
export interface AttentionItem { export interface AttentionItem {
kind: string; kind: string;
severity: "low" | "medium" | "high"; severity: "low" | "medium" | "high";
title: string; rule_type: string;
detail: string; detail: string;
link_type: "vehicle" | "booking" | "customer"; link_type: "vehicle" | "booking" | "customer";
link_ref: string; link_ref: string;
@@ -254,27 +254,24 @@ export interface IntegrationStatus {
export interface DemoScenario { export interface DemoScenario {
id: string; id: string;
title: string;
operational_problem: string;
estimated_minutes: number; estimated_minutes: number;
required_roles: Role[]; required_roles: Role[];
start_path: string; start_path: string;
demonstrates: string;
ready: boolean; ready: boolean;
blocked_reason: string | null; blocked_reason_code: string | null;
blocked_reason_params: Record<string, string>;
} }
export interface DemoIntegrationSummary { export interface DemoIntegrationSummary {
key: "n8n" | "ragcore" | "mcp_hub"; key: "n8n" | "ragcore" | "mcp_hub";
label: string; status_code: string;
status_label: string; detail_code: string;
detail: string; detail_params: Record<string, string | number>;
} }
export interface DemoManifest { export interface DemoManifest {
demo_mode: boolean; demo_mode: boolean;
organization_name: string; organization_name: string;
organization_description: string;
timezone: string; timezone: string;
synthetic_data: boolean; synthetic_data: boolean;
allow_reset: boolean; allow_reset: boolean;
+4 -2
View File
@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) { export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low"; const { t } = useTranslation("quality");
return <span className={`badge severity-${severity}`}>{label} severity</span>; return <span className={`badge severity-${severity}`}>{t(`severities.${severity}`)}</span>;
} }
export function StatusBadge({ status, label }: { status: string; label?: string }) { export function StatusBadge({ status, label }: { status: string; label?: string }) {
+17 -24
View File
@@ -1,18 +1,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { useDemoManifest } from "../context/DemoManifestContext"; import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { Icon } from "./Icons"; import { Icon } from "./Icons";
function formatDateTime(value: string | null): string {
if (!value) return "onbekend";
return new Date(value).toLocaleString("nl-BE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Brussels",
});
}
export function DemoBadge() { export function DemoBadge() {
const { t } = useTranslation("demo");
const { formatDateTime } = useLocaleFormat();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
@@ -44,35 +39,33 @@ export function DemoBadge() {
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
> >
<Icon name="shield" /> <Icon name="shield" />
<span>Synthetische demo</span> <span>{t("badge.trigger")}</span>
</button> </button>
{open && ( {open && (
<div className="demo-badge-popover" role="dialog" aria-label="Over deze demo-omgeving"> <div className="demo-badge-popover" role="dialog" aria-label={t("badge.dialogLabel")}>
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label="Sluiten"> <button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label={t("badge.close")}>
<Icon name="x" /> <Icon name="x" />
</button> </button>
<p> <p>
{manifest ? ( {manifest ? (
<> <Trans i18nKey="badge.orgIntro" t={t} values={{ orgName: manifest.organization_name }} components={{ strong: <strong /> }} />
<strong>{manifest.organization_name}</strong> is een fictieve organisatie. Alle
namen, voertuigen en boekingen zijn synthetisch.
</>
) : ( ) : (
"Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch." t("badge.orgIntroFallback")
)} )}
</p> </p>
<p> <p>{t("badge.realWorkflows")}</p>
De workflows, controles en automatisering zijn echt geïmplementeerd enkel de
gegevens zijn verzonnen.
</p>
{manifest && ( {manifest && (
<p className="demo-badge-reset"> <p className="demo-badge-reset">
Laatste reset: <strong>{formatDateTime(manifest.last_reset_at)}</strong> · deze <Trans
omgeving is op elk moment herstelbaar. i18nKey="badge.lastReset"
t={t}
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("badge.unknown") }}
components={{ strong: <strong /> }}
/>
</p> </p>
)} )}
<Link to="/about" onClick={() => setOpen(false)}> <Link to="/about" onClick={() => setOpen(false)}>
Over deze demo <Icon name="chevron" /> {t("badge.aboutLink")} <Icon name="chevron" />
</Link> </Link>
</div> </div>
)} )}
+170 -56
View File
@@ -1,13 +1,16 @@
import { useNavigate } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api, ApiError } from "../api/client";
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 { useViewportTier } from "../hooks/useViewportTier";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons"; import { Icon } from "./Icons";
export function DemoGuideTrigger() { export function DemoGuideTrigger() {
const { t } = useTranslation("demo");
const { user } = useAuth(); const { user } = useAuth();
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide(); const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
@@ -22,17 +25,35 @@ export function DemoGuideTrigger() {
onClick={toggleGuide} onClick={toggleGuide}
> >
<Icon name="spark" /> <Icon name="spark" />
<span>Demo-gids</span> <span>{t("guide.trigger")}</span>
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span> <span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
<span className="visually-hidden">, huidige stap {currentIndex + 1}</span> <span className="visually-hidden">, {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</span>
</button> </button>
); );
} }
function highlightTarget(selector: string | undefined) {
if (!selector) return;
const el = document.querySelector<HTMLElement>(selector);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
const previousTabIndex = el.getAttribute("tabindex");
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
el.classList.add("demo-guide-highlight");
window.setTimeout(() => {
el.classList.remove("demo-guide-highlight");
if (previousTabIndex === null) el.removeAttribute("tabindex");
}, 2200);
}
export function DemoGuide() { export function DemoGuide() {
const { t } = useTranslation("demo");
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuth(); const { logout } = useAuth();
const { manifest, refresh } = useDemoManifest(); const { manifest, refresh } = useDemoManifest();
const tier = useViewportTier();
const { const {
open, open,
closeGuide, closeGuide,
@@ -42,17 +63,48 @@ export function DemoGuide() {
goToStep, goToStep,
completeAndAdvance, completeAndAdvance,
restart, restart,
collapsedToChip,
setCollapsedToChip,
} = useDemoGuide(); } = useDemoGuide();
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null); const [resetError, setResetError] = useState<string | null>(null);
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
if (!open) return null; const pendingTarget = useRef<string | null>(null);
const step = DEMO_GUIDE_STEPS[currentIndex]; const step = DEMO_GUIDE_STEPS[currentIndex];
const isLastStep = currentIndex === totalSteps - 1; const isLastStep = currentIndex === totalSteps - 1;
useEffect(() => {
if (!open) return;
function handleKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
if (tier === "standard" && !collapsedToChip) {
setCollapsedToChip(true);
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
setMobileSheetState("collapsed");
} else {
closeGuide();
}
}
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide]);
useEffect(() => {
if (!pendingTarget.current) return;
const target = pendingTarget.current;
pendingTarget.current = null;
const raf = requestAnimationFrame(() => highlightTarget(target));
return () => cancelAnimationFrame(raf);
}, [location.pathname]);
if (!open) return null;
function goToStepRoute() { function goToStepRoute() {
pendingTarget.current = step.target ?? null;
navigate(step.route(manifest)); navigate(step.route(manifest));
if (tier === "standard") setCollapsedToChip(true);
if (tier === "mobile") setMobileSheetState("collapsed");
} }
async function handleRestartDemo() { async function handleRestartDemo() {
@@ -66,69 +118,131 @@ export function DemoGuide() {
await logout(); await logout();
navigate("/login"); navigate("/login");
} catch (err) { } catch (err) {
setResetError(err instanceof ApiError ? err.message : "De demo kon niet hersteld worden."); setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
} finally { } finally {
setResetting(false); setResetting(false);
} }
} }
return ( if (tier === "standard" && collapsedToChip) {
<aside className="demo-guide-panel" role="dialog" aria-label="Gegidste demo"> return (
<header className="demo-guide-header"> <div className="demo-guide-chip">
<div> <button
<p className="demo-guide-kicker">Gegidste demo · stap {currentIndex + 1} van {totalSteps}</p> type="button"
<h2>{step.title}</h2> className="demo-guide-chip-expand"
</div> onClick={() => setCollapsedToChip(false)}
<button type="button" className="icon-button" onClick={closeGuide} aria-label="Sluiten"> aria-label={`${t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}, ${t("guide.expand")}`}
>
<Icon name="spark" />
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
<Icon name="chevron" />
</button>
<button type="button" className="demo-guide-chip-close" onClick={closeGuide} aria-label={t("guide.close")}>
<Icon name="x" /> <Icon name="x" />
</button> </button>
</div>
);
}
const panelClassName = [
"demo-guide-panel",
tier === "wide" ? "is-wide" : "",
tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "",
]
.filter(Boolean)
.join(" ");
return (
<aside className={panelClassName} role="dialog" aria-label={t("guide.dialogLabel")}>
{tier === "mobile" && (
<button
type="button"
className="demo-guide-sheet-handle"
onClick={() =>
setMobileSheetState((s) => (s === "collapsed" ? "half" : s === "half" ? "full" : "collapsed"))
}
aria-label={
mobileSheetState === "full"
? t("guide.collapse")
: t("guide.expand")
}
>
<span aria-hidden="true" />
</button>
)}
<header className="demo-guide-header">
<div>
<p className="demo-guide-kicker">{t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</p>
<h2>{t(`guide.steps.${step.id}.title`)}</h2>
</div>
{tier !== "mobile" && (
<button
type="button"
className="icon-button"
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeGuide}
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
>
<Icon name="x" />
</button>
)}
</header> </header>
<div className="demo-guide-progress-bar" aria-hidden="true"> {mobileSheetState !== "collapsed" && (
{DEMO_GUIDE_STEPS.map((s, index) => ( <>
<span <div className="demo-guide-progress-bar" aria-hidden="true">
key={s.id} {DEMO_GUIDE_STEPS.map((s, index) => (
className={ <span
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : "" key={s.id}
} className={
/> index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
))} }
</div> />
))}
</div>
<div className="demo-guide-body"> {(mobileSheetState !== "half" || tier !== "mobile") && (
<p><strong>Wat je zal zien</strong><br />{step.whatYouWillSee}</p> <div className="demo-guide-body">
<p><strong>Waarom dit relevant is</strong><br />{step.whyItMatters}</p> <p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
<p><strong>Aan de slag</strong><br />{step.startAction}</p> <p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
<p><strong>Verwacht resultaat</strong><br />{step.expectedOutcome}</p> <p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
</div> <p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`)}</p>
</div>
)}
<nav className="demo-guide-steps" aria-label="Alle stappen"> {tier !== "mobile" && (
{DEMO_GUIDE_STEPS.map((s, index) => ( <nav className="demo-guide-steps" aria-label={t("guide.allStepsLabel")}>
<button {DEMO_GUIDE_STEPS.map((s, index) => (
key={s.id} <button
type="button" key={s.id}
className={index === currentIndex ? "is-current" : ""} type="button"
onClick={() => goToStep(index)} className={index === currentIndex ? "is-current" : ""}
> onClick={() => goToStep(index)}
{completed.has(s.id) && <Icon name="check" />} >
{s.title} {completed.has(s.id) && <Icon name="check" />}
</button> {t(`guide.steps.${s.id}.title`)}
))} </button>
</nav> ))}
</nav>
)}
{resetError && <p className="error" role="alert">{resetError}</p>} {resetError && <p className="error" role="alert">{resetError}</p>}
<footer className="demo-guide-footer"> <footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}> <button type="button" className="button button-secondary" onClick={goToStepRoute}>
Ga naar deze stap {t("guide.goToStep")}
</button> </button>
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}> <button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
Volgende {t("guide.next")}
</button> </button>
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}> {tier !== "mobile" && (
{resetting ? "Bezig met herstellen…" : "Demo opnieuw voorbereiden"} <button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
</button> {resetting ? t("guide.restarting") : t("guide.restart")}
</footer> </button>
)}
</footer>
</>
)}
</aside> </aside>
); );
} }
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import {
persistLanguage,
SUPPORTED_LANGUAGES,
type SupportedLanguage,
} from "../i18n/config";
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
const { t, i18n } = useTranslation("common");
const current = (i18n.language as SupportedLanguage) || "nl-BE";
function handleChange(next: SupportedLanguage) {
void i18n.changeLanguage(next);
persistLanguage(next);
}
return (
<label className={`language-switcher ${compact ? "language-switcher-compact" : ""}`}>
<span className="visually-hidden">{t("language.label")}</span>
<select
value={current}
onChange={(event) => handleChange(event.target.value as SupportedLanguage)}
aria-label={t("language.label")}
>
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{t(`language.${lang}`)}
</option>
))}
</select>
</label>
);
}
+48 -42
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api, ApiError } from "../api/client";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types"; import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons"; import { BrandMark, Icon, type IconName } from "./Icons";
import { DemoBadge } from "./DemoBadge"; import { DemoBadge } from "./DemoBadge";
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide"; import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext"; import { useDemoManifest } from "../context/DemoManifestContext";
@@ -18,36 +20,36 @@ const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
interface NavItem { interface NavItem {
to: string; to: string;
label: string; labelKey: string;
shortLabel: string;
icon: IconName; icon: IconName;
roles?: Role[]; roles?: Role[];
} }
const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
{ {
label: "Operate", labelKey: "groups.operate",
items: [ items: [
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" }, { to: "/dashboard", labelKey: "items.overview", icon: "activity" },
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" }, { to: "/vehicles", labelKey: "items.fleet", icon: "fleet" },
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" }, { to: "/bookings", labelKey: "items.bookings", icon: "bookings" },
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality", roles: ["operations_manager"] }, { to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] },
], ],
}, },
{ {
label: "Assure", labelKey: "groups.assure",
items: [ items: [
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" }, { to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations", roles: ["operations_manager"] }, { to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit", roles: ["operations_manager"] }, { to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
], ],
}, },
]; ];
export function Layout() { export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
const { open: guideOpen } = useDemoGuide(); const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
const navigate = useNavigate(); const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -135,7 +137,7 @@ export function Layout() {
await logout(); await logout();
navigate("/login"); navigate("/login");
} catch (err) { } catch (err) {
setResetError(err instanceof ApiError ? err.message : "Could not reset demo data."); setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
setResetConfirming(false); setResetConfirming(false);
} finally { } finally {
setResetting(false); setResetting(false);
@@ -170,23 +172,26 @@ export function Layout() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<a className="skip-link" href="#main-content">Skip to main content</a> <a className="skip-link" href="#main-content">{t("skipToContent")}</a>
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}> <aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
<div className="brand-lockup"> <div className="brand-lockup">
<BrandMark className="brand-mark" /> <BrandMark className="brand-mark" />
<div><strong>MobilityOps</strong><span>Control centre</span></div> <div><strong>{t("common:appName")}</strong><span>{t("common:brandTagline")}</span></div>
</div> </div>
<nav aria-label="Primary navigation"> <div className="sidebar-language">
<LanguageSwitcher />
</div>
<nav aria-label={t("primaryNavLabel")}>
{navGroups.map((group) => ( {navGroups.map((group) => (
<div className="nav-group" key={group.label}> <div className="nav-group" key={group.labelKey}>
<p>{group.label}</p> <p>{t(group.labelKey)}</p>
<ul> <ul>
{group.items.map((item) => ( {group.items.map((item) => (
<li key={item.to}> <li key={item.to}>
<NavLink to={item.to} onClick={() => setMobileOpen(false)}> <NavLink to={item.to} onClick={() => setMobileOpen(false)}>
<Icon name={item.icon} /> <Icon name={item.icon} />
<span>{item.label}</span> <span>{t(item.labelKey)}</span>
</NavLink> </NavLink>
</li> </li>
))} ))}
@@ -196,23 +201,23 @@ export function Layout() {
</nav> </nav>
<div className="sidebar-foot"> <div className="sidebar-foot">
<span className="environment-dot" /> <span className="environment-dot" />
<div><strong>Demo environment</strong><span>Synthetic data only</span></div> <div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
</div> </div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && ( {user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset"> <div className="sidebar-reset">
{resetError && <p className="error" role="alert">{resetError}</p>} {resetError && <p className="error" role="alert">{resetError}</p>}
{!resetConfirming ? ( {!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}> <button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
Reset demo data {t("resetDemoData")}
</button> </button>
) : ( ) : (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm demo reset"> <div className="confirm-bar" role="alertdialog" aria-label={t("resetConfirmTitle")}>
<p>All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.</p> <p>{t("resetConfirmBody")}</p>
<button type="button" onClick={handleDemoReset} disabled={resetting}> <button type="button" onClick={handleDemoReset} disabled={resetting}>
{resetting ? "Resetting" : "Yes, reset"} {resetting ? t("resetting") : t("resetConfirmYes")}
</button> </button>
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}> <button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
Cancel {t("resetCancel")}
</button> </button>
</div> </div>
)} )}
@@ -220,16 +225,16 @@ export function Layout() {
)} )}
</aside> </aside>
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />} {mobileOpen && <button className="nav-scrim" aria-label={t("closeNavigation")} onClick={() => setMobileOpen(false)} />}
<div className={`app-workspace ${guideOpen ? "guide-open" : ""}`}> <div className={`app-workspace ${guideOpen ? "guide-open" : ""} ${guideOpen && guideCollapsed ? "guide-collapsed" : ""}`}>
<header className="topbar"> <header className="topbar">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation"> <button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label={t("openNavigation")}>
<Icon name="menu" /> <Icon name="menu" />
</button> </button>
<div className="global-search" role="search" ref={searchBox}> <div className="global-search" role="search" ref={searchBox}>
<Icon name="search" /> <Icon name="search" />
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label> <label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel")}</label>
<input <input
id="global-search-input" id="global-search-input"
ref={searchInput} ref={searchInput}
@@ -240,7 +245,7 @@ export function Layout() {
aria-autocomplete="list" aria-autocomplete="list"
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined} aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
value={searchQuery} value={searchQuery}
placeholder="Search fleet, booking or section…" placeholder={t("searchPlaceholder")}
onFocus={() => setSearchOpen(true)} onFocus={() => setSearchOpen(true)}
onChange={(event) => { onChange={(event) => {
setSearchQuery(event.target.value); setSearchQuery(event.target.value);
@@ -248,13 +253,13 @@ export function Layout() {
}} }}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
/> />
<kbd>Ctrl K</kbd> <kbd>{t("searchShortcutHint")}</kbd>
{searchOpen && searchQuery.trim() && ( {searchOpen && searchQuery.trim() && (
<div className="search-results" id="global-search-results" role="listbox"> <div className="search-results" id="global-search-results" role="listbox">
{searchLoading && <p className="search-status">Searching</p>} {searchLoading && <p className="search-status">{t("searchSearching")}</p>}
{!searchLoading && searchError && <p className="search-status">Search is unavailable right now.</p>} {!searchLoading && searchError && <p className="search-status">{t("searchUnavailable")}</p>}
{!searchLoading && !searchError && searchResults.length === 0 && ( {!searchLoading && !searchError && searchResults.length === 0 && (
<p className="search-status">No matches for "{searchQuery.trim()}".</p> <p className="search-status">{t("searchNoResults", { query: searchQuery.trim() })}</p>
)} )}
{!searchLoading && {!searchLoading &&
!searchError && !searchError &&
@@ -280,35 +285,36 @@ export function Layout() {
)} )}
</div> </div>
<div className="topbar-meta"> <div className="topbar-meta">
<LanguageSwitcher compact />
<DemoGuideTrigger /> <DemoGuideTrigger />
<DemoBadge /> <DemoBadge />
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span> <span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
{user && ( {user && (
<div className="operator"> <div className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span> <span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? "Operations manager" : "Rental employee"}</small></span> <span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</div> </div>
)} )}
<button className="icon-button" type="button" onClick={handleLogout} aria-label="Switch role" title="Switch demo role"> <button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
<Icon name="logout" /> <Icon name="logout" />
</button> </button>
</div> </div>
</header> </header>
<main id="main-content" tabIndex={-1}><Outlet /></main> <main id="main-content" tabIndex={-1}><Outlet /></main>
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer> <footer className="app-footer"><span>{t("common:footer.productLine")}</span><span>{t("common:footer.locale")}</span></footer>
</div> </div>
<nav className="mobile-nav" aria-label="Mobile navigation"> <nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
{mobileItems.map((item) => ( {mobileItems.map((item) => (
<NavLink key={item.to} to={item.to}> <NavLink key={item.to} to={item.to}>
<Icon name={item.icon} /> <Icon name={item.icon} />
<span>{item.shortLabel}</span> <span>{t(item.labelKey)}</span>
</NavLink> </NavLink>
))} ))}
<button type="button" onClick={() => setMobileOpen(true)}> <button type="button" onClick={() => setMobileOpen(true)}>
<Icon name="menu" /> <Icon name="menu" />
<span>More</span> <span>{t("more")}</span>
</button> </button>
</nav> </nav>
+9 -4
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons"; import { Icon, type IconName } from "./Icons";
export function PageHeader({ export function PageHeader({
@@ -28,15 +29,17 @@ export function SectionHeading({
title, title,
description, description,
action, action,
headingId,
}: { }: {
title: string; title: string;
description?: string; description?: string;
action?: ReactNode; action?: ReactNode;
headingId?: string;
}) { }) {
return ( return (
<div className="section-heading"> <div className="section-heading">
<div> <div>
<h2>{title}</h2> <h2 id={headingId}>{title}</h2>
{description && <p>{description}</p>} {description && <p>{description}</p>}
</div> </div>
{action} {action}
@@ -44,20 +47,22 @@ export function SectionHeading({
); );
} }
export function LoadingState({ label = "Loading workspace…" }: { label?: string }) { export function LoadingState({ label }: { label?: string }) {
const { t } = useTranslation("common");
return ( return (
<div className="state-panel" role="status"> <div className="state-panel" role="status">
<span className="spinner" aria-hidden="true" /> <span className="spinner" aria-hidden="true" />
<p>{label}</p> <p>{label ?? t("states.loadingDefault")}</p>
</div> </div>
); );
} }
export function ErrorState({ message }: { message: string }) { export function ErrorState({ message }: { message: string }) {
const { t } = useTranslation("common");
return ( return (
<div className="state-panel state-error" role="alert"> <div className="state-panel state-error" role="alert">
<Icon name="alert" /> <Icon name="alert" />
<div><strong>We couldnt load this workspace.</strong><p>{message}</p></div> <div><strong>{t("states.errorTitle")}</strong><p>{message}</p></div>
</div> </div>
); );
} }
+1 -1
View File
@@ -8,7 +8,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
if (loading) { if (loading) {
return ( return (
<div className="page"> <div className="page">
<LoadingState label="Verifying session…" /> <LoadingState />
</div> </div>
); );
} }
+50 -53
View File
@@ -1,10 +1,12 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api, ApiError } from "../api/client";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
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 { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons"; import { Icon } from "./Icons";
import { StatusBadge } from "./Badge"; import { StatusBadge } from "./Badge";
@@ -16,6 +18,7 @@ function newIdempotencyKey(): string {
} }
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) { export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
const { t } = useTranslation("returns");
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
@@ -30,12 +33,12 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
return ( return (
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite"> <section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Committed locally</p><h2 id="return-result-heading">Return registered</h2></div></div> <div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("result.committedLocally")}</p><h2 id="return-result-heading">{t("result.heading")}</h2></div></div>
<dl className="detail-grid"> <dl className="detail-grid">
<div><dt>Inspection</dt><dd>{result.inspection_ref}</dd></div> <div><dt>{t("result.inspection")}</dt><dd>{result.inspection_ref}</dd></div>
<div><dt>Resulting vehicle status</dt><dd><StatusBadge status={result.resulting_vehicle_status} /></dd></div> <div><dt>{t("result.resultingStatus")}</dt><dd><StatusBadge status={result.resulting_vehicle_status} label={t(`fleet:statuses.${result.resulting_vehicle_status}`, { defaultValue: result.resulting_vehicle_status })} /></dd></div>
<div> <div>
<dt>Quality issue</dt> <dt>{t("result.qualityIssue")}</dt>
<dd> <dd>
{result.quality_issue_ref ? ( {result.quality_issue_ref ? (
canSeeQualityIssue ? ( canSeeQualityIssue ? (
@@ -44,40 +47,36 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
result.quality_issue_ref result.quality_issue_ref
) )
) : ( ) : (
"None created" t("result.noneCreated")
)} )}
</dd> </dd>
</div> </div>
<div> <div>
<dt>Automation event</dt> <dt>{t("result.automationEvent")}</dt>
<dd> <dd>{t("result.queuedForDelivery", { ref: result.workflow_event_id.slice(0, 8) })}</dd>
Queued for delivery ({result.workflow_event_id.slice(0, 8)}) local commit succeeded;
n8n delivery is asynchronous and not yet confirmed.
</dd>
</div> </div>
<div> <div>
<dt>Next booking risk</dt> <dt>{t("result.nextBookingRisk")}</dt>
<dd> <dd>
{result.next_booking_risk {result.next_booking_risk
? `${result.next_booking_risk.booking_ref} ${result.next_booking_risk.at_risk ? "— may be affected" : "— low risk"}` ? t("result.nextBookingRiskValue", {
: "No upcoming booking for this vehicle"} ref: result.next_booking_risk.booking_ref,
status: result.next_booking_risk.at_risk ? t("result.atRisk") : t("result.lowRisk"),
})
: t("result.noUpcomingBooking")}
</dd> </dd>
</div> </div>
</dl> </dl>
{result.odometer_regression && ( {result.odometer_regression && (
<p className="error" role="alert"> <p className="error" role="alert">{t("result.odometerRegressionNotice")}</p>
The submitted odometer reading was below the vehicle&apos;s canonical odometer. It was
recorded as-is; the canonical odometer was not changed, and a data-quality issue was
opened for review.
</p>
)} )}
<div className="result-links"> <div className="result-links">
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>View vehicle {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">View automation status<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">View audit trail<Icon name="chevron" /></Link> <Link className="button button-secondary" to="/audit">{t("result.viewAudit")}<Icon name="chevron" /></Link>
{guideOpen && ( {guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}> <button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" /> {t("result.continueDemo")} <Icon name="chevron" />
</button> </button>
)} )}
</div> </div>
@@ -94,6 +93,8 @@ export function ReturnForm({
onRegistered: (result: RegisterReturnResult) => void; onRegistered: (result: RegisterReturnResult) => void;
suggestedOdometerKm?: number; suggestedOdometerKm?: number;
}) { }) {
const { t } = useTranslation(["returns", "errors"]);
const { formatNumber, formatDateTime } = useLocaleFormat();
const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : ""); const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
const [fuel, setFuel] = useState("50"); const [fuel, setFuel] = useState("50");
const [cleanlinessOk, setCleanlinessOk] = useState(true); const [cleanlinessOk, setCleanlinessOk] = useState(true);
@@ -131,9 +132,7 @@ export function ReturnForm({
setPreview(evaluated); setPreview(evaluated);
setStep("review"); setStep("review");
} catch (err) { } catch (err) {
setError( setError(err instanceof ApiError ? err.message : t("errors:generic"));
err instanceof ApiError ? err.message : "Could not evaluate this return. Please try again.",
);
} finally { } finally {
setPreviewing(false); setPreviewing(false);
} }
@@ -149,11 +148,7 @@ export function ReturnForm({
); );
onRegistered(registered); onRegistered(registered);
} catch (err) { } catch (err) {
if (err instanceof ApiError) { setError(err instanceof ApiError ? err.message : t("errors:generic"));
setError(err.message);
} else {
setError("Could not register the return. Please try again.");
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -161,13 +156,13 @@ export function ReturnForm({
return ( return (
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading"> <form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<div className="return-progress" aria-label="Return registration progress"><span className="is-complete"><i>1</i> Capture</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> Review</span><b /><span><i>3</i> Result</span></div> <div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
<div className="section-heading"><div><p className="page-eyebrow">Booking {bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? "Register vehicle return" : "Review return impact"}</h2><p>{step === "capture" ? "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed." : "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation."}</p></div></div> <div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
{step === "capture" ? <div className="return-capture"> {step === "capture" ? <div className="return-capture">
<div className="form-grid"><label> <div className="form-grid"><label>
End odometer (km) {t("capture.endOdometer")}
<input <input
type="number" type="number"
required required
@@ -178,7 +173,7 @@ export function ReturnForm({
</label> </label>
<label> <label>
Fuel level (%) {t("capture.fuelLevel")}
<input <input
type="number" type="number"
required required
@@ -189,13 +184,13 @@ export function ReturnForm({
/> />
</label></div> </label></div>
<fieldset className="condition-fieldset"><legend>Vehicle condition</legend><label className="checkbox-label check-card"> <fieldset className="condition-fieldset"><legend>{t("capture.conditionLegend")}</legend><label className="checkbox-label check-card">
<input <input
type="checkbox" type="checkbox"
checked={cleanlinessOk} checked={cleanlinessOk}
onChange={(e) => setCleanlinessOk(e.target.checked)} onChange={(e) => setCleanlinessOk(e.target.checked)}
/> />
Cleanliness acceptable {t("capture.cleanlinessOk")}
</label> </label>
<label className="checkbox-label check-card"> <label className="checkbox-label check-card">
@@ -204,7 +199,7 @@ export function ReturnForm({
checked={damageReported} checked={damageReported}
onChange={(e) => setDamageReported(e.target.checked)} onChange={(e) => setDamageReported(e.target.checked)}
/> />
Damage reported {t("capture.damageReported")}
</label> </label>
<label className="checkbox-label check-card"> <label className="checkbox-label check-card">
@@ -213,48 +208,50 @@ export function ReturnForm({
checked={technicalWarning} checked={technicalWarning}
onChange={(e) => setTechnicalWarning(e.target.checked)} onChange={(e) => setTechnicalWarning(e.target.checked)}
/> />
Technical warning {t("capture.technicalWarning")}
</label></fieldset> </label></fieldset>
<label> <label>
Notes {t("capture.notes")}
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} /> <textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
</label></div> : preview && <div className="return-review" aria-live="polite"> </label></div> : preview && <div className="return-review" aria-live="polite">
<dl className="review-facts"><div><dt>Odometer</dt><dd>{Number(odometer).toLocaleString("en-GB")} km</dd></div><div><dt>Fuel</dt><dd>{fuel}%</dd></div><div><dt>Cleanliness</dt><dd>{cleanlinessOk ? "Accepted" : "Follow-up needed"}</dd></div><div><dt>Damage</dt><dd>{damageReported ? "Reported" : "None reported"}</dd></div><div><dt>Technical warning</dt><dd>{technicalWarning ? "Reported" : "None reported"}</dd></div></dl> <dl className="review-facts"><div><dt>{t("review.odometer")}</dt><dd>{formatNumber(Number(odometer))} km</dd></div><div><dt>{t("review.fuel")}</dt><dd>{fuel}%</dd></div><div><dt>{t("review.cleanliness")}</dt><dd>{cleanlinessOk ? t("review.cleanlinessAccepted") : t("review.cleanlinessFollowUp")}</dd></div><div><dt>{t("review.damage")}</dt><dd>{damageReported ? t("review.damageReported") : t("review.damageNone")}</dd></div><div><dt>{t("review.technicalWarning")}</dt><dd>{technicalWarning ? t("review.technicalWarningReported") : t("review.technicalWarningNone")}</dd></div></dl>
<div className={`impact-preview ${preview.attention_reasons.length > 0 ? "impact-warning" : "impact-ready"}`}> <div className={`impact-preview ${preview.attention_reasons.length > 0 ? "impact-warning" : "impact-ready"}`}>
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} /> <Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
<div> <div>
<strong>Expected fleet state: <StatusBadge status={preview.resulting_vehicle_status} /></strong> <strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
<p>{preview.status_reason}</p> <p>{preview.status_reason}</p>
</div> </div>
</div> </div>
{preview.odometer_regression && ( {preview.odometer_regression && (
<p className="error" role="alert"> <p className="error" role="alert">
Submitted odometer ({preview.submitted_odometer_km.toLocaleString("en-GB")} km) is below {t("review.odometerRegressionWarning", {
the canonical reading ({preview.canonical_odometer_km.toLocaleString("en-GB")} km). The submitted: formatNumber(preview.submitted_odometer_km),
canonical odometer will not change, and a data-quality issue will be opened. canonical: formatNumber(preview.canonical_odometer_km),
})}
</p> </p>
)} )}
{preview.next_booking_risk && ( {preview.next_booking_risk && (
<p className={preview.next_booking_risk.at_risk ? "error" : undefined} role={preview.next_booking_risk.at_risk ? "alert" : undefined}> <p className={preview.next_booking_risk.at_risk ? "error" : undefined} role={preview.next_booking_risk.at_risk ? "alert" : undefined}>
Next booking {preview.next_booking_risk.booking_ref} starts{" "} {t(preview.next_booking_risk.at_risk ? "review.nextBookingRisk" : "review.nextBookingLowRisk", {
{new Date(preview.next_booking_risk.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })} ref: preview.next_booking_risk.booking_ref,
{preview.next_booking_risk.at_risk ? " — may be affected by this return." : " — low risk."} when: formatDateTime(preview.next_booking_risk.starts_at),
})}
</p> </p>
)} )}
<ul className="commit-list"><li><Icon name="check" /> Create a return inspection</li><li><Icon name="check" /> Update the booking and vehicle atomically</li><li><Icon name="check" /> Queue n8n delivery after the local commit</li></ul> <ul className="commit-list"><li><Icon name="check" /> {t("review.commitList.inspection")}</li><li><Icon name="check" /> {t("review.commitList.updateAtomic")}</li><li><Icon name="check" /> {t("review.commitList.queueAutomation")}</li></ul>
</div>} </div>}
<div className="form-actions"> <div className="form-actions">
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> Edit details</button>} {step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> {t("review.editDetails")}</button>}
<button className="button button-primary" type="submit" disabled={submitting || previewing}> <button className="button button-primary" type="submit" disabled={submitting || previewing}>
{step === "capture" {step === "capture"
? previewing ? previewing
? "Evaluating" ? t("capture.evaluating")
: "Review return" : t("capture.reviewReturn")
: submitting : submitting
? "Registering" ? t("review.registering")
: "Confirm return"}{" "} : t("review.confirmReturn")}{" "}
{step === "capture" && !previewing && <Icon name="chevron" />} {step === "capture" && !previewing && <Icon name="chevron" />}
</button> </button>
</div> </div>
+13 -2
View File
@@ -28,6 +28,8 @@ interface DemoGuideState {
currentIndex: number; currentIndex: number;
completed: Set<string>; completed: Set<string>;
totalSteps: number; totalSteps: number;
collapsedToChip: boolean;
setCollapsedToChip: (value: boolean) => void;
openGuide: () => void; openGuide: () => void;
closeGuide: () => void; closeGuide: () => void;
toggleGuide: () => void; toggleGuide: () => void;
@@ -42,6 +44,7 @@ export function DemoGuideProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [completed, setCompleted] = useState<Set<string>>(new Set()); const [completed, setCompleted] = useState<Set<string>>(new Set());
const [collapsedToChip, setCollapsedToChip] = useState(false);
useEffect(() => { useEffect(() => {
const stored = readProgress(); const stored = readProgress();
@@ -86,9 +89,17 @@ export function DemoGuideProvider({ children }: { children: ReactNode }) {
currentIndex, currentIndex,
completed, completed,
totalSteps: DEMO_GUIDE_STEPS.length, totalSteps: DEMO_GUIDE_STEPS.length,
openGuide: () => setOpen(true), collapsedToChip,
setCollapsedToChip,
openGuide: () => {
setCollapsedToChip(false);
setOpen(true);
},
closeGuide: () => setOpen(false), closeGuide: () => setOpen(false),
toggleGuide: () => setOpen((v) => !v), toggleGuide: () => {
setCollapsedToChip(false);
setOpen((v) => !v);
},
goToStep, goToStep,
completeAndAdvance, completeAndAdvance,
restart, restart,
+13 -68
View File
@@ -2,93 +2,38 @@ import type { DemoManifest } from "../api/types";
export interface DemoGuideStep { export interface DemoGuideStep {
id: string; id: string;
title: string;
whatYouWillSee: string;
whyItMatters: string;
startAction: string;
expectedOutcome: string;
/** Resolves the route to link to, using live manifest data where a scenario supplies /** Resolves the route to link to, using live manifest data where a scenario supplies
* a concrete record (booking/issue ref) instead of hardcoding one that could drift. */ * a concrete record (booking/issue ref) instead of hardcoding one that could drift. */
route: (manifest: DemoManifest | null) => string; route: (manifest: DemoManifest | null) => string;
/** CSS selector for the on-page element this step is about. After navigating, the guide
* scrolls it into view, moves keyboard focus to it and applies a brief highlight -- this
* only works because the target already carries a stable id/class in the page itself, so
* a missing target (selector not found post-navigation) is a silent no-op, never an error. */
target?: string;
} }
export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
{ { id: "understand-state", route: () => "/dashboard", target: "#attention-heading" },
id: "understand-state",
title: "1. Begrijp de operationele status",
whatYouWillSee: "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
whyItMatters: "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
startAction: "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
expectedOutcome: "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen.",
route: () => "/dashboard",
},
{ {
id: "open-booking", id: "open-booking",
title: "2. Open een boeking die aandacht nodig heeft",
whatYouWillSee: "De boeking BK-DEMO-RETURN, actief en klaar voor retour vandaag.",
whyItMatters: "Retours zijn het moment waarop foute kilometerstanden of schade voor het eerst zichtbaar worden.",
startAction: "Open de boeking vanuit het dashboard of de boekingenlijst.",
expectedOutcome: "Je ziet de boekingsdetails en de knop om de retour te verwerken.",
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",
target: ".record-surface",
}, },
{ {
id: "process-return", id: "process-return",
title: "3. Verwerk een retour met een afwijkende kilometerstand",
whatYouWillSee: "Een retourformulier met een vooringevulde, verdachte kilometerstand lager dan de laatst gekende stand.",
whyItMatters: "Een dalende kilometerstand wijst op een foutieve invoer of een verwisseld voertuig — dit moet vóór vrijgave worden opgemerkt.",
startAction: "Vul de retour in met de voorgestelde waarde en bekijk de serverpreview vóór je bevestigt.",
expectedOutcome: "De retour wordt verwerkt, het voertuig krijgt een passende status en er wordt automatisch een datakwaliteitsprobleem aangemaakt.",
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",
target: "#return-form-heading",
}, },
{ { id: "handle-quality-issue", route: () => "/data-quality", target: ".table-shell" },
id: "handle-quality-issue",
title: "4. Bekijk en behandel het gecreëerde datakwaliteitsprobleem",
whatYouWillSee: "Een nieuw issue van het type 'kilometerstand-afwijking' bovenaan de werklijst.",
whyItMatters: "Elk gedetecteerd probleem heeft één afgebakende oplossingsstap — niets wordt automatisch stilzwijgend gecorrigeerd.",
startAction: "Open het datakwaliteitsoverzicht en kies het nieuwste issue.",
expectedOutcome: "Je ziet de aanbevolen actie, kiest een oplossing en het issue wordt opgelost met een audit-spoor.",
route: () => "/data-quality",
},
{ {
id: "merge-duplicate", id: "merge-duplicate",
title: "5. Beoordeel en behandel een mogelijke dubbele klant",
whatYouWillSee: "Twee klantprofielen met hetzelfde e-mailadres en telefoonnummer, naast elkaar vergeleken.",
whyItMatters: "Dubbele klanten leiden tot verspreide boekingsgeschiedenis en verwarrende communicatie.",
startAction: "Vergelijk beide profielen en kies welk profiel behouden blijft.",
expectedOutcome: "De profielen worden samengevoegd, boekingen worden herverbonden en het verliezende profiel wordt een tombstone.",
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",
target: "#compare-heading",
}, },
{ { id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" },
id: "ask-knowledge", { id: "check-automation-audit", route: () => "/automation", target: ".integration-cards" },
title: "6. Stel een vraag aan de procedureassistent", { id: "review-real-vs-simulated", route: () => "/about", target: ".about-cta" },
whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.",
whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.",
startAction:
'Klik op één van de voorbeeldvragen (bv. "What must I do when a vehicle returns ' +
'with damage?"). De geïndexeerde procedures zijn Engelstalig, dus gebruik de ' +
"voorgestelde vragen of stel je eigen vraag in het Engels.",
expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.",
route: () => "/knowledge",
},
{
id: "check-automation-audit",
title: "7. Controleer automatisering en audit trail",
whatYouWillSee: "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.",
whyItMatters: "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.",
startAction: "Open Systemen om de afleverstatus te zien, en Audit trail voor het volledige spoor.",
expectedOutcome: "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties.",
route: () => "/automation",
},
{
id: "review-real-vs-simulated",
title: "8. Bekijk wat echt is, gesimuleerd is, of nog niet gekoppeld",
whatYouWillSee: "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
whyItMatters: "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
startAction: "Lees de pagina 'Over deze demo'.",
expectedOutcome: "Je kan zelf uitleggen wat MobilityOps wel en niet is, zonder mondelinge toelichting.",
route: () => "/about",
},
]; ];
+11 -10
View File
@@ -3,16 +3,17 @@ import type { IntegrationStatus } from "../api/types";
// Plain-language status per section 12 of the demo brief: a visitor shouldn't have to // Plain-language status per section 12 of the demo brief: a visitor shouldn't have to
// decode raw backend state strings to tell whether an integration is actually working. // decode raw backend state strings to tell whether an integration is actually working.
// `statusClass` maps onto an existing `.status-*` CSS modifier so the badge keeps // `statusClass` maps onto an existing `.status-*` CSS modifier so the badge keeps
// correct colour-coding; `label` is the human-readable text shown instead of the raw value. // correct colour-coding; `labelKey` resolves to a localized label via the
export const N8N_STATE_META: Record<IntegrationStatus["n8n"]["state"], { statusClass: string; label: string }> = { // `integrations:statusLabels.*` namespace (see i18n/locales/*/integrations.json).
disabled: { statusClass: "not_configured", label: "Not connected" }, export const N8N_STATE_META: Record<IntegrationStatus["n8n"]["state"], { statusClass: string; labelKey: string }> = {
unavailable: { statusClass: "unavailable", label: "Delivery failed" }, disabled: { statusClass: "not_configured", labelKey: "notConnected" },
degraded: { statusClass: "needs_attention", label: "Retry available" }, unavailable: { statusClass: "unavailable", labelKey: "deliveryFailed" },
operational: { statusClass: "available", label: "Operational" }, degraded: { statusClass: "needs_attention", labelKey: "retryAvailable" },
no_evidence: { statusClass: "no_events", label: "Prepared" }, operational: { statusClass: "available", labelKey: "operational" },
no_evidence: { statusClass: "no_events", labelKey: "prepared" },
}; };
export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; label: string }> = { export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = {
not_configured: { statusClass: "not_configured", label: "Not connected" }, not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
configured: { statusClass: "no_events", label: "Prepared" }, configured: { statusClass: "no_events", labelKey: "prepared" },
}; };
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
export type ViewportTier = "wide" | "standard" | "mobile";
// Breakpoints reuse the app's existing, already-measured layout thresholds (see
// styles.css): 700px is where the sidebar collapses into the mobile bottom nav, and
// 1440px is the largest width in the project's own responsive test matrix -- chosen as
// the "extra-wide desktop" floor rather than an arbitrary new number.
const MOBILE_QUERY = "(max-width: 700px)";
const WIDE_QUERY = "(min-width: 1440px)";
function computeTier(): ViewportTier {
if (typeof window === "undefined") return "standard";
if (window.matchMedia(MOBILE_QUERY).matches) return "mobile";
if (window.matchMedia(WIDE_QUERY).matches) return "wide";
return "standard";
}
export function useViewportTier(): ViewportTier {
const [tier, setTier] = useState<ViewportTier>(computeTier);
useEffect(() => {
const mobile = window.matchMedia(MOBILE_QUERY);
const wide = window.matchMedia(WIDE_QUERY);
const update = () => setTier(computeTier());
mobile.addEventListener("change", update);
wide.addEventListener("change", update);
update();
return () => {
mobile.removeEventListener("change", update);
wide.removeEventListener("change", update);
};
}, []);
return tier;
}
+161
View File
@@ -0,0 +1,161 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import commonNl from "./locales/nl-BE/common.json";
import authNl from "./locales/nl-BE/auth.json";
import navigationNl from "./locales/nl-BE/navigation.json";
import dashboardNl from "./locales/nl-BE/dashboard.json";
import fleetNl from "./locales/nl-BE/fleet.json";
import bookingsNl from "./locales/nl-BE/bookings.json";
import returnsNl from "./locales/nl-BE/returns.json";
import qualityNl from "./locales/nl-BE/quality.json";
import knowledgeNl from "./locales/nl-BE/knowledge.json";
import integrationsNl from "./locales/nl-BE/integrations.json";
import auditNl from "./locales/nl-BE/audit.json";
import demoNl from "./locales/nl-BE/demo.json";
import errorsNl from "./locales/nl-BE/errors.json";
import accessibilityNl from "./locales/nl-BE/accessibility.json";
import commonEn from "./locales/en-GB/common.json";
import authEn from "./locales/en-GB/auth.json";
import navigationEn from "./locales/en-GB/navigation.json";
import dashboardEn from "./locales/en-GB/dashboard.json";
import fleetEn from "./locales/en-GB/fleet.json";
import bookingsEn from "./locales/en-GB/bookings.json";
import returnsEn from "./locales/en-GB/returns.json";
import qualityEn from "./locales/en-GB/quality.json";
import knowledgeEn from "./locales/en-GB/knowledge.json";
import integrationsEn from "./locales/en-GB/integrations.json";
import auditEn from "./locales/en-GB/audit.json";
import demoEn from "./locales/en-GB/demo.json";
import errorsEn from "./locales/en-GB/errors.json";
import accessibilityEn from "./locales/en-GB/accessibility.json";
import commonFr from "./locales/fr-BE/common.json";
import authFr from "./locales/fr-BE/auth.json";
import navigationFr from "./locales/fr-BE/navigation.json";
import dashboardFr from "./locales/fr-BE/dashboard.json";
import fleetFr from "./locales/fr-BE/fleet.json";
import bookingsFr from "./locales/fr-BE/bookings.json";
import returnsFr from "./locales/fr-BE/returns.json";
import qualityFr from "./locales/fr-BE/quality.json";
import knowledgeFr from "./locales/fr-BE/knowledge.json";
import integrationsFr from "./locales/fr-BE/integrations.json";
import auditFr from "./locales/fr-BE/audit.json";
import demoFr from "./locales/fr-BE/demo.json";
import errorsFr from "./locales/fr-BE/errors.json";
import accessibilityFr from "./locales/fr-BE/accessibility.json";
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const DEFAULT_LANGUAGE: SupportedLanguage = "nl-BE";
export const LANGUAGE_STORAGE_KEY = "fleetops.language";
export const NAMESPACES = [
"common",
"auth",
"navigation",
"dashboard",
"fleet",
"bookings",
"returns",
"quality",
"knowledge",
"integrations",
"audit",
"demo",
"errors",
"accessibility",
] as const;
function readStoredLanguage(): SupportedLanguage {
try {
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (stored && (SUPPORTED_LANGUAGES as readonly string[]).includes(stored)) {
return stored as SupportedLanguage;
}
} catch {
// localStorage unavailable (e.g. private-mode edge cases) -- fall back to default.
}
return DEFAULT_LANGUAGE;
}
export function persistLanguage(language: SupportedLanguage): void {
try {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
} catch {
// Best effort only; the in-memory i18next language still changes for this session.
}
}
void i18n
.use(initReactI18next)
.init({
lng: readStoredLanguage(),
fallbackLng: DEFAULT_LANGUAGE,
supportedLngs: SUPPORTED_LANGUAGES,
ns: NAMESPACES,
defaultNS: "common",
// A missing key must never render silently as empty/blank text -- surface it loudly
// (the key itself) so it's caught immediately in development and by the
// translation-coverage test, rather than shipping a blank UI label.
returnEmptyString: false,
interpolation: { escapeValue: false },
resources: {
"nl-BE": {
common: commonNl,
auth: authNl,
navigation: navigationNl,
dashboard: dashboardNl,
fleet: fleetNl,
bookings: bookingsNl,
returns: returnsNl,
quality: qualityNl,
knowledge: knowledgeNl,
integrations: integrationsNl,
audit: auditNl,
demo: demoNl,
errors: errorsNl,
accessibility: accessibilityNl,
},
"en-GB": {
common: commonEn,
auth: authEn,
navigation: navigationEn,
dashboard: dashboardEn,
fleet: fleetEn,
bookings: bookingsEn,
returns: returnsEn,
quality: qualityEn,
knowledge: knowledgeEn,
integrations: integrationsEn,
audit: auditEn,
demo: demoEn,
errors: errorsEn,
accessibility: accessibilityEn,
},
"fr-BE": {
common: commonFr,
auth: authFr,
navigation: navigationFr,
dashboard: dashboardFr,
fleet: fleetFr,
bookings: bookingsFr,
returns: returnsFr,
quality: qualityFr,
knowledge: knowledgeFr,
integrations: integrationsFr,
audit: auditFr,
demo: demoFr,
errors: errorsFr,
accessibility: accessibilityFr,
},
},
});
document.documentElement.lang = i18n.language;
i18n.on("languageChanged", (lng) => {
document.documentElement.lang = lng;
});
export default i18n;
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
const TIME_ZONE = "Europe/Brussels";
export function useLocaleFormat() {
const { i18n } = useTranslation();
const locale = i18n.language || "nl-BE";
return {
locale,
formatDate(value: string | Date): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: TIME_ZONE }).format(
new Date(value),
);
},
formatShortDate(value: string | Date): string {
return new Intl.DateTimeFormat(locale, { day: "2-digit", month: "short", timeZone: TIME_ZONE }).format(
new Date(value),
);
},
formatDateTime(value: string | Date): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone: TIME_ZONE,
}).format(new Date(value));
},
formatTime(value: string | Date): string {
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone: TIME_ZONE }).format(
new Date(value),
);
},
formatNumber(value: number): string {
return new Intl.NumberFormat(locale).format(value);
},
};
}
@@ -0,0 +1,7 @@
{
"openRow": "Open: {{label}}",
"reducedMotion": "Reduced motion active",
"closeDialog": "Close dialog",
"expandSection": "Expand section",
"collapseSection": "Collapse section"
}
@@ -0,0 +1,64 @@
{
"eyebrow": "Assure / Immutable history",
"title": "Audit trail",
"description": "Trace important state changes, actors and correlation references.",
"actionFilterLabel": "Action",
"actionFilterPlaceholder": "e.g. demo_login",
"managerOnly": "The audit trail is visible to Operations Managers only.",
"managerOnlyDetail": "Audit history is visible to Operations Managers only.",
"loading": "Loading audit trail…",
"unavailable": "Audit trail is unavailable right now.",
"empty": "No audit events found",
"emptyDetail": "Adjust the action filter.",
"relatedFilterActive": "Showing only events linked to this action ({{count}} related events).",
"clearFilter": "Clear this filter",
"count": "{{count}} events",
"storedRendered": "UTC stored · Brussels rendered",
"columns": {
"when": "When",
"actor": "Actor",
"action": "Action",
"entity": "Entity",
"change": "Change",
"followUp": "Follow-up",
"details": "Details"
},
"viewRelatedEvents": "View related events",
"noChangeDetail": "No recorded change detail.",
"noFieldChange": "No field-level change detected.",
"reference": "Reference",
"relatedEventsCount": "{{count}} linked event",
"relatedEventsCount_other": "{{count}} linked events",
"showTechnicalEvents": "Show {{count}} technical event",
"showTechnicalEvents_other": "Show {{count}} technical events",
"hideTechnicalEvents": "Hide technical events",
"technicalDetails": "Technical details",
"fullReference": "Full reference",
"correlationId": "Correlation ID",
"diff": {
"setTo": "{{field}}: set to {{value}}",
"was": "{{field}}: was {{value}}",
"changed": "{{field}}: {{before}} → {{after}}"
},
"actions": {
"demo_login": "Logged in",
"demo_logout": "Logged out",
"demo_reset": "Demo data reset",
"demo_data_seeded": "Demo data seeded",
"return_registered": "Vehicle return registered",
"vehicle_status_changed": "Vehicle status changed",
"data_quality_issue_resolved": "Data-quality issue resolved",
"data_quality_issue_deferred": "Data-quality issue deferred",
"data_quality_issue_rejected": "Data-quality issue rejected",
"data_quality_fields_provided": "Missing fields provided",
"data_quality_odometer_corrected": "Odometer corrected",
"data_quality_odometer_retained": "Canonical odometer retained",
"data_quality_status_applied": "Recommended status applied",
"data_quality_booking_blocked": "Booking blocked",
"data_quality_scan_run": "Quality scan run",
"customer_merged": "Customers merged",
"workflow_retry": "Automation retried",
"knowledge_question_asked": "Knowledge question asked",
"mcp_tool_request": "MCP tool called"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"brandTagline": "Control Centre",
"orgLine": "Demo organisation: {{orgName}} (fictional)",
"headline1": "Every hand-off.",
"headline2": "One clear view.",
"defaultDescription": "Fleet Ops brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
"accessEyebrow": "Demo access",
"accessHeading": "Choose how to start",
"accessIntro": "No password needed. Each role opens a scoped synthetic environment — all workflows and controls are really implemented.",
"startGuidedDemo": "Start guided demo",
"exploreAsOperationsManager": "Explore as Operations Manager",
"exploreAsOperationsManagerDetail": "Full overview, quality resolution and retries",
"exploreAsRentalEmployee": "Explore as Rental Employee",
"exploreAsRentalEmployeeDetail": "Bookings, returns, fleet and procedures",
"safeByDesignTitle": "Safe by design",
"safeByDesignDetail": "Every action is logged and is resettable in this demo.",
"loginFailed": "The demo session could not be started. The API may be unreachable.",
"roleOperationsManager": "Operations manager",
"roleRentalEmployee": "Rental employee",
"switchRole": "Switch role",
"logout": "Log out"
}
@@ -0,0 +1,52 @@
{
"list": {
"eyebrow": "Operations / Schedule",
"title": "Bookings",
"description": "Review active rental windows and upcoming vehicle commitments.",
"searchLabel": "Search",
"searchPlaceholder": "Booking, customer or vehicle",
"statusLabel": "Status",
"statusAll": "All statuses",
"loading": "Loading booking ledger…",
"empty": "No bookings found",
"emptyDetail": "Adjust the booking status filter.",
"noMatch": "No matching bookings",
"noMatchDetail": "Try a broader search term.",
"unavailable": "Booking list is unavailable right now.",
"count": "{{count}} bookings",
"pageOf": "Page {{page}} of {{total}}",
"columns": {
"reference": "Reference",
"customer": "Customer",
"vehicle": "Vehicle",
"window": "Window",
"status": "Status"
},
"paginationLabel": "Booking pages",
"previous": "Previous",
"next": "Next",
"rangeOf": "{{from}}{{to}} of {{total}}"
},
"statuses": {
"reserved": "reserved",
"active": "active",
"returned": "returned",
"cancelled": "cancelled",
"blocked": "blocked"
},
"detail": {
"backLink": "Booking ledger",
"eyebrow": "Bookings / Rental record",
"notFound": "This booking could not be found.",
"loading": "Loading booking record…",
"customer": "Customer",
"vehicle": "Vehicle",
"starts": "Starts",
"ends": "Ends",
"startOdometer": "Start odometer",
"endOdometer": "End odometer",
"requirementsComplete": "Requirements complete",
"yes": "Yes",
"no": "No"
}
}
@@ -0,0 +1,44 @@
{
"appName": "Fleet Ops",
"orgName": "Northstar Mobility",
"brandTagline": "Control Centre",
"actions": {
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"back": "Back",
"next": "Next",
"retry": "Retry",
"reset": "Reset",
"yes": "Yes",
"no": "No",
"viewDetails": "View details",
"technicalDetails": "Technical details"
},
"status": {
"loading": "Loading…",
"saving": "Saving…",
"error": "Something went wrong",
"success": "Success",
"noResults": "No results"
},
"states": {
"loadingDefault": "Loading workspace…",
"errorTitle": "We couldn't load this workspace."
},
"timezone": "Europe/Brussels",
"language": {
"label": "Language",
"nl-BE": "Nederlands",
"en-GB": "English",
"fr-BE": "Français"
},
"footer": {
"productLine": "Fleet Ops Demo",
"locale": "Europe/Brussels · Synthetic demo data"
},
"demo": {
"syntheticBadge": "Synthetic demo"
}
}
@@ -0,0 +1,67 @@
{
"eyebrow": "Operations / Live overview",
"title": "Good morning. Here's the fleet.",
"description": "Readiness, exceptions and hand-offs across today's operation.",
"viewFleet": "View fleet",
"demoStart": {
"title": "Try a demonstration scenario",
"readyCount": "{{ready}} of {{total}} scenarios ready for demo.",
"readyCountFallback": "Five focused scenarios.",
"startGuide": "Start demo guide",
"resumeGuide": "Continue demo guide ({{current}}/{{total}})",
"viewScenarios": "View scenarios"
},
"readiness": {
"title": "Fleet readiness",
"description": "Live from persisted vehicle state",
"openFleet": "Open fleet",
"available": "Available",
"rented": "Rented",
"cleaning": "Cleaning",
"maintenance": "Maintenance",
"blocked": "Blocked"
},
"attention": {
"title": "Attention queue",
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
"reviewQueue": "Review queue",
"filterPlaceholder": "Filter issues…",
"filterAriaLabel": "Search attention queue",
"severityAll": "All severity",
"severityHigh": "Critical",
"severityMedium": "Warning",
"severityLow": "Info",
"severityAriaLabel": "Severity",
"empty": "No issues match this filter.",
"openRecord": "Open {{title}}"
},
"movements": {
"title": "Today's movements",
"description": "Departures and returns in Europe/Brussels",
"allBookings": "All bookings",
"empty": "No movements scheduled today.",
"departure": "departure",
"return": "return",
"openBooking": "Open booking {{ref}}"
},
"integrationPulse": {
"title": "Integration pulse",
"description": "Current evidence from connected services",
"systemDetail": "System detail",
"n8nTitle": "n8n delivery",
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed",
"n8nLatest": "Latest event {{ref}}",
"n8nNoEvidence": "No workflow evidence recorded",
"knowledgeTitle": "Knowledge assistant",
"knowledgeSummary": "{{count}} procedures indexed",
"knowledgeUnavailable": "Health check unavailable",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registration enabled",
"mcpNotConnected": "Not yet connected"
},
"recent": {
"title": "Recent activity",
"description": "Latest audited workflow changes",
"empty": "No automation activity recorded."
}
}
+189
View File
@@ -0,0 +1,189 @@
{
"badge": {
"trigger": "Synthetic demo",
"dialogLabel": "About this demo environment",
"close": "Close",
"orgIntro": "<strong>{{orgName}}</strong> is a fictional organisation. All names, vehicles and bookings are synthetic.",
"orgIntroFallback": "All names, vehicles and bookings in this environment are synthetic.",
"realWorkflows": "The workflows, controls and automation are really implemented — only the data is invented.",
"lastReset": "Last reset: <strong>{{when}}</strong> · this environment is resettable at any time.",
"aboutLink": "About this demo",
"unknown": "unknown"
},
"guide": {
"dialogLabel": "Guided demo",
"trigger": "Demo guide",
"kicker": "Guided demo · step {{current}} of {{total}}",
"whatYouWillSee": "What you'll see",
"whyItMatters": "Why it matters",
"startAction": "Get started",
"expectedOutcome": "Expected outcome",
"goToStep": "Go to this step",
"next": "Next",
"close": "Close",
"restart": "Prepare demo again",
"restarting": "Restarting…",
"restartFailed": "Could not restart the demo.",
"allStepsLabel": "All steps",
"progressChip": "Demo guide · step {{current}} of {{total}}",
"expand": "Expand demo guide",
"collapse": "Collapse demo guide",
"steps": {
"understand-state": {
"title": "1. Understand the operational state",
"whatYouWillSee": "The dashboard shows fleet readiness, open attention points and today's movements.",
"whyItMatters": "An Operations Manager starts every day with this overview to decide where to step in.",
"startAction": "Open the dashboard and review the attention queue and today's timeline.",
"expectedOutcome": "You see which bookings, vehicles or data-quality issues need attention."
},
"open-booking": {
"title": "2. Open the booking needing attention",
"whatYouWillSee": "Booking BK-DEMO-RETURN, active and due for return today.",
"whyItMatters": "Returns are the moment incorrect odometer readings or damage first become visible.",
"startAction": "Open the booking from the dashboard or the booking list.",
"expectedOutcome": "You see the booking details and the button to process the return."
},
"process-return": {
"title": "3. Process a return with an odometer anomaly",
"whatYouWillSee": "A return form pre-filled with a suspicious odometer reading below the last known reading.",
"whyItMatters": "A falling odometer signals a data-entry mistake or a mixed-up vehicle — this must be caught before release.",
"startAction": "Submit the return with the suggested value and review the server preview before confirming.",
"expectedOutcome": "The return is processed, the vehicle gets an appropriate status, and a data-quality issue is created automatically."
},
"handle-quality-issue": {
"title": "4. Handle the created data-quality issue",
"whatYouWillSee": "A new 'odometer regression' issue at the top of the work queue.",
"whyItMatters": "Every detected issue has one bounded resolution step — nothing is silently auto-corrected.",
"startAction": "Open the data-quality overview and choose the newest issue.",
"expectedOutcome": "You see the recommended action, choose a resolution, and the issue resolves with an audit trail."
},
"merge-duplicate": {
"title": "5. Review and handle a possible duplicate customer",
"whatYouWillSee": "Two customer profiles with the same email and phone number, compared side by side.",
"whyItMatters": "Duplicate customers split booking history and confuse communication.",
"startAction": "Compare both profiles and choose which one survives.",
"expectedOutcome": "The profiles are merged, bookings are rewired, and the losing profile becomes a tombstone."
},
"ask-knowledge": {
"title": "6. Ask the procedure assistant a question",
"whatYouWillSee": "An answer with citation from the scoped demo knowledge base.",
"whyItMatters": "Staff need a quick, grounded answer about procedures, without guessing.",
"startAction": "Click one of the suggested questions, in the current interface language.",
"expectedOutcome": "You see the answer, the procedure used, and the source text — or an honest 'insufficient information' if none exists."
},
"check-automation-audit": {
"title": "7. Check automation and the audit trail",
"whatYouWillSee": "The delivery status of your return's automation job, and its related audit events.",
"whyItMatters": "Every important action must be traceable: who did what, when, and what followed.",
"startAction": "Open Integrations to see delivery status, and Audit trail for the full record.",
"expectedOutcome": "You see a successful (or recoverable) delivery and a readable audit trail of your actions."
},
"review-real-vs-simulated": {
"title": "8. Review what's real, simulated, or not yet connected",
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
"startAction": "Read the 'About this demo' page.",
"expectedOutcome": "You can explain yourself what Fleet Ops is and isn't, with no verbal explanation needed."
}
}
},
"scenarios": {
"eyebrow": "Demonstration scenarios",
"title": "Try a demonstration scenario",
"description": "Five focused scenarios that always use the same fixed bookings, customers and vehicles — always re-findable after a reset.",
"loading": "Loading scenarios…",
"ready": "Ready for demo",
"notReady": "Not available",
"duration": "Duration",
"durationValue": "± {{minutes}} min",
"role": "Role",
"demonstrates": "Demonstrates:",
"requiresRole": "Requires role: {{roles}}.",
"startScenario": "Start scenario",
"roleOr": "{{a}} or {{b}}",
"roles": {
"operations_manager": "Operations Manager",
"rental_employee": "Rental Employee"
},
"items": {
"return-anomaly": {
"title": "Return with an odometer anomaly",
"problem": "A vehicle comes back with an odometer reading lower than the last recorded reading — a sign of a data-entry mistake or a mixed-up vehicle.",
"demonstrates": "Return processing, automatic data-quality detection, and the audit trail it produces."
},
"duplicate-customer": {
"title": "Merge a possible duplicate customer",
"problem": "Two customer profiles share the same email address and phone number — likely the same person, registered twice.",
"demonstrates": "Merging customers while preserving booking history and the audit trail."
},
"booking-overlap": {
"title": "Resolve overlapping bookings",
"problem": "One vehicle is double-booked for overlapping periods — a scheduling error that must be resolved before departure.",
"demonstrates": "Detection and controlled resolution of scheduling conflicts."
},
"automation-retry": {
"title": "Retry a failed automation job",
"problem": "One earlier event could not be delivered to the automation job due to a simulated connection error.",
"demonstrates": "Reliable delivery with bounded retries and visible failure status."
},
"knowledge-question": {
"title": "Ask a procedure question",
"problem": "An employee isn't sure which procedure applies to a specific operational situation.",
"demonstrates": "Source-grounded answers from a scoped demo knowledge base."
}
},
"blockedReasons": {
"bookingNotFound": "Demo booking BK-DEMO-RETURN not found. {{resetHint}}",
"bookingAlreadyProcessed": "This booking has already been processed since the last reset. {{resetHint}}",
"duplicateIssueNotFound": "Demo issue DQ-DEMO-DUPLICATE not found. {{resetHint}}",
"issueAlreadyResolved": "This issue has already been resolved since the last reset. {{resetHint}}",
"overlapIssueNotFound": "Demo issue DQ-DEMO-OVERLAP not found. {{resetHint}}",
"failedEventNotFound": "The simulated failed event was not found. {{resetHint}}",
"eventAlreadyRecovered": "This event has already been recovered since the last reset. {{resetHint}}",
"knowledgeUnavailable": "The demo knowledge base is currently unavailable.",
"resetHint": "Reset the demo data to make this scenario available again."
}
},
"about": {
"eyebrow": "About this demo",
"title": "What Fleet Ops is and isn't",
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
"loading": "Loading demo information…",
"ctaTitle": "Prefer to jump right in?",
"ctaBody": "The guided demo walks through all eight steps above in practice.",
"ctaButton": "Start guided demo",
"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. Fleet Ops shows how one connected system flags these problems early and lets them be resolved under control.",
"scopeTitle": "Who it's for and its scope",
"scopeBody": "This demo is for anyone who wants to see how Fleet Ops tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
"realTitle": "What really works",
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
"syntheticTitle": "What's synthetic",
"syntheticBody": "The organisation, all customers, vehicles, bookings, maintenance history, procedures in the knowledge base, and the pre-set-up scenarios are entirely invented. No data refers to a real person, vehicle or company; email addresses only use the {{testDomain}} test domain.",
"architectureTitle": "Architecture in brief",
"architectureBody": "A React/TypeScript frontend talks to a FastAPI backend (PostgreSQL via SQLAlchemy/Alembic migrations); important business rules live in the backend, not in n8n or in prompts. Returns and other events are committed locally first and only then delivered asynchronously to n8n through an automation job, so a temporary automation outage never blocks an operational action.",
"securityTitle": "Security and access",
"securityBody": "Access runs through signed, HTTP-only session cookies per role; each role is bound to a set of allowed routes, both enforced server-side and reflected in navigation. Important state changes are always checked and logged — never silently auto-corrected.",
"testingTitle": "How this is tested",
"testingBody": "An automated backend test suite covers business rules and API contracts; a full Playwright end-to-end suite covers the user flows, including this demo experience itself in three languages. Every change is also validated against a clean checkout (empty database, rebuilt from seed data) before deployment.",
"integrationsTitle": "Integrations — honestly labelled",
"integrationsDescription": "What's operational, what's demo mode, and what's not yet connected.",
"resetTitle": "Restoring the demo environment",
"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.",
"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.",
"unknown": "unknown"
},
"integrationSummary": {
"titles": {
"n8n": "Automation (n8n)",
"ragcore": "Knowledge assistant (RAGcore)",
"mcp_hub": "ITWorx MCP Hub"
},
"n8nDetail": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending.",
"ragcoreDetail": "{{count}} procedures indexed in {{collection}}.",
"mcpDetailEnabled": "Prepared for future, controlled tool calls from the Hub.",
"mcpDetailNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub."
}
}
@@ -0,0 +1,8 @@
{
"generic": "Something went wrong. Please try again.",
"workspaceLoadFailed": "We couldn't load this workspace.",
"unauthorized": "Your session has expired. Please log in again.",
"forbidden": "You don't have access to this section.",
"notFound": "This record could not be found.",
"networkUnavailable": "The connection to the server is currently unavailable."
}
@@ -0,0 +1,68 @@
{
"list": {
"eyebrow": "Fleet / Registry",
"title": "Vehicle fleet",
"description": "Live operational state, location and service readiness.",
"searchLabel": "Search",
"searchPlaceholder": "Reference, make or location",
"statusLabel": "Status",
"statusAll": "All statuses",
"attentionOnly": "Attention only",
"loading": "Loading fleet registry…",
"empty": "No vehicles found",
"emptyDetail": "Adjust the current fleet filters.",
"noMatch": "No matching vehicles",
"noMatchDetail": "Try a broader search term.",
"unavailable": "Vehicle list is unavailable right now.",
"count": "{{count}} vehicles",
"persisted": "Persisted fleet data",
"columns": {
"reference": "Reference",
"makeModel": "Make / model",
"location": "Location",
"status": "Status",
"odometer": "Odometer (km)",
"attention": "Attention"
},
"needsAttention": "Needs attention"
},
"statuses": {
"available": "available",
"rented": "rented",
"cleaning": "cleaning",
"maintenance": "maintenance",
"blocked": "blocked"
},
"detail": {
"backLink": "Fleet registry",
"eyebrow": "Fleet / Vehicle record",
"notFound": "This vehicle could not be found.",
"loading": "Loading vehicle record…",
"needsAttention": "Needs attention",
"tabs": {
"overview": "Overview",
"bookings": "Bookings",
"inspections": "Inspections",
"maintenance": "Maintenance",
"quality": "Quality"
},
"tabsAriaLabel": "Vehicle sections",
"overview": {
"registration": "Registration",
"modelYear": "Model year",
"location": "Location",
"odometer": "Odometer",
"nextService": "Next service",
"active": "Active"
},
"yes": "Yes",
"no": "No",
"noBookings": "No bookings recorded.",
"noInspections": "No inspections recorded.",
"noMaintenance": "No maintenance records.",
"noQualityIssues": "No quality issues recorded.",
"fuel": "Fuel {{percent}}%",
"damage": "Damage",
"technicalWarning": "Technical warning"
}
}
@@ -0,0 +1,70 @@
{
"eyebrow": "Assure / Integrations",
"title": "Integration control",
"description": "Monitor delivery health, graceful degradation and retryable workflow events.",
"cards": {
"orchestrationKicker": "Orchestration",
"n8nTitle": "n8n delivery",
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending · {{delivering}} delivering",
"n8nFallback": "Return events are committed locally first and then delivered through the automation job.",
"knowledgeKicker": "Knowledge",
"knowledgeTitle": "Knowledge assistant",
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
"knowledgeUnavailable": "Health evidence is currently unavailable.",
"gatewayKicker": "Tool gateway",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registration is enabled for this deployment.",
"mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub."
},
"statusLabels": {
"notConnected": "Not connected",
"deliveryFailed": "Delivery failed",
"retryAvailable": "Retry available",
"operational": "Operational",
"prepared": "Prepared",
"demoMode": "Demo mode",
"unavailable": "Unavailable"
},
"ledger": {
"title": "Automation jobs",
"description": "Persisted automation attempts with the latest failure evidence.",
"filterLabel": "View",
"filterNeedsAttention": "Needs attention",
"filterRecent": "Recent",
"filterSucceeded": "Succeeded",
"filterAll": "All",
"statusFilterLabel": "Status",
"statusAll": "All statuses",
"statusPending": "Pending",
"statusDelivering": "Delivering",
"statusSucceeded": "Succeeded",
"statusFailed": "Failed",
"unavailable": "Automation jobs are unavailable right now.",
"managerOnly": "Automation is visible to Operations Managers only.",
"loading": "Loading automation jobs…",
"empty": "No automation jobs match this filter.",
"count": "{{count}} workflow events",
"boundedRetries": "Bounded retries",
"groupedSucceeded": "{{count}} succeeded vehicle-return jobs",
"showIndividually": "Show individual jobs",
"hideIndividually": "Hide individual jobs",
"columns": {
"event": "Job",
"type": "Type",
"booking": "Booking",
"status": "Status",
"attempts": "Attempts",
"lastError": "Last error",
"when": "When",
"action": "Action"
},
"retry": "Retry",
"retrying": "Retrying…",
"retryFailed": "Could not retry this delivery.",
"noAction": "—",
"eventTypes": {
"vehicle.returned.v1": "Vehicle return processed"
}
}
}
@@ -0,0 +1,41 @@
{
"eyebrow": "Assure / Grounded knowledge",
"title": "Procedure knowledge",
"description": "Ask operational questions. Answers are shown only when {{provider}} returns sufficient cited evidence.",
"providerDemo": "the demo knowledge base",
"providerRagcore": "RAGcore",
"statusAvailable": "Available",
"statusUnavailable": "Unavailable",
"proceduresIndexed": "{{count}} procedures indexed",
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
"askHeading": "Ask a procedure question",
"askSubheading": "Retrieval → evidence check → grounded answer",
"questionLabel": "Question",
"questionPlaceholder": "e.g. What must I do when a vehicle returns with damage?",
"ask": "Ask",
"asking": "Asking…",
"askFailed": "Could not reach the knowledge service.",
"suggestedLabel": "Try one:",
"suggestedQuestions": [
"What must I do when a vehicle returns with damage?",
"How do I register a vehicle return?",
"When may a vehicle be made available again?",
"Who reviews an unusual odometer reading?",
"Which checks are required before checkout?"
],
"emptyTitle": "Evidence before answers",
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. Fleet Ops will not invent an answer when evidence is missing.",
"retrievalFlow": {
"question": "Question",
"sources": "Sources",
"answer": "Answer"
},
"questionLabelExchange": "Question",
"evidenceStates": {
"grounded": "Grounded in cited procedures",
"insufficient": "Insufficient evidence",
"unavailable": "Knowledge service unavailable"
},
"unavailableBody": "The knowledge service is currently unreachable. Operational features are unaffected — try again later.",
"sourceVersion": "v{{version}}"
}
@@ -0,0 +1,39 @@
{
"skipToContent": "Skip to main content",
"groups": {
"operate": "Operate",
"assure": "Assure"
},
"items": {
"overview": "Overview",
"fleet": "Fleet",
"bookings": "Bookings",
"quality": "Data quality",
"knowledge": "Knowledge",
"integrations": "Integrations",
"audit": "Audit trail"
},
"primaryNavLabel": "Primary navigation",
"mobileNavLabel": "Mobile navigation",
"more": "More",
"openNavigation": "Open navigation",
"closeNavigation": "Close navigation",
"sidebarEnvironment": "Demo environment",
"sidebarEnvironmentDetail": "Synthetic data only",
"resetDemoData": "Reset demo data",
"resetConfirmTitle": "Confirm demo reset",
"resetConfirmBody": "All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.",
"resetting": "Resetting…",
"resetConfirmYes": "Yes, reset",
"resetCancel": "Cancel",
"resetFailed": "Could not reset demo data.",
"searchLabel": "Search Fleet Ops",
"searchPlaceholder": "Search fleet, booking or section…",
"searchShortcutHint": "Ctrl K",
"searchSearching": "Searching…",
"searchUnavailable": "Search is unavailable right now.",
"searchNoResults": "No matches for \"{{query}}\".",
"switchRole": "Switch role",
"switchRoleTitle": "Switch demo role",
"languageSwitcherLabel": "Change language"
}
@@ -0,0 +1,192 @@
{
"list": {
"eyebrow": "Assure / Workbench",
"title": "Data quality",
"description": "Resolve evidence-backed exceptions before they disrupt operations.",
"runScan": "Run quality scan",
"confirmScanTitle": "Confirm quality scan",
"confirmScanBody": "Run the deterministic scan across all five rule types now?",
"scanning": "Scanning…",
"confirmScanYes": "Yes, run scan",
"cancel": "Cancel",
"scanComplete": "Scan complete: {{summary}}",
"scanNoNew": "no new issues found (existing open issues are not recreated).",
"scanFailed": "Could not run the quality scan.",
"statusLabel": "Status",
"statusAll": "All statuses",
"statusOpen": "Open",
"statusDeferred": "Deferred",
"statusResolved": "Resolved",
"statusRejected": "Rejected",
"ruleTypeLabel": "Rule type",
"ruleTypeAll": "All rule types",
"demoScenariosOnly": "Demo scenarios only",
"loading": "Loading quality workbench…",
"queueClear": "Queue is clear",
"noIssuesMatch": "No issues match the current filters.",
"noDemoIssuesMatch": "No demo-scenario issues match",
"noDemoIssuesMatchDetail": "Uncheck 'Demo scenarios only' to see the full queue.",
"unavailable": "Data-quality issues are unavailable right now.",
"count": "{{count}} issues",
"evidenceBacked": "Evidence-backed detection",
"columns": {
"reference": "Reference",
"rule": "Rule",
"entity": "Entity",
"severity": "Severity",
"status": "Status"
}
},
"ruleTypes": {
"possible_duplicate_customer": "Possible duplicate customer",
"missing_required_field": "Missing required field",
"odometer_regression": "Odometer regression",
"booking_overlap": "Booking overlap",
"vehicle_status_conflict": "Vehicle status conflict"
},
"severities": {
"high": "Critical",
"medium": "Warning",
"low": "Info"
},
"detail": {
"backLink": "Quality workbench",
"eyebrow": "Quality / {{rule}}",
"title": "Review persisted evidence and record an audited resolution.",
"notFound": "This issue could not be found.",
"loading": "Loading issue evidence…",
"managerOnly": "The quality workbench is visible to Operations Managers only.",
"managerOnlyDetail": "Data-quality evidence and resolutions are visible to Operations Managers only.",
"summary": {
"rule": "Rule",
"entity": "Entity",
"evidenceSummary": "Evidence summary"
},
"resolved": {
"title": "Issue {{ref}} resolved",
"body": "The change has been applied and is recorded in the audit trail.",
"viewAudit": "View audit trail",
"viewVehicle": "View vehicle",
"continueDemo": "Continue the demo"
},
"deferOrReject": {
"heading": "Defer or reject",
"description": "Defer to review later, or reject if this is not a real issue.",
"defer": "Defer",
"reject": "Reject",
"deferFailed": "Could not defer this issue.",
"rejectFailed": "Could not reject this issue."
},
"explainer": {
"whatIsWrong": "What's wrong",
"whyItMatters": "Why it matters",
"possible_duplicate_customer": {
"whatIsWrong": "Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.",
"whyItMatters": "Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations."
},
"missing_required_field": {
"whatIsWrong": "This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).",
"whyItMatters": "Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks."
},
"odometer_regression": {
"whatIsWrong": "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.",
"whyItMatters": "A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history."
},
"booking_overlap": {
"whatIsWrong": "The same vehicle is committed to two bookings whose date ranges overlap.",
"whyItMatters": "Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else."
},
"vehicle_status_conflict": {
"whatIsWrong": "This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.",
"whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet."
}
},
"duplicateCustomer": {
"heading": "Compare and merge",
"description": "Choose the canonical customer and review each conflicting field.",
"keepAsSurvivor": "Keep as survivor",
"differs": "Differs",
"match": "Match",
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
"mergeInto": "Merge into {{ref}}",
"confirmMergeTitle": "Confirm merge",
"confirmMergeBody": "Merge {{loser}} into {{survivor}}? This cannot be undone.",
"merging": "Merging…",
"confirmMergeYes": "Yes, merge",
"mergeFailed": "Could not merge these customers.",
"bothMissing": "Both customers in this comparison could not be loaded.",
"fieldColumn": "Field",
"fields": {
"first_name": "First name",
"last_name": "Last name",
"email": "Email",
"phone": "Phone",
"postal_code": "Postal code",
"city": "City"
}
},
"missingField": {
"heading": "Provide the missing fields",
"description": "Complete the record for {{ref}}. The issue resolves automatically once nothing required is missing.",
"atLeastOne": "At least one of email or phone is required.",
"saveAndRecheck": "Save and re-check",
"saving": "Saving…",
"saveFailed": "Could not save these fields.",
"fields": {
"first_name": "First name",
"last_name": "Last name",
"email": "Email",
"phone": "Phone",
"registration_number": "Registration number",
"make": "Make",
"model": "Model",
"location": "Location"
}
},
"odometerRegression": {
"heading": "Resolve the odometer regression",
"description": "The canonical odometer is never lowered automatically -- choose how to reconcile it.",
"canonicalOdometer": "Canonical odometer",
"decisionLegend": "Decision",
"retainCanonical": "Retain canonical reading",
"retainCanonicalDetail": "Treat the submitted reading as erroneous; nothing changes on the vehicle record.",
"correctReading": "Correct the reading",
"correctReadingDetail": "Update both the booking and the canonical odometer with the correct value.",
"noBookingAttached": "No related booking is attached to this issue, so only 'retain canonical' is available.",
"booking": "Booking",
"correctedOdometer": "Corrected odometer (km)",
"note": "Note",
"resolving": "Resolving…",
"resolveIssue": "Resolve issue",
"resolveFailed": "Could not resolve this issue."
},
"bookingOverlap": {
"heading": "Resolve the booking overlap",
"description": "Block one of the two overlapping commitments. The other keeps its current status.",
"columns": {
"booking": "Booking",
"window": "Window",
"status": "Status",
"blockThis": "Block this one"
},
"blockLabel": "Block {{ref}}",
"note": "Note",
"resolving": "Resolving…",
"blockButton": "Block {{ref}}",
"blockButtonFallback": "Block booking",
"resolveFailed": "Could not resolve this overlap."
},
"vehicleStatusConflict": {
"heading": "Resolve the status conflict",
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
"currentStatus": "Current status",
"calculateAndApply": "Calculate and apply recommended status",
"confirmTitle": "Confirm status change",
"confirmBody": "Apply the authoritative recommended status for this vehicle?",
"applying": "Applying…",
"confirmYes": "Yes, apply",
"applied": "Applied {{status}} — {{reason}}",
"applyFailed": "Could not apply a recommended status."
}
}
}
@@ -0,0 +1,73 @@
{
"progress": {
"capture": "Capture",
"review": "Review",
"result": "Result",
"ariaLabel": "Return registration progress"
},
"capture": {
"heading": "Register vehicle return",
"description": "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed.",
"endOdometer": "End odometer (km)",
"fuelLevel": "Fuel level (%)",
"conditionLegend": "Vehicle condition",
"cleanlinessOk": "Cleanliness acceptable",
"damageReported": "Damage reported",
"technicalWarning": "Technical warning",
"notes": "Notes",
"evaluating": "Evaluating…",
"reviewReturn": "Review return"
},
"review": {
"heading": "Review return impact",
"description": "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation.",
"odometer": "Odometer",
"fuel": "Fuel",
"cleanliness": "Cleanliness",
"cleanlinessAccepted": "Accepted",
"cleanlinessFollowUp": "Follow-up needed",
"damage": "Damage",
"damageReported": "Reported",
"damageNone": "None reported",
"technicalWarning": "Technical warning",
"technicalWarningReported": "Reported",
"technicalWarningNone": "None reported",
"expectedState": "Expected fleet state:",
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
"commitList": {
"inspection": "Create a return inspection",
"updateAtomic": "Update the booking and vehicle atomically",
"queueAutomation": "Queue automation after the local commit"
},
"editDetails": "Edit details",
"registering": "Registering…",
"confirmReturn": "Confirm return"
},
"result": {
"committedLocally": "Committed locally",
"heading": "Return registered",
"inspection": "Inspection",
"resultingStatus": "Resulting vehicle status",
"qualityIssue": "Quality issue",
"noneCreated": "None created",
"automationEvent": "Automation job",
"queuedForDelivery": "Queued for delivery ({{ref}}) — local commit succeeded; automation delivery is asynchronous and not yet confirmed.",
"nextBookingRisk": "Next booking risk",
"nextBookingRiskValue": "{{ref}} {{status}}",
"atRisk": "— may be affected",
"lowRisk": "— low risk",
"noUpcomingBooking": "No upcoming booking for this vehicle",
"odometerRegressionNotice": "The submitted odometer reading was below the vehicle's canonical odometer. It was recorded as-is; the canonical odometer was not changed, and a data-quality issue was opened for review.",
"viewVehicle": "View vehicle {{ref}}",
"viewAutomation": "View automation status",
"viewAudit": "View audit trail",
"continueDemo": "Continue the demo"
},
"scenario": {
"title": "Demo scenario: odometer anomaly",
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how Fleet Ops detects and handles this.",
"preparing": "Preparing scenario…"
}
}
@@ -0,0 +1,7 @@
{
"openRow": "Ouvrir : {{label}}",
"reducedMotion": "Mouvement réduit actif",
"closeDialog": "Fermer la boîte de dialogue",
"expandSection": "Déplier la section",
"collapseSection": "Replier la section"
}
@@ -0,0 +1,64 @@
{
"eyebrow": "Assurance / Historique immuable",
"title": "Piste d'audit",
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
"actionFilterLabel": "Action",
"actionFilterPlaceholder": "p. ex. demo_login",
"managerOnly": "La piste d'audit est visible uniquement pour les Operations Managers.",
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Operations Managers.",
"loading": "Chargement de la piste d'audit…",
"unavailable": "La piste d'audit est actuellement indisponible.",
"empty": "Aucun événement d'audit trouvé",
"emptyDetail": "Ajustez le filtre d'action.",
"relatedFilterActive": "Affichage uniquement des événements liés à cette action ({{count}} événements liés).",
"clearFilter": "Effacer ce filtre",
"count": "{{count}} événements",
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
"columns": {
"when": "Quand",
"actor": "Acteur",
"action": "Action",
"entity": "Entité",
"change": "Changement",
"followUp": "Suite",
"details": "Détails"
},
"viewRelatedEvents": "Voir les événements liés",
"noChangeDetail": "Aucun détail de changement enregistré.",
"noFieldChange": "Aucun changement au niveau des champs détecté.",
"reference": "Référence",
"relatedEventsCount": "{{count}} événement lié",
"relatedEventsCount_other": "{{count}} événements liés",
"showTechnicalEvents": "Afficher {{count}} événement technique",
"showTechnicalEvents_other": "Afficher {{count}} événements techniques",
"hideTechnicalEvents": "Masquer les événements techniques",
"technicalDetails": "Détails techniques",
"fullReference": "Référence complète",
"correlationId": "ID de corrélation",
"diff": {
"setTo": "{{field}} : défini à {{value}}",
"was": "{{field}} : était {{value}}",
"changed": "{{field}} : {{before}} → {{after}}"
},
"actions": {
"demo_login": "Connecté",
"demo_logout": "Déconnecté",
"demo_reset": "Données de démo réinitialisées",
"demo_data_seeded": "Données de démo chargées",
"return_registered": "Retour de véhicule enregistré",
"vehicle_status_changed": "Statut du véhicule modifié",
"data_quality_issue_resolved": "Problème de qualité résolu",
"data_quality_issue_deferred": "Problème de qualité reporté",
"data_quality_issue_rejected": "Problème de qualité rejeté",
"data_quality_fields_provided": "Champs manquants complétés",
"data_quality_odometer_corrected": "Kilométrage corrigé",
"data_quality_odometer_retained": "Kilométrage de référence conservé",
"data_quality_status_applied": "Statut recommandé appliqué",
"data_quality_booking_blocked": "Réservation bloquée",
"data_quality_scan_run": "Contrôle qualité exécuté",
"customer_merged": "Clients fusionnés",
"workflow_retry": "Automatisation retentée",
"knowledge_question_asked": "Question de connaissance posée",
"mcp_tool_request": "Outil MCP appelé"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"brandTagline": "Centre de contrôle",
"orgLine": "Organisation de démo : {{orgName}} (fictive)",
"headline1": "Chaque transfert.",
"headline2": "Une vue claire.",
"defaultDescription": "Fleet Ops rassemble les données de véhicules, de réservations et d'exploitation, soutient les processus de location, détecte les problèmes de qualité des données et automatise les suites contrôlées.",
"footnote": "Démo synthétique · aucune donnée client ou véhicule réelle · réinitialisable à tout moment",
"accessEyebrow": "Accès démo",
"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.",
"startGuidedDemo": "Démarrer la démo guidée",
"exploreAsOperationsManager": "Explorer en tant qu'Operations Manager",
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
"exploreAsRentalEmployee": "Explorer en tant que Rental Employee",
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
"safeByDesignTitle": "Conçu pour la sécurité",
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
"roleOperationsManager": "Operations manager",
"roleRentalEmployee": "Rental employee",
"switchRole": "Changer de rôle",
"logout": "Déconnexion"
}
@@ -0,0 +1,52 @@
{
"list": {
"eyebrow": "Exploitation / Planning",
"title": "Réservations",
"description": "Consultez les périodes de location actives et les engagements de véhicules à venir.",
"searchLabel": "Rechercher",
"searchPlaceholder": "Réservation, client ou véhicule",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"loading": "Chargement du registre des réservations…",
"empty": "Aucune réservation trouvée",
"emptyDetail": "Ajustez le filtre de statut des réservations.",
"noMatch": "Aucune réservation correspondante",
"noMatchDetail": "Essayez un terme de recherche plus large.",
"unavailable": "La liste des réservations est actuellement indisponible.",
"count": "{{count}} réservations",
"pageOf": "Page {{page}} sur {{total}}",
"columns": {
"reference": "Référence",
"customer": "Client",
"vehicle": "Véhicule",
"window": "Période",
"status": "Statut"
},
"paginationLabel": "Pages de réservations",
"previous": "Précédent",
"next": "Suivant",
"rangeOf": "{{from}}{{to}} sur {{total}}"
},
"statuses": {
"reserved": "réservé",
"active": "actif",
"returned": "retourné",
"cancelled": "annulé",
"blocked": "bloqué"
},
"detail": {
"backLink": "Registre des réservations",
"eyebrow": "Réservations / Fiche de location",
"notFound": "Cette réservation est introuvable.",
"loading": "Chargement de la fiche de réservation…",
"customer": "Client",
"vehicle": "Véhicule",
"starts": "Début",
"ends": "Fin",
"startOdometer": "Kilométrage de départ",
"endOdometer": "Kilométrage de retour",
"requirementsComplete": "Exigences complètes",
"yes": "Oui",
"no": "Non"
}
}
@@ -0,0 +1,44 @@
{
"appName": "Fleet Ops",
"orgName": "Northstar Mobility",
"brandTagline": "Centre de contrôle",
"actions": {
"save": "Enregistrer",
"cancel": "Annuler",
"confirm": "Confirmer",
"close": "Fermer",
"back": "Retour",
"next": "Suivant",
"retry": "Réessayer",
"reset": "Réinitialiser",
"yes": "Oui",
"no": "Non",
"viewDetails": "Voir les détails",
"technicalDetails": "Détails techniques"
},
"status": {
"loading": "Chargement…",
"saving": "Enregistrement…",
"error": "Une erreur est survenue",
"success": "Réussi",
"noResults": "Aucun résultat"
},
"states": {
"loadingDefault": "Chargement de l'espace de travail…",
"errorTitle": "Impossible de charger cet espace de travail."
},
"timezone": "Europe/Bruxelles",
"language": {
"label": "Langue",
"nl-BE": "Nederlands",
"en-GB": "English",
"fr-BE": "Français"
},
"footer": {
"productLine": "Fleet Ops Demo",
"locale": "Europe/Bruxelles · Données de démonstration synthétiques"
},
"demo": {
"syntheticBadge": "Démo synthétique"
}
}
@@ -0,0 +1,67 @@
{
"eyebrow": "Exploitation / Aperçu en direct",
"title": "Bonjour. Voici votre flotte.",
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
"viewFleet": "Voir la flotte",
"demoStart": {
"title": "Essayer un scénario de démonstration",
"readyCount": "{{ready}} scénarios sur {{total}} prêts pour la démo.",
"readyCountFallback": "Cinq scénarios ciblés.",
"startGuide": "Démarrer le guide de démo",
"resumeGuide": "Poursuivre le guide de démo ({{current}}/{{total}})",
"viewScenarios": "Voir les scénarios"
},
"readiness": {
"title": "Disponibilité de la flotte",
"description": "En direct depuis l'état persistant des véhicules",
"openFleet": "Ouvrir la flotte",
"available": "Disponible",
"rented": "Loué",
"cleaning": "Nettoyage",
"maintenance": "Entretien",
"blocked": "Bloqué"
},
"attention": {
"title": "File d'attention",
"description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow",
"reviewQueue": "Examiner la file",
"filterPlaceholder": "Filtrer les problèmes…",
"filterAriaLabel": "Rechercher dans la file d'attention",
"severityAll": "Toute gravité",
"severityHigh": "Critique",
"severityMedium": "Avertissement",
"severityLow": "Info",
"severityAriaLabel": "Gravité",
"empty": "Aucun problème ne correspond à ce filtre.",
"openRecord": "Ouvrir {{title}}"
},
"movements": {
"title": "Mouvements du jour",
"description": "Départs et retours en Europe/Bruxelles",
"allBookings": "Toutes les réservations",
"empty": "Aucun mouvement prévu aujourd'hui.",
"departure": "départ",
"return": "retour",
"openBooking": "Ouvrir la réservation {{ref}}"
},
"integrationPulse": {
"title": "État des intégrations",
"description": "Preuves actuelles des services connectés",
"systemDetail": "Détail du système",
"n8nTitle": "Livraison n8n",
"n8nSummary": "{{succeeded}} réussies · {{failed}} échouées",
"n8nLatest": "Dernier événement {{ref}}",
"n8nNoEvidence": "Aucune preuve d'automatisation enregistrée",
"knowledgeTitle": "Assistant de connaissances",
"knowledgeSummary": "{{count}} procédures indexées",
"knowledgeUnavailable": "Vérification de santé indisponible",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Enregistrement activé",
"mcpNotConnected": "Pas encore connecté"
},
"recent": {
"title": "Activité récente",
"description": "Derniers changements de workflow audités",
"empty": "Aucune activité d'automatisation enregistrée."
}
}
+189
View File
@@ -0,0 +1,189 @@
{
"badge": {
"trigger": "Démo synthétique",
"dialogLabel": "À propos de cet environnement de démo",
"close": "Fermer",
"orgIntro": "<strong>{{orgName}}</strong> est une organisation fictive. Tous les noms, véhicules et réservations sont synthétiques.",
"orgIntroFallback": "Tous les noms, véhicules et réservations de cet environnement sont synthétiques.",
"realWorkflows": "Les workflows, contrôles et l'automatisation sont réellement implémentés — seules les données sont inventées.",
"lastReset": "Dernière réinitialisation : <strong>{{when}}</strong> · cet environnement peut être réinitialisé à tout moment.",
"aboutLink": "À propos de cette démo",
"unknown": "inconnu"
},
"guide": {
"dialogLabel": "Démo guidée",
"trigger": "Guide de démo",
"kicker": "Démo guidée · étape {{current}} sur {{total}}",
"whatYouWillSee": "Ce que vous allez voir",
"whyItMatters": "Pourquoi c'est important",
"startAction": "Pour commencer",
"expectedOutcome": "Résultat attendu",
"goToStep": "Aller à cette étape",
"next": "Suivant",
"close": "Fermer",
"restart": "Préparer à nouveau la démo",
"restarting": "Réinitialisation…",
"restartFailed": "Impossible de réinitialiser la démo.",
"allStepsLabel": "Toutes les étapes",
"progressChip": "Guide de démo · étape {{current}} sur {{total}}",
"expand": "Déplier le guide de démo",
"collapse": "Replier le guide de démo",
"steps": {
"understand-state": {
"title": "1. Comprendre l'état opérationnel",
"whatYouWillSee": "Le tableau de bord affiche la disponibilité de la flotte, les points d'attention ouverts et les mouvements du jour.",
"whyItMatters": "Un Operations Manager commence chaque journée par cet aperçu pour décider où intervenir.",
"startAction": "Ouvrez le tableau de bord et consultez la file d'attention et la chronologie du jour.",
"expectedOutcome": "Vous voyez quelles réservations, véhicules ou problèmes de qualité nécessitent de l'attention."
},
"open-booking": {
"title": "2. Ouvrir la réservation nécessitant de l'attention",
"whatYouWillSee": "La réservation BK-DEMO-RETURN, active et à retourner aujourd'hui.",
"whyItMatters": "Les retours sont le moment où les kilométrages incorrects ou les dommages deviennent visibles pour la première fois.",
"startAction": "Ouvrez la réservation depuis le tableau de bord ou la liste des réservations.",
"expectedOutcome": "Vous voyez les détails de la réservation et le bouton pour traiter le retour."
},
"process-return": {
"title": "3. Traiter un retour avec une anomalie de kilométrage",
"whatYouWillSee": "Un formulaire de retour pré-rempli avec un relevé de kilométrage suspect, inférieur au dernier relevé connu.",
"whyItMatters": "Un kilométrage en baisse signale une erreur de saisie ou un véhicule confondu — cela doit être détecté avant la libération.",
"startAction": "Soumettez le retour avec la valeur suggérée et examinez l'aperçu du serveur avant de confirmer.",
"expectedOutcome": "Le retour est traité, le véhicule reçoit un statut approprié, et un problème de qualité des données est créé automatiquement."
},
"handle-quality-issue": {
"title": "4. Traiter le problème de qualité créé",
"whatYouWillSee": "Un nouveau problème « anomalie de kilométrage » en haut de la file de travail.",
"whyItMatters": "Chaque problème détecté a une étape de résolution unique et délimitée — rien n'est corrigé automatiquement en silence.",
"startAction": "Ouvrez l'aperçu qualité des données et choisissez le problème le plus récent.",
"expectedOutcome": "Vous voyez l'action recommandée, choisissez une résolution, et le problème se résout avec une piste d'audit."
},
"merge-duplicate": {
"title": "5. Examiner et traiter un client potentiellement en double",
"whatYouWillSee": "Deux profils clients avec le même e-mail et numéro de téléphone, comparés côte à côte.",
"whyItMatters": "Les clients en double divisent l'historique de réservation et compliquent la communication.",
"startAction": "Comparez les deux profils et choisissez celui qui doit survivre.",
"expectedOutcome": "Les profils sont fusionnés, les réservations sont transférées, et le profil perdant devient une fiche archivée."
},
"ask-knowledge": {
"title": "6. Poser une question à l'assistant de procédures",
"whatYouWillSee": "Une réponse avec citation issue de la base de connaissances de démo délimitée.",
"whyItMatters": "Le personnel a besoin d'une réponse rapide et étayée sur les procédures, sans deviner.",
"startAction": "Cliquez sur l'une des questions suggérées, dans la langue actuelle de l'interface.",
"expectedOutcome": "Vous voyez la réponse, la procédure utilisée et le texte source — ou un honnête « informations insuffisantes » si aucune n'existe."
},
"check-automation-audit": {
"title": "7. Vérifier l'automatisation et la piste d'audit",
"whatYouWillSee": "Le statut de livraison de la tâche d'automatisation de votre retour, et ses événements d'audit liés.",
"whyItMatters": "Chaque action importante doit être traçable : qui a fait quoi, quand, et ce qui a suivi.",
"startAction": "Ouvrez Intégrations pour voir le statut de livraison, et Piste d'audit pour l'historique complet.",
"expectedOutcome": "Vous voyez une livraison réussie (ou récupérable) et une piste d'audit lisible de vos actions."
},
"review-real-vs-simulated": {
"title": "8. Passer en revue ce qui est réel, simulé ou pas encore connecté",
"whatYouWillSee": "Un aperçu de ce qui est fonctionnellement implémenté dans cette démo, ce qui est synthétique, et quelles intégrations ne sont pas encore en direct.",
"whyItMatters": "Une démo n'est convaincante que si les visiteurs peuvent vérifier eux-mêmes ce qui fonctionne réellement et ce qui reste à venir.",
"startAction": "Lisez la page « À propos de cette démo ».",
"expectedOutcome": "Vous pouvez expliquer vous-même ce que Fleet Ops est et n'est pas, sans explication verbale nécessaire."
}
}
},
"scenarios": {
"eyebrow": "Scénarios de démonstration",
"title": "Essayer un scénario de démonstration",
"description": "Cinq scénarios ciblés qui utilisent toujours les mêmes réservations, clients et véhicules fixes — toujours retrouvables après une réinitialisation.",
"loading": "Chargement des scénarios…",
"ready": "Prêt pour la démo",
"notReady": "Non disponible",
"duration": "Durée",
"durationValue": "± {{minutes}} min",
"role": "Rôle",
"demonstrates": "Démontre :",
"requiresRole": "Nécessite le rôle : {{roles}}.",
"startScenario": "Démarrer le scénario",
"roleOr": "{{a}} ou {{b}}",
"roles": {
"operations_manager": "Operations Manager",
"rental_employee": "Rental Employee"
},
"items": {
"return-anomaly": {
"title": "Retour avec anomalie de kilométrage",
"problem": "Un véhicule revient avec un relevé de kilométrage inférieur au dernier relevé enregistré — un signe d'erreur de saisie ou de véhicule confondu.",
"demonstrates": "Le traitement du retour, la détection automatique de problèmes de qualité des données et la piste d'audit qui en résulte."
},
"duplicate-customer": {
"title": "Fusionner un possible client en double",
"problem": "Deux fiches client partagent la même adresse e-mail et le même numéro de téléphone — probablement la même personne, enregistrée deux fois.",
"demonstrates": "La fusion de clients avec conservation de l'historique des réservations et de la piste d'audit."
},
"booking-overlap": {
"title": "Résoudre des réservations qui se chevauchent",
"problem": "Un véhicule est réservé en double pour des périodes qui se chevauchent — une erreur de planification à résoudre avant le départ.",
"demonstrates": "La détection et la résolution contrôlée des conflits de planification."
},
"automation-retry": {
"title": "Relancer une tâche d'automatisation échouée",
"problem": "Un événement précédent n'a pas pu être livré à l'automatisation en raison d'une erreur de connexion simulée.",
"demonstrates": "Une livraison fiable avec des nouvelles tentatives limitées et un statut d'échec visible."
},
"knowledge-question": {
"title": "Poser une question de procédure",
"problem": "Un employé n'est pas sûr de la procédure applicable à une situation opérationnelle précise.",
"demonstrates": "Des réponses étayées par les sources, issues d'une base de connaissances de démo délimitée."
}
},
"blockedReasons": {
"bookingNotFound": "Réservation de démo BK-DEMO-RETURN introuvable. {{resetHint}}",
"bookingAlreadyProcessed": "Cette réservation a déjà été traitée depuis la dernière réinitialisation. {{resetHint}}",
"duplicateIssueNotFound": "Problème de démo DQ-DEMO-DUPLICATE introuvable. {{resetHint}}",
"issueAlreadyResolved": "Ce problème a déjà été résolu depuis la dernière réinitialisation. {{resetHint}}",
"overlapIssueNotFound": "Problème de démo DQ-DEMO-OVERLAP introuvable. {{resetHint}}",
"failedEventNotFound": "L'événement simulé en échec est introuvable. {{resetHint}}",
"eventAlreadyRecovered": "Cet événement a déjà été rétabli depuis la dernière réinitialisation. {{resetHint}}",
"knowledgeUnavailable": "La base de connaissances de démo est actuellement indisponible.",
"resetHint": "Réinitialisez les données de démo pour rendre ce scénario à nouveau disponible."
}
},
"about": {
"eyebrow": "À propos de cette démo",
"title": "Ce que Fleet Ops 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.",
"loading": "Chargement des informations de démo…",
"ctaTitle": "Vous préférez vous lancer directement ?",
"ctaBody": "La démo guidée parcourt les huit étapes ci-dessus en pratique.",
"ctaButton": "Démarrer la démo guidée",
"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. Fleet Ops 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",
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment Fleet Ops traite les problèmes opérationnels d'un petit loueur : Operations Managers et Rental Employees, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
"realTitle": "Ce qui fonctionne réellement",
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
"syntheticTitle": "Ce qui est synthétique",
"syntheticBody": "L'organisation, tous les clients, véhicules, réservations, historiques d'entretien, procédures de la base de connaissances et les scénarios préconfigurés sont entièrement inventés. Aucune donnée ne fait référence à une personne, un véhicule ou une entreprise réels ; les adresses e-mail utilisent uniquement le domaine de test {{testDomain}}.",
"architectureTitle": "Architecture en bref",
"architectureBody": "Un frontend React/TypeScript communique avec un backend FastAPI (PostgreSQL via des migrations SQLAlchemy/Alembic) ; les règles métier importantes vivent dans le backend, pas dans n8n ni dans des prompts. Les retours et autres événements sont d'abord validés localement, puis livrés de façon asynchrone à n8n via une tâche d'automatisation, de sorte qu'une panne temporaire de l'automatisation ne bloque jamais une action opérationnelle.",
"securityTitle": "Sécurité et accès",
"securityBody": "L'accès passe par des cookies de session signés, HTTP-only, par rôle ; chaque rôle est lié à un ensemble de routes autorisées, appliqué côté serveur et reflété dans la navigation. Les changements d'état importants sont toujours vérifiés et enregistrés — jamais corrigés automatiquement en silence.",
"testingTitle": "Comment cela est testé",
"testingBody": "Une suite de tests backend automatisée couvre les règles métier et les contrats d'API ; une suite Playwright de bout en bout complète couvre les parcours utilisateurs, y compris cette expérience de démo elle-même en trois langues. Chaque modification est également validée sur une installation propre (base de données vide, reconstruite à partir des données initiales) avant le déploiement.",
"integrationsTitle": "Intégrations — étiquetées honnêtement",
"integrationsDescription": "Ce qui est opérationnel, ce qui est en mode démo, et ce qui n'est pas encore connecté.",
"resetTitle": "Restaurer l'environnement de démo",
"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 Operations Manager peut réinitialiser l'environnement de démo via la barre latérale.",
"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.",
"unknown": "inconnu"
},
"integrationSummary": {
"titles": {
"n8n": "Automatisation (n8n)",
"ragcore": "Assistant de connaissances (RAGcore)",
"mcp_hub": "ITWorx MCP Hub"
},
"n8nDetail": "{{succeeded}} réussi(s) · {{failed}} échoué(s) · {{pending}} en attente.",
"ragcoreDetail": "{{count}} procédures indexées dans {{collection}}.",
"mcpDetailEnabled": "Préparé pour de futurs appels d'outils contrôlés depuis le Hub.",
"mcpDetailNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub."
}
}
@@ -0,0 +1,8 @@
{
"generic": "Une erreur est survenue. Veuillez réessayer.",
"workspaceLoadFailed": "Nous n'avons pas pu charger cet espace de travail.",
"unauthorized": "Votre session a expiré. Veuillez vous reconnecter.",
"forbidden": "Vous n'avez pas accès à cette section.",
"notFound": "Cette fiche est introuvable.",
"networkUnavailable": "La connexion au serveur est actuellement indisponible."
}
@@ -0,0 +1,68 @@
{
"list": {
"eyebrow": "Flotte / Registre",
"title": "Flotte de véhicules",
"description": "État opérationnel en direct, localisation et disponibilité pour l'entretien.",
"searchLabel": "Rechercher",
"searchPlaceholder": "Référence, marque ou localisation",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"attentionOnly": "Attention uniquement",
"loading": "Chargement du registre de la flotte…",
"empty": "Aucun véhicule trouvé",
"emptyDetail": "Ajustez les filtres de flotte actuels.",
"noMatch": "Aucun véhicule correspondant",
"noMatchDetail": "Essayez un terme de recherche plus large.",
"unavailable": "La liste des véhicules est actuellement indisponible.",
"count": "{{count}} véhicules",
"persisted": "Données de flotte persistantes",
"columns": {
"reference": "Référence",
"makeModel": "Marque / modèle",
"location": "Localisation",
"status": "Statut",
"odometer": "Kilométrage (km)",
"attention": "Attention"
},
"needsAttention": "Nécessite de l'attention"
},
"statuses": {
"available": "disponible",
"rented": "loué",
"cleaning": "nettoyage",
"maintenance": "entretien",
"blocked": "bloqué"
},
"detail": {
"backLink": "Registre de la flotte",
"eyebrow": "Flotte / Fiche véhicule",
"notFound": "Ce véhicule est introuvable.",
"loading": "Chargement de la fiche véhicule…",
"needsAttention": "Nécessite de l'attention",
"tabs": {
"overview": "Aperçu",
"bookings": "Réservations",
"inspections": "Inspections",
"maintenance": "Entretien",
"quality": "Qualité"
},
"tabsAriaLabel": "Sections du véhicule",
"overview": {
"registration": "Immatriculation",
"modelYear": "Année du modèle",
"location": "Localisation",
"odometer": "Kilométrage",
"nextService": "Prochain entretien",
"active": "Actif"
},
"yes": "Oui",
"no": "Non",
"noBookings": "Aucune réservation enregistrée.",
"noInspections": "Aucune inspection enregistrée.",
"noMaintenance": "Aucun historique d'entretien.",
"noQualityIssues": "Aucun problème de qualité enregistré.",
"fuel": "Carburant {{percent}}%",
"damage": "Dommage",
"technicalWarning": "Avertissement technique"
}
}
@@ -0,0 +1,70 @@
{
"eyebrow": "Assurance / Intégrations",
"title": "Contrôle des intégrations",
"description": "Surveillez la santé des livraisons, la dégradation contrôlée et les événements de workflow réessayables.",
"cards": {
"orchestrationKicker": "Orchestration",
"n8nTitle": "Livraison n8n",
"n8nSummary": "{{succeeded}} réussies · {{failed}} échouées · {{pending}} en attente · {{delivering}} en cours",
"n8nFallback": "Les événements de retour sont d'abord validés localement, puis livrés via la tâche d'automatisation.",
"knowledgeKicker": "Connaissances",
"knowledgeTitle": "Assistant de connaissances",
"knowledgeSummaryDemo": "Base de connaissances de démo · {{count}} procédures indexées dans {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procédures indexées dans {{collection}}.",
"knowledgeUnavailable": "Preuves de santé actuellement indisponibles.",
"gatewayKicker": "Passerelle d'outils",
"mcpTitle": "MCP Hub",
"mcpEnabled": "L'enregistrement est activé pour ce déploiement.",
"mcpNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub."
},
"statusLabels": {
"notConnected": "Non connecté",
"deliveryFailed": "Livraison échouée",
"retryAvailable": "Nouvelle tentative possible",
"operational": "Opérationnel",
"prepared": "Préparé",
"demoMode": "Mode démo",
"unavailable": "Indisponible"
},
"ledger": {
"title": "Tâches d'automatisation",
"description": "Tentatives d'automatisation enregistrées avec les dernières preuves d'échec.",
"filterLabel": "Vue",
"filterNeedsAttention": "Nécessite de l'attention",
"filterRecent": "Récent",
"filterSucceeded": "Réussi",
"filterAll": "Tout",
"statusFilterLabel": "Statut",
"statusAll": "Tous les statuts",
"statusPending": "En attente",
"statusDelivering": "En cours",
"statusSucceeded": "Réussi",
"statusFailed": "Échoué",
"unavailable": "Les tâches d'automatisation sont actuellement indisponibles.",
"managerOnly": "L'automatisation est visible uniquement pour les Operations Managers.",
"loading": "Chargement des tâches d'automatisation…",
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
"count": "{{count}} événements de workflow",
"boundedRetries": "Nouvelles tentatives limitées",
"groupedSucceeded": "{{count}} retours de véhicules traités avec succès",
"showIndividually": "Afficher les tâches individuelles",
"hideIndividually": "Masquer les tâches individuelles",
"columns": {
"event": "Tâche",
"type": "Type",
"booking": "Réservation",
"status": "Statut",
"attempts": "Tentatives",
"lastError": "Dernière erreur",
"when": "Quand",
"action": "Action"
},
"retry": "Réessayer",
"retrying": "Nouvelle tentative…",
"retryFailed": "Impossible de réessayer cette livraison.",
"noAction": "—",
"eventTypes": {
"vehicle.returned.v1": "Retour de véhicule traité"
}
}
}
@@ -0,0 +1,41 @@
{
"eyebrow": "Assurance / Connaissances étayées",
"title": "Connaissance des procédures",
"description": "Posez des questions opérationnelles. Les réponses ne s'affichent que lorsque {{provider}} renvoie suffisamment de preuves citées.",
"providerDemo": "la base de connaissances de démo",
"providerRagcore": "RAGcore",
"statusAvailable": "Disponible",
"statusUnavailable": "Indisponible",
"proceduresIndexed": "{{count}} procédures indexées",
"providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.",
"askHeading": "Poser une question de procédure",
"askSubheading": "Recherche → vérification des preuves → réponse étayée",
"questionLabel": "Question",
"questionPlaceholder": "p. ex. Que dois-je faire quand un véhicule revient endommagé ?",
"ask": "Demander",
"asking": "Question en cours…",
"askFailed": "Impossible de joindre le service de connaissances.",
"suggestedLabel": "Essayez :",
"suggestedQuestions": [
"Que dois-je faire quand un véhicule revient avec des dommages ?",
"Comment enregistrer le retour d'un véhicule ?",
"Quand un véhicule peut-il redevenir disponible ?",
"Qui examine un relevé de kilométrage inhabituel ?",
"Quels contrôles sont requis avant le départ ?"
],
"emptyTitle": "Des preuves avant les réponses",
"emptyDescription": "Posez des questions sur les retours, les dommages, les inspections ou une autre procédure indexée. Fleet Ops n'invente jamais de réponse en l'absence de preuves.",
"retrievalFlow": {
"question": "Question",
"sources": "Sources",
"answer": "Réponse"
},
"questionLabelExchange": "Question",
"evidenceStates": {
"grounded": "Étayé par des procédures citées",
"insufficient": "Preuves insuffisantes",
"unavailable": "Service de connaissances indisponible"
},
"unavailableBody": "Le service de connaissances est actuellement inaccessible. Les fonctions opérationnelles ne sont pas affectées — réessayez plus tard.",
"sourceVersion": "v{{version}}"
}
@@ -0,0 +1,39 @@
{
"skipToContent": "Aller au contenu principal",
"groups": {
"operate": "Exploiter",
"assure": "Assurer"
},
"items": {
"overview": "Aperçu",
"fleet": "Flotte",
"bookings": "Réservations",
"quality": "Qualité des données",
"knowledge": "Connaissances",
"integrations": "Intégrations",
"audit": "Piste d'audit"
},
"primaryNavLabel": "Navigation principale",
"mobileNavLabel": "Navigation mobile",
"more": "Plus",
"openNavigation": "Ouvrir la navigation",
"closeNavigation": "Fermer la navigation",
"sidebarEnvironment": "Environnement de démo",
"sidebarEnvironmentDetail": "Données synthétiques uniquement",
"resetDemoData": "Réinitialiser les données de démo",
"resetConfirmTitle": "Confirmer la réinitialisation",
"resetConfirmBody": "Toutes les modifications synthétiques seront annulées et les données de démo déterministes restaurées. Vous serez déconnecté.",
"resetting": "Réinitialisation…",
"resetConfirmYes": "Oui, réinitialiser",
"resetCancel": "Annuler",
"resetFailed": "Impossible de réinitialiser les données de démo.",
"searchLabel": "Rechercher dans Fleet Ops",
"searchPlaceholder": "Rechercher flotte, réservation ou section…",
"searchShortcutHint": "Ctrl K",
"searchSearching": "Recherche…",
"searchUnavailable": "La recherche est momentanément indisponible.",
"searchNoResults": "Aucun résultat pour « {{query}} ».",
"switchRole": "Changer de rôle",
"switchRoleTitle": "Changer de rôle de démo",
"languageSwitcherLabel": "Changer de langue"
}
@@ -0,0 +1,192 @@
{
"list": {
"eyebrow": "Assurance / Atelier",
"title": "Qualité des données",
"description": "Résolvez les exceptions étayées par des preuves avant qu'elles ne perturbent l'exploitation.",
"runScan": "Lancer le contrôle qualité",
"confirmScanTitle": "Confirmer le contrôle qualité",
"confirmScanBody": "Lancer maintenant le contrôle déterministe sur les cinq types de règles ?",
"scanning": "Analyse en cours…",
"confirmScanYes": "Oui, lancer",
"cancel": "Annuler",
"scanComplete": "Contrôle terminé : {{summary}}",
"scanNoNew": "aucun nouveau problème trouvé (les problèmes ouverts existants ne sont pas recréés).",
"scanFailed": "Impossible d'exécuter le contrôle qualité.",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"statusOpen": "Ouvert",
"statusDeferred": "Reporté",
"statusResolved": "Résolu",
"statusRejected": "Rejeté",
"ruleTypeLabel": "Type de règle",
"ruleTypeAll": "Tous les types de règles",
"demoScenariosOnly": "Scénarios de démo uniquement",
"loading": "Chargement de l'atelier qualité…",
"queueClear": "La file est vide",
"noIssuesMatch": "Aucun problème ne correspond aux filtres actuels.",
"noDemoIssuesMatch": "Aucun problème de scénario de démo ne correspond",
"noDemoIssuesMatchDetail": "Décochez « Scénarios de démo uniquement » pour voir la file complète.",
"unavailable": "Les problèmes de qualité des données sont actuellement indisponibles.",
"count": "{{count}} problèmes",
"evidenceBacked": "Détection étayée par des preuves",
"columns": {
"reference": "Référence",
"rule": "Règle",
"entity": "Entité",
"severity": "Gravité",
"status": "Statut"
}
},
"ruleTypes": {
"possible_duplicate_customer": "Client peut-être en double",
"missing_required_field": "Champ obligatoire manquant",
"odometer_regression": "Anomalie de kilométrage",
"booking_overlap": "Chevauchement de réservations",
"vehicle_status_conflict": "Conflit de statut du véhicule"
},
"severities": {
"high": "Critique",
"medium": "Avertissement",
"low": "Info"
},
"detail": {
"backLink": "Atelier qualité",
"eyebrow": "Qualité / {{rule}}",
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
"notFound": "Ce problème est introuvable.",
"loading": "Chargement des preuves du problème…",
"managerOnly": "L'atelier qualité est visible uniquement pour les Operations Managers.",
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Operations Managers.",
"summary": {
"rule": "Règle",
"entity": "Entité",
"evidenceSummary": "Résumé des preuves"
},
"resolved": {
"title": "Problème {{ref}} résolu",
"body": "La modification a été appliquée et enregistrée dans la piste d'audit.",
"viewAudit": "Voir la piste d'audit",
"viewVehicle": "Voir le véhicule",
"continueDemo": "Poursuivre la démo"
},
"deferOrReject": {
"heading": "Reporter ou rejeter",
"description": "Reportez pour un examen ultérieur, ou rejetez si ce n'est pas un véritable problème.",
"defer": "Reporter",
"reject": "Rejeter",
"deferFailed": "Impossible de reporter ce problème.",
"rejectFailed": "Impossible de rejeter ce problème."
},
"explainer": {
"whatIsWrong": "Ce qui ne va pas",
"whyItMatters": "Pourquoi c'est important",
"possible_duplicate_customer": {
"whatIsWrong": "Deux profils clients partagent des données d'identification (e-mail, téléphone ou un nom très similaire) suffisamment fortement pour qu'il s'agisse probablement de la même personne, enregistrée deux fois.",
"whyItMatters": "Les clients en double répartissent l'historique de réservation entre deux fiches, risquent une double facturation et compliquent les échanges avec le support."
},
"missing_required_field": {
"whatIsWrong": "Cette fiche manque d'informations requises pour un fonctionnement normal (par exemple, un client sans e-mail ni numéro de téléphone enregistré).",
"whyItMatters": "Sans ces données, l'entreprise ne peut pas contacter le client, ni identifier de façon fiable le véhicule pour les contrôles de conformité et de remise."
},
"odometer_regression": {
"whatIsWrong": "Un relevé de kilométrage soumis est inférieur au dernier relevé connu (de référence) du véhicule.",
"whyItMatters": "Un kilométrage en baisse signifie généralement une erreur de saisie ou que les relevés ont été enregistrés pour le mauvais véhicule. Laisser passer cela silencieusement corromprait la planification de l'entretien et l'historique kilométrique à la revente."
},
"booking_overlap": {
"whatIsWrong": "Le même véhicule est engagé sur deux réservations dont les périodes se chevauchent.",
"whyItMatters": "Une seule de ces réservations peut réellement être honorée. Non résolu, un client arriverait pour constater que son véhicule est déjà chez quelqu'un d'autre."
},
"vehicle_status_conflict": {
"whatIsWrong": "Le statut opérationnel enregistré de ce véhicule ne correspond pas à ce que son propre historique de réservations et d'inspections implique.",
"whyItMatters": "Un statut incorrect peut faire apparaître un véhicule indisponible comme réservable, ou masquer un véhicule disponible de la flotte."
}
},
"duplicateCustomer": {
"heading": "Comparer et fusionner",
"description": "Choisissez le client de référence et examinez chaque champ divergent.",
"keepAsSurvivor": "Conserver comme fiche principale",
"differs": "Diffère",
"match": "Identique",
"mergePreview": "{{loser}} deviendra une fiche archivée liée à {{survivor}} ; ses réservations seront transférées.",
"mergeInto": "Fusionner avec {{ref}}",
"confirmMergeTitle": "Confirmer la fusion",
"confirmMergeBody": "Fusionner {{loser}} avec {{survivor}} ? Cette action est irréversible.",
"merging": "Fusion en cours…",
"confirmMergeYes": "Oui, fusionner",
"mergeFailed": "Impossible de fusionner ces clients.",
"bothMissing": "Les deux clients de cette comparaison n'ont pas pu être chargés.",
"fieldColumn": "Champ",
"fields": {
"first_name": "Prénom",
"last_name": "Nom",
"email": "E-mail",
"phone": "Téléphone",
"postal_code": "Code postal",
"city": "Ville"
}
},
"missingField": {
"heading": "Compléter les champs manquants",
"description": "Complétez la fiche pour {{ref}}. Le problème se résout automatiquement dès qu'aucune donnée requise ne manque.",
"atLeastOne": "Au moins un e-mail ou un numéro de téléphone est requis.",
"saveAndRecheck": "Enregistrer et revérifier",
"saving": "Enregistrement…",
"saveFailed": "Impossible d'enregistrer ces champs.",
"fields": {
"first_name": "Prénom",
"last_name": "Nom",
"email": "E-mail",
"phone": "Téléphone",
"registration_number": "Numéro d'immatriculation",
"make": "Marque",
"model": "Modèle",
"location": "Localisation"
}
},
"odometerRegression": {
"heading": "Résoudre l'anomalie de kilométrage",
"description": "Le kilométrage de référence n'est jamais abaissé automatiquement — choisissez comment le régulariser.",
"canonicalOdometer": "Kilométrage de référence",
"decisionLegend": "Décision",
"retainCanonical": "Conserver le relevé de référence",
"retainCanonicalDetail": "Considérer le relevé soumis comme erroné ; rien ne change sur la fiche du véhicule.",
"correctReading": "Corriger le relevé",
"correctReadingDetail": "Mettre à jour à la fois la réservation et le kilométrage de référence avec la valeur correcte.",
"noBookingAttached": "Aucune réservation liée à ce problème, seule l'option « conserver la référence » est donc disponible.",
"booking": "Réservation",
"correctedOdometer": "Kilométrage corrigé (km)",
"note": "Note",
"resolving": "Résolution en cours…",
"resolveIssue": "Résoudre le problème",
"resolveFailed": "Impossible de résoudre ce problème."
},
"bookingOverlap": {
"heading": "Résoudre le chevauchement de réservations",
"description": "Bloquez l'un des deux engagements qui se chevauchent. L'autre conserve son statut actuel.",
"columns": {
"booking": "Réservation",
"window": "Période",
"status": "Statut",
"blockThis": "Bloquer celle-ci"
},
"blockLabel": "Bloquer {{ref}}",
"note": "Note",
"resolving": "Résolution en cours…",
"blockButton": "Bloquer {{ref}}",
"blockButtonFallback": "Bloquer la réservation",
"resolveFailed": "Impossible de résoudre ce chevauchement."
},
"vehicleStatusConflict": {
"heading": "Résoudre le conflit de statut",
"description": "Une règle faisant autorité recommande un statut opérationnel corrigé pour ce véhicule.",
"currentStatus": "Statut actuel",
"calculateAndApply": "Calculer et appliquer le statut recommandé",
"confirmTitle": "Confirmer le changement de statut",
"confirmBody": "Appliquer le statut recommandé faisant autorité pour ce véhicule ?",
"applying": "Application en cours…",
"confirmYes": "Oui, appliquer",
"applied": "Appliqué {{status}} — {{reason}}",
"applyFailed": "Impossible d'appliquer un statut recommandé."
}
}
}
@@ -0,0 +1,73 @@
{
"progress": {
"capture": "Saisie",
"review": "Vérification",
"result": "Résultat",
"ariaLabel": "Progression de l'enregistrement du retour"
},
"capture": {
"heading": "Enregistrer le retour du véhicule",
"description": "Enregistrez l'état à la restitution. L'étape suivante évalue les conséquences opérationnelles exactes avant toute validation.",
"endOdometer": "Kilométrage de retour (km)",
"fuelLevel": "Niveau de carburant (%)",
"conditionLegend": "État du véhicule",
"cleanlinessOk": "Propreté acceptable",
"damageReported": "Dommage signalé",
"technicalWarning": "Avertissement technique",
"notes": "Notes",
"evaluating": "Évaluation…",
"reviewReturn": "Vérifier le retour"
},
"review": {
"heading": "Vérifier l'impact du retour",
"description": "Ceci est l'évaluation faisant autorité du serveur sur ce que la validation entraînera — confirmez avant la mise à jour de l'état de la flotte et le déclenchement de l'automatisation.",
"odometer": "Kilométrage",
"fuel": "Carburant",
"cleanliness": "Propreté",
"cleanlinessAccepted": "Acceptée",
"cleanlinessFollowUp": "Suivi nécessaire",
"damage": "Dommage",
"damageReported": "Signalé",
"damageNone": "Aucun signalé",
"technicalWarning": "Avertissement technique",
"technicalWarningReported": "Signalé",
"technicalWarningNone": "Aucun signalé",
"expectedState": "État de flotte attendu :",
"odometerRegressionWarning": "Le kilométrage saisi ({{submitted}} km) est inférieur au relevé de référence ({{canonical}} km). Le kilométrage de référence ne changera pas et un problème de qualité des données sera ouvert.",
"nextBookingRisk": "La prochaine réservation {{ref}} débute {{when}} — peut être affectée par ce retour.",
"nextBookingLowRisk": "La prochaine réservation {{ref}} débute {{when}} — risque faible.",
"commitList": {
"inspection": "Créer une inspection de retour",
"updateAtomic": "Mettre à jour la réservation et le véhicule de façon atomique",
"queueAutomation": "Planifier l'automatisation après la validation locale"
},
"editDetails": "Modifier les détails",
"registering": "Enregistrement…",
"confirmReturn": "Confirmer le retour"
},
"result": {
"committedLocally": "Validé localement",
"heading": "Retour enregistré",
"inspection": "Inspection",
"resultingStatus": "Statut de véhicule résultant",
"qualityIssue": "Problème de qualité",
"noneCreated": "Aucun créé",
"automationEvent": "Tâche d'automatisation",
"queuedForDelivery": "En attente de traitement ({{ref}}) — validation locale réussie ; la livraison par automatisation est asynchrone et pas encore confirmée.",
"nextBookingRisk": "Risque prochaine réservation",
"nextBookingRiskValue": "{{ref}} {{status}}",
"atRisk": "— peut être affectée",
"lowRisk": "— risque faible",
"noUpcomingBooking": "Aucune réservation à venir pour ce véhicule",
"odometerRegressionNotice": "Le kilométrage saisi était inférieur au kilométrage de référence du véhicule. Il a été enregistré tel quel ; le kilométrage de référence n'a pas changé et un problème de qualité des données a été ouvert pour examen.",
"viewVehicle": "Voir le véhicule {{ref}}",
"viewAutomation": "Voir le statut d'automatisation",
"viewAudit": "Voir la piste d'audit",
"continueDemo": "Poursuivre la démo"
},
"scenario": {
"title": "Scénario de démo : anomalie de kilométrage",
"body": "Ce véhicule affiche actuellement {{odometer}} km. Le formulaire ci-dessous est pré-rempli avec un relevé de retour inférieur — signe d'une erreur de saisie ou d'un véhicule confondu. Confirmez le retour pour voir comment Fleet Ops détecte et traite cela.",
"preparing": "Préparation du scénario…"
}
}
@@ -0,0 +1,7 @@
{
"openRow": "Openen: {{label}}",
"reducedMotion": "Beperkte beweging actief",
"closeDialog": "Dialoogvenster sluiten",
"expandSection": "Sectie uitklappen",
"collapseSection": "Sectie inklappen"
}
@@ -0,0 +1,64 @@
{
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
"title": "Audit trail",
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
"actionFilterLabel": "Actie",
"actionFilterPlaceholder": "bv. demo-login",
"managerOnly": "De audit trail is enkel zichtbaar voor Operations Managers.",
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operations Managers.",
"loading": "Audit trail laden…",
"unavailable": "Audit trail is momenteel niet beschikbaar.",
"empty": "Geen auditgebeurtenissen gevonden",
"emptyDetail": "Pas het actiefilter aan.",
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
"clearFilter": "Filter wissen",
"count": "{{count}} gebeurtenissen",
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
"columns": {
"when": "Wanneer",
"actor": "Actor",
"action": "Actie",
"entity": "Entiteit",
"change": "Wijziging",
"followUp": "Vervolg",
"details": "Details"
},
"viewRelatedEvents": "Gerelateerde gebeurtenissen bekijken",
"noChangeDetail": "Geen wijzigingsdetail geregistreerd.",
"noFieldChange": "Geen wijziging op veldniveau gedetecteerd.",
"reference": "Referentie",
"relatedEventsCount": "{{count}} gekoppelde gebeurtenis",
"relatedEventsCount_other": "{{count}} gekoppelde gebeurtenissen",
"showTechnicalEvents": "{{count}} technische gebeurtenis tonen",
"showTechnicalEvents_other": "{{count}} technische gebeurtenissen tonen",
"hideTechnicalEvents": "Technische gebeurtenissen verbergen",
"technicalDetails": "Technische details",
"fullReference": "Volledige referentie",
"correlationId": "Gekoppelde gebeurtenis-ID",
"diff": {
"setTo": "{{field}}: ingesteld op {{value}}",
"was": "{{field}}: was {{value}}",
"changed": "{{field}}: {{before}} → {{after}}"
},
"actions": {
"demo_login": "Ingelogd",
"demo_logout": "Uitgelogd",
"demo_reset": "Demogegevens hersteld",
"demo_data_seeded": "Demogegevens ingeladen",
"return_registered": "Voertuigretour geregistreerd",
"vehicle_status_changed": "Voertuigstatus gewijzigd",
"data_quality_issue_resolved": "Datakwaliteitsprobleem opgelost",
"data_quality_issue_deferred": "Datakwaliteitsprobleem uitgesteld",
"data_quality_issue_rejected": "Datakwaliteitsprobleem verworpen",
"data_quality_fields_provided": "Ontbrekende velden aangevuld",
"data_quality_odometer_corrected": "Kilometerstand gecorrigeerd",
"data_quality_odometer_retained": "Laatst bevestigde kilometerstand behouden",
"data_quality_status_applied": "Aanbevolen status toegepast",
"data_quality_booking_blocked": "Boeking geblokkeerd",
"data_quality_scan_run": "Kwaliteitscontrole uitgevoerd",
"customer_merged": "Klanten samengevoegd",
"workflow_retry": "Automatisering opnieuw geprobeerd",
"knowledge_question_asked": "Kennisvraag gesteld",
"mcp_tool_request": "MCP-tool aangeroepen"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"brandTagline": "Bedieningscentrum",
"orgLine": "Demo-organisatie: {{orgName}} (fictief)",
"headline1": "Elke overdracht.",
"headline2": "Eén helder overzicht.",
"defaultDescription": "Fleet Ops brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde vervolgstappen.",
"footnote": "Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar",
"accessEyebrow": "Demo-toegang",
"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.",
"startGuidedDemo": "Start begeleide demo",
"exploreAsOperationsManager": "Verken als Operations Manager",
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
"exploreAsRentalEmployee": "Verken als Rental Employee",
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
"safeByDesignTitle": "Veilig ontworpen",
"safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.",
"loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.",
"roleOperationsManager": "Operations manager",
"roleRentalEmployee": "Rental employee",
"switchRole": "Wissel van rol",
"logout": "Uitloggen"
}
@@ -0,0 +1,52 @@
{
"list": {
"eyebrow": "Uitvoeren / Planning",
"title": "Boekingen",
"description": "Bekijk actieve verhuurperiodes en aankomende voertuigverbintenissen.",
"searchLabel": "Zoeken",
"searchPlaceholder": "Boeking, klant of voertuig",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"loading": "Boekingsoverzicht laden…",
"empty": "Geen boekingen gevonden",
"emptyDetail": "Pas het statusfilter voor boekingen aan.",
"noMatch": "Geen overeenkomende boekingen",
"noMatchDetail": "Probeer een bredere zoekterm.",
"unavailable": "Boekingsoverzicht is momenteel niet beschikbaar.",
"count": "{{count}} boekingen",
"pageOf": "Pagina {{page}} van {{total}}",
"columns": {
"reference": "Referentie",
"customer": "Klant",
"vehicle": "Voertuig",
"window": "Periode",
"status": "Status"
},
"paginationLabel": "Boekingspagina's",
"previous": "Vorige",
"next": "Volgende",
"rangeOf": "{{from}}{{to}} van {{total}}"
},
"statuses": {
"reserved": "gereserveerd",
"active": "actief",
"returned": "geretourneerd",
"cancelled": "geannuleerd",
"blocked": "geblokkeerd"
},
"detail": {
"backLink": "Boekingsoverzicht",
"eyebrow": "Boekingen / Huurrecord",
"notFound": "Deze boeking kon niet gevonden worden.",
"loading": "Boekingsrecord laden…",
"customer": "Klant",
"vehicle": "Voertuig",
"starts": "Start",
"ends": "Einde",
"startOdometer": "Startkilometerstand",
"endOdometer": "Eindkilometerstand",
"requirementsComplete": "Vereisten volledig",
"yes": "Ja",
"no": "Nee"
}
}
@@ -0,0 +1,44 @@
{
"appName": "Fleet Ops",
"orgName": "Northstar Mobility",
"brandTagline": "Bedieningscentrum",
"actions": {
"save": "Opslaan",
"cancel": "Annuleren",
"confirm": "Bevestigen",
"close": "Sluiten",
"back": "Terug",
"next": "Volgende",
"retry": "Opnieuw proberen",
"reset": "Herstellen",
"yes": "Ja",
"no": "Nee",
"viewDetails": "Details bekijken",
"technicalDetails": "Technische details"
},
"status": {
"loading": "Laden…",
"saving": "Opslaan…",
"error": "Er ging iets mis",
"success": "Gelukt",
"noResults": "Geen resultaten"
},
"states": {
"loadingDefault": "Werkruimte wordt geladen…",
"errorTitle": "We konden deze werkruimte niet laden."
},
"timezone": "Europa/Brussel",
"language": {
"label": "Taal",
"nl-BE": "Nederlands",
"en-GB": "English",
"fr-BE": "Français"
},
"footer": {
"productLine": "Fleet Ops Demo",
"locale": "Europa/Brussel · Synthetische demogegevens"
},
"demo": {
"syntheticBadge": "Synthetische demo"
}
}
@@ -0,0 +1,67 @@
{
"eyebrow": "Uitvoeren / Live overzicht",
"title": "Goedemorgen. Hier is je wagenpark.",
"description": "Beschikbaarheid, uitzonderingen en overdrachten doorheen de huidige werking.",
"viewFleet": "Wagenpark bekijken",
"demoStart": {
"title": "Probeer een demonstratiescenario",
"readyCount": "{{ready}} van {{total}} scenario's klaar voor demo.",
"readyCountFallback": "Vijf afgebakende scenario's.",
"startGuide": "Start demo-gids",
"resumeGuide": "Verder met demo-gids ({{current}}/{{total}})",
"viewScenarios": "Bekijk scenario's"
},
"readiness": {
"title": "Wagenparkstatus",
"description": "Live vanuit persistente voertuigstatus",
"openFleet": "Wagenpark openen",
"available": "Beschikbaar",
"rented": "Verhuurd",
"cleaning": "Reiniging",
"maintenance": "Onderhoud",
"blocked": "Geblokkeerd"
},
"attention": {
"title": "Aandachtspunten",
"description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen",
"reviewQueue": "Wachtrij bekijken",
"filterPlaceholder": "Filter aandachtspunten…",
"filterAriaLabel": "Zoek in aandachtspunten",
"severityAll": "Alle ernst",
"severityHigh": "Kritiek",
"severityMedium": "Waarschuwing",
"severityLow": "Info",
"severityAriaLabel": "Ernst",
"empty": "Geen aandachtspunten voor dit filter.",
"openRecord": "Open {{title}}"
},
"movements": {
"title": "Bewegingen vandaag",
"description": "Vertrekken en retours in Europa/Brussel",
"allBookings": "Alle boekingen",
"empty": "Geen bewegingen gepland vandaag.",
"departure": "vertrek",
"return": "retour",
"openBooking": "Open boeking {{ref}}"
},
"integrationPulse": {
"title": "Integratiestatus",
"description": "Actuele evidentie van gekoppelde diensten",
"systemDetail": "Systeemdetail",
"n8nTitle": "n8n-aflevering",
"n8nSummary": "{{succeeded}} geslaagd · {{failed}} mislukt",
"n8nLatest": "Meest recente gebeurtenis {{ref}}",
"n8nNoEvidence": "Geen automatiseringsevidentie geregistreerd",
"knowledgeTitle": "Kennisassistent",
"knowledgeSummary": "{{count}} procedures geïndexeerd",
"knowledgeUnavailable": "Statuscontrole niet beschikbaar",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registratie ingeschakeld",
"mcpNotConnected": "Nog niet gekoppeld"
},
"recent": {
"title": "Recente activiteit",
"description": "Laatst geauditeerde workflowwijzigingen",
"empty": "Geen automatiseringsactiviteit geregistreerd."
}
}
+189
View File
@@ -0,0 +1,189 @@
{
"badge": {
"trigger": "Synthetische demo",
"dialogLabel": "Over deze demo-omgeving",
"close": "Sluiten",
"orgIntro": "<strong>{{orgName}}</strong> is een fictieve organisatie. Alle namen, voertuigen en boekingen zijn synthetisch.",
"orgIntroFallback": "Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch.",
"realWorkflows": "De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de gegevens zijn verzonnen.",
"lastReset": "Laatste reset: <strong>{{when}}</strong> · deze omgeving is op elk moment herstelbaar.",
"aboutLink": "Over deze demo",
"unknown": "onbekend"
},
"guide": {
"dialogLabel": "Gegidste demo",
"trigger": "Demo-gids",
"kicker": "Gegidste demo · stap {{current}} van {{total}}",
"whatYouWillSee": "Wat je zal zien",
"whyItMatters": "Waarom dit relevant is",
"startAction": "Aan de slag",
"expectedOutcome": "Verwacht resultaat",
"goToStep": "Ga naar deze stap",
"next": "Volgende",
"close": "Sluiten",
"restart": "Demo opnieuw voorbereiden",
"restarting": "Bezig met herstellen…",
"restartFailed": "De demo kon niet hersteld worden.",
"allStepsLabel": "Alle stappen",
"progressChip": "Demo-gids · stap {{current}} van {{total}}",
"expand": "Demo-gids uitklappen",
"collapse": "Demo-gids inklappen",
"steps": {
"understand-state": {
"title": "1. Begrijp de operationele status",
"whatYouWillSee": "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
"whyItMatters": "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
"startAction": "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
"expectedOutcome": "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen."
},
"open-booking": {
"title": "2. Open een boeking die aandacht nodig heeft",
"whatYouWillSee": "De boeking BK-DEMO-RETURN, actief en klaar voor retour vandaag.",
"whyItMatters": "Retours zijn het moment waarop foute kilometerstanden of schade voor het eerst zichtbaar worden.",
"startAction": "Open de boeking vanuit het dashboard of de boekingenlijst.",
"expectedOutcome": "Je ziet de boekingsdetails en de knop om de retour te verwerken."
},
"process-return": {
"title": "3. Verwerk een retour met een afwijkende kilometerstand",
"whatYouWillSee": "Een retourformulier met een vooringevulde, verdachte kilometerstand lager dan de laatst gekende stand.",
"whyItMatters": "Een dalende kilometerstand wijst op een foutieve invoer of een verwisseld voertuig — dit moet vóór vrijgave worden opgemerkt.",
"startAction": "Vul de retour in met de voorgestelde waarde en bekijk de serverpreview vóór je bevestigt.",
"expectedOutcome": "De retour wordt verwerkt, het voertuig krijgt een passende status en er wordt automatisch een datakwaliteitsprobleem aangemaakt."
},
"handle-quality-issue": {
"title": "4. Bekijk en behandel het gecreëerde datakwaliteitsprobleem",
"whatYouWillSee": "Een nieuw issue van het type 'kilometerstand-afwijking' bovenaan de werklijst.",
"whyItMatters": "Elk gedetecteerd probleem heeft één afgebakende oplossingsstap — niets wordt automatisch stilzwijgend gecorrigeerd.",
"startAction": "Open het datakwaliteitsoverzicht en kies het nieuwste issue.",
"expectedOutcome": "Je ziet de aanbevolen actie, kiest een oplossing en het issue wordt opgelost met een audit-spoor."
},
"merge-duplicate": {
"title": "5. Beoordeel en behandel een mogelijke dubbele klant",
"whatYouWillSee": "Twee klantprofielen met hetzelfde e-mailadres en telefoonnummer, naast elkaar vergeleken.",
"whyItMatters": "Dubbele klanten leiden tot verspreide boekingsgeschiedenis en verwarrende communicatie.",
"startAction": "Vergelijk beide profielen en kies welk profiel behouden blijft.",
"expectedOutcome": "De profielen worden samengevoegd, boekingen worden herverbonden en het verliezende profiel wordt een tombstone."
},
"ask-knowledge": {
"title": "6. Stel een vraag aan de procedureassistent",
"whatYouWillSee": "Een antwoord met bronvermelding uit de afgebakende demokennisbank.",
"whyItMatters": "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.",
"startAction": "Klik op één van de voorbeeldvragen, in de huidige interfacetaal.",
"expectedOutcome": "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is."
},
"check-automation-audit": {
"title": "7. Controleer automatisering en audit trail",
"whatYouWillSee": "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.",
"whyItMatters": "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.",
"startAction": "Open Integraties om de afleverstatus te zien, en Audit trail voor het volledige spoor.",
"expectedOutcome": "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties."
},
"review-real-vs-simulated": {
"title": "8. Bekijk wat echt is, gesimuleerd is, of nog niet gekoppeld",
"whatYouWillSee": "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
"whyItMatters": "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
"startAction": "Lees de pagina 'Over deze demo'.",
"expectedOutcome": "Je kan zelf uitleggen wat Fleet Ops wel en niet is, zonder mondelinge toelichting."
}
}
},
"scenarios": {
"eyebrow": "Demonstratiescenario's",
"title": "Probeer een demonstratiescenario",
"description": "Vijf afgebakende scenario's die telkens dezelfde vaste boekingen, klanten en voertuigen gebruiken — na een reset zijn ze altijd opnieuw te vinden.",
"loading": "Scenario's laden…",
"ready": "Klaar voor demo",
"notReady": "Niet beschikbaar",
"duration": "Duur",
"durationValue": "± {{minutes}} min",
"role": "Rol",
"demonstrates": "Toont aan:",
"requiresRole": "Vereist rol: {{roles}}.",
"startScenario": "Start scenario",
"roleOr": "{{a}} of {{b}}",
"roles": {
"operations_manager": "Operations Manager",
"rental_employee": "Rental Employee"
},
"items": {
"return-anomaly": {
"title": "Retour met afwijkende kilometerstand",
"problem": "Een voertuig komt terug met een kilometerstand die lager ligt dan de laatst geregistreerde stand — een teken van een foutieve invoer of een verwisseld voertuig.",
"demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de audit trail die daaruit ontstaat."
},
"duplicate-customer": {
"title": "Mogelijke dubbele klant samenvoegen",
"problem": "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — waarschijnlijk dezelfde persoon, twee keer geregistreerd.",
"demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail."
},
"booking-overlap": {
"title": "Overlappende boekingen herstellen",
"problem": "Eén voertuig staat dubbel gereserveerd voor overlappende periodes — een planningsfout die vóór vertrek moet worden opgelost.",
"demonstrates": "Detectie en gecontroleerde oplossing van planningsconflicten."
},
"automation-retry": {
"title": "Mislukte automatisering opnieuw proberen",
"problem": "Eén eerdere gebeurtenis kon niet worden afgeleverd aan de automatisering door een gesimuleerde verbindingsfout.",
"demonstrates": "Betrouwbare aflevering met begrensde herpogingen en zichtbare foutstatus."
},
"knowledge-question": {
"title": "Een procedurevraag stellen",
"problem": "Een medewerker weet niet zeker welke procedure van toepassing is bij een specifieke operationele situatie.",
"demonstrates": "Antwoorden met brongebaseerde onderbouwing uit een afgebakende demokennisbank."
}
},
"blockedReasons": {
"bookingNotFound": "Demoboeking BK-DEMO-RETURN niet gevonden. {{resetHint}}",
"bookingAlreadyProcessed": "Deze boeking is al verwerkt sinds de laatste reset. {{resetHint}}",
"duplicateIssueNotFound": "Demo-issue DQ-DEMO-DUPLICATE niet gevonden. {{resetHint}}",
"issueAlreadyResolved": "Dit issue is al opgelost sinds de laatste reset. {{resetHint}}",
"overlapIssueNotFound": "Demo-issue DQ-DEMO-OVERLAP niet gevonden. {{resetHint}}",
"failedEventNotFound": "Gesimuleerde mislukte gebeurtenis niet gevonden. {{resetHint}}",
"eventAlreadyRecovered": "Deze gebeurtenis is al hersteld sinds de laatste reset. {{resetHint}}",
"knowledgeUnavailable": "De demokennisbank is momenteel niet beschikbaar.",
"resetHint": "Reset de demogegevens om dit scenario opnieuw beschikbaar te maken."
}
},
"about": {
"eyebrow": "Over deze demo",
"title": "Wat Fleet Ops wel en niet is",
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
"loading": "Demo-informatie laden…",
"ctaTitle": "Liever meteen aan de slag?",
"ctaBody": "De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.",
"ctaButton": "Start begeleide demo",
"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. Fleet Ops toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
"scopeTitle": "Voor wie en met welke scope",
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe Fleet Ops operationele problemen bij een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
"realTitle": "Wat écht werkt",
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
"syntheticTitle": "Wat synthetisch is",
"syntheticBody": "De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of bedrijf; e-mailadressen gebruiken uitsluitend het testdomein {{testDomain}}.",
"architectureTitle": "Architectuur in het kort",
"architectureBody": "Een React/TypeScript-frontend praat met een FastAPI-backend (PostgreSQL via SQLAlchemy/Alembic-migraties); belangrijke bedrijfsregels leven in de backend, niet in n8n of in prompts. Retours en andere gebeurtenissen worden eerst lokaal gecommit en pas daarna asynchroon via een automatiseringsopdracht aan n8n afgeleverd, zodat een tijdelijke storing in de automatisering nooit een operationele actie blokkeert.",
"securityTitle": "Beveiliging en toegang",
"securityBody": "Toegang verloopt via ondertekende, HTTP-only sessiecookies per rol; elke rol gebonden aan een set toegestane routes, zowel serverzijdig afgedwongen als in de navigatie weerspiegeld. Belangrijke statuswijzigingen worden altijd gecontroleerd en gelogd — nooit stilzwijgend automatisch gecorrigeerd.",
"testingTitle": "Hoe dit getest is",
"testingBody": "Een geautomatiseerde backend-testsuite dekt bedrijfsregels en API-contracten; een volledige Playwright-eindtot-eind-suite dekt de gebruikersstromen, inclusief deze demo-ervaring zelf in drie talen. Elke wijziging wordt bovendien tegen een schone checkout (lege database, opnieuw opgebouwd vanaf de seed-data) gevalideerd voor deployment.",
"integrationsTitle": "Koppelingen — eerlijk gelabeld",
"integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.",
"resetTitle": "Demo-omgeving herstellen",
"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 Operations Manager kan de demo-omgeving herstellen via de zijbalk.",
"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.",
"unknown": "onbekend"
},
"integrationSummary": {
"titles": {
"n8n": "Automatisering (n8n)",
"ragcore": "Kennisassistent (RAGcore)",
"mcp_hub": "ITWorx MCP Hub"
},
"n8nDetail": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij.",
"ragcoreDetail": "{{count}} procedures geïndexeerd in {{collection}}.",
"mcpDetailEnabled": "Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.",
"mcpDetailNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub."
}
}
@@ -0,0 +1,8 @@
{
"generic": "Er is een fout opgetreden. Probeer opnieuw.",
"workspaceLoadFailed": "We konden deze werkruimte niet laden.",
"unauthorized": "Je sessie is verlopen. Log opnieuw in.",
"forbidden": "Je hebt geen toegang tot dit onderdeel.",
"notFound": "Dit record kon niet gevonden worden.",
"networkUnavailable": "De verbinding met de server is momenteel niet beschikbaar."
}
@@ -0,0 +1,68 @@
{
"list": {
"eyebrow": "Wagenpark / Register",
"title": "Wagenpark",
"description": "Live operationele status, locatie en onderhoudsgereedheid.",
"searchLabel": "Zoeken",
"searchPlaceholder": "Referentie, merk of locatie",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"attentionOnly": "Enkel aandachtspunten",
"loading": "Wagenparkregister laden…",
"empty": "Geen voertuigen gevonden",
"emptyDetail": "Pas de huidige wagenparkfilters aan.",
"noMatch": "Geen overeenkomende voertuigen",
"noMatchDetail": "Probeer een bredere zoekterm.",
"unavailable": "Wagenparklijst is momenteel niet beschikbaar.",
"count": "{{count}} voertuigen",
"persisted": "Persistente wagenparkgegevens",
"columns": {
"reference": "Referentie",
"makeModel": "Merk / model",
"location": "Locatie",
"status": "Status",
"odometer": "Kilometerstand (km)",
"attention": "Aandacht"
},
"needsAttention": "Vraagt aandacht"
},
"statuses": {
"available": "beschikbaar",
"rented": "verhuurd",
"cleaning": "reiniging",
"maintenance": "onderhoud",
"blocked": "geblokkeerd"
},
"detail": {
"backLink": "Wagenparkregister",
"eyebrow": "Wagenpark / Voertuigrecord",
"notFound": "Dit voertuig kon niet gevonden worden.",
"loading": "Voertuigrecord laden…",
"needsAttention": "Vraagt aandacht",
"tabs": {
"overview": "Overzicht",
"bookings": "Boekingen",
"inspections": "Inspecties",
"maintenance": "Onderhoud",
"quality": "Kwaliteit"
},
"tabsAriaLabel": "Voertuigonderdelen",
"overview": {
"registration": "Kenteken",
"modelYear": "Bouwjaar",
"location": "Locatie",
"odometer": "Kilometerstand",
"nextService": "Volgende onderhoudsbeurt",
"active": "Actief"
},
"yes": "Ja",
"no": "Nee",
"noBookings": "Geen boekingen geregistreerd.",
"noInspections": "Geen inspecties geregistreerd.",
"noMaintenance": "Geen onderhoudsgegevens.",
"noQualityIssues": "Geen kwaliteitsproblemen geregistreerd.",
"fuel": "Brandstof {{percent}}%",
"damage": "Schade",
"technicalWarning": "Technische melding"
}
}
@@ -0,0 +1,70 @@
{
"eyebrow": "Bewaken / Integraties",
"title": "Integratiebeheer",
"description": "Bewaak afleverkwaliteit, beperkte beschikbaarheid en herprobeerbare workflowgebeurtenissen.",
"cards": {
"orchestrationKicker": "Orkestratie",
"n8nTitle": "n8n-aflevering",
"n8nSummary": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij · {{delivering}} bezig",
"n8nFallback": "Retourgebeurtenissen worden eerst lokaal verwerkt en pas daarna via de automatiseringsopdracht afgeleverd.",
"knowledgeKicker": "Kennis",
"knowledgeTitle": "Kennisassistent",
"knowledgeSummaryDemo": "Demokennisbank · {{count}} procedures geïndexeerd in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures geïndexeerd in {{collection}}.",
"knowledgeUnavailable": "Statusevidentie momenteel niet beschikbaar.",
"gatewayKicker": "Tool-gateway",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registratie is ingeschakeld voor deze omgeving.",
"mcpNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub."
},
"statusLabels": {
"notConnected": "Niet gekoppeld",
"deliveryFailed": "Verwerking mislukt",
"retryAvailable": "Opnieuw proberen mogelijk",
"operational": "Operationeel",
"prepared": "Voorbereid",
"demoMode": "Demomodus",
"unavailable": "Niet beschikbaar"
},
"ledger": {
"title": "Automatiseringsopdrachten",
"description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.",
"filterLabel": "Weergave",
"filterNeedsAttention": "Vraagt aandacht",
"filterRecent": "Recent",
"filterSucceeded": "Geslaagd",
"filterAll": "Alle",
"statusFilterLabel": "Status",
"statusAll": "Alle statussen",
"statusPending": "In wachtrij",
"statusDelivering": "Bezig",
"statusSucceeded": "Geslaagd",
"statusFailed": "Mislukt",
"unavailable": "Automatiseringsopdrachten zijn momenteel niet beschikbaar.",
"managerOnly": "Automatisering is enkel zichtbaar voor Operations Managers.",
"loading": "Automatiseringsopdrachten laden…",
"empty": "Geen automatiseringsopdrachten voor dit filter.",
"count": "{{count}} workflowgebeurtenissen",
"boundedRetries": "Begrensde herpogingen",
"groupedSucceeded": "{{count}} geslaagde voertuigretourverwerkingen",
"showIndividually": "Individuele opdrachten tonen",
"hideIndividually": "Individuele opdrachten verbergen",
"columns": {
"event": "Opdracht",
"type": "Type",
"booking": "Boeking",
"status": "Status",
"attempts": "Pogingen",
"lastError": "Laatste fout",
"when": "Wanneer",
"action": "Actie"
},
"retry": "Opnieuw proberen",
"retrying": "Bezig met opnieuw proberen…",
"retryFailed": "Kon deze aflevering niet opnieuw proberen.",
"noAction": "—",
"eventTypes": {
"vehicle.returned.v1": "Voertuigretour verwerkt"
}
}
}
@@ -0,0 +1,41 @@
{
"eyebrow": "Bewaken / Onderbouwde kennis",
"title": "Procedurekennis",
"description": "Stel operationele vragen. Antwoorden verschijnen enkel wanneer {{provider}} voldoende onderbouwde evidentie teruggeeft.",
"providerDemo": "de demokennisbank",
"providerRagcore": "RAGcore",
"statusAvailable": "Beschikbaar",
"statusUnavailable": "Niet beschikbaar",
"proceduresIndexed": "{{count}} procedures geïndexeerd",
"providerNote": "Deze demo beantwoordt vanuit een kleine, vaste set geïndexeerde procedures — geen live RAGcore-koppeling. Een live RAGcore-backend zal later dezelfde interface overnemen, zonder dat deze pagina verandert.",
"askHeading": "Stel een procedurevraag",
"askSubheading": "Ophalen → evidentiecontrole → onderbouwd antwoord",
"questionLabel": "Vraag",
"questionPlaceholder": "bv. Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
"ask": "Vraag stellen",
"asking": "Bezig met vragen…",
"askFailed": "Kon de kennisdienst niet bereiken.",
"suggestedLabel": "Probeer:",
"suggestedQuestions": [
"Wat moet ik doen wanneer een voertuig terugkomt met schade?",
"Hoe registreer ik een voertuigretour?",
"Wanneer mag een voertuig opnieuw beschikbaar gezet worden?",
"Wie beoordeelt een ongewone kilometerstand?",
"Welke controles zijn verplicht vóór vertrek?"
],
"emptyTitle": "Evidentie voor antwoorden",
"emptyDescription": "Vraag naar retours, schade, inspecties of een andere geïndexeerde procedure. Fleet Ops verzint geen antwoord wanneer evidentie ontbreekt.",
"retrievalFlow": {
"question": "Vraag",
"sources": "Bronnen",
"answer": "Antwoord"
},
"questionLabelExchange": "Vraag",
"evidenceStates": {
"grounded": "Onderbouwd met geciteerde procedures",
"insufficient": "Onvoldoende evidentie",
"unavailable": "Kennisdienst niet beschikbaar"
},
"unavailableBody": "De kennisdienst is momenteel niet bereikbaar. Operationele functies zijn hier niet door beïnvloed — probeer later opnieuw.",
"sourceVersion": "v{{version}}"
}
@@ -0,0 +1,39 @@
{
"skipToContent": "Ga naar hoofdinhoud",
"groups": {
"operate": "Uitvoeren",
"assure": "Bewaken"
},
"items": {
"overview": "Overzicht",
"fleet": "Wagenpark",
"bookings": "Boekingen",
"quality": "Datakwaliteit",
"knowledge": "Kennis",
"integrations": "Integraties",
"audit": "Audit trail"
},
"primaryNavLabel": "Hoofdnavigatie",
"mobileNavLabel": "Mobiele navigatie",
"more": "Meer",
"openNavigation": "Navigatie openen",
"closeNavigation": "Navigatie sluiten",
"sidebarEnvironment": "Demo-omgeving",
"sidebarEnvironmentDetail": "Enkel synthetische gegevens",
"resetDemoData": "Demogegevens herstellen",
"resetConfirmTitle": "Demoreset bevestigen",
"resetConfirmBody": "Alle synthetische wijzigingen worden ongedaan gemaakt en de deterministische demogegevens worden hersteld. Je wordt uitgelogd.",
"resetting": "Bezig met herstellen…",
"resetConfirmYes": "Ja, herstellen",
"resetCancel": "Annuleren",
"resetFailed": "Demogegevens konden niet hersteld worden.",
"searchLabel": "Zoek in Fleet Ops",
"searchPlaceholder": "Zoek wagenpark, boeking of onderdeel…",
"searchShortcutHint": "Ctrl K",
"searchSearching": "Zoeken…",
"searchUnavailable": "Zoeken is momenteel niet beschikbaar.",
"searchNoResults": "Geen resultaten voor \"{{query}}\".",
"switchRole": "Wissel van rol",
"switchRoleTitle": "Wissel van demo-rol",
"languageSwitcherLabel": "Taal wijzigen"
}
@@ -0,0 +1,192 @@
{
"list": {
"eyebrow": "Bewaken / Werkbank",
"title": "Datakwaliteit",
"description": "Los evidentie-onderbouwde uitzonderingen op voor ze de werking verstoren.",
"runScan": "Kwaliteitscontrole uitvoeren",
"confirmScanTitle": "Kwaliteitscontrole bevestigen",
"confirmScanBody": "Nu de deterministische controle over alle vijf regeltypes uitvoeren?",
"scanning": "Bezig met controleren…",
"confirmScanYes": "Ja, uitvoeren",
"cancel": "Annuleren",
"scanComplete": "Controle voltooid: {{summary}}",
"scanNoNew": "geen nieuwe problemen gevonden (bestaande open problemen worden niet opnieuw aangemaakt).",
"scanFailed": "Kwaliteitscontrole kon niet uitgevoerd worden.",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"statusOpen": "Open",
"statusDeferred": "Uitgesteld",
"statusResolved": "Opgelost",
"statusRejected": "Verworpen",
"ruleTypeLabel": "Regeltype",
"ruleTypeAll": "Alle regeltypes",
"demoScenariosOnly": "Enkel demoscenario's",
"loading": "Kwaliteitswerkbank laden…",
"queueClear": "Wachtrij is leeg",
"noIssuesMatch": "Geen problemen komen overeen met de huidige filters.",
"noDemoIssuesMatch": "Geen demoscenario-problemen komen overeen",
"noDemoIssuesMatchDetail": "Vink 'Enkel demoscenario's' uit om de volledige wachtrij te zien.",
"unavailable": "Datakwaliteitsproblemen zijn momenteel niet beschikbaar.",
"count": "{{count}} problemen",
"evidenceBacked": "Evidentie-onderbouwde detectie",
"columns": {
"reference": "Referentie",
"rule": "Regel",
"entity": "Entiteit",
"severity": "Ernst",
"status": "Status"
}
},
"ruleTypes": {
"possible_duplicate_customer": "Mogelijke dubbele klant",
"missing_required_field": "Ontbrekend verplicht veld",
"odometer_regression": "Afwijkende kilometerstand",
"booking_overlap": "Overlappende boeking",
"vehicle_status_conflict": "Statusconflict voertuig"
},
"severities": {
"high": "Kritiek",
"medium": "Waarschuwing",
"low": "Info"
},
"detail": {
"backLink": "Kwaliteitswerkbank",
"eyebrow": "Kwaliteit / {{rule}}",
"title": "Bekijk vastgelegde evidentie en registreer een geauditeerde oplossing.",
"notFound": "Dit probleem kon niet gevonden worden.",
"loading": "Probleemevidentie laden…",
"managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operations Managers.",
"managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operations Managers.",
"summary": {
"rule": "Regel",
"entity": "Entiteit",
"evidenceSummary": "Evidentiesamenvatting"
},
"resolved": {
"title": "Probleem {{ref}} opgelost",
"body": "De wijziging is doorgevoerd en vastgelegd in de audit trail.",
"viewAudit": "Audit trail bekijken",
"viewVehicle": "Voertuig bekijken",
"continueDemo": "Ga verder met de demo"
},
"deferOrReject": {
"heading": "Uitstellen of verwerpen",
"description": "Stel uit om later opnieuw te bekijken, of verwerp als dit geen echt probleem is.",
"defer": "Uitstellen",
"reject": "Verwerpen",
"deferFailed": "Kon dit probleem niet uitstellen.",
"rejectFailed": "Kon dit probleem niet verwerpen."
},
"explainer": {
"whatIsWrong": "Wat is er mis",
"whyItMatters": "Waarom dit belangrijk is",
"possible_duplicate_customer": {
"whatIsWrong": "Twee klantprofielen delen identificerende gegevens (e-mail, telefoon of een erg gelijkende naam) sterk genoeg om waarschijnlijk dezelfde persoon te zijn, twee keer geregistreerd.",
"whyItMatters": "Dubbele klanten verdelen de boekingsgeschiedenis over twee records, riskeren dubbele facturatie en verwarren klantcontacten."
},
"missing_required_field": {
"whatIsWrong": "Dit record mist gegevens die vereist zijn voor normale werking (bijvoorbeeld een klant zonder e-mail of telefoonnummer).",
"whyItMatters": "Zonder deze gegevens kan het bedrijf de klant niet bereiken, of het voertuig niet betrouwbaar identificeren voor compliance- en overdrachtscontroles."
},
"odometer_regression": {
"whatIsWrong": "Een ingevoerde kilometerstand ligt lager dan de laatst bevestigde (canonieke) stand van het voertuig.",
"whyItMatters": "Een dalende kilometerstand wijst meestal op een invoerfout of een verwisseld voertuig. Dit stilzwijgend doorlaten zou de onderhoudsplanning en de kilometerhistoriek bij wederverkoop verstoren."
},
"booking_overlap": {
"whatIsWrong": "Hetzelfde voertuig is toegewezen aan twee boekingen met overlappende periodes.",
"whyItMatters": "Slechts één van deze boekingen kan effectief worden nagekomen. Onopgelost zou een klant aankomen en merken dat zijn voertuig al bij iemand anders is."
},
"vehicle_status_conflict": {
"whatIsWrong": "De opgeslagen operationele status van dit voertuig komt niet overeen met wat de eigen boekings- en inspectiegeschiedenis impliceert.",
"whyItMatters": "Een foutieve status kan een niet-beschikbaar voertuig boekbaar laten lijken, of een beschikbaar voertuig verbergen voor het wagenpark."
}
},
"duplicateCustomer": {
"heading": "Vergelijken en samenvoegen",
"description": "Kies de klant die behouden blijft en bekijk elk afwijkend veld.",
"keepAsSurvivor": "Behouden als hoofdprofiel",
"differs": "Verschilt",
"match": "Gelijk",
"mergePreview": "{{loser}} wordt een tombstone gekoppeld aan {{survivor}}; de boekingen worden herverbonden.",
"mergeInto": "Samenvoegen met {{ref}}",
"confirmMergeTitle": "Samenvoegen bevestigen",
"confirmMergeBody": "{{loser}} samenvoegen met {{survivor}}? Dit kan niet ongedaan gemaakt worden.",
"merging": "Bezig met samenvoegen…",
"confirmMergeYes": "Ja, samenvoegen",
"mergeFailed": "Kon deze klanten niet samenvoegen.",
"bothMissing": "Beide klanten in deze vergelijking konden niet geladen worden.",
"fieldColumn": "Veld",
"fields": {
"first_name": "Voornaam",
"last_name": "Achternaam",
"email": "E-mail",
"phone": "Telefoon",
"postal_code": "Postcode",
"city": "Stad"
}
},
"missingField": {
"heading": "Ontbrekende velden invullen",
"description": "Vervolledig het record voor {{ref}}. Het probleem wordt automatisch opgelost zodra niets verplichts meer ontbreekt.",
"atLeastOne": "Minstens één van e-mail of telefoon is vereist.",
"saveAndRecheck": "Opslaan en herchecken",
"saving": "Opslaan…",
"saveFailed": "Kon deze velden niet opslaan.",
"fields": {
"first_name": "Voornaam",
"last_name": "Achternaam",
"email": "E-mail",
"phone": "Telefoon",
"registration_number": "Kenteken",
"make": "Merk",
"model": "Model",
"location": "Locatie"
}
},
"odometerRegression": {
"heading": "Kilometerafwijking oplossen",
"description": "De laatst bevestigde kilometerstand wordt nooit automatisch verlaagd — kies hoe dit te verwerken.",
"canonicalOdometer": "Laatst bevestigde kilometerstand",
"decisionLegend": "Beslissing",
"retainCanonical": "Laatst bevestigde stand behouden",
"retainCanonicalDetail": "Behandel de ingevoerde stand als foutief; er verandert niets aan het voertuigrecord.",
"correctReading": "Stand corrigeren",
"correctReadingDetail": "Werk zowel de boeking als de laatst bevestigde kilometerstand bij met de juiste waarde.",
"noBookingAttached": "Geen gekoppelde boeking bij dit probleem, dus enkel 'laatst bevestigde stand behouden' is beschikbaar.",
"booking": "Boeking",
"correctedOdometer": "Gecorrigeerde kilometerstand (km)",
"note": "Notitie",
"resolving": "Bezig met oplossen…",
"resolveIssue": "Probleem oplossen",
"resolveFailed": "Kon dit probleem niet oplossen."
},
"bookingOverlap": {
"heading": "Boekingsoverlap oplossen",
"description": "Blokkeer één van de twee overlappende verbintenissen. De andere behoudt zijn huidige status.",
"columns": {
"booking": "Boeking",
"window": "Periode",
"status": "Status",
"blockThis": "Deze blokkeren"
},
"blockLabel": "{{ref}} blokkeren",
"note": "Notitie",
"resolving": "Bezig met oplossen…",
"blockButton": "{{ref}} blokkeren",
"blockButtonFallback": "Boeking blokkeren",
"resolveFailed": "Kon deze overlap niet oplossen."
},
"vehicleStatusConflict": {
"heading": "Statusconflict oplossen",
"description": "Eén gezaghebbende regel beveelt een gecorrigeerde operationele status voor dit voertuig aan.",
"currentStatus": "Huidige status",
"calculateAndApply": "Aanbevolen status berekenen en toepassen",
"confirmTitle": "Statuswijziging bevestigen",
"confirmBody": "De gezaghebbende aanbevolen status voor dit voertuig toepassen?",
"applying": "Bezig met toepassen…",
"confirmYes": "Ja, toepassen",
"applied": "Toegepast {{status}} — {{reason}}",
"applyFailed": "Kon geen aanbevolen status toepassen."
}
}
}
@@ -0,0 +1,73 @@
{
"progress": {
"capture": "Vastleggen",
"review": "Nakijken",
"result": "Resultaat",
"ariaLabel": "Voortgang retourregistratie"
},
"capture": {
"heading": "Voertuigretour registreren",
"description": "Leg de staat bij inlevering vast. De volgende stap evalueert de exacte operationele gevolgen voor er iets wordt bevestigd.",
"endOdometer": "Eindkilometerstand (km)",
"fuelLevel": "Brandstofniveau (%)",
"conditionLegend": "Voertuigstaat",
"cleanlinessOk": "Netheid aanvaardbaar",
"damageReported": "Schade gemeld",
"technicalWarning": "Technische melding",
"notes": "Notities",
"evaluating": "Evalueren…",
"reviewReturn": "Retour nakijken"
},
"review": {
"heading": "Retourimpact nakijken",
"description": "Dit is de gezaghebbende serverevaluatie van wat bevestigen zal doen — controleer dit voor het de wagenparkstatus bijwerkt en automatisering start.",
"odometer": "Kilometerstand",
"fuel": "Brandstof",
"cleanliness": "Netheid",
"cleanlinessAccepted": "Aanvaard",
"cleanlinessFollowUp": "Opvolging nodig",
"damage": "Schade",
"damageReported": "Gemeld",
"damageNone": "Niet gemeld",
"technicalWarning": "Technische melding",
"technicalWarningReported": "Gemeld",
"technicalWarningNone": "Niet gemeld",
"expectedState": "Verwachte wagenparkstatus:",
"odometerRegressionWarning": "De ingevoerde kilometerstand ({{submitted}} km) ligt onder de laatst bevestigde stand ({{canonical}} km). De laatst bevestigde kilometerstand wijzigt niet en er wordt een datakwaliteitsprobleem geopend.",
"nextBookingRisk": "Volgende boeking {{ref}} start {{when}} — mogelijk beïnvloed door deze retour.",
"nextBookingLowRisk": "Volgende boeking {{ref}} start {{when}} — laag risico.",
"commitList": {
"inspection": "Retourinspectie aanmaken",
"updateAtomic": "Boeking en voertuig atomair bijwerken",
"queueAutomation": "Automatisering inplannen na de lokale verwerking"
},
"editDetails": "Gegevens bewerken",
"registering": "Registreren…",
"confirmReturn": "Retour bevestigen"
},
"result": {
"committedLocally": "Lokaal verwerkt",
"heading": "Retour geregistreerd",
"inspection": "Inspectie",
"resultingStatus": "Resulterende wagenparkstatus",
"qualityIssue": "Datakwaliteitsprobleem",
"noneCreated": "Geen aangemaakt",
"automationEvent": "Automatiseringsopdracht",
"queuedForDelivery": "Klaargezet voor verwerking ({{ref}}) — lokale verwerking gelukt; aflevering via automatisering gebeurt asynchroon en is nog niet bevestigd.",
"nextBookingRisk": "Risico volgende boeking",
"nextBookingRiskValue": "{{ref}} {{status}}",
"atRisk": "— mogelijk beïnvloed",
"lowRisk": "— laag risico",
"noUpcomingBooking": "Geen aankomende boeking voor dit voertuig",
"odometerRegressionNotice": "De ingevoerde kilometerstand lag onder de laatst bevestigde stand van het voertuig. Ze werd zo geregistreerd; de laatst bevestigde kilometerstand is niet gewijzigd en er is een datakwaliteitsprobleem geopend ter controle.",
"viewVehicle": "Voertuig {{ref}} bekijken",
"viewAutomation": "Automatiseringsstatus bekijken",
"viewAudit": "Audit trail bekijken",
"continueDemo": "Ga verder met de demo"
},
"scenario": {
"title": "Demonstratiescenario: afwijkende kilometerstand",
"body": "Dit voertuig staat momenteel op {{odometer}} km. Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder ligt — een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de retour om te zien hoe Fleet Ops dit detecteert en afhandelt.",
"preparing": "Scenario voorbereiden…"
}
}
+1
View File
@@ -2,6 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { App } from "./App"; import { App } from "./App";
import "./i18n/config";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
+55 -101
View File
@@ -1,147 +1,108 @@
import { Link } from "react-router-dom"; 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 { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
function formatDateTime(value: string | null): string {
if (!value) return "onbekend";
return new Date(value).toLocaleString("nl-BE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Brussels",
});
}
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = { const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
n8n: "n8n", n8n: "n8n",
ragcore: "rag", ragcore: "rag",
mcp_hub: "mcp", mcp_hub: "mcp",
}; };
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 { formatDateTime } = useLocaleFormat();
const { manifest, loading } = useDemoManifest(); const { manifest, loading } = useDemoManifest();
const { user } = useAuth(); const { user } = useAuth();
const { openGuide } = useDemoGuide(); const { openGuide } = useDemoGuide();
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">
<PageHeader <PageHeader
eyebrow="Over deze demo" eyebrow={t("about.eyebrow")}
title="Wat MobilityOps wel en niet is" title={t("about.title")}
description={ description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined}
manifest
? `${manifest.organization_name} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.`
: undefined
}
/> />
{loading && <LoadingState label="Demo-informatie laden…" />} {loading && <LoadingState label={t("about.loading")} />}
{manifest && ( {manifest && (
<> <>
{user?.role === "operations_manager" && ( {user?.role === "operations_manager" && (
<section className="record-surface about-card about-cta"> <section className="record-surface about-card about-cta">
<div> <div>
<strong>Liever meteen aan de slag?</strong> <strong>{t("about.ctaTitle")}</strong>
<p>De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.</p> <p>{t("about.ctaBody")}</p>
</div> </div>
<button type="button" className="button button-primary" onClick={openGuide}> <button type="button" className="button button-primary" onClick={openGuide}>
<Icon name="spark" /> Start begeleide demo <Icon name="spark" /> {t("about.ctaButton")}
</button> </button>
</section> </section>
)} )}
<section className="record-surface about-card"> <section className="record-surface about-card">
<h2>Het fictieve probleem</h2> <h2>{t("about.problemTitle")}</h2>
<p> <p>{t("about.problemBody", { orgName: manifest.organization_name })}</p>
{manifest.organization_name} 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.
MobilityOps toont hoe één samenhangend systeem die problemen vroeg signaleert en
gecontroleerd laat oplossen.
</p>
</section> </section>
<section className="record-surface about-card"> <section className="record-surface about-card">
<h2>Voor wie en met welke scope</h2> <h2>{t("about.scopeTitle")}</h2>
<p> <p>{t("about.scopeBody")}</p>
Deze demo is bedoeld voor wie wil zien hoe MobilityOps operationele problemen bij
een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en
iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één
samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke
reservaties, geen volledig CRM of ERP.
</p>
</section> </section>
<section className="record-surface about-card"> <section className="record-surface about-card">
<h2>Wat écht werkt</h2> <h2>{t("about.realTitle")}</h2>
<p> <p>{t("about.realBody")}</p>
Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde
toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met
serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen
oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n
met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde
testsuite (backend en Playwright end-to-end).
</p>
</section> </section>
<section className="record-surface about-card"> <section className="record-surface about-card">
<h2>Wat synthetisch is</h2> <h2>{t("about.syntheticTitle")}</h2>
<p> <p>{t("about.syntheticBody", { testDomain: ".test" })}</p>
De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis,
procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig
verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of
bedrijf; e-mailadressen gebruiken uitsluitend het testdomein <code>.test</code>.
</p>
</section> </section>
<section className="record-surface about-card"> <section className="record-surface about-card">
<h2>Architectuur in het kort</h2> <h2>{t("about.architectureTitle")}</h2>
<p> <p>{t("about.architectureBody")}</p>
Een React/TypeScript-frontend praat met een FastAPI-backend (PostgreSQL via
SQLAlchemy/Alembic-migraties); belangrijke bedrijfsregels leven in de backend, niet
in n8n of in prompts. Retours en andere gebeurtenissen worden eerst lokaal
gecommit en pas daarna asynchroon via een outbox-patroon aan n8n afgeleverd, zodat
een tijdelijke storing in de automatisering nooit een operationele actie blokkeert.
</p>
</section> </section>
<section className="record-surface about-card"> <section className="record-surface about-card">
<h2>Beveiliging en toegang</h2> <h2>{t("about.securityTitle")}</h2>
<p> <p>{t("about.securityBody")}</p>
Toegang verloopt via ondertekende, HTTP-only sessiecookies per rol; elke rol
gebonden aan een set toegestane routes, zowel serverzijdig afgedwongen als in de
navigatie weerspiegeld. Belangrijke statuswijzigingen worden altijd gecontroleerd
en gelogd nooit stilzwijgend automatisch gecorrigeerd.
</p>
</section> </section>
<section className="record-surface about-card"> <section className="record-surface about-card">
<h2>Hoe dit getest is</h2> <h2>{t("about.testingTitle")}</h2>
<p> <p>{t("about.testingBody")}</p>
Een geautomatiseerde backend-testsuite dekt bedrijfsregels en API-contracten;
een volledige Playwright-eindtot-eind-suite dekt de gebruikersstromen, inclusief
deze demo-ervaring zelf. Elke wijziging wordt bovendien tegen een schone checkout
(lege database, opnieuw opgebouwd vanaf de seed-data) gevalideerd voor deployment.
</p>
</section> </section>
<section aria-label="Koppelingsstatus" className="record-surface"> <section aria-label={t("about.integrationsTitle")} className="record-surface">
<SectionHeading <SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
title="Koppelingen — eerlijk gelabeld"
description="Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is."
/>
<div className="integration-cards"> <div className="integration-cards">
{manifest.integrations.map((integration) => ( {manifest.integrations.map((integration) => (
<article key={integration.key}> <article key={integration.key}>
<IntegrationMark kind={INTEGRATION_ICON[integration.key]} /> <IntegrationMark kind={INTEGRATION_ICON[integration.key]} />
<div> <div>
<span className="integration-kicker">{integration.status_label}</span> <span className="integration-kicker">
<h2>{integration.label}</h2> {t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)}
<p>{integration.detail}</p> </span>
<h2>{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}</h2>
<p>{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}</p>
</div> </div>
</article> </article>
))} ))}
@@ -149,29 +110,22 @@ export function AboutDemo() {
</section> </section>
<section className="record-surface about-card"> <section className="record-surface about-card">
<h2>Demo-omgeving herstellen</h2> <h2>{t("about.resetTitle")}</h2>
<p> <p>
De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset:{" "} <Trans
<strong>{formatDateTime(manifest.last_reset_at)}</strong>.{" "} i18nKey={user?.role === "operations_manager" ? "about.resetBodyManager" : "about.resetBodyEmployee"}
{user?.role === "operations_manager" ? ( t={t}
<> values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("about.unknown") }}
Gebruik <strong>Reset demo data</strong> in de zijbalk om opnieuw te beginnen. components={{ strong: <strong /> }}
</> />
) : (
<>Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.</>
)}
</p> </p>
</section> </section>
<section className="access-note record-surface" aria-label="Beperkingen"> <section className="access-note record-surface" aria-label={t("about.limitationsTitle")}>
<Icon name="shield" /> <Icon name="shield" />
<p> <p>
<strong>Beperkingen</strong> <strong>{t("about.limitationsTitle")}</strong>
<span> <span>{t("about.limitationsBody")}</span>
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.
</span>
</p> </p>
</section> </section>
</> </>
+154 -65
View File
@@ -1,31 +1,71 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { AuditEvent } from "../api/types"; import type { AuditEvent } from "../api/types";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
function describeChanges(before: Record<string, unknown> | null, after: Record<string, unknown> | null): string { function humanizeField(field: string): string {
if (!before && !after) return "No recorded change detail."; const spaced = field.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function shortRef(id: string): string {
return `AUD-${id.slice(0, 8).toUpperCase()}`;
}
function actionLabel(t: (key: string, options?: Record<string, unknown>) => string, action: string): string {
return t(`actions.${action}`, { defaultValue: action.replace(/_/g, " ") });
}
function ChangeDiff({
before,
after,
t,
}: {
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
if (!before && !after) return <p className="table-subtext">{t("noChangeDetail")}</p>;
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
const lines: string[] = []; const lines: { field: string; text: string }[] = [];
for (const key of keys) { for (const key of keys) {
const b = before?.[key]; const b = before?.[key];
const a = after?.[key]; const a = after?.[key];
if (JSON.stringify(b) === JSON.stringify(a)) continue; if (JSON.stringify(b) === JSON.stringify(a)) continue;
if (b === undefined) lines.push(`${key}: set to ${JSON.stringify(a)}`); const field = humanizeField(key);
else if (a === undefined) lines.push(`${key}: was ${JSON.stringify(b)}`); if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: JSON.stringify(a) }) });
else lines.push(`${key}: ${JSON.stringify(b)}${JSON.stringify(a)}`); else if (a === undefined) lines.push({ field, text: t("diff.was", { field, value: JSON.stringify(b) }) });
else lines.push({ field, text: t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }) });
} }
return lines.length > 0 ? lines.join("; ") : "No field-level change detected."; if (lines.length === 0) return <p className="table-subtext">{t("noFieldChange")}</p>;
return (
<ul className="change-diff">
{lines.map((line) => (
<li key={line.field}>{line.text}</li>
))}
</ul>
);
}
interface EventGroup {
correlationId: string;
primary: AuditEvent;
related: AuditEvent[];
} }
export function Audit() { export function Audit() {
const { t } = useTranslation("audit");
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth(); const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [events, setEvents] = useState<AuditEvent[] | null>(null); const [events, setEvents] = useState<AuditEvent[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [action, setAction] = useState(searchParams.get("action") ?? ""); const [action, setAction] = useState(searchParams.get("action") ?? "");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const correlationId = searchParams.get("correlation_id") ?? ""; const correlationId = searchParams.get("correlation_id") ?? "";
useEffect(() => { useEffect(() => {
@@ -38,9 +78,26 @@ export function Audit() {
api api
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`) .get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
.then(setEvents) .then(setEvents)
.catch(() => setError("Audit trail is unavailable right now.")); .catch(() => setError(t("unavailable")));
}, [action, correlationId, user]); }, [action, correlationId, user]);
const groups = useMemo<EventGroup[]>(() => {
if (!events) return [];
const order: string[] = [];
const byCorrelation = new Map<string, AuditEvent[]>();
for (const event of events) {
if (!byCorrelation.has(event.correlation_id)) {
order.push(event.correlation_id);
byCorrelation.set(event.correlation_id, []);
}
byCorrelation.get(event.correlation_id)!.push(event);
}
return order.map((id) => {
const group = byCorrelation.get(id)!;
return { correlationId: id, primary: group[0], related: group.slice(1) };
});
}, [events]);
function showRelatedEvents(id: string) { function showRelatedEvents(id: string) {
setSearchParams({ correlation_id: id }); setSearchParams({ correlation_id: id });
} }
@@ -49,91 +106,123 @@ export function Audit() {
setSearchParams(action ? { action } : {}); setSearchParams(action ? { action } : {});
} }
function toggleExpanded(id: string) {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
if (user?.role !== "operations_manager") { if (user?.role !== "operations_manager") {
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Assurance / Immutable history" title="Audit trail" description="The audit trail is visible to Operations Managers only." /> <PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
<p>Audit history is visible to Operations Managers only.</p> <p>{t("managerOnlyDetail")}</p>
</div> </div>
); );
} }
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Assurance / Immutable history" title="Audit trail" description="Trace important state changes, actors and correlation references." /> <PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<form className="filters" aria-label="Filter audit events"> <form className="filters" aria-label={t("title")}>
<label> <label>
Action {t("actionFilterLabel")}
<input <input
type="text" type="text"
value={action} value={action}
onChange={(e) => setAction(e.target.value)} onChange={(e) => setAction(e.target.value)}
placeholder="e.g. demo_login" placeholder={t("actionFilterPlaceholder")}
/> />
</label> </label>
</form> </form>
{correlationId && ( {correlationId && (
<p className="quiet-empty" role="status"> <p className="quiet-empty" role="status">
Showing only events linked to this action ({events?.length ?? "…"} related events).{" "} {t("relatedFilterActive", { count: events?.length ?? 0 })}{" "}
<button type="button" className="link-button" onClick={clearCorrelationFilter}> <button type="button" className="link-button" onClick={clearCorrelationFilter}>
Clear this filter {t("clearFilter")}
</button> </button>
</p> </p>
)} )}
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!error && !events && <LoadingState label="Loading audit trail…" />} {!error && !events && <LoadingState label={t("loading")} />}
{events && events.length === 0 && <EmptyState icon="audit" title="No audit events found" detail="Adjust the action filter." />} {events && events.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
{events && events.length > 0 && ( {groups.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{events.length} events</span><span>UTC stored · Brussels rendered</span></div><table className="data-table"> <div className="table-shell">
<caption className="visually-hidden">Audit events</caption> <div className="table-meta"><span>{t("count", { count: events!.length })}</span><span>{t("storedRendered")}</span></div>
<thead> <ul className="audit-group-list">
<tr> {groups.map((group) => {
<th scope="col">When</th> const e = group.primary;
<th scope="col">Actor</th> const isExpanded = expanded[group.correlationId] ?? false;
<th scope="col">Action</th> return (
<th scope="col">Entity</th> <li key={group.correlationId} className="audit-group panel">
<th scope="col">Change</th> <div className="audit-group-summary">
<th scope="col">Follow-up</th> <div className="audit-group-heading">
<th scope="col">Details</th> <strong>{actionLabel(t, e.action)}</strong>
</tr> <span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
</thead> </div>
<tbody> <div className="audit-group-meta">
{events.map((e) => ( <span><strong>{e.actor_label}</strong> <small className="table-subtext">{e.actor_type}</small></span>
<tr key={e.id}> {e.entity_link ? (
<td data-label="When"> <Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
<time dateTime={e.occurred_at}> ) : (
{new Date(e.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })} <span>{e.entity_ref ?? e.entity_type}</span>
</time> )}
</td> </div>
<td data-label="Actor"><strong>{e.actor_label}</strong><small className="table-subtext">{e.actor_type}</small></td> <ChangeDiff before={e.before} after={e.after} t={t} />
<td data-label="Action">{e.action.replace(/_/g, " ")}</td> <div className="audit-group-actions">
<td data-label="Entity"> {group.related.length > 0 && (
{e.entity_link ? ( <button type="button" className="link-button" onClick={() => toggleExpanded(group.correlationId)}>
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link> {isExpanded
) : ( ? t("hideTechnicalEvents")
e.entity_ref ?? e.entity_type : t("showTechnicalEvents", { count: group.related.length })}
)} </button>
</td> )}
<td data-label="Change">{describeChanges(e.before, e.after)}</td> {!correlationId && (
<td data-label="Follow-up"> <button type="button" className="link-button" onClick={() => showRelatedEvents(group.correlationId)}>
<button type="button" className="link-button" onClick={() => showRelatedEvents(e.correlation_id)}> {t("viewRelatedEvents")}
View related events </button>
</button> )}
</td> </div>
<td className="mono" data-label="Details"> </div>
<details>
<summary>{e.correlation_id.slice(0, 8)}</summary> <details className="evidence-disclosure">
<pre>{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre> <summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div>
<div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre>
</details> </details>
</td>
</tr> {isExpanded && group.related.length > 0 && (
))} <ul className="audit-related-list">
</tbody> {group.related.map((related) => (
</table></div> <li key={related.id}>
<div className="audit-group-heading">
<strong>{actionLabel(t, related.action)}</strong>
<span className="table-subtext">{formatDateTime(related.occurred_at)}</span>
</div>
<ChangeDiff before={related.before} after={related.after} t={t} />
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(related.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{related.id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre>
</details>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</div>
)} )}
</div> </div>
); );
+151 -90
View File
@@ -1,16 +1,30 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api, ApiError } from "../api/client";
import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types"; import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types";
import { StatusBadge } from "../components/Badge"; import { StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels"; import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
function deriveDisplayRef(run: AutomationRun): string {
const digits = run.aggregate_ref.match(/(\d+)$/)?.[1];
if (digits) return `AUT-RET-${digits}`;
return `AUT-${run.event_id.slice(0, 4).toUpperCase()}`;
}
export function Automation() { export function Automation() {
const { t } = useTranslation(["integrations", "common"]);
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth(); const { user } = useAuth();
const [runs, setRuns] = useState<AutomationRun[] | null>(null); const [runs, setRuns] = useState<AutomationRun[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [view, setView] = useState<ViewFilter>("attention");
const [expandSucceeded, setExpandSucceeded] = useState(false);
const [retryError, setRetryError] = useState<string | null>(null); const [retryError, setRetryError] = useState<string | null>(null);
const [retrying, setRetrying] = useState<string | null>(null); const [retrying, setRetrying] = useState<string | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null); const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
@@ -25,13 +39,9 @@ export function Automation() {
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`) .get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`)
.then(setRuns) .then(setRuns)
.catch(() => .catch(() =>
setError( setError(user?.role === "operations_manager" ? t("ledger.unavailable") : t("ledger.managerOnly")),
user?.role === "operations_manager"
? "Automation runs are unavailable right now."
: "Automation is only visible to Operations Managers.",
),
); );
}, [status, user]); }, [status, user, t]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -60,35 +70,90 @@ export function Automation() {
load(); load();
loadIntegrationStatus(); loadIntegrationStatus();
} catch (err) { } catch (err) {
setRetryError(err instanceof ApiError ? err.message : "Could not retry this delivery."); setRetryError(err instanceof ApiError ? err.message : t("ledger.retryFailed"));
} finally { } finally {
setRetrying(null); setRetrying(null);
} }
} }
const visibleRuns = useMemo(() => {
if (!runs) return null;
switch (view) {
case "attention":
return runs.filter((r) => r.status === "failed" || r.status === "pending" || r.status === "delivering");
case "recent":
return runs.slice(0, 5);
case "succeeded":
return runs.filter((r) => r.status === "succeeded");
default:
return runs;
}
}, [runs, view]);
const succeededRuns = useMemo(() => (visibleRuns ?? []).filter((r) => r.status === "succeeded"), [visibleRuns]);
const otherRuns = useMemo(() => (visibleRuns ?? []).filter((r) => r.status !== "succeeded"), [visibleRuns]);
const groupSucceeded = view !== "succeeded" && succeededRuns.length > 3;
if (user?.role !== "operations_manager") { if (user?.role !== "operations_manager") {
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Assurance / Integrations" title="Integrations" description="Delivery evidence is visible to Operations Managers only." /> <PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("ledger.managerOnly")} />
<p>Automation delivery status is visible to Operations Managers only.</p> <p>{t("ledger.managerOnly")}</p>
</div> </div>
); );
} }
function renderRow(r: AutomationRun) {
return (
<tr key={r.event_id}>
<td className="mono" data-label={t("ledger.columns.event")}>
<details>
<summary>{deriveDisplayRef(r)}</summary>
<span className="table-subtext">{r.event_id}</span>
</details>
</td>
<td data-label={t("ledger.columns.type")}>{t(`ledger.eventTypes.${r.event_type}`, { defaultValue: r.event_type })}</td>
<td data-label={t("ledger.columns.booking")}>{r.aggregate_ref}</td>
<td data-label={t("ledger.columns.status")}>
<StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} />
</td>
<td data-label={t("ledger.columns.attempts")}>{r.attempts}</td>
<td data-label={t("ledger.columns.lastError")}>{r.last_error ?? "—"}</td>
<td data-label={t("ledger.columns.when")}>
<time dateTime={r.occurred_at}>{formatDateTime(r.occurred_at)}</time>
</td>
<td data-label={t("ledger.columns.action")}>
{r.status === "failed" ? (
<button type="button" onClick={() => handleRetry(r.event_id)} disabled={retrying === r.event_id}>
{retrying === r.event_id ? t("ledger.retrying") : t("ledger.retry")}
</button>
) : (
t("ledger.noAction")
)}
</td>
</tr>
);
}
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Assurance / Integrations" title="Integration control" description="Monitor delivery health, graceful degradation and retryable workflow events." /> <PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<section className="integration-cards" aria-label="Integration health"> <section className="integration-cards" aria-label={t("title")}>
<article> <article>
<IntegrationMark kind="n8n" /> <IntegrationMark kind="n8n" />
<div> <div>
<span className="integration-kicker">Orchestration</span> <span className="integration-kicker">{t("cards.orchestrationKicker")}</span>
<h2>n8n delivery</h2> <h2>{t("cards.n8nTitle")}</h2>
<p> <p>
{integrationStatus {integrationStatus
? `${integrationStatus.n8n.succeeded} succeeded · ${integrationStatus.n8n.failed} failed · ${integrationStatus.n8n.pending} pending · ${integrationStatus.n8n.delivering} delivering.` ? t("cards.n8nSummary", {
: "Return events are committed locally first and then delivered through the outbox."} succeeded: integrationStatus.n8n.succeeded,
failed: integrationStatus.n8n.failed,
pending: integrationStatus.n8n.pending,
delivering: integrationStatus.n8n.delivering,
})
: t("cards.n8nFallback")}
</p> </p>
</div> </div>
{(() => { {(() => {
@@ -96,7 +161,7 @@ export function Automation() {
return ( return (
<StatusBadge <StatusBadge
status={meta?.statusClass ?? (runs?.[0]?.status ?? "no_events")} status={meta?.statusClass ?? (runs?.[0]?.status ?? "no_events")}
label={meta?.label} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined}
/> />
); );
})()} })()}
@@ -104,111 +169,107 @@ export function Automation() {
<article> <article>
<IntegrationMark kind="rag" /> <IntegrationMark kind="rag" />
<div> <div>
<span className="integration-kicker">Knowledge</span> <span className="integration-kicker">{t("cards.knowledgeKicker")}</span>
<h2>Knowledge assistant</h2> <h2>{t("cards.knowledgeTitle")}</h2>
<p> <p>
{knowledge {knowledge
? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed in ${knowledge.collection}.` ? t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
: "Health evidence is currently unavailable."} count: knowledge.document_count,
collection: knowledge.collection,
})
: t("cards.knowledgeUnavailable")}
</p> </p>
</div> </div>
<StatusBadge <StatusBadge
status={knowledge?.available ? "available" : "unavailable"} status={knowledge?.available ? "available" : "unavailable"}
label={ label={
knowledge?.available knowledge?.available
? knowledge.provider === "ragcore" ? t(`statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
? "Operational" : t("statusLabels.unavailable")
: "Demo mode"
: "Unavailable"
} }
/> />
</article> </article>
<article> <article>
<IntegrationMark kind="mcp" /> <IntegrationMark kind="mcp" />
<div> <div>
<span className="integration-kicker">Tool gateway</span> <span className="integration-kicker">{t("cards.gatewayKicker")}</span>
<h2>MCP Hub</h2> <h2>{t("cards.mcpTitle")}</h2>
<p> <p>{integrationStatus?.mcp_hub.registration_enabled ? t("cards.mcpEnabled") : t("cards.mcpNotConnected")}</p>
{integrationStatus?.mcp_hub.registration_enabled
? "Registration is enabled for this deployment."
: "Not yet connected — prepared for future controlled tool calls from the ITWorx MCP Hub."}
</p>
</div> </div>
{(() => { {(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta?.label} />; return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />;
})()} })()}
</article> </article>
</section> </section>
<SectionHeading title="Delivery ledger" description="Persisted outbox attempts with the latest failure evidence." /> <SectionHeading title={t("ledger.title")} description={t("ledger.description")} />
<form className="filters" aria-label="Filter automation runs"> <form className="filters" aria-label={t("ledger.title")}>
<label> <label>
Status {t("ledger.filterLabel")}
<select value={view} onChange={(e) => setView(e.target.value as ViewFilter)}>
<option value="attention">{t("ledger.filterNeedsAttention")}</option>
<option value="recent">{t("ledger.filterRecent")}</option>
<option value="succeeded">{t("ledger.filterSucceeded")}</option>
<option value="all">{t("ledger.filterAll")}</option>
</select>
</label>
<label>
{t("ledger.statusFilterLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}> <select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option> <option value="">{t("ledger.statusAll")}</option>
<option value="pending">Pending</option> <option value="pending">{t("ledger.statusPending")}</option>
<option value="delivering">Delivering</option> <option value="delivering">{t("ledger.statusDelivering")}</option>
<option value="succeeded">Succeeded</option> <option value="succeeded">{t("ledger.statusSucceeded")}</option>
<option value="failed">Failed</option> <option value="failed">{t("ledger.statusFailed")}</option>
</select> </select>
</label> </label>
</form> </form>
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{retryError && <p className="error" role="alert">{retryError}</p>} {retryError && <p className="error" role="alert">{retryError}</p>}
{!error && !runs && <LoadingState label="Loading workflow delivery ledger…" />} {!error && !runs && <LoadingState label={t("ledger.loading")} />}
{runs && runs.length === 0 && <p>No automation runs match this filter.</p>} {visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
{runs && runs.length > 0 && ( {visibleRuns && visibleRuns.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{runs.length} workflow events</span><span>Bounded retries</span></div><table className="data-table"> <div className="table-shell">
<caption className="visually-hidden">Automation runs</caption> <div className="table-meta"><span>{t("ledger.count", { count: visibleRuns.length })}</span><span>{t("ledger.boundedRetries")}</span></div>
<thead> <table className="data-table">
<tr> <caption className="visually-hidden">{t("ledger.title")}</caption>
<th scope="col">Event</th> <thead>
<th scope="col">Type</th> <tr>
<th scope="col">Booking</th> <th scope="col">{t("ledger.columns.event")}</th>
<th scope="col">Status</th> <th scope="col">{t("ledger.columns.type")}</th>
<th scope="col">Attempts</th> <th scope="col">{t("ledger.columns.booking")}</th>
<th scope="col">Last error</th> <th scope="col">{t("ledger.columns.status")}</th>
<th scope="col">When</th> <th scope="col">{t("ledger.columns.attempts")}</th>
<th scope="col">Action</th> <th scope="col">{t("ledger.columns.lastError")}</th>
</tr> <th scope="col">{t("ledger.columns.when")}</th>
</thead> <th scope="col">{t("ledger.columns.action")}</th>
<tbody>
{runs.map((r) => (
<tr key={r.event_id}>
<td className="mono" data-label="Event">{r.event_id.slice(0, 8)}</td>
<td data-label="Type">{r.event_type}</td>
<td data-label="Booking">{r.aggregate_ref}</td>
<td data-label="Status">
<StatusBadge status={r.status} />
</td>
<td data-label="Attempts">{r.attempts}</td>
<td data-label="Last error">{r.last_error ?? "—"}</td>
<td data-label="When">
<time dateTime={r.occurred_at}>
{new Date(r.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
</time>
</td>
<td data-label="Action">
{r.status === "failed" ? (
<button
type="button"
onClick={() => handleRetry(r.event_id)}
disabled={retrying === r.event_id}
>
{retrying === r.event_id ? "Retrying…" : "Retry"}
</button>
) : (
"—"
)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table></div> {otherRuns.map(renderRow)}
{groupSucceeded ? (
<>
<tr>
<td colSpan={8}>
<button type="button" className="link-button" onClick={() => setExpandSucceeded((v) => !v)}>
{expandSucceeded
? t("ledger.hideIndividually")
: t("ledger.groupedSucceeded", { count: succeededRuns.length })}
</button>
</td>
</tr>
{expandSucceeded && succeededRuns.map(renderRow)}
</>
) : (
succeededRuns.map(renderRow)
)}
</tbody>
</table>
</div>
)} )}
</div> </div>
); );
+19 -20
View File
@@ -1,14 +1,18 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types"; import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types";
import { useDemoManifest } from "../context/DemoManifestContext"; import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge"; import { StatusBadge } from "../components/Badge";
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm"; import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function BookingDetail() { export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns"]);
const { formatDateTime, formatNumber } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>(); const { publicRef } = useParams<{ publicRef: string }>();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
const [booking, setBooking] = useState<Booking | null>(null); const [booking, setBooking] = useState<Booking | null>(null);
@@ -21,7 +25,7 @@ export function BookingDetail() {
api api
.get<Booking>(`/api/v1/bookings/${publicRef}`) .get<Booking>(`/api/v1/bookings/${publicRef}`)
.then(setBooking) .then(setBooking)
.catch(() => setError("This booking could not be found.")); .catch(() => setError(t("detail.notFound")));
}, [publicRef]); }, [publicRef]);
useEffect(() => { useEffect(() => {
@@ -52,33 +56,28 @@ export function BookingDetail() {
} }
if (error) return <ErrorState message={error} />; if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label="Loading booking record…" />; if (!booking) return <LoadingState label={t("detail.loading")} />;
return ( return (
<div className="page"> <div className="page">
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> Booking ledger</Link> <Link className="back-link" to="/bookings"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow="Bookings / Rental record" title={booking.public_ref} description={`${booking.customer_name} · ${booking.vehicle_ref}`} actions={<StatusBadge status={booking.status} />} /> <PageHeader eyebrow={t("detail.eyebrow")} title={booking.public_ref} description={`${booking.customer_name} · ${booking.vehicle_ref}`} actions={<StatusBadge status={booking.status} label={t(`statuses.${booking.status}`, { defaultValue: booking.status })} />} />
<section className="record-surface" aria-label="Booking facts"><dl className="detail-grid"> <section className="record-surface" aria-label={t("detail.eyebrow")}><dl className="detail-grid">
<div><dt>Customer</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div> <div><dt>{t("detail.customer")}</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
<div><dt>Vehicle</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div> <div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
<div><dt>Starts</dt><dd>{new Date(booking.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div> <div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div>
<div><dt>Ends</dt><dd>{new Date(booking.ends_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div> <div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div>
<div><dt>Start odometer</dt><dd>{booking.start_odometer_km ?? "—"} km</dd></div> <div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : "—"}</dd></div>
<div><dt>End odometer</dt><dd>{booking.end_odometer_km ?? "—"} km</dd></div> <div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : "—"}</dd></div>
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div> <div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section> </dl></section>
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && ( {isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label="Demo scenario"> <section className="record-surface scenario-callout" aria-label="Demo scenario">
<Icon name="spark" /> <Icon name="spark" />
<div> <div>
<strong>Demonstratiescenario: afwijkende kilometerstand</strong> <strong>{t("returns:scenario.title")}</strong>
<p> <p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm) })}</p>
Dit voertuig staat momenteel op <strong>{canonicalOdometerKm.toLocaleString("en-GB")} km</strong>.
Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder
ligt een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de
retour om te zien hoe MobilityOps dit detecteert en afhandelt.
</p>
</div> </div>
</section> </section>
)} )}
@@ -89,7 +88,7 @@ export function BookingDetail() {
from its very first render -- never updated asynchronously after mount, which from its very first render -- never updated asynchronously after mount, which
previously raced with anyone already typing into the field. */} previously raced with anyone already typing into the field. */}
{!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && ( {!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && (
<LoadingState label="Scenario voorbereiden…" /> <LoadingState label={t("returns:scenario.preparing")} />
)} )}
{!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && ( {!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
<ReturnForm <ReturnForm
+33 -28
View File
@@ -1,13 +1,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Booking } from "../api/types"; import type { Booking } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge"; import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"]; const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
export function Bookings() { export function Bookings() {
const { t } = useTranslation("bookings");
const { formatShortDate } = useLocaleFormat();
const [bookings, setBookings] = useState<Booking[] | null>(null); const [bookings, setBookings] = useState<Booking[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
@@ -23,7 +27,7 @@ export function Bookings() {
api api
.get<Booking[]>(`/api/v1/bookings?${params.toString()}`) .get<Booking[]>(`/api/v1/bookings?${params.toString()}`)
.then(setBookings) .then(setBookings)
.catch(() => setError("Booking list is unavailable right now.")); .catch(() => setError(t("list.unavailable")));
}, [status]); }, [status]);
useEffect(() => { useEffect(() => {
@@ -37,20 +41,20 @@ export function Bookings() {
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Operations / Schedule" title="Bookings" description="Review active rental windows and upcoming vehicle commitments." /> <PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} />
<form className="filters" aria-label="Filter bookings"> <form className="filters" aria-label={t("list.title")}>
<label> <label>
Search {t("list.searchLabel")}
<input type="text" value={query} onChange={(e) => { setQuery(e.target.value); setPage(1); }} placeholder="Booking, customer or vehicle" /> <input type="text" value={query} onChange={(e) => { setQuery(e.target.value); setPage(1); }} placeholder={t("list.searchPlaceholder")} />
</label> </label>
<label> <label>
Status {t("list.statusLabel")}
<select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}> <select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
<option value="">All statuses</option> <option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((s) => ( {STATUS_OPTIONS.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{s} {t(`statuses.${s}`)}
</option> </option>
))} ))}
</select> </select>
@@ -58,44 +62,45 @@ export function Bookings() {
</form> </form>
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!error && !bookings && <LoadingState label="Loading booking ledger…" />} {!error && !bookings && <LoadingState label={t("list.loading")} />}
{bookings && bookings.length === 0 && <EmptyState icon="bookings" title="No bookings found" detail="Adjust the booking status filter." />} {bookings && bookings.length === 0 && <EmptyState icon="bookings" title={t("list.empty")} detail={t("list.emptyDetail")} />}
{bookings && bookings.length > 0 && (() => { {bookings && bookings.length > 0 && (() => {
const filtered = bookings.filter((b) => `${b.public_ref} ${b.customer_name} ${b.vehicle_ref}`.toLowerCase().includes(query.toLowerCase())); const filtered = bookings.filter((b) => `${b.public_ref} ${b.customer_name} ${b.vehicle_ref}`.toLowerCase().includes(query.toLowerCase()));
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage)); const totalPages = Math.max(1, Math.ceil(filtered.length / perPage));
const visible = filtered.slice((page - 1) * perPage, page * perPage); const visible = filtered.slice((page - 1) * perPage, page * perPage);
return filtered.length === 0 ? <EmptyState icon="search" title="No matching bookings" detail="Try a broader search term." /> : <div className="table-shell"><div className="table-meta"><span>{filtered.length} bookings</span><span>Page {page} of {totalPages}</span></div><table className="data-table"> return filtered.length === 0 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.pageOf", { page, total: totalPages })}</span></div><table className="data-table">
<caption className="visually-hidden">Bookings</caption> <caption className="visually-hidden">{t("list.title")}</caption>
<thead> <thead>
<tr> <tr>
<th scope="col">Reference</th> <th scope="col">{t("list.columns.reference")}</th>
<th scope="col">Customer</th> <th scope="col">{t("list.columns.customer")}</th>
<th scope="col">Vehicle</th> <th scope="col">{t("list.columns.vehicle")}</th>
<th scope="col">Window</th> <th scope="col">{t("list.columns.window")}</th>
<th scope="col">Status</th> <th scope="col">{t("list.columns.status")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visible.map((b) => ( {visible.map((b) => (
<tr key={b.public_ref}> <tr key={b.public_ref} className="row-clickable">
<th scope="row" data-label="Reference"> <th scope="row" data-label={t("list.columns.reference")}>
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link> {b.public_ref}
<Link className="row-link" to={`/bookings/${b.public_ref}`}><span className="visually-hidden">{b.public_ref}</span></Link>
</th> </th>
<td data-label="Customer">{b.customer_name}</td> <td data-label={t("list.columns.customer")}>{b.customer_name}</td>
<td data-label="Vehicle"> <td data-label={t("list.columns.vehicle")}>
<Link to={`/vehicles/${b.vehicle_ref}`}>{b.vehicle_ref}</Link> <Link to={`/vehicles/${b.vehicle_ref}`} className="cell-link">{b.vehicle_ref}</Link>
</td> </td>
<td data-label="Window"> <td data-label={t("list.columns.window")}>
{new Date(b.starts_at).toLocaleDateString("en-GB")} {new Date(b.ends_at).toLocaleDateString("en-GB")} {formatShortDate(b.starts_at)} {formatShortDate(b.ends_at)}
</td> </td>
<td data-label="Status"> <td data-label={t("list.columns.status")}>
<StatusBadge status={b.status} /> <StatusBadge status={b.status} label={t(`statuses.${b.status}`, { defaultValue: b.status })} />
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table><div className="pagination" aria-label="Booking pages"><button type="button" disabled={page === 1} onClick={() => setPage((p) => p - 1)}>Previous</button><span>{(page - 1) * perPage + 1}{Math.min(page * perPage, filtered.length)} of {filtered.length}</span><button type="button" disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}>Next</button></div></div>; </table><div className="pagination" aria-label={t("list.paginationLabel")}><button type="button" disabled={page === 1} onClick={() => setPage((p) => p - 1)}>{t("list.previous")}</button><span>{t("list.rangeOf", { from: (page - 1) * perPage + 1, to: Math.min(page * perPage, filtered.length), total: filtered.length })}</span><button type="button" disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}>{t("list.next")}</button></div></div>;
})()} })()}
</div> </div>
); );
+88 -73
View File
@@ -1,33 +1,36 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types"; import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types";
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 { SeverityBadge, StatusBadge } from "../components/Badge"; import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels"; import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
const FLEET_METRICS: Array<{ key: keyof DashboardData["metrics"]; label: string; tone: string }> = [ const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [
{ key: "available", label: "Available", tone: "ready" }, { key: "available", labelKey: "readiness.available", tone: "ready" },
{ key: "rented", label: "Rented", tone: "neutral" }, { key: "rented", labelKey: "readiness.rented", tone: "neutral" },
{ key: "cleaning", label: "Cleaning", tone: "neutral" }, { key: "cleaning", labelKey: "readiness.cleaning", tone: "neutral" },
{ key: "maintenance", label: "Maintenance", tone: "warning" }, { key: "maintenance", labelKey: "readiness.maintenance", tone: "warning" },
{ key: "blocked", label: "Blocked", tone: "critical" }, { key: "blocked", labelKey: "readiness.blocked", tone: "critical" },
]; ];
function localTime(value: string, withDate = false) { function attentionItemTitle(
return new Date(value).toLocaleString("en-GB", { t: (key: string, options?: Record<string, unknown>) => string,
...(withDate ? { day: "2-digit", month: "short" } : {}), item: { rule_type: string; link_ref: string },
hour: "2-digit", ): string {
minute: "2-digit", const ruleLabel = t(`quality:ruleTypes.${item.rule_type}`, { defaultValue: item.rule_type.replace(/_/g, " ") });
timeZone: "Europe/Brussels", return `${ruleLabel}${item.link_ref}`;
});
} }
export function Dashboard() { export function Dashboard() {
const { t } = useTranslation(["dashboard", "common", "integrations"]);
const { formatTime, formatShortDate } = useLocaleFormat();
const { user } = useAuth(); const { user } = useAuth();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide(); const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
@@ -52,7 +55,7 @@ export function Dashboard() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
useEffect(() => { useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError("Dashboard data is unavailable right now.")); api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error")));
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null)); api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, []); }, []);
@@ -66,96 +69,104 @@ export function Dashboard() {
const attention = useMemo(() => data?.attention_items.filter((item) => { const attention = useMemo(() => data?.attention_items.filter((item) => {
const matchesSeverity = severity === "all" || item.severity === severity; const matchesSeverity = severity === "all" || item.severity === severity;
const haystack = `${item.title} ${item.detail} ${item.link_ref}`.toLowerCase(); const haystack = `${attentionItemTitle(t, item)} ${item.detail} ${item.link_ref}`.toLowerCase();
return matchesSeverity && haystack.includes(query.toLowerCase()); return matchesSeverity && haystack.includes(query.toLowerCase());
}) ?? [], [data, query, severity]); }) ?? [], [data, query, severity, t]);
if (error) return <ErrorState message={error} />; if (error) return <ErrorState message={error} />;
if (!data) return <LoadingState label="Loading operations overview…" />; if (!data) return <LoadingState label={t("common:status.loading")} />;
const latestRun = data.recent_automation[0]; const latestRun = data.recent_automation[0];
const n8nState = !latestRun ? "No delivery yet" : latestRun.status === "failed" ? "Needs attention" : latestRun.status; const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
return ( return (
<div className="page dashboard-page"> <div className="page dashboard-page">
<PageHeader eyebrow="Operations / Live overview" title="Good morning. Heres the fleet." description="Readiness, exceptions and hand-offs across todays operation." actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> View fleet</Link>} /> <PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
<section className="demo-start-panel" aria-label="Demo starten"> <section className="demo-start-panel" aria-label={t("demoStart.title")}>
<div> <div>
<Icon name="spark" /> <Icon name="spark" />
<div> <div>
<strong>Probeer een demonstratiescenario</strong> <strong>{t("demoStart.title")}</strong>
<span> <span>
{manifest ? `${manifest.scenarios.filter((s) => s.ready).length} van ${manifest.scenarios.length} scenario's klaar voor demo.` : "Vijf afgebakende scenario's."} {manifest ? t("demoStart.readyCount", { ready: readyCount, total: manifest.scenarios.length }) : t("demoStart.readyCountFallback")}
</span> </span>
</div> </div>
</div> </div>
<div className="demo-start-actions"> <div className="demo-start-actions">
{user?.role === "operations_manager" && ( {user?.role === "operations_manager" && (
<button type="button" className="button button-secondary" onClick={openGuide}> <button type="button" className="button button-secondary" onClick={openGuide}>
<Icon name="spark" /> {completed.size > 0 ? `Verder met demo-gids (${currentIndex + 1}/${totalSteps})` : "Start demo-gids"} <Icon name="spark" /> {completed.size > 0 ? t("demoStart.resumeGuide", { current: currentIndex + 1, total: totalSteps }) : t("demoStart.startGuide")}
</button> </button>
)} )}
<Link className="button button-primary" to="/scenarios">Bekijk scenario's <Icon name="chevron" /></Link> <Link className="button button-primary" to="/scenarios">{t("demoStart.viewScenarios")} <Icon name="chevron" /></Link>
</div> </div>
</section> </section>
<section className="readiness-band" aria-labelledby="readiness-heading"> <section className="readiness-band" aria-labelledby="readiness-heading">
<div className="readiness-label"> <div className="readiness-label">
<span className="live-indicator" /> <span className="live-indicator" />
<div><h2 id="readiness-heading">Fleet readiness</h2><p>Live from persisted vehicle state</p></div> <div><h2 id="readiness-heading">{t("readiness.title")}</h2><p>{t("readiness.description")}</p></div>
</div> </div>
<dl className="readiness-metrics"> <dl className="readiness-metrics">
{FLEET_METRICS.map((metric) => ( {FLEET_METRIC_KEYS.map((metric) => (
<div key={metric.key} className={`metric-cell metric-${metric.tone}`}> <div key={metric.key} className={`metric-cell metric-${metric.tone}`}>
<dt>{metric.label}</dt><dd>{data.metrics[metric.key]}</dd> <dt>{t(metric.labelKey)}</dt><dd>{data.metrics[metric.key]}</dd>
</div> </div>
))} ))}
</dl> </dl>
<Link className="inline-action" to="/vehicles">Open fleet <Icon name="chevron" /></Link> <Link className="inline-action" to="/vehicles">{t("readiness.openFleet")} <Icon name="chevron" /></Link>
</section> </section>
<div className="operations-grid"> <div className="operations-grid">
<section className="work-panel attention-panel" aria-labelledby="attention-heading"> <section className="work-panel attention-panel" aria-labelledby="attention-heading">
<SectionHeading title="Attention queue" description={`${data.metrics.open_quality_issues} open quality issues · ${data.metrics.pending_or_failed_workflows} workflow exceptions`} action={canSeeQuality ? <Link to="/data-quality">Review queue <Icon name="chevron" /></Link> : undefined} /> <SectionHeading headingId="attention-heading" title={t("attention.title")} description={t("attention.description", { openIssues: data.metrics.open_quality_issues, workflowExceptions: data.metrics.pending_or_failed_workflows })} action={canSeeQuality ? <Link to="/data-quality">{t("attention.reviewQueue")} <Icon name="chevron" /></Link> : undefined} />
<div className="queue-controls"> <div className="queue-controls">
<label className="compact-search"><Icon name="search" /><span className="visually-hidden">Search attention queue</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter issues…" /></label> <label className="compact-search"><Icon name="search" /><span className="visually-hidden">{t("attention.filterAriaLabel")}</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("attention.filterPlaceholder")} /></label>
<label><span className="visually-hidden">Severity</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">All severity</option><option value="high">Critical</option><option value="medium">Warning</option><option value="low">Info</option></select></label> <label><span className="visually-hidden">{t("attention.severityAriaLabel")}</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">{t("attention.severityAll")}</option><option value="high">{t("attention.severityHigh")}</option><option value="medium">{t("attention.severityMedium")}</option><option value="low">{t("attention.severityLow")}</option></select></label>
</div> </div>
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> No issues match this filter.</p> : ( {attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> {t("attention.empty")}</p> : (
<ul className="attention-list"> <ul className="attention-list">
{attention.slice(0, 6).map((item, index) => ( {attention.slice(0, 6).map((item, index) => {
<li key={`${item.link_ref}-${index}`}> const href = item.issue_ref && canSeeQuality
<SeverityBadge severity={item.severity} /> ? `/data-quality/${item.issue_ref}`
<div className="queue-copy"> : item.link_type === "vehicle"
<p className="attention-title"> ? `/vehicles/${item.link_ref}`
{item.issue_ref && canSeeQuality ? ( : null;
<Link to={`/data-quality/${item.issue_ref}`}>{item.title}</Link> const title = attentionItemTitle(t, item);
) : item.link_type === "vehicle" ? ( return (
<Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link> <li key={`${item.link_ref}-${index}`} className={href ? "row-clickable" : ""}>
) : ( <SeverityBadge severity={item.severity} />
item.title <div className="queue-copy">
)} <p className="attention-title">{title}</p>
</p> <p className="attention-detail">{item.detail}</p>
<p className="attention-detail">{item.detail}</p> </div>
</div> <span className="queue-ref">{item.link_ref}</span>
<span className="queue-ref">{item.link_ref}</span> <Icon name="chevron" className="row-chevron" />
<Icon name="chevron" className="row-chevron" /> {href && (
</li> <Link className="row-link" to={href} aria-label={t("attention.openRecord", { title })}>
))} <span className="visually-hidden">{title}</span>
</Link>
)}
</li>
);
})}
</ul> </ul>
)} )}
</section> </section>
<section className="work-panel timeline-panel" aria-labelledby="today-heading"> <section className="work-panel timeline-panel" aria-labelledby="today-heading">
<SectionHeading title="Todays movements" description="Departures and returns in Europe/Brussels" action={<Link to="/bookings">All bookings <Icon name="chevron" /></Link>} /> <SectionHeading headingId="today-heading" title={t("movements.title")} description={t("movements.description")} action={<Link to="/bookings">{t("movements.allBookings")} <Icon name="chevron" /></Link>} />
{data.today.length === 0 ? <p className="quiet-empty"><Icon name="clock" /> No movements scheduled today.</p> : ( {data.today.length === 0 ? <p className="quiet-empty"><Icon name="clock" /> {t("movements.empty")}</p> : (
<ol className="movement-timeline"> <ol className="movement-timeline">
{data.today.slice(0, 6).map((item) => ( {data.today.map((item) => (
<li key={`${item.kind}-${item.booking_ref}`}> <li key={`${item.kind}-${item.booking_ref}`} className="row-clickable">
<time dateTime={item.scheduled_at}>{localTime(item.scheduled_at)}</time> <time dateTime={item.scheduled_at}>{formatTime(item.scheduled_at)}</time>
<span className={`timeline-node timeline-${item.kind}`}><Icon name={item.kind === "return" ? "arrow-left" : "chevron"} /></span> <span className={`timeline-node timeline-${item.kind}`}><Icon name={item.kind === "return" ? "arrow-left" : "chevron"} /></span>
<div><span className="movement-kind">{item.kind}</span><Link to={`/bookings/${item.booking_ref}`}>{item.booking_ref}</Link><small>{item.vehicle_ref}</small></div> <div><span className="movement-kind">{item.kind === "return" ? t("movements.return") : t("movements.departure")}</span><span className="movement-ref">{item.booking_ref}</span><small>{item.vehicle_ref}</small></div>
<Link className="row-link" to={`/bookings/${item.booking_ref}`} aria-label={t("movements.openBooking", { ref: item.booking_ref })}>
<span className="visually-hidden">{item.booking_ref}</span>
</Link>
</li> </li>
))} ))}
</ol> </ol>
@@ -165,26 +176,26 @@ export function Dashboard() {
<div className="secondary-grid"> <div className="secondary-grid">
<section className="work-panel integration-panel" aria-labelledby="integration-heading"> <section className="work-panel integration-panel" aria-labelledby="integration-heading">
<SectionHeading title="Integration pulse" description="Current evidence from connected services" action={canSeeAutomation ? <Link to="/automation">System detail <Icon name="chevron" /></Link> : undefined} /> <SectionHeading headingId="integration-heading" title={t("integrationPulse.title")} description={t("integrationPulse.description")} action={canSeeAutomation ? <Link to="/automation">{t("integrationPulse.systemDetail")} <Icon name="chevron" /></Link> : undefined} />
<ul className="integration-list"> <ul className="integration-list">
<li> <li>
<IntegrationMark kind="n8n" /> <IntegrationMark kind="n8n" />
<div> <div>
<strong>n8n delivery</strong> <strong>{t("integrationPulse.n8nTitle")}</strong>
<span> <span>
{integrationStatus {integrationStatus
? `${integrationStatus.n8n.succeeded} succeeded · ${integrationStatus.n8n.failed} failed` ? t("integrationPulse.n8nSummary", { succeeded: integrationStatus.n8n.succeeded, failed: integrationStatus.n8n.failed })
: latestRun : latestRun
? `Latest event ${latestRun.aggregate_ref}` ? t("integrationPulse.n8nLatest", { ref: latestRun.aggregate_ref })
: "No workflow evidence recorded"} : t("integrationPulse.n8nNoEvidence")}
</span> </span>
</div> </div>
{(() => { {(() => {
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
return ( return (
<StatusBadge <StatusBadge
status={meta?.statusClass ?? n8nState.toLowerCase().replace(/ /g, "_")} status={meta?.statusClass ?? "no_events"}
label={meta?.label} label={meta ? t(`integrations:statusLabels.${meta.labelKey}`) : undefined}
/> />
); );
})()} })()}
@@ -192,34 +203,38 @@ export function Dashboard() {
<li> <li>
<IntegrationMark kind="rag" /> <IntegrationMark kind="rag" />
<div> <div>
<strong>Knowledge assistant</strong> <strong>{t("integrationPulse.knowledgeTitle")}</strong>
<span>{knowledge ? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed` : "Health check unavailable"}</span> <span>{knowledge ? t("integrationPulse.knowledgeSummary", { count: knowledge.document_count }) : t("integrationPulse.knowledgeUnavailable")}</span>
</div> </div>
<StatusBadge <StatusBadge
status={knowledge?.available ? "available" : "unavailable"} status={knowledge?.available ? "available" : "unavailable"}
label={knowledge?.available ? (knowledge.provider === "ragcore" ? "Operational" : "Demo mode") : "Unavailable"} label={
knowledge?.available
? t(`integrations:statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
: t("integrations:statusLabels.unavailable")
}
/> />
</li> </li>
<li> <li>
<IntegrationMark kind="mcp" /> <IntegrationMark kind="mcp" />
<div> <div>
<strong>MCP Hub</strong> <strong>{t("integrationPulse.mcpTitle")}</strong>
<span>{integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "Not yet connected"}</span> <span>{integrationStatus?.mcp_hub.registration_enabled ? t("integrationPulse.mcpEnabled") : t("integrationPulse.mcpNotConnected")}</span>
</div> </div>
{(() => { {(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta?.label} />; return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`integrations:statusLabels.${meta.labelKey}`) : undefined} />;
})()} })()}
</li> </li>
</ul> </ul>
</section> </section>
<section className="work-panel recent-panel" aria-labelledby="recent-heading"> <section className="work-panel recent-panel" aria-labelledby="recent-heading">
<SectionHeading title="Recent activity" description="Latest audited workflow changes" /> <SectionHeading headingId="recent-heading" title={t("recent.title")} description={t("recent.description")} />
{data.recent_automation.length === 0 ? <p className="quiet-empty">No automation activity recorded.</p> : ( {data.recent_automation.length === 0 ? <p className="quiet-empty">{t("recent.empty")}</p> : (
<ul className="recent-list"> <ul className="recent-list">
{data.recent_automation.slice(0, 4).map((run) => ( {data.recent_automation.slice(0, 4).map((run) => (
<li key={run.event_id}><span className="activity-icon"><Icon name="activity" /></span><div><strong>{run.event_type.replace(/_/g, " ")}</strong><span>{run.aggregate_ref}</span></div><StatusBadge status={run.status} /><time dateTime={run.occurred_at}>{localTime(run.occurred_at, true)}</time></li> <li key={run.event_id}><span className="activity-icon"><Icon name="activity" /></span><div><strong>{t(`integrations:ledger.eventTypes.${run.event_type}`, { defaultValue: run.event_type.replace(/_/g, " ") })}</strong><span>{run.aggregate_ref}</span></div><StatusBadge status={run.status} label={t(`integrations:ledger.status${run.status.charAt(0).toUpperCase()}${run.status.slice(1)}`, { defaultValue: run.status })} /><time dateTime={run.occurred_at}>{formatShortDate(run.occurred_at)}</time></li>
))} ))}
</ul> </ul>
)} )}
+51 -46
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api, ApiError } from "../api/client";
import type { DataQualityIssue, ScanResult } from "../api/types"; import type { DataQualityIssue, ScanResult } from "../api/types";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -15,6 +16,7 @@ const RULE_TYPES = [
]; ];
export function DataQuality() { export function DataQuality() {
const { t } = useTranslation("quality");
const { user } = useAuth(); const { user } = useAuth();
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null); const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -36,7 +38,7 @@ export function DataQuality() {
api api
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`) .get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues) .then(setIssues)
.catch(() => setError("Data-quality issues are unavailable right now.")); .catch(() => setError(t("list.unavailable")));
}, [status, ruleType, user]); }, [status, ruleType, user]);
useEffect(() => { useEffect(() => {
@@ -52,7 +54,7 @@ export function DataQuality() {
setConfirmingScan(false); setConfirmingScan(false);
load(); load();
} catch (err) { } catch (err) {
setScanError(err instanceof ApiError ? err.message : "Could not run the quality scan."); setScanError(err instanceof ApiError ? err.message : t("list.scanFailed"));
} finally { } finally {
setScanning(false); setScanning(false);
} }
@@ -61,8 +63,8 @@ export function DataQuality() {
if (user?.role !== "operations_manager") { if (user?.role !== "operations_manager") {
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Assurance / Workbench" title="Data quality" description="The quality workbench is visible to Operations Managers only." /> <PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("detail.managerOnlyDetail")} />
<p>Data-quality evidence and resolutions are visible to Operations Managers only.</p> <p>{t("detail.managerOnlyDetail")}</p>
</div> </div>
); );
} }
@@ -77,22 +79,22 @@ export function DataQuality() {
return ( return (
<div className="page"> <div className="page">
<PageHeader <PageHeader
eyebrow="Assurance / Workbench" eyebrow={t("list.eyebrow")}
title="Data quality" title={t("list.title")}
description="Resolve evidence-backed exceptions before they disrupt operations." description={t("list.description")}
actions={ actions={
!confirmingScan ? ( !confirmingScan ? (
<button className="button button-secondary" type="button" onClick={() => setConfirmingScan(true)} disabled={scanning}> <button className="button button-secondary" type="button" onClick={() => setConfirmingScan(true)} disabled={scanning}>
Run quality scan {t("list.runScan")}
</button> </button>
) : ( ) : (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm quality scan"> <div className="confirm-bar" role="alertdialog" aria-label={t("list.confirmScanTitle")}>
<p>Run the deterministic scan across all five rule types now?</p> <p>{t("list.confirmScanBody")}</p>
<button type="button" onClick={handleScan} disabled={scanning}> <button type="button" onClick={handleScan} disabled={scanning}>
{scanning ? "Scanning" : "Yes, run scan"} {scanning ? t("list.scanning") : t("list.confirmScanYes")}
</button> </button>
<button type="button" onClick={() => setConfirmingScan(false)} disabled={scanning}> <button type="button" onClick={() => setConfirmingScan(false)} disabled={scanning}>
Cancel {t("list.cancel")}
</button> </button>
</div> </div>
) )
@@ -102,32 +104,34 @@ export function DataQuality() {
{scanError && <p className="error" role="alert">{scanError}</p>} {scanError && <p className="error" role="alert">{scanError}</p>}
{scanResult && ( {scanResult && (
<p className="quiet-empty" role="status"> <p className="quiet-empty" role="status">
Scan complete: {scanTotal === 0 {t("list.scanComplete", {
? "no new issues found (existing open issues are not recreated)." summary: scanTotal === 0
: Object.entries(scanResult.created) ? t("list.scanNoNew")
.map(([rule, count]) => `${count} new ${rule.replace(/_/g, " ")}`) : Object.entries(scanResult.created)
.join(", ")} .map(([rule, count]) => `${count} ${t(`ruleTypes.${rule}`, { defaultValue: rule.replace(/_/g, " ") })}`)
.join(", "),
})}
</p> </p>
)} )}
<form className="filters" aria-label="Filter data-quality issues"> <form className="filters" aria-label={t("list.title")}>
<label> <label>
Status {t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}> <select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option> <option value="">{t("list.statusAll")}</option>
<option value="open">Open</option> <option value="open">{t("list.statusOpen")}</option>
<option value="deferred">Deferred</option> <option value="deferred">{t("list.statusDeferred")}</option>
<option value="resolved">Resolved</option> <option value="resolved">{t("list.statusResolved")}</option>
<option value="rejected">Rejected</option> <option value="rejected">{t("list.statusRejected")}</option>
</select> </select>
</label> </label>
<label> <label>
Rule type {t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}> <select value={ruleType} onChange={(e) => setRuleType(e.target.value)}>
<option value="">All rule types</option> <option value="">{t("list.ruleTypeAll")}</option>
{RULE_TYPES.map((r) => ( {RULE_TYPES.map((r) => (
<option key={r} value={r}> <option key={r} value={r}>
{r.replace(/_/g, " ")} {t(`ruleTypes.${r}`)}
</option> </option>
))} ))}
</select> </select>
@@ -138,42 +142,43 @@ export function DataQuality() {
checked={demoScenariosOnly} checked={demoScenariosOnly}
onChange={(e) => setDemoScenariosOnly(e.target.checked)} onChange={(e) => setDemoScenariosOnly(e.target.checked)}
/> />
Demo scenario's only {t("list.demoScenariosOnly")}
</label> </label>
</form> </form>
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label="Loading quality workbench…" />} {!error && !issues && <LoadingState label={t("list.loading")} />}
{issues && issues.length === 0 && <EmptyState icon="check" title="Queue is clear" detail="No issues match the current filters." />} {issues && issues.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.length > 0 && visibleIssues.length === 0 && ( {issues && issues.length > 0 && visibleIssues.length === 0 && (
<EmptyState icon="check" title="No demo-scenario issues match" detail="Uncheck 'Demo scenario's only' to see the full queue." /> <EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
)} )}
{visibleIssues.length > 0 && ( {visibleIssues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{visibleIssues.length} issues</span><span>Evidence-backed detection</span></div><table className="data-table"> <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<caption className="visually-hidden">Data-quality issues</caption> <caption className="visually-hidden">{t("list.title")}</caption>
<thead> <thead>
<tr> <tr>
<th scope="col">Reference</th> <th scope="col">{t("list.columns.reference")}</th>
<th scope="col">Rule</th> <th scope="col">{t("list.columns.rule")}</th>
<th scope="col">Entity</th> <th scope="col">{t("list.columns.entity")}</th>
<th scope="col">Severity</th> <th scope="col">{t("list.columns.severity")}</th>
<th scope="col">Status</th> <th scope="col">{t("list.columns.status")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleIssues.map((i) => ( {visibleIssues.map((i) => (
<tr key={i.public_ref}> <tr key={i.public_ref} className="row-clickable">
<th scope="row" data-label="Reference"> <th scope="row" data-label={t("list.columns.reference")}>
<Link to={`/data-quality/${i.public_ref}`}>{i.public_ref}</Link> {i.public_ref}
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
</th> </th>
<td data-label="Rule">{i.rule_type.replace(/_/g, " ")}</td> <td data-label={t("list.columns.rule")}>{t(`ruleTypes.${i.rule_type}`, { defaultValue: i.rule_type.replace(/_/g, " ") })}</td>
<td data-label="Entity">{i.entity_ref}</td> <td data-label={t("list.columns.entity")}>{i.entity_ref}</td>
<td data-label="Severity"> <td data-label={t("list.columns.severity")}>
<SeverityBadge severity={i.severity} /> <SeverityBadge severity={i.severity} />
</td> </td>
<td data-label="Status"> <td data-label={t("list.columns.status")}>
<StatusBadge status={i.status} /> <StatusBadge status={i.status} label={t(`list.status${i.status.charAt(0).toUpperCase()}${i.status.slice(1)}`, { defaultValue: i.status })} />
</td> </td>
</tr> </tr>
))} ))}
+144 -181
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, type FormEvent } from "react"; import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api, ApiError } from "../api/client";
import type { import type {
ApplyRecommendedStatusResult, ApplyRecommendedStatusResult,
@@ -10,70 +11,42 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
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 { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"]; const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
const RULE_EXPLAINERS: Record<string, { whatIsWrong: string; whyItMatters: string }> = {
possible_duplicate_customer: {
whatIsWrong:
"Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.",
whyItMatters:
"Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations.",
},
missing_required_field: {
whatIsWrong:
"This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).",
whyItMatters:
"Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks.",
},
odometer_regression: {
whatIsWrong: "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.",
whyItMatters:
"A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history.",
},
booking_overlap: {
whatIsWrong: "The same vehicle is committed to two bookings whose date ranges overlap.",
whyItMatters:
"Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else.",
},
vehicle_status_conflict: {
whatIsWrong:
"This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.",
whyItMatters:
"An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet.",
},
};
function RuleExplainer({ ruleType }: { ruleType: string }) { function RuleExplainer({ ruleType }: { ruleType: string }) {
const explainer = RULE_EXPLAINERS[ruleType]; const { t } = useTranslation("quality");
if (!explainer) return null; if (!t(`detail.explainer.${ruleType}.whatIsWrong`, { defaultValue: "" })) return null;
return ( return (
<section className="rule-explainer" aria-label="Why this matters"> <section className="rule-explainer" aria-label={t("detail.explainer.whyItMatters")}>
<div> <div>
<strong>What's wrong</strong> <strong>{t("detail.explainer.whatIsWrong")}</strong>
<p>{explainer.whatIsWrong}</p> <p>{t(`detail.explainer.${ruleType}.whatIsWrong`)}</p>
</div> </div>
<div> <div>
<strong>Why it matters</strong> <strong>{t("detail.explainer.whyItMatters")}</strong>
<p>{explainer.whyItMatters}</p> <p>{t(`detail.explainer.${ruleType}.whyItMatters`)}</p>
</div> </div>
</section> </section>
); );
} }
function EvidenceDisclosure({ issue }: { issue: IssueDetail }) { function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("common");
return ( return (
<details className="evidence-disclosure"> <details className="evidence-disclosure">
<summary>Technical evidence</summary> <summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre> <pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre>
</details> </details>
); );
} }
function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { user } = useAuth(); const { user } = useAuth();
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? ""); const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({}); const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
@@ -82,7 +55,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
if (!issue.entity_snapshot || !issue.related_snapshots[0]) { if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
return <p className="error">Both customers in this comparison could not be loaded.</p>; return <p className="error">{t("detail.duplicateCustomer.bothMissing")}</p>;
} }
const a: EntitySnapshot = issue.entity_snapshot; const a: EntitySnapshot = issue.entity_snapshot;
const b: EntitySnapshot = issue.related_snapshots[0]; const b: EntitySnapshot = issue.related_snapshots[0];
@@ -108,7 +81,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
}); });
onResolved(); onResolved();
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Could not merge these customers."); setError(err instanceof ApiError ? err.message : t("detail.duplicateCustomer.mergeFailed"));
setConfirming(false); setConfirming(false);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -117,44 +90,41 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
if (user?.role !== "operations_manager") { if (user?.role !== "operations_manager") {
return ( return (
<p className="panel"> <p className="panel">{t("detail.managerOnlyDetail")}</p>
Merging duplicate customers requires the Operations Manager role. Switch role to resolve
this issue.
</p>
); );
} }
return ( return (
<section className="panel duplicate-compare" aria-labelledby="compare-heading"> <section className="panel duplicate-compare" aria-labelledby="compare-heading">
<SectionHeading title="Compare and merge" description="Choose the canonical customer and review each conflicting field." /> <SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<fieldset> <fieldset className="choice-fieldset">
<legend>Keep as survivor</legend> <legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
<label className="checkbox-label"> <label className={`choice-card ${survivorRef === a.public_ref ? "is-selected" : ""}`}>
<input <input
type="radio" type="radio"
name="survivor" name="survivor"
checked={survivorRef === a.public_ref} checked={survivorRef === a.public_ref}
onChange={() => setSurvivorRef(a.public_ref)} onChange={() => setSurvivorRef(a.public_ref)}
/> />
{a.public_ref} <span className="choice-card-title">{a.public_ref}</span>
</label> </label>
<label className="checkbox-label"> <label className={`choice-card ${survivorRef === b.public_ref ? "is-selected" : ""}`}>
<input <input
type="radio" type="radio"
name="survivor" name="survivor"
checked={survivorRef === b.public_ref} checked={survivorRef === b.public_ref}
onChange={() => setSurvivorRef(b.public_ref)} onChange={() => setSurvivorRef(b.public_ref)}
/> />
{b.public_ref} <span className="choice-card-title">{b.public_ref}</span>
</label> </label>
</fieldset> </fieldset>
<table className="data-table compare-table"> <table className="data-table compare-table">
<thead> <thead>
<tr> <tr>
<th scope="col">Field</th> <th scope="col">{t("detail.duplicateCustomer.fieldColumn")}</th>
<th scope="col">{a.public_ref}</th> <th scope="col">{a.public_ref}</th>
<th scope="col">{b.public_ref}</th> <th scope="col">{b.public_ref}</th>
</tr> </tr>
@@ -166,7 +136,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const differ = valueA !== valueB; const differ = valueA !== valueB;
return ( return (
<tr key={field}> <tr key={field}>
<th scope="row" data-label="Field">{field.replace(/_/g, " ")}{differ ? <span className="difference-mark">Differs</span> : <span className="match-mark">Match</span>}</th> <th scope="row" data-label="Field">{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<td data-label={a.public_ref}> <td data-label={a.public_ref}>
{differ ? ( {differ ? (
<label className="checkbox-label"> <label className="checkbox-label">
@@ -204,25 +174,22 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
</table> </table>
<p className="merge-preview"> <p className="merge-preview">
<strong>{loser.public_ref}</strong> will become a tombstone linked to{" "} {t("detail.duplicateCustomer.mergePreview", { loser: loser.public_ref, survivor: survivor.public_ref })}
<strong>{survivor.public_ref}</strong>; its bookings will be rewired to the survivor.
</p> </p>
{!confirming && ( {!confirming && (
<button type="button" onClick={() => setConfirming(true)}> <button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
Merge into {survivor.public_ref} {t("detail.duplicateCustomer.mergeInto", { ref: survivor.public_ref })}
</button> </button>
)} )}
{confirming && ( {confirming && (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm merge"> <div className="confirm-bar" role="alertdialog" aria-label={t("detail.duplicateCustomer.confirmMergeTitle")}>
<p> <p>{t("detail.duplicateCustomer.confirmMergeBody", { loser: loser.public_ref, survivor: survivor.public_ref })}</p>
Merge {loser.public_ref} into {survivor.public_ref}? This cannot be undone. <button type="button" className="button button-primary" onClick={handleMerge} disabled={submitting}>
</p> {submitting ? t("detail.duplicateCustomer.merging") : t("detail.duplicateCustomer.confirmMergeYes")}
<button type="button" onClick={handleMerge} disabled={submitting}>
{submitting ? "Merging…" : "Yes, merge"}
</button> </button>
<button type="button" onClick={() => setConfirming(false)} disabled={submitting}> <button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
Cancel {t("list.cancel")}
</button> </button>
</div> </div>
)} )}
@@ -230,26 +197,16 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
); );
} }
const CUSTOMER_FIELD_LABELS: Record<string, string> = {
first_name: "First name",
last_name: "Last name",
email: "Email",
phone: "Phone",
};
const VEHICLE_FIELD_LABELS: Record<string, string> = {
registration_number: "Registration number",
make: "Make",
model: "Model",
location: "Location",
};
function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const isCustomer = issue.entity_type === "customer"; const isCustomer = issue.entity_type === "customer";
const labels = isCustomer ? CUSTOMER_FIELD_LABELS : VEHICLE_FIELD_LABELS; const fieldKeys = isCustomer
? ["first_name", "last_name", "email", "phone"]
: ["registration_number", "make", "model", "location"];
const snapshot = issue.entity_snapshot; const snapshot = issue.entity_snapshot;
const [values, setValues] = useState<Record<string, string>>(() => { const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}; const initial: Record<string, string> = {};
for (const key of Object.keys(labels)) { for (const key of fieldKeys) {
initial[key] = snapshot && snapshot[key] ? String(snapshot[key]) : ""; initial[key] = snapshot && snapshot[key] ? String(snapshot[key]) : "";
} }
return initial; return initial;
@@ -268,7 +225,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields }); await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved(); onResolved();
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Could not save these fields."); setError(err instanceof ApiError ? err.message : t("detail.missingField.saveFailed"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -277,14 +234,15 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
return ( return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="missing-field-heading"> <form className="panel" onSubmit={handleSubmit} aria-labelledby="missing-field-heading">
<SectionHeading <SectionHeading
title="Provide the missing fields" headingId="missing-field-heading"
description={`Complete the record for ${snapshot?.public_ref ?? issue.entity_ref}. The issue resolves automatically once nothing required is missing.`} title={t("detail.missingField.heading")}
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
/> />
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<div className="form-grid"> <div className="form-grid">
{Object.entries(labels).map(([field, label]) => ( {fieldKeys.map((field) => (
<label key={field}> <label key={field}>
{label} {t(`detail.missingField.fields.${field}`)}
<input <input
type="text" type="text"
value={values[field] ?? ""} value={values[field] ?? ""}
@@ -294,11 +252,11 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
))} ))}
</div> </div>
{isCustomer && ( {isCustomer && (
<p className="table-subtext">At least one of email or phone is required.</p> <p className="table-subtext">{t("detail.missingField.atLeastOne")}</p>
)} )}
<div className="form-actions"> <div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}> <button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save and re-check"} {submitting ? t("detail.missingField.saving") : t("detail.missingField.saveAndRecheck")}
</button> </button>
</div> </div>
</form> </form>
@@ -306,6 +264,8 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
} }
function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const bookingSnapshots = issue.related_snapshots.filter((s) => s.entity_type === "booking"); const bookingSnapshots = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [decision, setDecision] = useState<"retain_canonical" | "correct_reading">("retain_canonical"); const [decision, setDecision] = useState<"retain_canonical" | "correct_reading">("retain_canonical");
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? ""); const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
@@ -328,7 +288,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
}); });
onResolved(); onResolved();
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Could not resolve this issue."); setError(err instanceof ApiError ? err.message : t("detail.odometerRegression.resolveFailed"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -337,25 +297,29 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
return ( return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="odometer-heading"> <form className="panel" onSubmit={handleSubmit} aria-labelledby="odometer-heading">
<SectionHeading <SectionHeading
title="Resolve the odometer regression" headingId="odometer-heading"
description="The canonical odometer is never lowered automatically -- choose how to reconcile it." title={t("detail.odometerRegression.heading")}
description={t("detail.odometerRegression.description")}
/> />
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<dl className="detail-grid"> <dl className="detail-grid">
<div><dt>Canonical odometer</dt><dd>{Number(issue.entity_snapshot?.odometer_km ?? 0).toLocaleString("en-GB")} km</dd></div> <div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
</dl> </dl>
<fieldset> <fieldset className="choice-fieldset">
<legend>Decision</legend> <legend>{t("detail.odometerRegression.decisionLegend")}</legend>
<label className="checkbox-label check-card"> <label className={`choice-card ${decision === "retain_canonical" ? "is-selected" : ""}`}>
<input <input
type="radio" type="radio"
name="decision" name="decision"
checked={decision === "retain_canonical"} checked={decision === "retain_canonical"}
onChange={() => setDecision("retain_canonical")} onChange={() => setDecision("retain_canonical")}
/> />
Retain canonical -- treat the submitted reading as erroneous <span className="choice-card-body">
<span className="choice-card-title">{t("detail.odometerRegression.retainCanonical")}</span>
<span className="choice-card-detail">{t("detail.odometerRegression.retainCanonicalDetail")}</span>
</span>
</label> </label>
<label className="checkbox-label check-card"> <label className={`choice-card ${decision === "correct_reading" ? "is-selected" : ""} ${bookingSnapshots.length === 0 ? "is-disabled" : ""}`}>
<input <input
type="radio" type="radio"
name="decision" name="decision"
@@ -363,26 +327,29 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
onChange={() => setDecision("correct_reading")} onChange={() => setDecision("correct_reading")}
disabled={bookingSnapshots.length === 0} disabled={bookingSnapshots.length === 0}
/> />
Correct the reading -- update the booking and canonical odometer <span className="choice-card-body">
<span className="choice-card-title">{t("detail.odometerRegression.correctReading")}</span>
<span className="choice-card-detail">{t("detail.odometerRegression.correctReadingDetail")}</span>
</span>
</label> </label>
{bookingSnapshots.length === 0 && ( {bookingSnapshots.length === 0 && (
<p className="table-subtext">No related booking is attached to this issue, so only "retain canonical" is available.</p> <p className="table-subtext">{t("detail.odometerRegression.noBookingAttached")}</p>
)} )}
</fieldset> </fieldset>
{decision === "correct_reading" && ( {decision === "correct_reading" && (
<div className="form-grid"> <div className="form-grid">
<label> <label>
Booking {t("detail.odometerRegression.booking")}
<select value={bookingRef} onChange={(e) => setBookingRef(e.target.value)}> <select value={bookingRef} onChange={(e) => setBookingRef(e.target.value)}>
{bookingSnapshots.map((snap) => ( {bookingSnapshots.map((snap) => (
<option key={snap.public_ref} value={snap.public_ref}> <option key={snap.public_ref} value={snap.public_ref}>
{snap.public_ref} ({typeof snap.end_odometer_km === "number" ? snap.end_odometer_km.toLocaleString("en-GB") : "—"} km) {snap.public_ref} ({typeof snap.end_odometer_km === "number" ? formatNumber(snap.end_odometer_km) : "—"} km)
</option> </option>
))} ))}
</select> </select>
</label> </label>
<label> <label>
Corrected odometer (km) {t("detail.odometerRegression.correctedOdometer")}
<input <input
type="number" type="number"
min={0} min={0}
@@ -394,12 +361,12 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
</div> </div>
)} )}
<label> <label>
Note {t("detail.odometerRegression.note")}
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} /> <textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label> </label>
<div className="form-actions"> <div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}> <button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Resolving…" : "Resolve issue"} {submitting ? t("detail.odometerRegression.resolving") : t("detail.odometerRegression.resolveIssue")}
</button> </button>
</div> </div>
</form> </form>
@@ -407,6 +374,8 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
} }
function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatShortDate } = useLocaleFormat();
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking"); const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? ""); const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
const [note, setNote] = useState(""); const [note, setNote] = useState("");
@@ -424,7 +393,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
}); });
onResolved(); onResolved();
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Could not resolve this overlap."); setError(err instanceof ApiError ? err.message : t("detail.bookingOverlap.resolveFailed"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -433,50 +402,41 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
return ( return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="overlap-heading"> <form className="panel" onSubmit={handleSubmit} aria-labelledby="overlap-heading">
<SectionHeading <SectionHeading
title="Resolve the booking overlap" headingId="overlap-heading"
description="Block one of the two overlapping commitments. The other keeps its current status." title={t("detail.bookingOverlap.heading")}
description={t("detail.bookingOverlap.description")}
/> />
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<table className="data-table compare-table"> <fieldset className="choice-fieldset choice-fieldset-grid">
<thead> <legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
<tr> {bookings.map((b) => (
<th scope="col">Booking</th> <label key={b.public_ref} className={`choice-card ${bookingRef === b.public_ref ? "is-selected" : ""}`}>
<th scope="col">Window</th> <input
<th scope="col">Status</th> type="radio"
<th scope="col">Block this one</th> name="overlap-booking"
</tr> checked={bookingRef === b.public_ref}
</thead> onChange={() => setBookingRef(b.public_ref)}
<tbody> aria-label={`${t("detail.bookingOverlap.blockLabel", { ref: b.public_ref })}, ${
{bookings.map((b) => ( b.starts_at ? formatShortDate(String(b.starts_at)) : "—"
<tr key={b.public_ref}> }${b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}, ${b.status}`}
<th scope="row">{b.public_ref}</th> />
<td> <span className="choice-card-body">
{b.starts_at ? new Date(String(b.starts_at)).toLocaleDateString("en-GB") : "—"} →{" "} <span className="choice-card-title">{b.public_ref}</span>
{b.ends_at ? new Date(String(b.ends_at)).toLocaleDateString("en-GB") : "—"} <span className="choice-card-detail">
</td> {b.starts_at ? formatShortDate(String(b.starts_at)) : "—"} {b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}
<td><StatusBadge status={String(b.status)} /></td> </span>
<td> <StatusBadge status={String(b.status)} label={t(`bookings:statuses.${b.status}`, { defaultValue: String(b.status) })} />
<label className="checkbox-label"> </span>
<input </label>
type="radio" ))}
name="overlap-booking" </fieldset>
checked={bookingRef === b.public_ref}
onChange={() => setBookingRef(b.public_ref)}
/>
<span className="visually-hidden">Block {b.public_ref}</span>
</label>
</td>
</tr>
))}
</tbody>
</table>
<label> <label>
Note {t("detail.bookingOverlap.note")}
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} /> <textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label> </label>
<div className="form-actions"> <div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting || !bookingRef}> <button className="button button-primary" type="submit" disabled={submitting || !bookingRef}>
{submitting ? "Resolving…" : `Block ${bookingRef || "booking"}`} {submitting ? t("detail.bookingOverlap.resolving") : t("detail.bookingOverlap.blockButton", { ref: bookingRef || t("detail.bookingOverlap.blockButtonFallback") })}
</button> </button>
</div> </div>
</form> </form>
@@ -484,6 +444,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
} }
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const navigate = useNavigate(); const navigate = useNavigate();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide(); const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
@@ -514,7 +475,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
setResult(applied); setResult(applied);
onResolved(); onResolved();
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Could not apply a recommended status."); setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
setConfirming(false); setConfirming(false);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -524,40 +485,41 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
return ( return (
<section className="panel" aria-labelledby="status-conflict-heading"> <section className="panel" aria-labelledby="status-conflict-heading">
<SectionHeading <SectionHeading
title="Resolve the status conflict" headingId="status-conflict-heading"
description="One authoritative rule recommends a corrected operational status for this vehicle." title={t("detail.vehicleStatusConflict.heading")}
description={t("detail.vehicleStatusConflict.description")}
/> />
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<dl className="detail-grid"> <dl className="detail-grid">
<div><dt>Current status</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} /></dd></div> <div><dt>{t("detail.vehicleStatusConflict.currentStatus")}</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} label={t(`fleet:statuses.${issue.entity_snapshot?.operational_status}`, { defaultValue: String(issue.entity_snapshot?.operational_status ?? "") })} /></dd></div>
</dl> </dl>
{result ? ( {result ? (
<> <>
<p className="quiet-empty"> <p className="quiet-empty">
<Icon name="check" /> Applied <StatusBadge status={result.applied_status} /> {result.reason} <Icon name="check" /> {t("detail.vehicleStatusConflict.applied", { status: result.applied_status, reason: result.reason })}
</p> </p>
<div className="result-links"> <div className="result-links">
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link> <Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>View vehicle<Icon name="chevron" /></Link> <Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>{t("detail.resolved.viewVehicle")}<Icon name="chevron" /></Link>
{guideOpen && ( {guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}> <button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" /> {t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button> </button>
)} )}
</div> </div>
</> </>
) : !confirming ? ( ) : !confirming ? (
<button type="button" onClick={() => setConfirming(true)}> <button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
Calculate and apply recommended status {t("detail.vehicleStatusConflict.calculateAndApply")}
</button> </button>
) : ( ) : (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm status change"> <div className="confirm-bar" role="alertdialog" aria-label={t("detail.vehicleStatusConflict.confirmTitle")}>
<p>Apply the authoritative recommended status for this vehicle?</p> <p>{t("detail.vehicleStatusConflict.confirmBody")}</p>
<button type="button" onClick={handleApply} disabled={submitting}> <button type="button" className="button button-primary" onClick={handleApply} disabled={submitting}>
{submitting ? "Applying" : "Yes, apply"} {submitting ? t("detail.vehicleStatusConflict.applying") : t("detail.vehicleStatusConflict.confirmYes")}
</button> </button>
<button type="button" onClick={() => setConfirming(false)} disabled={submitting}> <button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
Cancel {t("list.cancel")}
</button> </button>
</div> </div>
)} )}
@@ -566,6 +528,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
} }
export function DataQualityIssueDetail() { export function DataQualityIssueDetail() {
const { t } = useTranslation("quality");
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
@@ -581,7 +544,7 @@ export function DataQualityIssueDetail() {
api api
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`) .get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
.then(setIssue) .then(setIssue)
.catch(() => setError("This issue could not be found.")); .catch(() => setError(t("detail.notFound")));
}, [publicRef]); }, [publicRef]);
useEffect(() => { useEffect(() => {
@@ -610,30 +573,30 @@ export function DataQualityIssueDetail() {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`); await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
load(); load();
} catch (err) { } catch (err) {
setActionError(err instanceof ApiError ? err.message : `Could not ${action} this issue.`); setActionError(err instanceof ApiError ? err.message : t(`detail.deferOrReject.${action}Failed`));
} }
} }
if (user?.role !== "operations_manager") { if (user?.role !== "operations_manager") {
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Assurance / Workbench" title="Data quality issue" description="The quality workbench is visible to Operations Managers only." /> <PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("detail.managerOnly")} />
<p>Data-quality evidence and resolutions are visible to Operations Managers only.</p> <p>{t("detail.managerOnlyDetail")}</p>
</div> </div>
); );
} }
if (error) return <ErrorState message={error} />; if (error) return <ErrorState message={error} />;
if (!issue) return <LoadingState label="Loading issue evidence…" />; if (!issue) return <LoadingState label={t("detail.loading")} />;
return ( return (
<div className="page"> <div className="page">
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> Quality workbench</Link> <Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={`Quality / ${issue.rule_type.replace(/_/g, " ")}`} title={issue.public_ref} description="Review persisted evidence and record an audited resolution." actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} /></div>} /> <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>} />
<section className="record-surface" aria-label="Issue summary"><dl className="detail-grid"> <section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid">
<div><dt>Rule</dt><dd>{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>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>
<div><dt>Evidence summary</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div> <div><dt>{t("detail.summary.evidenceSummary")}</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
</dl> </dl>
<EvidenceDisclosure issue={issue} /></section> <EvidenceDisclosure issue={issue} /></section>
@@ -643,16 +606,16 @@ export function DataQualityIssueDetail() {
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && ( {justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
<section className="panel success-panel" aria-live="polite"> <section className="panel success-panel" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Resolved</p><h2>Issue {issue.public_ref} resolved</h2></div></div> <div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("list.statusResolved")}</p><h2>{t("detail.resolved.title", { ref: issue.public_ref })}</h2></div></div>
<p>The change has been applied and is recorded in the audit trail.</p> <p>{t("detail.resolved.body")}</p>
<div className="result-links"> <div className="result-links">
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link> <Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
{issue.entity_type === "vehicle" && ( {issue.entity_type === "vehicle" && (
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>View vehicle<Icon name="chevron" /></Link> <Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>{t("detail.resolved.viewVehicle")}<Icon name="chevron" /></Link>
)} )}
{guideOpen && ( {guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}> <button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" /> {t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button> </button>
)} )}
</div> </div>
@@ -676,15 +639,15 @@ export function DataQualityIssueDetail() {
)} )}
{issue.status === "open" && ( {issue.status === "open" && (
<section className="panel" aria-labelledby="resolution-heading"> <section className="panel defer-reject-panel" aria-labelledby="resolution-heading">
<h2 id="resolution-heading">Defer or reject</h2> <h2 id="resolution-heading">{t("detail.deferOrReject.heading")}</h2>
<p>Defer to review later, or reject if this is not a real issue.</p> <p>{t("detail.deferOrReject.description")}</p>
<div className="resolution-actions"> <div className="resolution-actions">
<button type="button" onClick={() => handleAction("defer")}> <button type="button" className="button-tertiary" onClick={() => handleAction("defer")}>
Defer {t("detail.deferOrReject.defer")}
</button> </button>
<button type="button" onClick={() => handleAction("reject")}> <button type="button" className="button-tertiary button-tertiary-destructive" onClick={() => handleAction("reject")}>
Reject {t("detail.deferOrReject.reject")}
</button> </button>
</div> </div>
</section> </section>
+57 -37
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client"; import { api, ApiError } from "../api/client";
import type { GroundedAnswer, KnowledgeHealth } from "../api/types"; import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
@@ -9,42 +10,35 @@ interface Exchange {
answer: GroundedAnswer; answer: GroundedAnswer;
} }
const EVIDENCE_LABEL: Record<GroundedAnswer["evidence_state"], string> = {
grounded: "Grounded in cited procedures",
insufficient: "Insufficient evidence",
unavailable: "Knowledge service unavailable",
};
const SUGGESTED_QUESTIONS = [
"What must I do when a vehicle returns with damage?",
"When may a vehicle be made available again?",
"Who reviews an unusual odometer reading?",
"Which checks are required before departure?",
];
export function Knowledge() { export function Knowledge() {
const { t, i18n } = useTranslation(["knowledge", "errors"]);
const [status, setStatus] = useState<KnowledgeHealth | null>(null); const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [exchanges, setExchanges] = useState<Exchange[]>([]); const [exchanges, setExchanges] = useState<Exchange[]>([]);
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
useEffect(() => { useEffect(() => {
api api
.get<KnowledgeHealth>("/api/v1/knowledge/status") .get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`)
.then(setStatus) .then(setStatus)
.catch(() => setStatus(null)); .catch(() => setStatus(null));
}, []); }, [language]);
async function ask(questionText: string) { async function ask(questionText: string) {
setError(null); setError(null);
setSubmitting(true); setSubmitting(true);
try { try {
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { question: questionText }); const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", {
question: questionText,
language,
});
setExchanges((prev) => [{ question: questionText, answer }, ...prev]); setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
setQuestion(""); setQuestion("");
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Could not reach the knowledge service."); setError(err instanceof ApiError ? err.message : t("askFailed"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -56,26 +50,45 @@ export function Knowledge() {
await ask(question); await ask(question);
} }
const providerLabel = status?.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"; const providerLabel = status?.provider === "ragcore" ? t("providerRagcore") : t("providerDemo");
const suggestedQuestions = t("suggestedQuestions", { returnObjects: true, defaultValue: [] }) as string[];
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Assurance / Grounded knowledge" title="Procedure knowledge" description={`Ask operational questions. Answers are shown only when the ${providerLabel.toLowerCase()} returns sufficient cited evidence.`} /> <PageHeader
eyebrow={t("eyebrow")}
title={t("title")}
description={t("description", { provider: providerLabel.toLowerCase() })}
/>
{status && ( {status && (
<div className="knowledge-status"><span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} /><div><strong>{providerLabel}</strong><span>{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed</span></div><small>{status.collection}</small></div> <div className="knowledge-status">
<span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} />
<div>
<strong>{providerLabel}</strong>
<span>
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
{t("proceduresIndexed", { count: status.document_count })}
</span>
</div>
<small>{status.collection}</small>
</div>
)} )}
{status?.provider !== "ragcore" && ( {status?.provider !== "ragcore" && (
<p className="knowledge-provider-note"> <p className="knowledge-provider-note">
<Icon name="shield" /> This demo answers from a small, fixed set of indexed <Icon name="shield" /> {t("providerNote")}
procedures not a live RAGcore connection. A live RAGcore backend will later
take over the same interface without changing how this page works.
</p> </p>
)} )}
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading"> <form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
<div className="ask-heading"><span><Icon name="spark" /></span><div><h2 id="ask-heading">Ask a procedure question</h2><p>Retrieval evidence check grounded answer</p></div></div> <div className="ask-heading">
<span><Icon name="spark" /></span>
<div>
<h2 id="ask-heading">{t("askHeading")}</h2>
<p>{t("askSubheading")}</p>
</div>
</div>
<label htmlFor="knowledge-question" className="visually-hidden"> <label htmlFor="knowledge-question" className="visually-hidden">
Question {t("questionLabel")}
</label> </label>
<div className="knowledge-input-row"> <div className="knowledge-input-row">
<input <input
@@ -83,20 +96,20 @@ export function Knowledge() {
type="text" type="text"
value={question} value={question}
onChange={(e) => setQuestion(e.target.value)} onChange={(e) => setQuestion(e.target.value)}
placeholder="e.g. What must I do when a vehicle returns with damage?" placeholder={t("questionPlaceholder")}
minLength={3} minLength={3}
maxLength={1000} maxLength={1000}
required required
/> />
<button className="button button-primary" type="submit" disabled={submitting}> <button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Asking" : "Ask"} {submitting ? t("asking") : t("ask")}
{!submitting && <Icon name="chevron" />} {!submitting && <Icon name="chevron" />}
</button> </button>
</div> </div>
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<div className="knowledge-suggestions"> <div className="knowledge-suggestions">
<span>Try one:</span> <span>{t("suggestedLabel")}</span>
{SUGGESTED_QUESTIONS.map((q) => ( {suggestedQuestions.map((q) => (
<button key={q} type="button" className="suggestion-chip" onClick={() => ask(q)} disabled={submitting}> <button key={q} type="button" className="suggestion-chip" onClick={() => ask(q)} disabled={submitting}>
{q} {q}
</button> </button>
@@ -105,24 +118,31 @@ export function Knowledge() {
</form> </form>
{exchanges.length === 0 && !error && ( {exchanges.length === 0 && !error && (
<div className="knowledge-empty"><span><Icon name="knowledge" /></span><h2>Evidence before answers</h2><p>Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.</p><div className="retrieval-flow" aria-hidden="true"><span>Question</span><i /><span>{providerLabel}</span><i /><span>Sources</span><i /><span>Answer</span></div></div> <div className="knowledge-empty">
<span><Icon name="knowledge" /></span>
<h2>{t("emptyTitle")}</h2>
<p>{t("emptyDescription")}</p>
<div className="retrieval-flow" aria-hidden="true">
<span>{t("retrievalFlow.question")}</span><i />
<span>{providerLabel}</span><i />
<span>{t("retrievalFlow.sources")}</span><i />
<span>{t("retrievalFlow.answer")}</span>
</div>
</div>
)} )}
<ul className="exchange-list"> <ul className="exchange-list">
{exchanges.map((exchange, index) => ( {exchanges.map((exchange, index) => (
<li key={index} className="panel exchange"> <li key={index} className="panel exchange">
<p className="exchange-question"> <p className="exchange-question">
<strong>Question</strong> {exchange.question} <strong>{t("questionLabelExchange")}</strong> {exchange.question}
</p> </p>
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}> <p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
{EVIDENCE_LABEL[exchange.answer.evidence_state]} {t(`evidenceStates.${exchange.answer.evidence_state}`)}
</p> </p>
{exchange.answer.evidence_state === "unavailable" ? ( {exchange.answer.evidence_state === "unavailable" ? (
<p> <p>{t("unavailableBody")}</p>
The knowledge service is currently unreachable. Operational features are
unaffected try again later.
</p>
) : ( ) : (
<p>{exchange.answer.answer}</p> <p>{exchange.answer.answer}</p>
)} )}
@@ -132,7 +152,7 @@ export function Knowledge() {
{exchange.answer.sources.map((source) => ( {exchange.answer.sources.map((source) => (
<li key={`${source.document_id}-${source.section}`} className="source-card"> <li key={`${source.document_id}-${source.section}`} className="source-card">
<p className="source-title"> <p className="source-title">
{source.title} <span className="source-version">v{source.version}</span> {source.title} <span className="source-version">{t("sourceVersion", { version: source.version })}</span>
</p> </p>
<p className="source-section">{source.section}</p> <p className="source-section">{source.section}</p>
<p className="source-excerpt">{source.excerpt}</p> <p className="source-excerpt">{source.excerpt}</p>
+25 -20
View File
@@ -1,11 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext"; import { useDemoManifest } from "../context/DemoManifestContext";
import { LanguageSwitcher } from "../components/LanguageSwitcher";
import type { Role } from "../api/types"; import type { Role } from "../api/types";
import { BrandMark, Icon } from "../components/Icons"; import { BrandMark, Icon } from "../components/Icons";
export function Login() { export function Login() {
const { t } = useTranslation(["auth", "common"]);
const { loginAs, loading } = useAuth(); const { loginAs, loading } = useAuth();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,24 +20,23 @@ export function Login() {
await loginAs(role); await loginAs(role);
navigate(guided ? "/dashboard?guide=start" : "/dashboard"); navigate(guided ? "/dashboard?guide=start" : "/dashboard");
} catch { } catch {
setError("De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar."); setError(t("loginFailed"));
} }
} }
const orgName = manifest?.organization_name ?? "Northstar Mobility"; const orgName = manifest?.organization_name ?? t("common:orgName");
const description = const description = t("defaultDescription");
manifest?.organization_description ??
"MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt " +
"verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde " +
"vervolgstappen.";
return ( return (
<main className="login-shell"> <main className="login-shell">
<section className="login-story" aria-labelledby="product-name"> <section className="login-story" aria-labelledby="product-name">
<div className="brand-lockup login-brand"><BrandMark className="brand-mark" /><div><strong>MobilityOps</strong><span>Bedieningscentrum</span></div></div> <div className="brand-lockup login-brand">
<BrandMark className="brand-mark" />
<div><strong>{t("common:appName")}</strong><span>{t("brandTagline")}</span></div>
</div>
<div className="login-message"> <div className="login-message">
<p className="eyebrow">Demo-organisatie: {orgName} (fictief)</p> <p className="eyebrow">{t("orgLine", { orgName })}</p>
<h1 id="product-name">Elke overdracht.<br />Eén helder overzicht.</h1> <h1 id="product-name">{t("headline1")}<br />{t("headline2")}</h1>
<p>{description}</p> <p>{description}</p>
</div> </div>
<div className="control-illustration" aria-hidden="true"> <div className="control-illustration" aria-hidden="true">
@@ -45,14 +47,17 @@ export function Login() {
<span className="illustration-node node-two"><Icon name="bookings" /></span> <span className="illustration-node node-two"><Icon name="bookings" /></span>
<span className="illustration-node node-three"><Icon name="quality" /></span> <span className="illustration-node node-three"><Icon name="quality" /></span>
</div> </div>
<p className="login-footnote"><Icon name="shield" /> Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar</p> <p className="login-footnote"><Icon name="shield" /> {t("footnote")}</p>
</section> </section>
<section className="login-access" aria-labelledby="login-heading"> <section className="login-access" aria-labelledby="login-heading">
<div className="login-panel"> <div className="login-panel">
<p className="page-eyebrow">Demo-toegang</p> <div className="login-panel-top">
<h2 id="login-heading">Kies hoe je wil starten</h2> <p className="page-eyebrow">{t("accessEyebrow")}</p>
<p className="login-intro">Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving alle workflows en controles zijn echt geïmplementeerd.</p> <LanguageSwitcher />
</div>
<h2 id="login-heading">{t("accessHeading")}</h2>
<p className="login-intro">{t("accessIntro")}</p>
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<button <button
@@ -62,22 +67,22 @@ export function Login() {
onClick={() => handleLogin("operations_manager", true)} onClick={() => handleLogin("operations_manager", true)}
> >
<Icon name="spark" /> <Icon name="spark" />
Start begeleide demo {t("startGuidedDemo")}
</button> </button>
<div className="login-options"> <div className="login-options">
<button type="button" aria-label="Verken als Operations Manager" disabled={loading} onClick={() => handleLogin("operations_manager")}> <button type="button" aria-label={t("exploreAsOperationsManager")} disabled={loading} onClick={() => handleLogin("operations_manager")}>
<span className="role-icon"><Icon name="activity" /></span> <span className="role-icon"><Icon name="activity" /></span>
<span><strong>Verken als Operations Manager</strong><small>Volledig overzicht, kwaliteitsoplossing en herpogingen</small></span> <span><strong>{t("exploreAsOperationsManager")}</strong><small>{t("exploreAsOperationsManagerDetail")}</small></span>
<Icon name="chevron" /> <Icon name="chevron" />
</button> </button>
<button type="button" aria-label="Verken als Rental Employee" disabled={loading} onClick={() => handleLogin("rental_employee")}> <button type="button" aria-label={t("exploreAsRentalEmployee")} disabled={loading} onClick={() => handleLogin("rental_employee")}>
<span className="role-icon"><Icon name="user" /></span> <span className="role-icon"><Icon name="user" /></span>
<span><strong>Verken als Rental Employee</strong><small>Boekingen, retours, wagenpark en procedures</small></span> <span><strong>{t("exploreAsRentalEmployee")}</strong><small>{t("exploreAsRentalEmployeeDetail")}</small></span>
<Icon name="chevron" /> <Icon name="chevron" />
</button> </button>
</div> </div>
<div className="access-note"><Icon name="shield" /><p><strong>Veilig ontworpen</strong><span>Elke actie wordt gelogd en is in deze demo herstelbaar.</span></p></div> <div className="access-note"><Icon name="shield" /><p><strong>{t("safeByDesignTitle")}</strong><span>{t("safeByDesignDetail")}</span></p></div>
</div> </div>
</section> </section>
</main> </main>
+27 -19
View File
@@ -1,51 +1,59 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext"; import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { LoadingState, PageHeader } from "../components/PageChrome"; import { LoadingState, PageHeader } from "../components/PageChrome";
const ROLE_LABEL: Record<string, string> = {
operations_manager: "Operations Manager",
rental_employee: "Rental Employee",
};
export function Scenarios() { export function Scenarios() {
const { t } = useTranslation("demo");
const { manifest, loading } = useDemoManifest(); const { manifest, loading } = useDemoManifest();
const { user } = useAuth(); const { user } = useAuth();
function roleLabel(roles: string[]): string {
const labels = roles.map((r) => t(`scenarios.roles.${r}`, { defaultValue: r }));
return labels.length > 1 ? t("scenarios.roleOr", { a: labels[0], b: labels[1] }) : labels[0];
}
return ( return (
<div className="page"> <div className="page">
<PageHeader <PageHeader
eyebrow="Demonstratiescenario's" eyebrow={t("scenarios.eyebrow")}
title="Probeer een demonstratiescenario" title={t("scenarios.title")}
description="Vijf afgebakende scenario's die telkens dezelfde vaste boekingen, klanten en voertuigen gebruiken — na een reset zijn ze altijd opnieuw te vinden." description={t("scenarios.description")}
/> />
{loading && <LoadingState label="Scenario's laden…" />} {loading && <LoadingState label={t("scenarios.loading")} />}
{manifest && ( {manifest && (
<div className="scenario-grid"> <div className="scenario-grid">
{manifest.scenarios.map((scenario) => { {manifest.scenarios.map((scenario) => {
const canRun = !user || scenario.required_roles.includes(user.role); const canRun = !user || scenario.required_roles.includes(user.role);
const blockedText = scenario.blocked_reason_code
? t(`scenarios.blockedReasons.${scenario.blocked_reason_code}`, {
resetHint: t("scenarios.blockedReasons.resetHint"),
...scenario.blocked_reason_params,
})
: null;
return ( return (
<article key={scenario.id} className="scenario-card"> <article key={scenario.id} className="scenario-card">
<header> <header>
<h2>{scenario.title}</h2> <h2>{t(`scenarios.items.${scenario.id}.title`)}</h2>
<span className={`badge ${scenario.ready ? "status-available" : "status-unavailable"}`}> <span className={`badge ${scenario.ready ? "status-available" : "status-unavailable"}`}>
{scenario.ready ? "Klaar voor demo" : "Niet beschikbaar"} {scenario.ready ? t("scenarios.ready") : t("scenarios.notReady")}
</span> </span>
</header> </header>
<p className="scenario-problem">{scenario.operational_problem}</p> <p className="scenario-problem">{t(`scenarios.items.${scenario.id}.problem`)}</p>
<dl className="scenario-meta"> <dl className="scenario-meta">
<div><dt>Duur</dt><dd>± {scenario.estimated_minutes} min</dd></div> <div><dt>{t("scenarios.duration")}</dt><dd>{t("scenarios.durationValue", { minutes: scenario.estimated_minutes })}</dd></div>
<div><dt>Rol</dt><dd>{scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}</dd></div> <div><dt>{t("scenarios.role")}</dt><dd>{roleLabel(scenario.required_roles)}</dd></div>
</dl> </dl>
<p className="scenario-demonstrates"><strong>Toont aan:</strong> {scenario.demonstrates}</p> <p className="scenario-demonstrates"><strong>{t("scenarios.demonstrates")}</strong> {t(`scenarios.items.${scenario.id}.demonstrates`)}</p>
{!scenario.ready && scenario.blocked_reason && ( {!scenario.ready && blockedText && (
<p className="scenario-blocked"><Icon name="alert" /> {scenario.blocked_reason}</p> <p className="scenario-blocked"><Icon name="alert" /> {blockedText}</p>
)} )}
{!canRun && ( {!canRun && (
<p className="scenario-blocked"><Icon name="alert" /> Vereist rol: {scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}.</p> <p className="scenario-blocked"><Icon name="alert" /> {t("scenarios.requiresRole", { roles: roleLabel(scenario.required_roles) })}</p>
)} )}
<Link <Link
className={`button ${scenario.ready && canRun ? "button-primary" : "button-secondary"}`} className={`button ${scenario.ready && canRun ? "button-primary" : "button-secondary"}`}
@@ -55,7 +63,7 @@ export function Scenarios() {
if (!scenario.ready || !canRun) event.preventDefault(); if (!scenario.ready || !canRun) event.preventDefault();
}} }}
> >
Start scenario <Icon name="chevron" /> {t("scenarios.startScenario")} <Icon name="chevron" />
</Link> </Link>
</article> </article>
); );
+36 -32
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { VehicleDetail as VehicleDetailData } from "../api/types"; import type { VehicleDetail as VehicleDetailData } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { SeverityBadge, StatusBadge } from "../components/Badge"; import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
@@ -10,6 +12,8 @@ const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] a
type Tab = (typeof TABS)[number]; type Tab = (typeof TABS)[number];
export function VehicleDetail() { export function VehicleDetail() {
const { t } = useTranslation(["fleet", "bookings", "quality"]);
const { formatNumber, formatShortDate } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>(); const { publicRef } = useParams<{ publicRef: string }>();
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null); const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -22,52 +26,52 @@ export function VehicleDetail() {
api api
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`) .get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
.then(setVehicle) .then(setVehicle)
.catch(() => setError("This vehicle could not be found.")); .catch(() => setError(t("detail.notFound")));
}, [publicRef]); }, [publicRef]);
if (error) return <ErrorState message={error} />; if (error) return <ErrorState message={error} />;
if (!vehicle) return <LoadingState label="Loading vehicle record…" />; if (!vehicle) return <LoadingState label={t("detail.loading")} />;
return ( return (
<div className="page"> <div className="page">
<Link className="back-link" to="/vehicles"><Icon name="arrow-left" /> Fleet registry</Link> <Link className="back-link" to="/vehicles"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow="Fleet / Vehicle record" title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} />{vehicle.attention && <span className="badge severity-high">Needs attention</span>}</div>} /> <PageHeader eyebrow={t("detail.eyebrow")} title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} label={t(`statuses.${vehicle.operational_status}`, { defaultValue: vehicle.operational_status })} />{vehicle.attention && <span className="badge severity-high">{t("detail.needsAttention")}</span>}</div>} />
<div role="tablist" aria-label="Vehicle sections" className="tabs" aria-orientation="horizontal"> <div role="tablist" aria-label={t("detail.tabsAriaLabel")} className="tabs" aria-orientation="horizontal">
{TABS.map((t) => ( {TABS.map((tb) => (
<button <button
key={t} key={tb}
role="tab" role="tab"
type="button" type="button"
aria-selected={tab === t} aria-selected={tab === tb}
className={tab === t ? "active" : ""} className={tab === tb ? "active" : ""}
onClick={() => setTab(t)} onClick={() => setTab(tb)}
> >
{t.charAt(0).toUpperCase() + t.slice(1)} {t(`detail.tabs.${tb}`)}
</button> </button>
))} ))}
</div> </div>
{tab === "overview" && ( {tab === "overview" && (
<section className="record-surface" aria-label="Vehicle overview"><dl className="detail-grid"> <section className="record-surface" aria-label={t("detail.tabs.overview")}><dl className="detail-grid">
<div><dt>Registration</dt><dd>{vehicle.registration_number}</dd></div> <div><dt>{t("detail.overview.registration")}</dt><dd>{vehicle.registration_number}</dd></div>
<div><dt>Model year</dt><dd>{vehicle.model_year}</dd></div> <div><dt>{t("detail.overview.modelYear")}</dt><dd>{vehicle.model_year}</dd></div>
<div><dt>Location</dt><dd>{vehicle.location}</dd></div> <div><dt>{t("detail.overview.location")}</dt><dd>{vehicle.location}</dd></div>
<div><dt>Odometer</dt><dd>{vehicle.odometer_km.toLocaleString("en-GB")} km</dd></div> <div><dt>{t("detail.overview.odometer")}</dt><dd>{formatNumber(vehicle.odometer_km)} km</dd></div>
<div><dt>Next service</dt><dd>{vehicle.next_service_km.toLocaleString("en-GB")} km</dd></div> <div><dt>{t("detail.overview.nextService")}</dt><dd>{formatNumber(vehicle.next_service_km)} km</dd></div>
<div><dt>Active</dt><dd>{vehicle.active ? "Yes" : "No"}</dd></div> <div><dt>{t("detail.overview.active")}</dt><dd>{vehicle.active ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section> </dl></section>
)} )}
{tab === "bookings" && ( {tab === "bookings" && (
<ul className="record-list"> <ul className="record-list">
{vehicle.bookings.length === 0 && <li>No bookings recorded.</li>} {vehicle.bookings.length === 0 && <li>{t("detail.noBookings")}</li>}
{vehicle.bookings.map((b) => ( {vehicle.bookings.map((b) => (
<li key={b.public_ref}> <li key={b.public_ref}>
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link> <Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
<StatusBadge status={b.status} /> <StatusBadge status={b.status} label={t(`bookings:statuses.${b.status}`, { defaultValue: b.status })} />
<span> <span>
{new Date(b.starts_at).toLocaleDateString("en-GB")} {new Date(b.ends_at).toLocaleDateString("en-GB")} {formatShortDate(b.starts_at)} {formatShortDate(b.ends_at)}
</span> </span>
</li> </li>
))} ))}
@@ -76,15 +80,15 @@ export function VehicleDetail() {
{tab === "inspections" && ( {tab === "inspections" && (
<ul className="record-list"> <ul className="record-list">
{vehicle.inspections.length === 0 && <li>No inspections recorded.</li>} {vehicle.inspections.length === 0 && <li>{t("detail.noInspections")}</li>}
{vehicle.inspections.map((i) => ( {vehicle.inspections.map((i) => (
<li key={i.public_ref}> <li key={i.public_ref}>
<span>{i.type}</span> <span>{i.type}</span>
<span>{i.odometer_km.toLocaleString("en-GB")} km</span> <span>{formatNumber(i.odometer_km)} km</span>
<span>Fuel {i.fuel_level_percent}%</span> <span>{t("detail.fuel", { percent: i.fuel_level_percent })}</span>
{i.damage_reported && <span className="badge severity-high">Damage</span>} {i.damage_reported && <span className="badge severity-high">{t("detail.damage")}</span>}
{i.technical_warning && <span className="badge severity-high">Technical warning</span>} {i.technical_warning && <span className="badge severity-high">{t("detail.technicalWarning")}</span>}
<time dateTime={i.completed_at}>{new Date(i.completed_at).toLocaleDateString("en-GB")}</time> <time dateTime={i.completed_at}>{formatShortDate(i.completed_at)}</time>
</li> </li>
))} ))}
</ul> </ul>
@@ -92,12 +96,12 @@ export function VehicleDetail() {
{tab === "maintenance" && ( {tab === "maintenance" && (
<ul className="record-list"> <ul className="record-list">
{vehicle.maintenance.length === 0 && <li>No maintenance records.</li>} {vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
{vehicle.maintenance.map((m) => ( {vehicle.maintenance.map((m) => (
<li key={m.public_ref}> <li key={m.public_ref}>
<span>{m.category}</span> <span>{m.category}</span>
<span>{m.summary}</span> <span>{m.summary}</span>
<time dateTime={m.occurred_at}>{new Date(m.occurred_at).toLocaleDateString("en-GB")}</time> <time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
</li> </li>
))} ))}
</ul> </ul>
@@ -105,13 +109,13 @@ export function VehicleDetail() {
{tab === "quality" && ( {tab === "quality" && (
<ul className="record-list"> <ul className="record-list">
{vehicle.quality_issues.length === 0 && <li>No quality issues recorded.</li>} {vehicle.quality_issues.length === 0 && <li>{t("detail.noQualityIssues")}</li>}
{vehicle.quality_issues.map((q) => ( {vehicle.quality_issues.map((q) => (
<li key={q.public_ref}> <li key={q.public_ref}>
<Link to={`/data-quality/${q.public_ref}`}>{q.public_ref}</Link> <Link to={`/data-quality/${q.public_ref}`}>{q.public_ref}</Link>
<SeverityBadge severity={q.severity} /> <SeverityBadge severity={q.severity} />
<span>{q.rule_type.replace(/_/g, " ")}</span> <span>{t(`quality:ruleTypes.${q.rule_type}`, { defaultValue: q.rule_type.replace(/_/g, " ") })}</span>
<StatusBadge status={q.status} /> <StatusBadge status={q.status} label={t(`quality:list.status${q.status.charAt(0).toUpperCase()}${q.status.slice(1)}`, { defaultValue: q.status })} />
</li> </li>
))} ))}
</ul> </ul>
+33 -28
View File
@@ -1,13 +1,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Vehicle } from "../api/types"; import type { Vehicle } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge"; import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"]; const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
export function Vehicles() { export function Vehicles() {
const { t } = useTranslation("fleet");
const { formatNumber } = useLocaleFormat();
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null); const [vehicles, setVehicles] = useState<Vehicle[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
@@ -23,25 +27,25 @@ export function Vehicles() {
api api
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`) .get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles) .then(setVehicles)
.catch(() => setError("Vehicle list is unavailable right now.")); .catch(() => setError(t("list.unavailable")));
}, [status, attentionOnly]); }, [status, attentionOnly]);
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow="Fleet / Registry" title="Vehicle fleet" description="Live operational state, location and service readiness." /> <PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} />
<form className="filters" aria-label="Filter vehicles"> <form className="filters" aria-label={t("list.title")}>
<label> <label>
Search {t("list.searchLabel")}
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Reference, make or location" /> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("list.searchPlaceholder")} />
</label> </label>
<label> <label>
Status {t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}> <select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option> <option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((s) => ( {STATUS_OPTIONS.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{s} {t(`statuses.${s}`)}
</option> </option>
))} ))}
</select> </select>
@@ -52,43 +56,44 @@ export function Vehicles() {
checked={attentionOnly} checked={attentionOnly}
onChange={(e) => setAttentionOnly(e.target.checked)} onChange={(e) => setAttentionOnly(e.target.checked)}
/> />
Attention only {t("list.attentionOnly")}
</label> </label>
</form> </form>
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!error && !vehicles && <LoadingState label="Loading fleet registry…" />} {!error && !vehicles && <LoadingState label={t("list.loading")} />}
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title="No vehicles found" detail="Adjust the current fleet filters." />} {vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title={t("list.empty")} detail={t("list.emptyDetail")} />}
{vehicles && vehicles.length > 0 && (() => { {vehicles && vehicles.length > 0 && (() => {
const filtered = vehicles.filter((v) => `${v.public_ref} ${v.make} ${v.model} ${v.location}`.toLowerCase().includes(query.toLowerCase())); const filtered = vehicles.filter((v) => `${v.public_ref} ${v.make} ${v.model} ${v.location}`.toLowerCase().includes(query.toLowerCase()));
return filtered.length === 0 ? <EmptyState icon="search" title="No matching vehicles" detail="Try a broader search term." /> : <div className="table-shell"><div className="table-meta"><span>{filtered.length} vehicles</span><span>Persisted fleet data</span></div><table className="data-table"> return filtered.length === 0 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
<caption className="visually-hidden">Vehicle fleet</caption> <caption className="visually-hidden">{t("list.title")}</caption>
<thead> <thead>
<tr> <tr>
<th scope="col">Reference</th> <th scope="col">{t("list.columns.reference")}</th>
<th scope="col">Make / model</th> <th scope="col">{t("list.columns.makeModel")}</th>
<th scope="col">Location</th> <th scope="col">{t("list.columns.location")}</th>
<th scope="col">Status</th> <th scope="col">{t("list.columns.status")}</th>
<th scope="col">Odometer (km)</th> <th scope="col">{t("list.columns.odometer")}</th>
<th scope="col">Attention</th> <th scope="col">{t("list.columns.attention")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map((v) => ( {filtered.map((v) => (
<tr key={v.public_ref} className={v.attention ? "row-attention" : ""}> <tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}>
<th scope="row" data-label="Reference"> <th scope="row" data-label={t("list.columns.reference")}>
<Link to={`/vehicles/${v.public_ref}`}>{v.public_ref}</Link> {v.public_ref}
<Link className="row-link" to={`/vehicles/${v.public_ref}`}><span className="visually-hidden">{v.public_ref}</span></Link>
</th> </th>
<td data-label="Make / model"> <td data-label={t("list.columns.makeModel")}>
{v.make} {v.model} ({v.model_year}) {v.make} {v.model} ({v.model_year})
</td> </td>
<td data-label="Location">{v.location}</td> <td data-label={t("list.columns.location")}>{v.location}</td>
<td data-label="Status"> <td data-label={t("list.columns.status")}>
<StatusBadge status={v.operational_status} /> <StatusBadge status={v.operational_status} label={t(`statuses.${v.operational_status}`, { defaultValue: v.operational_status })} />
</td> </td>
<td data-label="Odometer">{v.odometer_km.toLocaleString("en-GB")}</td> <td data-label={t("list.columns.odometer")}>{formatNumber(v.odometer_km)}</td>
<td data-label="Attention">{v.attention ? <span className="attention-flag">Needs attention</span> : "—"}</td> <td data-label={t("list.columns.attention")}>{v.attention ? <span className="attention-flag">{t("list.needsAttention")}</span> : "—"}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+68 -4
View File
@@ -103,6 +103,11 @@ a:hover { color: var(--teal); }
.icon-button:hover { background: var(--surface-subtle); border-color: var(--line); } .icon-button:hover { background: var(--surface-subtle); border-color: var(--line); }
.icon-button svg { width: 18px; height: 18px; } .icon-button svg { width: 18px; height: 18px; }
.mobile-menu { display: none; } .mobile-menu { display: none; }
.language-switcher select { height: 32px; padding: 0 8px; color: var(--ink-soft); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 600; cursor: pointer; }
.language-switcher-compact select { height: 28px; font-size: .64rem; }
.sidebar-language { display: none; padding: 0 20px 14px; }
.login-panel-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.login-panel-top .page-eyebrow { margin: 0; }
.demo-badge { position: relative; } .demo-badge { position: relative; }
.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; } .demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; }
.demo-badge-trigger:hover { background: #dfe8ef; } .demo-badge-trigger:hover { background: #dfe8ef; }
@@ -183,7 +188,13 @@ a:hover { color: var(--teal); }
.timeline-departure { color: var(--info); border-color: #6a98b3; } .timeline-departure { color: var(--info); border-color: #6a98b3; }
.movement-timeline li > div { min-width: 0; display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; align-items: baseline; } .movement-timeline li > div { min-width: 0; display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; align-items: baseline; }
.movement-kind { color: var(--muted); font-size: .59rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } .movement-kind { color: var(--muted); font-size: .59rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.movement-timeline a { color: var(--ink); font-size: .75rem; font-weight: 700; text-decoration: none; }.movement-timeline small { grid-column: 2; color: var(--muted); font-size: .66rem; } .movement-timeline .movement-ref { color: var(--ink); font-size: .75rem; font-weight: 700; }.movement-timeline small { grid-column: 2; color: var(--muted); font-size: .66rem; }
.row-clickable { position: relative; cursor: pointer; }
.row-link { position: absolute; inset: 0; z-index: 1; border-radius: inherit; }
.row-link:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.attention-list li.row-clickable:hover .attention-title, .movement-timeline li.row-clickable:hover .movement-ref { color: var(--teal-dark); }
.data-table tr.row-clickable:hover { background: var(--surface-subtle); }
.data-table tr.row-clickable .cell-link { position: relative; z-index: 2; }
.integration-list li, .recent-list li { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; } .integration-list li, .recent-list li { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; }
.integration-list li:last-child, .recent-list li:last-child { border-bottom: 0; }.integration-list li > div, .recent-list li > div { flex: 1; display: grid; gap: 3px; } .integration-list li:last-child, .recent-list li:last-child { border-bottom: 0; }.integration-list li > div, .recent-list li > div { flex: 1; display: grid; gap: 3px; }
@@ -267,6 +278,36 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); } .merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.confirm-bar { margin-top: 13px; padding: 15px; background: var(--warning-pale); border: 1px solid #eed4aa; }.confirm-bar p { margin: 0 0 11px; color: #75420c; font-size: .75rem; font-weight: 700; }.confirm-bar button:first-of-type { color: white; background: var(--critical); border-color: var(--critical); } .confirm-bar { margin-top: 13px; padding: 15px; background: var(--warning-pale); border: 1px solid #eed4aa; }.confirm-bar p { margin: 0 0 11px; color: #75420c; font-size: .75rem; font-weight: 700; }.confirm-bar button:first-of-type { color: white; background: var(--critical); border-color: var(--critical); }
.resolution-actions { display: flex; gap: 9px; }.evidence-block { max-width: 100%; padding: 13px; overflow: auto; color: #324054; background: var(--surface-subtle); border: 1px solid var(--line); font-size: .68rem; } .resolution-actions { display: flex; gap: 9px; }.evidence-block { max-width: 100%; padding: 13px; overflow: auto; color: #324054; background: var(--surface-subtle); border: 1px solid var(--line); font-size: .68rem; }
.evidence-disclosure { margin-top: 14px; }.evidence-disclosure summary { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.evidence-disclosure .evidence-block { margin-top: 8px; }
.choice-fieldset { display: grid; gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.choice-fieldset legend { margin-bottom: 2px; padding: 0; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.choice-fieldset-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.choice-card { display: flex !important; flex-direction: row; align-items: flex-start; gap: 11px; padding: 13px 15px; background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--radius); cursor: pointer; transition: border-color .15s ease, background .15s ease; }
.choice-card:hover { border-color: var(--teal); }
.choice-card:has(input:checked), .choice-card.is-selected { border-color: var(--teal-dark); background: var(--teal-pale); }
.choice-card:has(input:disabled), .choice-card.is-disabled { cursor: not-allowed; opacity: .55; }
.choice-card input[type="radio"] { flex: 0 0 auto; margin-top: 2px; }
.choice-card-body { display: grid; gap: 3px; }
.choice-card-title { color: var(--ink); font-size: .82rem; font-weight: 700; }
.choice-card-detail { color: var(--muted); font-size: .72rem; line-height: 1.5; }
.defer-reject-panel { padding: 16px 18px; background: var(--surface-subtle); }
.defer-reject-panel h2 { margin: 0 0 4px; color: var(--ink-soft); font-size: .78rem; font-weight: 700; }
.defer-reject-panel > p { margin: 0 0 12px; color: var(--muted); font-size: .72rem; }
.audit-group-list { list-style: none; display: grid; gap: 12px; margin: 0; padding: 16px; }
.audit-group { padding: 16px 18px; }
.audit-group-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.audit-group-heading strong { color: var(--ink); font-size: .85rem; }
.audit-group-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 6px; font-size: .76rem; }
.change-diff { list-style: none; display: grid; gap: 4px; margin: 10px 0 0; padding: 0; color: var(--ink-soft); font-size: .74rem; }
.change-diff li { padding: 6px 10px; background: var(--surface-subtle); border-radius: 4px; }
.audit-group-actions { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px; }
.audit-related-list { list-style: none; display: grid; gap: 10px; margin: 12px 0 0; padding: 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.audit-related-list > li { padding: 10px 12px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }
.audit-technical-grid { margin-top: 8px; }
.resolution-actions .button-tertiary, .resolution-actions .button-tertiary-destructive { min-height: 36px; padding: 7px 12px; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; }
.resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); }
.resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); }
.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } .integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
@@ -330,7 +371,30 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
@media (min-width: 701px) { @media (min-width: 701px) {
/* The guide panel is a fixed right-side overlay at this width -- push page content /* The guide panel is a fixed right-side overlay at this width -- push page content
aside while it's open so it never sits underneath and blocks actionable buttons. */ aside while it's open so it never sits underneath and blocks actionable buttons. */
.app-workspace.guide-open { padding-right: min(400px, 92vw); } .app-workspace.guide-open { padding-right: min(400px, 92vw); transition: padding-right .2s ease; }
.app-workspace.guide-open.guide-collapsed { padding-right: 0; }
}
/* Extra-wide desktop (>=1440px, see useViewportTier): a docked rail rather than a
floating overlay -- guaranteed minimum width, no shadow (it reads as part of the
layout, not something hovering above it), and it never auto-collapses to a chip. */
.demo-guide-panel.is-wide { width: 420px; min-width: 420px; box-shadow: none; }
.demo-guide-chip { position: fixed; z-index: 40; right: 20px; bottom: 20px; display: flex; align-items: stretch; height: 44px; background: white; border: 1px solid #bfe6df; border-radius: 999px; box-shadow: var(--shadow-float); overflow: hidden; }
.demo-guide-chip-expand { display: flex; align-items: center; gap: 8px; padding: 0 14px; color: var(--teal-dark); background: transparent; border: 0; font-size: .72rem; font-weight: 700; cursor: pointer; }
.demo-guide-chip-expand:hover { background: var(--teal-pale); }
.demo-guide-chip-expand svg { width: 14px; }
.demo-guide-chip-close { display: flex; align-items: center; padding: 0 12px; color: var(--muted); background: transparent; border: 0; border-left: 1px solid var(--line); cursor: pointer; }
.demo-guide-chip-close:hover { color: var(--ink); background: var(--surface-subtle); }
.demo-guide-chip-close svg { width: 12px; }
.demo-guide-highlight { animation: demo-guide-pulse 2.2s ease; outline: 3px solid var(--teal); outline-offset: 3px; border-radius: var(--radius); }
@keyframes demo-guide-pulse {
0% { outline-color: var(--teal-dark); }
70% { outline-color: var(--teal-dark); }
100% { outline-color: transparent; }
}
@media (prefers-reduced-motion: reduce) {
.demo-guide-highlight { animation: none; }
} }
.demo-guide-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } .demo-guide-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.demo-guide-kicker { margin: 0 0 4px; color: var(--teal-dark); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } .demo-guide-kicker { margin: 0 0 4px; color: var(--teal-dark); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
@@ -353,11 +417,11 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; } .demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 960px) { @media (max-width: 960px) {
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; } .app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; } .filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: .57rem; font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; } .table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: .57rem; font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }
.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; }

Some files were not shown because too many files have changed in this diff Show More