UX: implement visual product roadmap

This commit is contained in:
NuklearRabbit
2026-08-10 01:05:07 +02:00
parent 13ad2ba6a3
commit f2cdad194c
40 changed files with 1010 additions and 281 deletions
+79 -182
View File
@@ -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>;
}
+6 -4
View File
@@ -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>
+9 -2
View File
@@ -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"}
+42 -17
View File
@@ -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>
);
+1 -1
View File
@@ -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>
+30 -18
View File
@@ -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>
);
}