M36: deepen operational and mobile UX
This commit is contained in:
@@ -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">
|
||||
{(() => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user