feat(ui): redesign return and data-quality experiences
This commit is contained in:
@@ -2,6 +2,8 @@ import { useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import type { RegisterReturnRequest, RegisterReturnResult } from "../api/types";
|
||||
import { Icon } from "./Icons";
|
||||
import { StatusBadge } from "./Badge";
|
||||
|
||||
function newIdempotencyKey(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
@@ -11,11 +13,11 @@ function newIdempotencyKey(): string {
|
||||
|
||||
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
|
||||
return (
|
||||
<section className="panel return-result" aria-labelledby="return-result-heading">
|
||||
<h2 id="return-result-heading">Return registered</h2>
|
||||
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
|
||||
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Committed locally</p><h2 id="return-result-heading">Return registered</h2></div></div>
|
||||
<dl className="detail-grid">
|
||||
<div><dt>Inspection</dt><dd>{result.inspection_ref}</dd></div>
|
||||
<div><dt>Resulting vehicle status</dt><dd>{result.resulting_vehicle_status}</dd></div>
|
||||
<div><dt>Resulting vehicle status</dt><dd><StatusBadge status={result.resulting_vehicle_status} /></dd></div>
|
||||
<div>
|
||||
<dt>Quality issue</dt>
|
||||
<dd>{result.quality_issue_ref ?? "None created"}</dd>
|
||||
@@ -38,7 +40,7 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<Link to={`/vehicles/${result.vehicle_ref}`}>View vehicle {result.vehicle_ref}</Link>
|
||||
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>View vehicle {result.vehicle_ref}<Icon name="chevron" /></Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
@@ -60,9 +62,14 @@ export function ReturnForm({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||
const [step, setStep] = useState<"capture" | "review">("capture");
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (step === "capture") {
|
||||
setStep("review");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
@@ -93,10 +100,12 @@ export function ReturnForm({
|
||||
|
||||
return (
|
||||
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
||||
<h2 id="return-form-heading">Register return</h2>
|
||||
<div className="return-progress" aria-label="Return registration progress"><span className="is-complete"><i>1</i> Capture</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> Review</span><b /><span><i>3</i> Result</span></div>
|
||||
<div className="section-heading"><div><p className="page-eyebrow">Booking {bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? "Register vehicle return" : "Review return impact"}</h2><p>{step === "capture" ? "Record the hand-back condition. Operational consequences are calculated on commit." : "Confirm the inspection facts before they update fleet state and queue automation."}</p></div></div>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
<label>
|
||||
{step === "capture" ? <div className="return-capture">
|
||||
<div className="form-grid"><label>
|
||||
End odometer (km)
|
||||
<input
|
||||
type="number"
|
||||
@@ -117,9 +126,9 @@ export function ReturnForm({
|
||||
value={fuel}
|
||||
onChange={(e) => setFuel(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</label></div>
|
||||
|
||||
<label className="checkbox-label">
|
||||
<fieldset className="condition-fieldset"><legend>Vehicle condition</legend><label className="checkbox-label check-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanlinessOk}
|
||||
@@ -128,7 +137,7 @@ export function ReturnForm({
|
||||
Cleanliness acceptable
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label">
|
||||
<label className="checkbox-label check-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={damageReported}
|
||||
@@ -137,23 +146,33 @@ export function ReturnForm({
|
||||
Damage reported
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label">
|
||||
<label className="checkbox-label check-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={technicalWarning}
|
||||
onChange={(e) => setTechnicalWarning(e.target.checked)}
|
||||
/>
|
||||
Technical warning
|
||||
</label>
|
||||
</label></fieldset>
|
||||
|
||||
<label>
|
||||
Notes
|
||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
|
||||
</label>
|
||||
</label></div> : <div className="return-review" aria-live="polite">
|
||||
<dl className="review-facts"><div><dt>Odometer</dt><dd>{Number(odometer).toLocaleString("en-GB")} km</dd></div><div><dt>Fuel</dt><dd>{fuel}%</dd></div><div><dt>Cleanliness</dt><dd>{cleanlinessOk ? "Accepted" : "Follow-up needed"}</dd></div><div><dt>Damage</dt><dd>{damageReported ? "Reported" : "None reported"}</dd></div><div><dt>Technical warning</dt><dd>{technicalWarning ? "Reported" : "None reported"}</dd></div></dl>
|
||||
<div className={`impact-preview ${damageReported || technicalWarning || !cleanlinessOk ? "impact-warning" : "impact-ready"}`}>
|
||||
<Icon name={damageReported || technicalWarning || !cleanlinessOk ? "alert" : "check"} />
|
||||
<div><strong>Expected fleet state</strong><p>{damageReported || technicalWarning ? "The vehicle will move to maintenance and may put its next booking at risk." : !cleanlinessOk ? "The vehicle will move to cleaning before it becomes available." : "The vehicle is expected to become available after the return is committed."}</p></div>
|
||||
</div>
|
||||
<ul className="commit-list"><li><Icon name="check" /> Create a return inspection</li><li><Icon name="check" /> Update the booking and vehicle atomically</li><li><Icon name="check" /> Queue n8n delivery after the local commit</li></ul>
|
||||
</div>}
|
||||
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? "Registering…" : "Register return"}
|
||||
</button>
|
||||
<div className="form-actions">
|
||||
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> Edit details</button>}
|
||||
<button className="button button-primary" type="submit" disabled={submitting}>
|
||||
{submitting ? "Registering…" : step === "capture" ? "Review return" : "Confirm return"} {step === "capture" && <Icon name="chevron" />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { DataQualityIssue } from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
|
||||
const RULE_TYPES = [
|
||||
"possible_duplicate_customer",
|
||||
@@ -19,6 +20,8 @@ export function DataQuality() {
|
||||
const [ruleType, setRuleType] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setIssues(null);
|
||||
setError(null);
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (ruleType) params.set("rule_type", ruleType);
|
||||
@@ -30,7 +33,7 @@ export function DataQuality() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Data Quality</h1>
|
||||
<PageHeader eyebrow="Assurance / Workbench" title="Data quality" description="Resolve evidence-backed exceptions before they disrupt operations." />
|
||||
|
||||
<form className="filters" aria-label="Filter data-quality issues">
|
||||
<label>
|
||||
@@ -56,12 +59,12 @@ export function DataQuality() {
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!error && !issues && <p>Loading issues…</p>}
|
||||
{issues && issues.length === 0 && <p>No issues match these filters.</p>}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !issues && <LoadingState label="Loading quality workbench…" />}
|
||||
{issues && issues.length === 0 && <EmptyState icon="check" title="Queue is clear" detail="No issues match the current filters." />}
|
||||
|
||||
{issues && issues.length > 0 && (
|
||||
<table className="data-table">
|
||||
<div className="table-shell"><div className="table-meta"><span>{issues.length} issues</span><span>Evidence-backed detection</span></div><table className="data-table">
|
||||
<caption className="visually-hidden">Data-quality issues</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -75,21 +78,21 @@ export function DataQuality() {
|
||||
<tbody>
|
||||
{issues.map((i) => (
|
||||
<tr key={i.public_ref}>
|
||||
<th scope="row">
|
||||
<th scope="row" data-label="Reference">
|
||||
<Link to={`/data-quality/${i.public_ref}`}>{i.public_ref}</Link>
|
||||
</th>
|
||||
<td>{i.rule_type.replace(/_/g, " ")}</td>
|
||||
<td>{i.entity_ref}</td>
|
||||
<td>
|
||||
<td data-label="Rule">{i.rule_type.replace(/_/g, " ")}</td>
|
||||
<td data-label="Entity">{i.entity_ref}</td>
|
||||
<td data-label="Severity">
|
||||
<SeverityBadge severity={i.severity} />
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status">
|
||||
<StatusBadge status={i.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { api, ApiError } from "../api/client";
|
||||
import type { DataQualityIssueDetail as IssueDetail, EntitySnapshot } from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
|
||||
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
|
||||
|
||||
@@ -60,7 +62,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
||||
|
||||
return (
|
||||
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
|
||||
<h2 id="compare-heading">Compare and merge</h2>
|
||||
<SectionHeading title="Compare and merge" description="Choose the canonical customer and review each conflicting field." />
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
<fieldset>
|
||||
@@ -100,8 +102,8 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
||||
const differ = valueA !== valueB;
|
||||
return (
|
||||
<tr key={field}>
|
||||
<th scope="row">{field.replace(/_/g, " ")}</th>
|
||||
<td>
|
||||
<th scope="row" data-label="Field">{field.replace(/_/g, " ")}{differ ? <span className="difference-mark">Differs</span> : <span className="match-mark">Match</span>}</th>
|
||||
<td data-label={a.public_ref}>
|
||||
{differ ? (
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
@@ -116,7 +118,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
||||
valueA
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label={b.public_ref}>
|
||||
{differ ? (
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
@@ -137,7 +139,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<p className="merge-preview">
|
||||
<strong>{loser.public_ref}</strong> will become a tombstone linked to{" "}
|
||||
<strong>{survivor.public_ref}</strong>; its bookings will be rewired to the survivor.
|
||||
</p>
|
||||
@@ -195,21 +197,18 @@ export function DataQualityIssueDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!issue) return <p>Loading issue…</p>;
|
||||
if (error) return <ErrorState message={error} />;
|
||||
if (!issue) return <LoadingState label="Loading issue evidence…" />;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<p><Link to="/data-quality">← Back to data quality</Link></p>
|
||||
<h1>{issue.public_ref}</h1>
|
||||
<p>
|
||||
<SeverityBadge severity={issue.severity} /> <StatusBadge status={issue.status} />
|
||||
</p>
|
||||
<dl className="detail-grid">
|
||||
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> Quality workbench</Link>
|
||||
<PageHeader eyebrow={`Quality / ${issue.rule_type.replace(/_/g, " ")}`} title={issue.public_ref} description="Review persisted evidence and record an audited resolution." actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} /></div>} />
|
||||
<section className="record-surface" aria-label="Issue summary"><dl className="detail-grid">
|
||||
<div><dt>Rule</dt><dd>{issue.rule_type.replace(/_/g, " ")}</dd></div>
|
||||
<div><dt>Entity</dt><dd>{issue.entity_ref}</dd></div>
|
||||
<div><dt>Evidence</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
|
||||
</dl>
|
||||
</dl></section>
|
||||
|
||||
{actionError && <p className="error" role="alert">{actionError}</p>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user