feat(ui): redesign fleet and booking workflows
This commit is contained in:
@@ -4,6 +4,8 @@ import { api } from "../api/client";
|
|||||||
import type { Booking, RegisterReturnResult } from "../api/types";
|
import type { Booking, RegisterReturnResult } from "../api/types";
|
||||||
import { StatusBadge } from "../components/Badge";
|
import { StatusBadge } from "../components/Badge";
|
||||||
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
|
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
|
||||||
|
import { Icon } from "../components/Icons";
|
||||||
|
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
|
||||||
export function BookingDetail() {
|
export function BookingDetail() {
|
||||||
const { publicRef } = useParams<{ publicRef: string }>();
|
const { publicRef } = useParams<{ publicRef: string }>();
|
||||||
@@ -31,15 +33,14 @@ export function BookingDetail() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) return <p className="error" role="alert">{error}</p>;
|
if (error) return <ErrorState message={error} />;
|
||||||
if (!booking) return <p>Loading booking…</p>;
|
if (!booking) return <LoadingState label="Loading booking record…" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<p><Link to="/bookings">← Back to bookings</Link></p>
|
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> Booking ledger</Link>
|
||||||
<h1>{booking.public_ref}</h1>
|
<PageHeader eyebrow="Bookings / Rental record" title={booking.public_ref} description={`${booking.customer_name} · ${booking.vehicle_ref}`} actions={<StatusBadge status={booking.status} />} />
|
||||||
<p><StatusBadge status={booking.status} /></p>
|
<section className="record-surface" aria-label="Booking facts"><dl className="detail-grid">
|
||||||
<dl className="detail-grid">
|
|
||||||
<div><dt>Customer</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
|
<div><dt>Customer</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
|
||||||
<div><dt>Vehicle</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
|
<div><dt>Vehicle</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
|
||||||
<div><dt>Starts</dt><dd>{new Date(booking.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
|
<div><dt>Starts</dt><dd>{new Date(booking.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
|
||||||
@@ -47,7 +48,7 @@ export function BookingDetail() {
|
|||||||
<div><dt>Start odometer</dt><dd>{booking.start_odometer_km ?? "—"} km</dd></div>
|
<div><dt>Start odometer</dt><dd>{booking.start_odometer_km ?? "—"} km</dd></div>
|
||||||
<div><dt>End odometer</dt><dd>{booking.end_odometer_km ?? "—"} km</dd></div>
|
<div><dt>End odometer</dt><dd>{booking.end_odometer_km ?? "—"} km</dd></div>
|
||||||
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
|
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
|
||||||
</dl>
|
</dl></section>
|
||||||
|
|
||||||
{returnResult && <ReturnResultPanel result={returnResult} />}
|
{returnResult && <ReturnResultPanel result={returnResult} />}
|
||||||
{!returnResult && booking.status === "active" && (
|
{!returnResult && booking.status === "active" && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Booking } from "../api/types";
|
import type { Booking } from "../api/types";
|
||||||
import { StatusBadge } from "../components/Badge";
|
import { StatusBadge } from "../components/Badge";
|
||||||
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
|
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
|
||||||
|
|
||||||
@@ -10,8 +11,13 @@ export function Bookings() {
|
|||||||
const [bookings, setBookings] = useState<Booking[] | null>(null);
|
const [bookings, setBookings] = useState<Booking[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const perPage = 25;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setBookings(null);
|
||||||
|
setError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (status) params.set("status", status);
|
if (status) params.set("status", status);
|
||||||
api
|
api
|
||||||
@@ -22,12 +28,16 @@ export function Bookings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Bookings</h1>
|
<PageHeader eyebrow="Operations / Schedule" title="Bookings" description="Review active rental windows and upcoming vehicle commitments." />
|
||||||
|
|
||||||
<form className="filters" aria-label="Filter bookings">
|
<form className="filters" aria-label="Filter bookings">
|
||||||
|
<label>
|
||||||
|
Search
|
||||||
|
<input type="text" value={query} onChange={(e) => { setQuery(e.target.value); setPage(1); }} placeholder="Booking, customer or vehicle" />
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Status
|
Status
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
<select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
{STATUS_OPTIONS.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
@@ -38,12 +48,15 @@ export function Bookings() {
|
|||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <ErrorState message={error} />}
|
||||||
{!error && !bookings && <p>Loading bookings…</p>}
|
{!error && !bookings && <LoadingState label="Loading booking ledger…" />}
|
||||||
{bookings && bookings.length === 0 && <p>No bookings match these filters.</p>}
|
{bookings && bookings.length === 0 && <EmptyState icon="bookings" title="No bookings found" detail="Adjust the booking status filter." />}
|
||||||
|
|
||||||
{bookings && bookings.length > 0 && (
|
{bookings && bookings.length > 0 && (() => {
|
||||||
<table className="data-table">
|
const filtered = bookings.filter((b) => `${b.public_ref} ${b.customer_name} ${b.vehicle_ref}`.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage));
|
||||||
|
const visible = filtered.slice((page - 1) * perPage, page * perPage);
|
||||||
|
return filtered.length === 0 ? <EmptyState icon="search" title="No matching bookings" detail="Try a broader search term." /> : <div className="table-shell"><div className="table-meta"><span>{filtered.length} bookings</span><span>Page {page} of {totalPages}</span></div><table className="data-table">
|
||||||
<caption className="visually-hidden">Bookings</caption>
|
<caption className="visually-hidden">Bookings</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -57,24 +70,24 @@ export function Bookings() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{bookings.map((b) => (
|
{bookings.map((b) => (
|
||||||
<tr key={b.public_ref}>
|
<tr key={b.public_ref}>
|
||||||
<th scope="row">
|
<th scope="row" data-label="Reference">
|
||||||
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
|
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
|
||||||
</th>
|
</th>
|
||||||
<td>{b.customer_name}</td>
|
<td data-label="Customer">{b.customer_name}</td>
|
||||||
<td>
|
<td data-label="Vehicle">
|
||||||
<Link to={`/vehicles/${b.vehicle_ref}`}>{b.vehicle_ref}</Link>
|
<Link to={`/vehicles/${b.vehicle_ref}`}>{b.vehicle_ref}</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Window">
|
||||||
{new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")}
|
{new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Status">
|
||||||
<StatusBadge status={b.status} />
|
<StatusBadge status={b.status} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table><div className="pagination" aria-label="Booking pages"><button type="button" disabled={page === 1} onClick={() => setPage((p) => p - 1)}>Previous</button><span>{(page - 1) * perPage + 1}–{Math.min(page * perPage, filtered.length)} of {filtered.length}</span><button type="button" disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}>Next</button></div></div>;
|
||||||
)}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Link, useParams } from "react-router-dom";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { VehicleDetail as VehicleDetailData } from "../api/types";
|
import type { VehicleDetail as VehicleDetailData } from "../api/types";
|
||||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||||
|
import { Icon } from "../components/Icons";
|
||||||
|
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
|
||||||
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
|
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
|
||||||
type Tab = (typeof TABS)[number];
|
type Tab = (typeof TABS)[number];
|
||||||
@@ -23,21 +25,15 @@ export function VehicleDetail() {
|
|||||||
.catch(() => setError("This vehicle could not be found."));
|
.catch(() => setError("This vehicle could not be found."));
|
||||||
}, [publicRef]);
|
}, [publicRef]);
|
||||||
|
|
||||||
if (error) return <p className="error" role="alert">{error}</p>;
|
if (error) return <ErrorState message={error} />;
|
||||||
if (!vehicle) return <p>Loading vehicle…</p>;
|
if (!vehicle) return <LoadingState label="Loading vehicle record…" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<p><Link to="/vehicles">← Back to vehicles</Link></p>
|
<Link className="back-link" to="/vehicles"><Icon name="arrow-left" /> Fleet registry</Link>
|
||||||
<h1>
|
<PageHeader eyebrow="Fleet / Vehicle record" title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} />{vehicle.attention && <span className="badge severity-high">Needs attention</span>}</div>} />
|
||||||
{vehicle.public_ref} — {vehicle.make} {vehicle.model}
|
|
||||||
</h1>
|
|
||||||
<p>
|
|
||||||
<StatusBadge status={vehicle.operational_status} />
|
|
||||||
{vehicle.attention && <span className="badge severity-high">Needs attention</span>}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div role="tablist" aria-label="Vehicle sections" className="tabs">
|
<div role="tablist" aria-label="Vehicle sections" className="tabs" aria-orientation="horizontal">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
@@ -53,14 +49,14 @@ export function VehicleDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "overview" && (
|
{tab === "overview" && (
|
||||||
<dl className="detail-grid">
|
<section className="record-surface" aria-label="Vehicle overview"><dl className="detail-grid">
|
||||||
<div><dt>Registration</dt><dd>{vehicle.registration_number}</dd></div>
|
<div><dt>Registration</dt><dd>{vehicle.registration_number}</dd></div>
|
||||||
<div><dt>Model year</dt><dd>{vehicle.model_year}</dd></div>
|
<div><dt>Model year</dt><dd>{vehicle.model_year}</dd></div>
|
||||||
<div><dt>Location</dt><dd>{vehicle.location}</dd></div>
|
<div><dt>Location</dt><dd>{vehicle.location}</dd></div>
|
||||||
<div><dt>Odometer</dt><dd>{vehicle.odometer_km.toLocaleString("en-GB")} km</dd></div>
|
<div><dt>Odometer</dt><dd>{vehicle.odometer_km.toLocaleString("en-GB")} km</dd></div>
|
||||||
<div><dt>Next service</dt><dd>{vehicle.next_service_km.toLocaleString("en-GB")} km</dd></div>
|
<div><dt>Next service</dt><dd>{vehicle.next_service_km.toLocaleString("en-GB")} km</dd></div>
|
||||||
<div><dt>Active</dt><dd>{vehicle.active ? "Yes" : "No"}</dd></div>
|
<div><dt>Active</dt><dd>{vehicle.active ? "Yes" : "No"}</dd></div>
|
||||||
</dl>
|
</dl></section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "bookings" && (
|
{tab === "bookings" && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Vehicle } from "../api/types";
|
import type { Vehicle } from "../api/types";
|
||||||
import { StatusBadge } from "../components/Badge";
|
import { StatusBadge } from "../components/Badge";
|
||||||
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
|
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
|
||||||
|
|
||||||
@@ -11,8 +12,11 @@ export function Vehicles() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [attentionOnly, setAttentionOnly] = useState(false);
|
const [attentionOnly, setAttentionOnly] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setVehicles(null);
|
||||||
|
setError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (status) params.set("status", status);
|
if (status) params.set("status", status);
|
||||||
if (attentionOnly) params.set("attention_only", "true");
|
if (attentionOnly) params.set("attention_only", "true");
|
||||||
@@ -24,9 +28,13 @@ export function Vehicles() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h1>Vehicles</h1>
|
<PageHeader eyebrow="Fleet / Registry" title="Vehicle fleet" description="Live operational state, location and service readiness." />
|
||||||
|
|
||||||
<form className="filters" aria-label="Filter vehicles">
|
<form className="filters" aria-label="Filter vehicles">
|
||||||
|
<label>
|
||||||
|
Search
|
||||||
|
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Reference, make or location" />
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Status
|
Status
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
@@ -48,12 +56,13 @@ export function Vehicles() {
|
|||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <ErrorState message={error} />}
|
||||||
{!error && !vehicles && <p>Loading vehicles…</p>}
|
{!error && !vehicles && <LoadingState label="Loading fleet registry…" />}
|
||||||
{vehicles && vehicles.length === 0 && <p>No vehicles match these filters.</p>}
|
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title="No vehicles found" detail="Adjust the current fleet filters." />}
|
||||||
|
|
||||||
{vehicles && vehicles.length > 0 && (
|
{vehicles && vehicles.length > 0 && (() => {
|
||||||
<table className="data-table">
|
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="No matching vehicles" detail="Try a broader search term." /> : <div className="table-shell"><div className="table-meta"><span>{filtered.length} vehicles</span><span>Persisted fleet data</span></div><table className="data-table">
|
||||||
<caption className="visually-hidden">Vehicle fleet</caption>
|
<caption className="visually-hidden">Vehicle fleet</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -67,24 +76,24 @@ export function Vehicles() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{vehicles.map((v) => (
|
{vehicles.map((v) => (
|
||||||
<tr key={v.public_ref}>
|
<tr key={v.public_ref} className={v.attention ? "row-attention" : ""}>
|
||||||
<th scope="row">
|
<th scope="row" data-label="Reference">
|
||||||
<Link to={`/vehicles/${v.public_ref}`}>{v.public_ref}</Link>
|
<Link to={`/vehicles/${v.public_ref}`}>{v.public_ref}</Link>
|
||||||
</th>
|
</th>
|
||||||
<td>
|
<td data-label="Make / model">
|
||||||
{v.make} {v.model} ({v.model_year})
|
{v.make} {v.model} ({v.model_year})
|
||||||
</td>
|
</td>
|
||||||
<td>{v.location}</td>
|
<td data-label="Location">{v.location}</td>
|
||||||
<td>
|
<td data-label="Status">
|
||||||
<StatusBadge status={v.operational_status} />
|
<StatusBadge status={v.operational_status} />
|
||||||
</td>
|
</td>
|
||||||
<td>{v.odometer_km.toLocaleString("en-GB")}</td>
|
<td data-label="Odometer">{v.odometer_km.toLocaleString("en-GB")}</td>
|
||||||
<td>{v.attention ? "Needs attention" : "—"}</td>
|
<td data-label="Attention">{v.attention ? <span className="attention-flag">Needs attention</span> : "—"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table></div>;
|
||||||
)}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user