UX: implement visual product roadmap
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
|
||||
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
|
||||
expect(vehicles.ok()).toBeTruthy();
|
||||
const vehiclePage = await vehicles.json();
|
||||
expect(vehiclePage.items).toHaveLength(25);
|
||||
expect(vehiclePage.total).toBeGreaterThanOrEqual(25);
|
||||
|
||||
const audit = await request.get("/api/v1/audit?page=1&page_size=25");
|
||||
expect(audit.ok()).toBeTruthy();
|
||||
const auditPage = await audit.json();
|
||||
expect(auditPage.items.length).toBeLessThanOrEqual(25);
|
||||
|
||||
await page.goto("/vehicles?status=maintenance&page=1");
|
||||
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
|
||||
await expect(page).toHaveURL(/status=maintenance/);
|
||||
});
|
||||
|
||||
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||
await expect(page.locator(".page-actions .badge")).toBeVisible();
|
||||
await page.goto("/vehicles");
|
||||
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
|
||||
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
|
||||
});
|
||||
@@ -20,6 +20,14 @@ export interface Vehicle {
|
||||
attention: boolean;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface BookingSummary {
|
||||
public_ref: string;
|
||||
customer_ref: string;
|
||||
@@ -260,7 +268,7 @@ export interface KnowledgeHealth {
|
||||
tenant: string;
|
||||
workspace: string;
|
||||
collection: string;
|
||||
document_count: number;
|
||||
document_count: number | null;
|
||||
}
|
||||
|
||||
export interface N8nWorkflowEvidence {
|
||||
|
||||
@@ -313,19 +313,23 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
<LanguageSwitcher compact />
|
||||
<DemoGuideTrigger />
|
||||
<DemoBadge />
|
||||
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
||||
{user && (
|
||||
<div className="operator">
|
||||
<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" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
||||
</div>
|
||||
<details className="operator-menu">
|
||||
<summary className="operator">
|
||||
<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" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
||||
</summary>
|
||||
<div className="operator-popover">
|
||||
<LanguageSwitcher compact />
|
||||
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
||||
<button className="operator-logout" type="button" onClick={handleLogout}>
|
||||
<Icon name="logout" /> {t("switchRole")}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
|
||||
<Icon name="logout" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
if (totalPages <= 1) return null;
|
||||
return (
|
||||
<nav className="pagination" aria-label={t("pagination.label")}>
|
||||
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
|
||||
{t("pagination.previous")}
|
||||
</button>
|
||||
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
|
||||
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
|
||||
{t("pagination.next")}
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,12 @@
|
||||
"description": "Trace important state changes, actors and correlation references.",
|
||||
"actionFilterLabel": "Action",
|
||||
"actionFilterPlaceholder": "e.g. demo_login",
|
||||
"actorFilterLabel": "Actor",
|
||||
"actorFilterPlaceholder": "Name or service",
|
||||
"entityFilterLabel": "Record",
|
||||
"entityFilterPlaceholder": "e.g. MO-016",
|
||||
"fromFilterLabel": "From",
|
||||
"toFilterLabel": "To",
|
||||
"managerOnly": "The audit trail is visible to Operations Managers only.",
|
||||
"managerOnlyDetail": "Audit history is visible to Operations Managers only.",
|
||||
"loading": "Loading audit trail…",
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"success": "Success",
|
||||
"noResults": "No results"
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Page navigation",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageOf": "Page {{page}} of {{total}}"
|
||||
},
|
||||
"states": {
|
||||
"loadingDefault": "Loading workspace…",
|
||||
"errorTitle": "We couldn't load this workspace."
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"title": "Attention queue",
|
||||
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
|
||||
"reviewQueue": "Review queue",
|
||||
"remaining": "View {{count}} more attention item(s)",
|
||||
"filterPlaceholder": "Filter issues…",
|
||||
"filterAriaLabel": "Search attention queue",
|
||||
"severityAll": "All severity",
|
||||
@@ -68,6 +69,7 @@
|
||||
"n8nNoEvidence": "No workflow evidence recorded",
|
||||
"knowledgeTitle": "Knowledge assistant",
|
||||
"knowledgeSummary": "{{count}} procedures indexed",
|
||||
"knowledgeIndexUnknown": "Knowledge source available · index size unknown",
|
||||
"knowledgeUnavailable": "Health check unavailable",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Registration enabled",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"knowledgeTitle": "Knowledge assistant",
|
||||
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
|
||||
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
|
||||
"knowledgeSummaryIndexUnknown": "Knowledge source available · index size is unavailable for {{collection}}.",
|
||||
"knowledgeUnavailable": "Health evidence is currently unavailable.",
|
||||
"gatewayKicker": "Tool gateway",
|
||||
"mcpTitle": "MCP Hub",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"statusAvailable": "Available",
|
||||
"statusUnavailable": "Unavailable",
|
||||
"proceduresIndexed": "{{count}} procedures indexed",
|
||||
"proceduresIndexUnknown": "Index size is not available through this connection",
|
||||
"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",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"statusRejected": "Rejected",
|
||||
"ruleTypeLabel": "Rule type",
|
||||
"ruleTypeAll": "All rule types",
|
||||
"severityLabel": "Severity",
|
||||
"severityAll": "All severity levels",
|
||||
"demoScenariosOnly": "Demo scenarios only",
|
||||
"loading": "Loading quality workbench…",
|
||||
"queueClear": "Queue is clear",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
||||
"actionFilterLabel": "Action",
|
||||
"actionFilterPlaceholder": "p. ex. demo_login",
|
||||
"actorFilterLabel": "Intervenant",
|
||||
"actorFilterPlaceholder": "Nom ou service",
|
||||
"entityFilterLabel": "Dossier",
|
||||
"entityFilterPlaceholder": "p. ex. MO-016",
|
||||
"fromFilterLabel": "À partir du",
|
||||
"toFilterLabel": "Jusqu’au",
|
||||
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
|
||||
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
|
||||
"loading": "Chargement de la piste d'audit…",
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"success": "Réussi",
|
||||
"noResults": "Aucun résultat"
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Navigation entre les pages",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"pageOf": "Page {{page}} sur {{total}}"
|
||||
},
|
||||
"states": {
|
||||
"loadingDefault": "Chargement de l'espace de travail…",
|
||||
"errorTitle": "Impossible de charger cet espace de travail."
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"title": "File d'attention",
|
||||
"description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow",
|
||||
"reviewQueue": "Examiner la file",
|
||||
"remaining": "Voir encore {{count}} point(s) d’attention",
|
||||
"filterPlaceholder": "Filtrer les problèmes…",
|
||||
"filterAriaLabel": "Rechercher dans la file d'attention",
|
||||
"severityAll": "Toute gravité",
|
||||
@@ -68,6 +69,7 @@
|
||||
"n8nNoEvidence": "Aucune preuve d'automatisation enregistrée",
|
||||
"knowledgeTitle": "Assistant de connaissances",
|
||||
"knowledgeSummary": "{{count}} procédures indexées",
|
||||
"knowledgeIndexUnknown": "Source de connaissances disponible · taille d’index inconnue",
|
||||
"knowledgeUnavailable": "Vérification de santé indisponible",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Enregistrement activé",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"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}}.",
|
||||
"knowledgeSummaryIndexUnknown": "Source de connaissances disponible · taille d’index indisponible pour {{collection}}.",
|
||||
"knowledgeUnavailable": "Preuves de santé actuellement indisponibles.",
|
||||
"gatewayKicker": "Passerelle d'outils",
|
||||
"mcpTitle": "MCP Hub",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"statusAvailable": "Disponible",
|
||||
"statusUnavailable": "Indisponible",
|
||||
"proceduresIndexed": "{{count}} procédures indexées",
|
||||
"proceduresIndexUnknown": "La taille de l’index n’est pas disponible via cette connexion",
|
||||
"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",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"statusRejected": "Rejeté",
|
||||
"ruleTypeLabel": "Type de règle",
|
||||
"ruleTypeAll": "Tous les types de règles",
|
||||
"severityLabel": "Gravité",
|
||||
"severityAll": "Tous les niveaux de gravité",
|
||||
"demoScenariosOnly": "Scénarios de démo uniquement",
|
||||
"loading": "Chargement de l'atelier qualité…",
|
||||
"queueClear": "La file est vide",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
|
||||
"actionFilterLabel": "Actie",
|
||||
"actionFilterPlaceholder": "bv. demo-login",
|
||||
"actorFilterLabel": "Uitvoerder",
|
||||
"actorFilterPlaceholder": "Naam of dienst",
|
||||
"entityFilterLabel": "Record",
|
||||
"entityFilterPlaceholder": "bv. MO-016",
|
||||
"fromFilterLabel": "Vanaf",
|
||||
"toFilterLabel": "Tot en met",
|
||||
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||
"loading": "Auditgeschiedenis laden…",
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"success": "Gelukt",
|
||||
"noResults": "Geen resultaten"
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Paginanavigatie",
|
||||
"previous": "Vorige",
|
||||
"next": "Volgende",
|
||||
"pageOf": "Pagina {{page}} van {{total}}"
|
||||
},
|
||||
"states": {
|
||||
"loadingDefault": "Werkruimte wordt geladen…",
|
||||
"errorTitle": "We konden deze werkruimte niet laden."
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"title": "Aandachtspunten",
|
||||
"description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen",
|
||||
"reviewQueue": "Wachtrij bekijken",
|
||||
"remaining": "Nog {{count}} aandachtspunt(en) bekijken",
|
||||
"filterPlaceholder": "Filter aandachtspunten…",
|
||||
"filterAriaLabel": "Zoek in aandachtspunten",
|
||||
"severityAll": "Alle ernst",
|
||||
@@ -68,6 +69,7 @@
|
||||
"n8nNoEvidence": "Geen automatiseringsevidentie geregistreerd",
|
||||
"knowledgeTitle": "Kennisassistent",
|
||||
"knowledgeSummary": "{{count}} procedures geïndexeerd",
|
||||
"knowledgeIndexUnknown": "Kennisbron bereikbaar · indexomvang onbekend",
|
||||
"knowledgeUnavailable": "Statuscontrole niet beschikbaar",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Registratie ingeschakeld",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"knowledgeTitle": "Kennisassistent",
|
||||
"knowledgeSummaryDemo": "Demokennisbank · {{count}} procedures geïndexeerd in {{collection}}.",
|
||||
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures geïndexeerd in {{collection}}.",
|
||||
"knowledgeSummaryIndexUnknown": "Kennisbron bereikbaar · indexomvang niet beschikbaar voor {{collection}}.",
|
||||
"knowledgeUnavailable": "Statusevidentie momenteel niet beschikbaar.",
|
||||
"gatewayKicker": "Tool-gateway",
|
||||
"mcpTitle": "MCP Hub",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"statusAvailable": "Beschikbaar",
|
||||
"statusUnavailable": "Niet beschikbaar",
|
||||
"proceduresIndexed": "{{count}} procedures geïndexeerd",
|
||||
"proceduresIndexUnknown": "Indexomvang niet beschikbaar via deze koppeling",
|
||||
"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",
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"statusRejected": "Verworpen",
|
||||
"ruleTypeLabel": "Regeltype",
|
||||
"ruleTypeAll": "Alle regeltypes",
|
||||
"severityLabel": "Ernst",
|
||||
"severityAll": "Alle ernstniveaus",
|
||||
"demoScenariosOnly": "Enkel demoscenario's",
|
||||
"loading": "Kwaliteitswerkbank laden…",
|
||||
"queueClear": "Wachtrij is leeg",
|
||||
|
||||
+79
-182
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import type { AuditEvent } from "../api/types";
|
||||
import type { AuditEvent, Page } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
|
||||
function humanizeField(field: string): string {
|
||||
const spaced = field.replace(/_/g, " ");
|
||||
@@ -20,210 +21,106 @@ function actionLabel(t: (key: string, options?: Record<string, unknown>) => stri
|
||||
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;
|
||||
}) {
|
||||
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 lines: { field: string; text: string }[] = [];
|
||||
for (const key of keys) {
|
||||
const b = before?.[key];
|
||||
const a = after?.[key];
|
||||
if (JSON.stringify(b) === JSON.stringify(a)) continue;
|
||||
const field = t(`fields.${key}`, { defaultValue: humanizeField(key) });
|
||||
if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: 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) }) });
|
||||
}
|
||||
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>
|
||||
);
|
||||
const lines = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])]
|
||||
.filter((key) => JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key]))
|
||||
.slice(0, 3)
|
||||
.map((key) => {
|
||||
const field = t(`fields.${key}`, { defaultValue: humanizeField(key) });
|
||||
const b = before?.[key];
|
||||
const a = after?.[key];
|
||||
if (b === undefined) return t("diff.setTo", { field, value: JSON.stringify(a) });
|
||||
if (a === undefined) return t("diff.was", { field, value: JSON.stringify(b) });
|
||||
return t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) });
|
||||
});
|
||||
return lines.length ? <ul className="change-diff">{lines.map((line) => <li key={line}>{line}</li>)}</ul> : <p className="table-subtext">{t("noFieldChange")}</p>;
|
||||
}
|
||||
|
||||
interface EventGroup {
|
||||
correlationId: string;
|
||||
primary: AuditEvent;
|
||||
related: AuditEvent[];
|
||||
}
|
||||
interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; }
|
||||
|
||||
export function Audit() {
|
||||
const { t } = useTranslation("audit");
|
||||
const { formatDateTime } = useLocaleFormat();
|
||||
const { user } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
||||
const [events, setEvents] = useState<Page<AuditEvent> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [action, setAction] = useState(searchParams.get("action") ?? "");
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const action = searchParams.get("action") ?? "";
|
||||
const actor = searchParams.get("actor") ?? "";
|
||||
const entityRef = searchParams.get("entity_ref") ?? "";
|
||||
const from = searchParams.get("from") ?? "";
|
||||
const to = searchParams.get("to") ?? "";
|
||||
const correlationId = searchParams.get("correlation_id") ?? "";
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||
|
||||
function updateParams(updates: Record<string, string | number | null>) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === "") next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "operations_manager") return;
|
||||
setEvents(null);
|
||||
setError(null);
|
||||
const params = new URLSearchParams();
|
||||
const params = new URLSearchParams({ page: String(page), page_size: "25" });
|
||||
if (action) params.set("action", action);
|
||||
if (actor) params.set("actor_label", actor);
|
||||
if (entityRef) params.set("entity_ref", entityRef);
|
||||
if (correlationId) params.set("correlation_id", correlationId);
|
||||
api
|
||||
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
|
||||
.then(setEvents)
|
||||
.catch(() => setError(t("unavailable")));
|
||||
}, [action, correlationId, user]);
|
||||
if (from) params.set("occurred_from", `${from}T00:00:00Z`);
|
||||
if (to) params.set("occurred_to", `${to}T23:59:59Z`);
|
||||
api.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable")));
|
||||
}, [action, actor, correlationId, entityRef, from, page, t, to, 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) };
|
||||
});
|
||||
for (const event of events?.items ?? []) byCorrelation.set(event.correlation_id, [...(byCorrelation.get(event.correlation_id) ?? []), event]);
|
||||
return [...byCorrelation.entries()].map(([id, grouped]) => ({ correlationId: id, primary: grouped[0], related: grouped.slice(1) }));
|
||||
}, [events]);
|
||||
|
||||
function showRelatedEvents(id: string) {
|
||||
setSearchParams({ correlation_id: id });
|
||||
}
|
||||
if (user?.role !== "operations_manager") return <div className="page"><PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} /><p>{t("managerOnlyDetail")}</p></div>;
|
||||
|
||||
function clearCorrelationFilter() {
|
||||
setSearchParams(action ? { action } : {});
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
}
|
||||
|
||||
if (user?.role !== "operations_manager") {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
|
||||
<p>{t("managerOnlyDetail")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
|
||||
|
||||
<form className="filters" aria-label={t("title")}>
|
||||
<label>
|
||||
{t("actionFilterLabel")}
|
||||
<input
|
||||
type="text"
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
placeholder={t("actionFilterPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{correlationId && (
|
||||
<p className="quiet-empty" role="status">
|
||||
{t("relatedFilterActive", { count: events?.length ?? 0 })}{" "}
|
||||
<button type="button" className="link-button" onClick={clearCorrelationFilter}>
|
||||
{t("clearFilter")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !events && <LoadingState label={t("loading")} />}
|
||||
{events && events.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div className="table-shell">
|
||||
<div className="table-meta"><span>{t("count", { count: events!.length })}</span><span>{t("storedRendered")}</span></div>
|
||||
<ul className="audit-group-list">
|
||||
{groups.map((group) => {
|
||||
const e = group.primary;
|
||||
const isExpanded = expanded[group.correlationId] ?? false;
|
||||
return (
|
||||
<li key={group.correlationId} className="audit-group panel">
|
||||
<div className="audit-group-summary">
|
||||
<div className="audit-group-heading">
|
||||
<strong>{actionLabel(t, e.action)}</strong>
|
||||
<span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
|
||||
</div>
|
||||
<div className="audit-group-meta">
|
||||
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>
|
||||
{e.entity_link ? (
|
||||
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
|
||||
) : (
|
||||
<span>{e.entity_ref ?? e.entity_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChangeDiff before={e.before} after={e.after} t={t} />
|
||||
<div className="audit-group-actions">
|
||||
{group.related.length > 0 && (
|
||||
<button type="button" className="link-button" onClick={() => toggleExpanded(group.correlationId)}>
|
||||
{isExpanded
|
||||
? t("hideTechnicalEvents")
|
||||
: t("showTechnicalEvents", { count: group.related.length })}
|
||||
</button>
|
||||
)}
|
||||
{!correlationId && (
|
||||
<button type="button" className="link-button" onClick={() => showRelatedEvents(group.correlationId)}>
|
||||
{t("viewRelatedEvents")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="evidence-disclosure">
|
||||
<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>
|
||||
|
||||
{isExpanded && group.related.length > 0 && (
|
||||
<ul className="audit-related-list">
|
||||
{group.related.map((related) => (
|
||||
<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>
|
||||
);
|
||||
return <div className="page">
|
||||
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
|
||||
<form className="filters" aria-label={t("title")} onSubmit={(event) => event.preventDefault()}>
|
||||
<label>{t("actionFilterLabel")}<input type="search" value={action} onChange={(e) => updateParams({ action: e.target.value, page: 1 })} placeholder={t("actionFilterPlaceholder")} /></label>
|
||||
<label>{t("actorFilterLabel")}<input type="search" value={actor} onChange={(e) => updateParams({ actor: e.target.value, page: 1 })} placeholder={t("actorFilterPlaceholder")} /></label>
|
||||
<label>{t("entityFilterLabel")}<input type="search" value={entityRef} onChange={(e) => updateParams({ entity_ref: e.target.value, page: 1 })} placeholder={t("entityFilterPlaceholder")} /></label>
|
||||
<label>{t("fromFilterLabel")}<input type="date" value={from} onChange={(e) => updateParams({ from: e.target.value, page: 1 })} /></label>
|
||||
<label>{t("toFilterLabel")}<input type="date" value={to} onChange={(e) => updateParams({ to: e.target.value, page: 1 })} /></label>
|
||||
</form>
|
||||
{correlationId && <p className="quiet-empty" role="status">{t("relatedFilterActive", { count: events?.total ?? 0 })} <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: null, page: 1 })}>{t("clearFilter")}</button></p>}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !events && <LoadingState label={t("loading")} />}
|
||||
{events && events.items.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
|
||||
{groups.length > 0 && <div className="table-shell audit-shell">
|
||||
<div className="table-meta"><span>{t("count", { count: events!.total })}</span><span>{t("storedRendered")}</span></div>
|
||||
<ul className="audit-group-list">
|
||||
{groups.map((group) => {
|
||||
const e = group.primary;
|
||||
const isExpanded = expanded[group.correlationId] ?? false;
|
||||
return <li key={group.correlationId} className="audit-group panel">
|
||||
<div className="audit-group-summary">
|
||||
<div className="audit-group-heading"><strong>{actionLabel(t, e.action)}</strong><span className="table-subtext">{formatDateTime(e.occurred_at)}</span></div>
|
||||
<div className="audit-group-meta"><span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>{e.entity_link ? <Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link> : <span>{e.entity_ref ?? e.entity_type}</span>}</div>
|
||||
<ChangeDiff before={e.before} after={e.after} t={t} />
|
||||
<div className="audit-group-actions">
|
||||
{group.related.length > 0 && <button type="button" className="link-button" onClick={() => setExpanded((current) => ({ ...current, [group.correlationId]: !current[group.correlationId] }))}>{isExpanded ? t("hideTechnicalEvents") : t("showTechnicalEvents", { count: group.related.length })}</button>}
|
||||
{!correlationId && <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: group.correlationId, page: 1 })}>{t("viewRelatedEvents")}</button>}
|
||||
</div>
|
||||
</div>
|
||||
<details className="evidence-disclosure"><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>
|
||||
{isExpanded && group.related.length > 0 && <ul className="audit-related-list">{group.related.map((related) => <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><pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre></details></li>)}</ul>}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
<Pagination page={events!.page} totalPages={events!.total_pages} onPageChange={(nextPage) => updateParams({ page: nextPage })} />
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -201,10 +201,12 @@ export function Automation() {
|
||||
<h2>{t("cards.knowledgeTitle")}</h2>
|
||||
<p>
|
||||
{knowledge
|
||||
? t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
|
||||
count: knowledge.document_count,
|
||||
collection: knowledge.collection,
|
||||
})
|
||||
? knowledge.document_count === null
|
||||
? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection })
|
||||
: t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
|
||||
count: knowledge.document_count,
|
||||
collection: knowledge.collection,
|
||||
})
|
||||
: t("cards.knowledgeUnavailable")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,11 @@ export function Dashboard() {
|
||||
const isUnfiltered = severity === "all" && query === "";
|
||||
const attentionTiers = useMemo(() => {
|
||||
if (!isUnfiltered) return [{ key: "all", labelKey: "", items: attention.slice(0, 6) }];
|
||||
const bySeverity = (level: string) => attention.filter((item) => item.severity === level);
|
||||
// Keep the initial dashboard queue deliberately bounded. The full evidence-backed
|
||||
// queue remains one click away in Data Quality; this view is for deciding what to
|
||||
// do next, not for rendering the whole backlog.
|
||||
const visible = ["high", "medium", "low"].flatMap((level) => attention.filter((item) => item.severity === level)).slice(0, 6);
|
||||
const bySeverity = (level: string) => visible.filter((item) => item.severity === level);
|
||||
return [
|
||||
{ key: "high", labelKey: "attention.tierNow", items: bySeverity("high") },
|
||||
{ key: "medium", labelKey: "attention.tierToday", items: bySeverity("medium") },
|
||||
@@ -185,6 +189,9 @@ export function Dashboard() {
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{isUnfiltered && attention.length > 6 && canSeeQuality && (
|
||||
<p className="queue-more"><Link to="/data-quality">{t("attention.remaining", { count: attention.length - 6 })} <Icon name="chevron" /></Link></p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
@@ -238,7 +245,7 @@ export function Dashboard() {
|
||||
<IntegrationMark kind="rag" />
|
||||
<div>
|
||||
<strong>{t("integrationPulse.knowledgeTitle")}</strong>
|
||||
<span>{knowledge ? t("integrationPulse.knowledgeSummary", { count: knowledge.document_count }) : t("integrationPulse.knowledgeUnavailable")}</span>
|
||||
<span>{knowledge ? (knowledge.document_count === null ? t("integrationPulse.knowledgeIndexUnknown") : t("integrationPulse.knowledgeSummary", { count: knowledge.document_count })) : t("integrationPulse.knowledgeUnavailable")}</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={knowledge?.available ? "available" : "unavailable"}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { DataQualityIssue, ScanResult } from "../api/types";
|
||||
import type { DataQualityIssue, Page, ScanResult } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
|
||||
const RULE_TYPES = [
|
||||
"possible_duplicate_customer",
|
||||
@@ -19,15 +20,27 @@ const RULE_TYPES = [
|
||||
export function DataQuality() {
|
||||
const { t } = useTranslation("quality");
|
||||
const { user } = useAuth();
|
||||
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("open");
|
||||
const [ruleType, setRuleType] = useState("");
|
||||
const status = searchParams.get("status") ?? "open";
|
||||
const ruleType = searchParams.get("rule_type") ?? "";
|
||||
const severity = searchParams.get("severity") ?? "";
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [confirmingScan, setConfirmingScan] = useState(false);
|
||||
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
|
||||
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
|
||||
|
||||
function updateFilters(updates: Record<string, string | boolean | number | null>) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === "" || value === false) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next);
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (user?.role !== "operations_manager") return;
|
||||
@@ -36,11 +49,14 @@ export function DataQuality() {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (ruleType) params.set("rule_type", ruleType);
|
||||
if (severity) params.set("severity", severity);
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", "25");
|
||||
api
|
||||
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
|
||||
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
|
||||
.then(setIssues)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
}, [status, ruleType, user]);
|
||||
}, [status, ruleType, severity, page, user, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -73,8 +89,8 @@ export function DataQuality() {
|
||||
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
|
||||
const visibleIssues = issues
|
||||
? demoScenariosOnly
|
||||
? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
|
||||
: issues
|
||||
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
|
||||
: issues.items
|
||||
: [];
|
||||
|
||||
return (
|
||||
@@ -118,7 +134,7 @@ export function DataQuality() {
|
||||
<form className="filters" aria-label={t("list.title")}>
|
||||
<label>
|
||||
{t("list.statusLabel")}
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="open">{t("list.statusOpen")}</option>
|
||||
<option value="deferred">{t("list.statusDeferred")}</option>
|
||||
@@ -128,7 +144,7 @@ export function DataQuality() {
|
||||
</label>
|
||||
<label>
|
||||
{t("list.ruleTypeLabel")}
|
||||
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}>
|
||||
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
|
||||
<option value="">{t("list.ruleTypeAll")}</option>
|
||||
{RULE_TYPES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
@@ -137,11 +153,20 @@ export function DataQuality() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("list.severityLabel")}
|
||||
<select value={severity} onChange={(e) => updateFilters({ severity: e.target.value, page: 1 })}>
|
||||
<option value="">{t("list.severityAll")}</option>
|
||||
<option value="high">{t("severities.high")}</option>
|
||||
<option value="medium">{t("severities.medium")}</option>
|
||||
<option value="low">{t("severities.low")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={demoScenariosOnly}
|
||||
onChange={(e) => setDemoScenariosOnly(e.target.checked)}
|
||||
onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
|
||||
/>
|
||||
{t("list.demoScenariosOnly")}
|
||||
</label>
|
||||
@@ -149,13 +174,13 @@ export function DataQuality() {
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !issues && <LoadingState label={t("list.loading")} />}
|
||||
{issues && issues.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
|
||||
{issues && issues.length > 0 && visibleIssues.length === 0 && (
|
||||
{issues && issues.items.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
|
||||
{issues && issues.items.length > 0 && visibleIssues.length === 0 && (
|
||||
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
|
||||
)}
|
||||
|
||||
{visibleIssues.length > 0 && (
|
||||
<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">
|
||||
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
|
||||
<caption className="visually-hidden">{t("list.title")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -184,7 +209,7 @@ export function DataQuality() {
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</table>{issues && !demoScenariosOnly && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ export function Knowledge() {
|
||||
<strong>{providerLabel}</strong>
|
||||
<span>
|
||||
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
|
||||
{t("proceduresIndexed", { count: status.document_count })}
|
||||
{status.document_count === null ? t("proceduresIndexUnknown") : t("proceduresIndexed", { count: status.document_count })}
|
||||
</span>
|
||||
</div>
|
||||
<small>{status.collection}</small>
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import type { Vehicle } from "../api/types";
|
||||
import type { Page, Vehicle } from "../api/types";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
|
||||
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
|
||||
|
||||
export function Vehicles() {
|
||||
const { t } = useTranslation("fleet");
|
||||
const { formatNumber } = useLocaleFormat();
|
||||
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [attentionOnly, setAttentionOnly] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const status = searchParams.get("status") ?? "";
|
||||
const attentionOnly = searchParams.get("attention_only") === "true";
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||
|
||||
function updateFilters(updates: Record<string, string | boolean | number | null>) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === "" || value === false) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setVehicles(null);
|
||||
@@ -24,11 +36,14 @@ export function Vehicles() {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (attentionOnly) params.set("attention_only", "true");
|
||||
if (query) params.set("query", query);
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", "25");
|
||||
api
|
||||
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
|
||||
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
|
||||
.then(setVehicles)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
}, [status, attentionOnly]);
|
||||
}, [status, attentionOnly, query, page, t]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -37,11 +52,11 @@ export function Vehicles() {
|
||||
<form className="filters" aria-label={t("list.title")}>
|
||||
<label>
|
||||
{t("list.searchLabel")}
|
||||
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("list.searchPlaceholder")} />
|
||||
<input type="search" value={query} onChange={(e) => updateFilters({ q: e.target.value, page: 1 })} placeholder={t("list.searchPlaceholder")} />
|
||||
</label>
|
||||
<label>
|
||||
{t("list.statusLabel")}
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
@@ -54,7 +69,7 @@ export function Vehicles() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attentionOnly}
|
||||
onChange={(e) => setAttentionOnly(e.target.checked)}
|
||||
onChange={(e) => updateFilters({ attention_only: e.target.checked, page: 1 })}
|
||||
/>
|
||||
{t("list.attentionOnly")}
|
||||
</label>
|
||||
@@ -62,11 +77,9 @@ export function Vehicles() {
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !vehicles && <LoadingState label={t("list.loading")} />}
|
||||
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title={t("list.empty")} detail={t("list.emptyDetail")} />}
|
||||
{vehicles && vehicles.items.length === 0 && <EmptyState icon={query ? "search" : "fleet"} title={query ? t("list.noMatch") : t("list.empty")} detail={query ? t("list.noMatchDetail") : t("list.emptyDetail")} />}
|
||||
|
||||
{vehicles && vehicles.length > 0 && (() => {
|
||||
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={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">
|
||||
{vehicles && vehicles.items.length > 0 && <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: vehicles.total })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
|
||||
<caption className="visually-hidden">{t("list.title")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -79,7 +92,7 @@ export function Vehicles() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((v) => (
|
||||
{vehicles.items.map((v) => (
|
||||
<tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}>
|
||||
<th scope="row" data-label={t("list.columns.reference")}>
|
||||
{v.public_ref}
|
||||
@@ -97,8 +110,7 @@ export function Vehicles() {
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>;
|
||||
})()}
|
||||
</table><Pagination page={vehicles.page} totalPages={vehicles.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+27
-21
@@ -28,6 +28,10 @@
|
||||
--focus: #14b8a6;
|
||||
--radius: 4px;
|
||||
--shadow-float: 0 16px 40px rgba(15, 23, 42, .12);
|
||||
--type-body: .875rem;
|
||||
--type-meta: .75rem;
|
||||
--type-label: .75rem;
|
||||
--type-badge: .75rem;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -91,10 +95,11 @@ a:hover { color: var(--teal); }
|
||||
.search-result-copy { display: flex; flex-direction: column; min-width: 0; }
|
||||
.search-result-copy strong { font-size: .82rem; }
|
||||
.search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; }
|
||||
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; }
|
||||
.timezone svg { width: 15px; }
|
||||
.operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); }
|
||||
.operator-menu { position: relative; }.operator-menu summary { list-style: none; cursor: pointer; }.operator-menu summary::-webkit-details-marker { display: none; }.operator-menu[open] .operator { color: var(--teal-dark); }.operator-popover { position: absolute; top: calc(100% + 8px); right: 0; z-index: 31; width: 220px; display: grid; gap: 10px; padding: 12px; background: white; border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.operator-popover .timezone { font-size: var(--type-meta); }.operator-logout { min-height: 40px; display: flex; align-items: center; gap: 8px; padding: 8px 10px; color: var(--ink-soft); background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); font-size: var(--type-meta); font-weight: 700; cursor: pointer; }.operator-logout svg { width: 16px; }
|
||||
.avatar { width: 32px; height: 32px; display: grid; place-items: center; color: white; background: var(--petrol); border-radius: 50%; font-size: .67rem; font-weight: 700; }
|
||||
.operator > span:last-child { display: grid; gap: 1px; min-width: 0; }
|
||||
.operator strong { font-size: .74rem; overflow-wrap: anywhere; }
|
||||
@@ -175,6 +180,7 @@ a:hover { color: var(--teal); }
|
||||
.attention-list, .integration-list, .recent-list, .record-list, .automation-list, .today-list { list-style: none; margin: 0; padding: 0; }
|
||||
.attention-list li { min-height: 68px; display: grid; grid-template-columns: auto minmax(0,1fr) auto 16px; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; transition: background .14s ease; }
|
||||
.attention-list li:last-child { border-bottom: 0; }.attention-list li:hover { background: #f9fbfc; }
|
||||
.queue-more { margin: 0; padding: 13px 18px; border-top: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }.queue-more a { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.attention-title { margin: 0; font-size: .78rem; font-weight: 700; }.attention-title a { color: var(--ink); text-decoration: none; }
|
||||
.attention-detail { max-width: 58ch; margin: 4px 0 0; color: var(--muted); font-size: .69rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.queue-ref { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .65rem; }.row-chevron { width: 14px; color: var(--muted-light); }
|
||||
@@ -206,7 +212,7 @@ a:hover { color: var(--teal); }
|
||||
.activity-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.activity-icon svg { width: 15px; }
|
||||
.recent-list time { color: var(--muted); font-size: .62rem; white-space: nowrap; }
|
||||
|
||||
.badge { min-height: 20px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border: 1px solid transparent; border-radius: 999px; font-size: .59rem; font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; }
|
||||
.badge { min-height: 24px; display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid transparent; border-radius: 999px; font-size: var(--type-badge); font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; }
|
||||
.badge::before, .badge-dot { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.severity-high, .status-failed, .status-blocked, .status-rejected, .status-unavailable { color: #9f2929; background: var(--critical-pale); border-color: #f3c5c5; }
|
||||
.severity-medium, .status-pending, .status-delivering, .status-needs_attention { color: #93520c; background: var(--warning-pale); border-color: #eed4aa; }
|
||||
@@ -216,25 +222,25 @@ a:hover { color: var(--teal); }
|
||||
.badge-dot { display: inline-block; }
|
||||
|
||||
.filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 16px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.filters label, .return-form label, .form-grid label, .panel label:has(> textarea) { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; letter-spacing: .01em; }
|
||||
.filters input[type="text"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input, .form-grid input, .form-grid select, .panel label:has(> textarea) textarea { min-height: 40px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .78rem; }
|
||||
.filters input[type="text"] { min-width: 230px; }.filters select { min-width: 160px; }
|
||||
.filters label, .return-form label, .form-grid label, .panel label:has(> textarea) { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; letter-spacing: .01em; }
|
||||
.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input, .form-grid input, .form-grid select, .panel label:has(> textarea) textarea { min-height: 44px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: var(--type-body); }
|
||||
.filters input[type="text"], .filters input[type="search"] { min-width: 230px; }.filters select { min-width: 160px; }
|
||||
.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; }
|
||||
input[type="checkbox"], input[type="radio"] { width: 17px; height: 17px; accent-color: var(--teal-dark); }
|
||||
|
||||
.table-shell { overflow: hidden; }
|
||||
.table-meta { min-height: 40px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: .65rem; }
|
||||
.table-meta { min-height: 44px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td { height: 50px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: .74rem; vertical-align: middle; }
|
||||
.data-table th, .data-table td { min-height: 52px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: var(--type-body); vertical-align: middle; }
|
||||
.data-table tbody tr:last-child th, .data-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.data-table tbody tr { transition: background .14s ease; }.data-table tbody tr:hover { background: #f9fbfc; }
|
||||
.data-table thead th { height: 38px; color: var(--muted); background: #f6f8fa; font-size: .61rem; font-weight: 700; text-transform: uppercase; letter-spacing: .075em; }
|
||||
.data-table thead th { height: 42px; color: var(--muted); background: #f6f8fa; font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .075em; }
|
||||
.data-table th[scope="row"] { color: var(--ink); font-weight: 700; }.data-table th[scope="row"] a { color: var(--teal-dark); text-decoration: none; }
|
||||
.row-attention { box-shadow: inset 2px 0 var(--warning); }.attention-flag { color: var(--warning); font-size: .67rem; font-weight: 700; }
|
||||
.data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button { min-height: 36px; padding: 7px 12px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; }
|
||||
.data-table td button:hover, .pagination button:hover:not(:disabled) { background: var(--surface-subtle); }.data-table td button:disabled, .pagination button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: .62rem; }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.pagination { min-height: 52px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: .68rem; }
|
||||
.table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: var(--type-meta); }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.pagination { min-height: 56px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: var(--type-meta); }
|
||||
details summary { cursor: pointer; color: var(--teal-dark); }.data-table details pre { max-width: 280px; overflow: auto; color: var(--ink-soft); white-space: pre-wrap; }
|
||||
|
||||
.tabs { display: flex; gap: 2px; margin: 0 0 16px; padding: 0 4px; overflow-x: auto; border-bottom: 1px solid var(--line); }
|
||||
@@ -259,18 +265,18 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.about-details summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); }
|
||||
.detail-grid div { min-height: 76px; padding: 13px 14px; background: white; }
|
||||
.detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; }
|
||||
.detail-grid dt { margin: 0; color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: var(--type-body); font-weight: 700; }
|
||||
.record-list { display: grid; gap: 1px; margin-bottom: 16px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
||||
.record-list li { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 11px; padding: 10px 14px; background: white; font-size: .75rem; }
|
||||
|
||||
.return-form, .return-result { max-width: 880px; margin-top: 22px; padding: 0; overflow: hidden; }
|
||||
.return-form > .section-heading { padding: 19px 22px; }.return-progress { min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: .65rem; font-weight: 700; }
|
||||
.return-form > .section-heading { padding: 19px 22px; }.return-progress { position: sticky; top: 64px; z-index: 8; min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }
|
||||
.return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; }
|
||||
.return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
|
||||
.return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; }
|
||||
.condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); }
|
||||
.review-facts { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: .6rem; text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: .76rem; font-weight: 700; }
|
||||
.review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; }
|
||||
.impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; }
|
||||
.commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); }
|
||||
.success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; }
|
||||
@@ -293,7 +299,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.evidence-list li { margin-bottom: 2px; }
|
||||
.status-decision .button { margin-top: 16px; }
|
||||
|
||||
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }
|
||||
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }.duplicate-compare .merge-record-preview { position: sticky; bottom: 16px; z-index: 2; box-shadow: 0 10px 24px rgba(15,23,42,.08); }
|
||||
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
|
||||
.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-summary-counts { margin: 0 0 12px; color: var(--muted); font-size: .72rem; font-weight: 700; }
|
||||
@@ -337,9 +343,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.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 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 0; 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 .integration-badge-stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-self: end; }
|
||||
.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: .6rem; }.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 small { display: block; margin-top: 4px; color: var(--muted-light); font-size: var(--type-meta); }.integration-cards h2 { margin: 3px 0 7px; font-size: 1rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
|
||||
|
||||
.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
.scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
@@ -453,16 +459,16 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
@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; }.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; }
|
||||
.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: 68px; 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: 44px; min-height: 44px; 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: .69rem; 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: 68px; }.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) {
|
||||
#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; }
|
||||
.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; }
|
||||
#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: var(--type-meta); }.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 { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.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: .65rem; }.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 input[type="search"], .filters input[type="date"], .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: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.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: var(--type-label); 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; }
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
|
||||
.global-search { max-width: 144px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 58px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: var(--type-badge); }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user