M36: deepen operational and mobile UX

This commit is contained in:
NuklearRabbit
2026-08-10 22:53:45 +02:00
parent 809ba0ddcc
commit 9dfbd7c4bf
25 changed files with 341 additions and 60 deletions
+7 -1
View File
@@ -256,7 +256,7 @@ export function Automation() {
const hub = integrationStatus?.mcp_hub;
if (!hub?.registration_enabled) return t("cards.mcpNotConnected");
if (hub.total_calls > 0) {
return t("cards.mcpEvidence", { tool: hub.last_tool, client: hub.last_client, count: hub.total_calls });
return t("cards.mcpEvidence", { tool: hub.last_tool, count: hub.total_calls });
}
return t("cards.mcpNoEvidence");
})()}
@@ -264,6 +264,12 @@ export function Automation() {
{integrationStatus?.mcp_hub.last_called_at && (
<small>{formatDateTime(integrationStatus.mcp_hub.last_called_at)}</small>
)}
{integrationStatus?.mcp_hub.last_client && (
<details className="technical-identity">
<summary>{t("cards.mcpTechnicalIdentity")}</summary>
<code>{integrationStatus.mcp_hub.last_client}</code>
</details>
)}
</div>
<div className="integration-badge-stack">
{(() => {
+46
View File
@@ -13,6 +13,7 @@ import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome";
import { CheckoutForm } from "../components/CheckoutForm";
import { brusselsLocalToIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
@@ -29,6 +30,10 @@ export function BookingDetail() {
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
const [scheduleStart, setScheduleStart] = useState("");
const [scheduleEnd, setScheduleEnd] = useState("");
const [scheduleReason, setScheduleReason] = useState("");
const [rescheduling, setRescheduling] = useState(false);
const load = useCallback(() => {
if (!publicRef) return;
@@ -45,6 +50,12 @@ export function BookingDetail() {
load();
}, [load]);
useEffect(() => {
if (!booking) return;
setScheduleStart(toBrusselsDateTimeLocal(new Date(booking.starts_at)));
setScheduleEnd(toBrusselsDateTimeLocal(new Date(booking.ends_at)));
}, [booking?.public_ref]);
// The odometer-regression demo scenario supplies its own suspicious reading (per the
// brief: never ask a demo visitor to invent one) -- only fetched for that one known
// scenario booking, not for every return.
@@ -104,6 +115,26 @@ export function BookingDetail() {
}
}
async function reschedule(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
setRescheduling(true);
setActionError(null);
try {
const updated = await api.patch<Booking>(`/api/v1/bookings/${publicRef}/schedule`, {
starts_at: brusselsLocalToIso(scheduleStart),
ends_at: brusselsLocalToIso(scheduleEnd),
reason: scheduleReason,
});
setBooking(updated);
setScheduleReason("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.rescheduleFailed"));
} finally {
setRescheduling(false);
}
}
if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label={t("detail.loading")} />;
@@ -133,6 +164,21 @@ export function BookingDetail() {
</form>
) : null}
{booking.status === "reserved" ? (
<details className="record-surface booking-reschedule">
<summary>{t("detail.rescheduleAction")}</summary>
<form onSubmit={reschedule}>
<p>{t("detail.rescheduleDetail")}</p>
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
</div>
<label>{t("detail.rescheduleReason")}<textarea required minLength={3} maxLength={500} value={scheduleReason} onChange={(event) => setScheduleReason(event.target.value)} /></label>
<div className="form-actions"><button className="button button-secondary" type="submit" disabled={rescheduling || scheduleReason.trim().length < 3 || scheduleEnd <= scheduleStart}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
</form>
</details>
) : null}
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
<h2>{t("detail.cancelAction")}</h2>
<ApiErrorNotice error={actionError} />
+32 -5
View File
@@ -9,6 +9,7 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
import { useLocaleFormat } from "../i18n/format";
import { brusselsLocalToIso } from "../i18n/brusselsDateTime";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -42,6 +43,7 @@ export function DataQuality() {
const [bulkDueAt, setBulkDueAt] = useState("");
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(null);
const [filtersOpen, setFiltersOpen] = useState(false);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
@@ -87,7 +89,7 @@ export function DataQuality() {
await api.post("/api/v1/data-quality/issues/bulk-work", {
issue_refs: [...selected],
assigned_to_ref: bulkAssignee || undefined,
due_at: bulkDueAt ? new Date(bulkDueAt).toISOString() : undefined,
due_at: bulkDueAt ? brusselsLocalToIso(bulkDueAt) : undefined,
});
setSelected(new Set());
setBulkAssignee("");
@@ -108,6 +110,16 @@ export function DataQuality() {
});
}
function toggleVisibleSelection() {
setSelected((current) => {
const visibleRefs = visibleIssues.map((issue) => issue.public_ref);
const allSelected = visibleRefs.every((ref) => current.has(ref));
const next = new Set(current);
visibleRefs.forEach((ref) => allSelected ? next.delete(ref) : next.add(ref));
return next;
});
}
async function handleScan() {
setScanError(null);
setScanning(true);
@@ -138,6 +150,8 @@ export function DataQuality() {
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
: issues.items
: [];
const activeFilterCount = [status !== "open", Boolean(ruleType), Boolean(severity), Boolean(assignee), overdueOnly, demoScenariosOnly].filter(Boolean).length;
const allVisibleSelected = visibleIssues.length > 0 && visibleIssues.every((issue) => selected.has(issue.public_ref));
return (
<div className="page">
@@ -178,7 +192,10 @@ export function DataQuality() {
</p>
)}
<form className="filters" aria-label={t("list.title")}>
<button className="button button-secondary filter-toggle" type="button" aria-expanded={filtersOpen} aria-controls="quality-filters" onClick={() => setFiltersOpen((open) => !open)}>
{t("list.filtersToggle", { count: activeFilterCount })}
</button>
<form id="quality-filters" className={`filters quality-filters${filtersOpen ? " is-open" : ""}`} aria-label={t("list.title")}>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
@@ -230,6 +247,16 @@ export function DataQuality() {
{t("list.demoScenariosOnly")}
</label>
</form>
{activeFilterCount > 0 && (
<div className="active-filter-chips" aria-label={t("list.activeFilters")}>
{status !== "open" && <button type="button" onClick={() => updateFilters({ status: "open", page: 1 })}>{t(`list.status${status.charAt(0).toUpperCase()}${status.slice(1)}`)} ×</button>}
{ruleType && <button type="button" onClick={() => updateFilters({ rule_type: null, page: 1 })}>{t(`ruleTypes.${ruleType}`)} ×</button>}
{severity && <button type="button" onClick={() => updateFilters({ severity: null, page: 1 })}>{t(`severities.${severity}`)} ×</button>}
{assignee && <button type="button" onClick={() => updateFilters({ assignee: null, page: 1 })}>{assignee === "unassigned" ? t("list.unassigned") : users.find((record) => record.public_ref === assignee)?.display_name ?? assignee} ×</button>}
{overdueOnly && <button type="button" onClick={() => updateFilters({ overdue: null, page: 1 })}>{t("list.overdueOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => { setDemoScenariosOnly(false); updateFilters({ demo: null, page: 1 }); }}>{t("list.demoScenariosOnly")} ×</button>}
</div>
)}
{error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label={t("list.loading")} />}
@@ -249,11 +276,11 @@ export function DataQuality() {
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
</div>
)}
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><button className="select-visible" type="button" onClick={toggleVisibleSelection}>{t(allVisibleSelected ? "list.deselectVisible" : "list.selectVisible")}</button><span>{t("list.evidenceBacked")}</span></div><table className="data-table quality-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col"><span className="visually-hidden">{t("list.columns.select")}</span></th>
<th scope="col" className="selection-cell"><input type="checkbox" checked={allVisibleSelected} onChange={toggleVisibleSelection} aria-label={t("list.selectVisible")} /></th>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.rule")}</th>
<th scope="col">{t("list.columns.entity")}</th>
@@ -268,7 +295,7 @@ export function DataQuality() {
<tr key={i.public_ref} className="row-clickable">
<td className="selection-cell"><input type="checkbox" checked={selected.has(i.public_ref)} onChange={() => toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /></td>
<th scope="row" data-label={t("list.columns.reference")}>
{i.public_ref}
<span aria-hidden="true">{i.public_ref}</span>
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
</th>
<td data-label={t("list.columns.rule")}>{t(`ruleTypes.${i.rule_type}`, { defaultValue: i.rule_type.replace(/_/g, " ") })}</td>
+1
View File
@@ -154,6 +154,7 @@ export function Knowledge() {
)}
{statusSettled && status?.provider === "ragcore" && (
<div className="knowledge-index-evidence" role="status">
<p className="knowledge-statistics-scope">{t("statistics.scope")}</p>
<div><strong>{status.document_count ?? "—"}</strong><span>{t("statistics.verifiedIndexed")}</span></div>
<div><strong>{status.source_document_count}</strong><span>{t("statistics.sourceDocuments")}</span></div>
<div>