UX: implement visual product roadmap
This commit is contained in:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user