M11: implement operational booking lifecycle

This commit is contained in:
NuklearRabbit
2026-08-10 03:06:30 +02:00
parent 3f13912739
commit 4a3c3bd0a9
15 changed files with 516 additions and 7 deletions
+2
View File
@@ -10,6 +10,7 @@ import { Vehicles } from "./pages/Vehicles";
import { VehicleDetail } from "./pages/VehicleDetail";
import { Bookings } from "./pages/Bookings";
import { BookingDetail } from "./pages/BookingDetail";
import { BookingCreate } from "./pages/BookingCreate";
import { DataQuality } from "./pages/DataQuality";
import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
import { Automation } from "./pages/Automation";
@@ -36,6 +37,7 @@ export function App() {
<Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/bookings/new" element={<BookingCreate />} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
<Route path="/data-quality" element={<DataQuality />} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
+15
View File
@@ -51,6 +51,21 @@ export interface Booking extends BookingSummary {
customer_name: string;
}
export interface CustomerOption {
public_ref: string;
display_name: string;
email: string | null;
}
export interface AvailableVehicle {
public_ref: string;
make: string;
model: string;
registration_number: string;
location: string;
operational_status: string;
}
export interface Inspection {
public_ref: string;
booking_ref: string;
+30 -1
View File
@@ -3,6 +3,7 @@
"eyebrow": "Operations / Schedule",
"title": "Bookings",
"description": "Review active rental windows and upcoming vehicle commitments.",
"create": "New booking",
"searchLabel": "Search",
"searchPlaceholder": "Booking, customer or vehicle",
"statusLabel": "Status",
@@ -27,6 +28,28 @@
"next": "Next",
"rangeOf": "{{from}}{{to}} of {{total}}"
},
"create": {
"backLink": "Booking ledger",
"eyebrow": "Bookings / New reservation",
"title": "Create booking",
"description": "Select a customer and a vehicle that is available for the full rental window.",
"startsAt": "Starts",
"endsAt": "Ends",
"customerSearch": "Search customer",
"customerPlaceholder": "Name, email or customer reference",
"customer": "Customer",
"searchFirst": "Search for a customer first",
"chooseCustomer": "Select a customer",
"vehicle": "Available vehicle",
"loadingVehicles": "Checking availability…",
"chooseVehicle": "Select a vehicle",
"noVehicles": "No vehicle available for this window",
"requirementsComplete": "Driving licence and rental requirements have been checked",
"cancel": "Cancel",
"save": "Create booking",
"saving": "Saving booking…",
"failed": "The booking could not be created. Check the window and availability."
},
"statuses": {
"reserved": "reserved",
"active": "active",
@@ -49,6 +72,12 @@
"endOdometerPending": "Not yet recorded — not yet closed",
"requirementsComplete": "Requirements complete",
"yes": "Yes",
"no": "No"
"no": "No",
"cancelAction": "Cancel booking",
"cancelReason": "Reason",
"cancelReasonPlaceholder": "Why is this booking being cancelled?",
"confirmCancel": "Confirm cancellation",
"cancelling": "Cancelling…",
"cancelFailed": "The booking could not be cancelled."
}
}
+30 -1
View File
@@ -3,6 +3,7 @@
"eyebrow": "Exploitation / Planning",
"title": "Réservations",
"description": "Consultez les périodes de location actives et les engagements de véhicules à venir.",
"create": "Nouvelle réservation",
"searchLabel": "Rechercher",
"searchPlaceholder": "Réservation, client ou véhicule",
"statusLabel": "Statut",
@@ -27,6 +28,28 @@
"next": "Suivant",
"rangeOf": "{{from}}{{to}} sur {{total}}"
},
"create": {
"backLink": "Registre des réservations",
"eyebrow": "Réservations / Nouvelle réservation",
"title": "Créer une réservation",
"description": "Sélectionnez un client et un véhicule disponible pendant toute la période de location.",
"startsAt": "Début",
"endsAt": "Fin",
"customerSearch": "Rechercher un client",
"customerPlaceholder": "Nom, e-mail ou référence client",
"customer": "Client",
"searchFirst": "Recherchez d'abord un client",
"chooseCustomer": "Sélectionnez un client",
"vehicle": "Véhicule disponible",
"loadingVehicles": "Vérification des disponibilités…",
"chooseVehicle": "Sélectionnez un véhicule",
"noVehicles": "Aucun véhicule disponible pour cette période",
"requirementsComplete": "Le permis de conduire et les exigences de location ont été vérifiés",
"cancel": "Annuler",
"save": "Créer la réservation",
"saving": "Enregistrement…",
"failed": "La réservation n'a pas pu être créée. Vérifiez la période et les disponibilités."
},
"statuses": {
"reserved": "réservé",
"active": "actif",
@@ -49,6 +72,12 @@
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
"requirementsComplete": "Exigences complètes",
"yes": "Oui",
"no": "Non"
"no": "Non",
"cancelAction": "Annuler la réservation",
"cancelReason": "Motif",
"cancelReasonPlaceholder": "Pourquoi cette réservation est-elle annulée ?",
"confirmCancel": "Confirmer l'annulation",
"cancelling": "Annulation…",
"cancelFailed": "La réservation n'a pas pu être annulée."
}
}
+30 -1
View File
@@ -3,6 +3,7 @@
"eyebrow": "Uitvoeren / Planning",
"title": "Boekingen",
"description": "Bekijk actieve verhuurperiodes en aankomende voertuigverbintenissen.",
"create": "Nieuwe boeking",
"searchLabel": "Zoeken",
"searchPlaceholder": "Boeking, klant of voertuig",
"statusLabel": "Status",
@@ -27,6 +28,28 @@
"next": "Volgende",
"rangeOf": "{{from}}{{to}} van {{total}}"
},
"create": {
"backLink": "Boekingsoverzicht",
"eyebrow": "Boekingen / Nieuwe reservatie",
"title": "Boeking aanmaken",
"description": "Selecteer een klant en een voertuig dat voor de volledige huurperiode beschikbaar is.",
"startsAt": "Start",
"endsAt": "Einde",
"customerSearch": "Klant zoeken",
"customerPlaceholder": "Naam, e-mail of klantreferentie",
"customer": "Klant",
"searchFirst": "Zoek eerst een klant",
"chooseCustomer": "Selecteer een klant",
"vehicle": "Beschikbaar voertuig",
"loadingVehicles": "Beschikbaarheid controleren…",
"chooseVehicle": "Selecteer een voertuig",
"noVehicles": "Geen beschikbaar voertuig in deze periode",
"requirementsComplete": "Rijbewijs- en huurvereisten zijn gecontroleerd",
"cancel": "Annuleren",
"save": "Boeking aanmaken",
"saving": "Boeking opslaan…",
"failed": "De boeking kon niet worden aangemaakt. Controleer de periode en beschikbaarheid."
},
"statuses": {
"reserved": "gereserveerd",
"active": "actief",
@@ -49,6 +72,12 @@
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
"requirementsComplete": "Vereisten volledig",
"yes": "Ja",
"no": "Nee"
"no": "Nee",
"cancelAction": "Boeking annuleren",
"cancelReason": "Reden",
"cancelReasonPlaceholder": "Waarom wordt deze boeking geannuleerd?",
"confirmCancel": "Annulering bevestigen",
"cancelling": "Annuleren…",
"cancelFailed": "De boeking kon niet worden geannuleerd."
}
}
+111
View File
@@ -0,0 +1,111 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { AvailableVehicle, Booking, CustomerOption } from "../api/types";
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
import { Icon } from "../components/Icons";
function localDateTime(hoursFromNow: number): string {
const value = new Date(Date.now() + hoursFromNow * 60 * 60 * 1000);
value.setMinutes(0, 0, 0);
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
export function BookingCreate() {
const { t } = useTranslation(["bookings", "errors"]);
const navigate = useNavigate();
const [customerQuery, setCustomerQuery] = useState("");
const [customers, setCustomers] = useState<CustomerOption[]>([]);
const [customerRef, setCustomerRef] = useState("");
const [startsAt, setStartsAt] = useState(() => localDateTime(2));
const [endsAt, setEndsAt] = useState(() => localDateTime(26));
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
const [vehicleRef, setVehicleRef] = useState("");
const [requirementsComplete, setRequirementsComplete] = useState(true);
const [loadingVehicles, setLoadingVehicles] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const windowValid = useMemo(
() => Boolean(startsAt && endsAt && new Date(endsAt) > new Date(startsAt)),
[endsAt, startsAt],
);
useEffect(() => {
if (customerQuery.trim().length < 2) {
setCustomers([]);
return;
}
const timeout = window.setTimeout(() => {
api.get<CustomerOption[]>(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`)
.then((result) => {
setCustomers(result);
if (!result.some((customer) => customer.public_ref === customerRef)) setCustomerRef("");
})
.catch((err) => setError(describeApiError(t, err)));
}, 250);
return () => window.clearTimeout(timeout);
}, [customerQuery, customerRef, t]);
useEffect(() => {
if (!windowValid) {
setVehicles([]);
setVehicleRef("");
return;
}
setLoadingVehicles(true);
const params = new URLSearchParams({
starts_at: new Date(startsAt).toISOString(),
ends_at: new Date(endsAt).toISOString(),
});
api.get<AvailableVehicle[]>(`/api/v1/bookings/availability?${params.toString()}`)
.then((result) => {
setVehicles(result);
setVehicleRef((current) => result.some((vehicle) => vehicle.public_ref === current) ? current : "");
})
.catch((err) => setError(describeApiError(t, err)))
.finally(() => setLoadingVehicles(false));
}, [endsAt, startsAt, t, windowValid]);
async function submit(event: FormEvent) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
const booking = await api.post<Booking>("/api/v1/bookings", {
customer_ref: customerRef,
vehicle_ref: vehicleRef,
starts_at: new Date(startsAt).toISOString(),
ends_at: new Date(endsAt).toISOString(),
requirements_complete: requirementsComplete,
});
navigate(`/bookings/${booking.public_ref}`);
} catch (err) {
setError(describeApiError(t, err, "bookings:create.failed"));
} finally {
setSubmitting(false);
}
}
return (
<div className="page">
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> {t("create.backLink")}</Link>
<PageHeader eyebrow={t("create.eyebrow")} title={t("create.title")} description={t("create.description")} />
<ApiErrorNotice error={error} />
<form className="record-surface booking-create-form" onSubmit={submit}>
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={startsAt} value={endsAt} onChange={(event) => setEndsAt(event.target.value)} /></label>
<label>{t("create.customerSearch")}<input type="search" value={customerQuery} onChange={(event) => setCustomerQuery(event.target.value)} placeholder={t("create.customerPlaceholder")} /></label>
<label>{t("create.customer")}<select required value={customerRef} onChange={(event) => setCustomerRef(event.target.value)} disabled={customers.length === 0}><option value="">{t(customers.length ? "create.chooseCustomer" : "create.searchFirst")}</option>{customers.map((customer) => <option key={customer.public_ref} value={customer.public_ref}>{customer.display_name} · {customer.public_ref}{customer.email ? ` · ${customer.email}` : ""}</option>)}</select></label>
<label>{t("create.vehicle")}<select required value={vehicleRef} onChange={(event) => setVehicleRef(event.target.value)} disabled={!windowValid || loadingVehicles}><option value="">{t(loadingVehicles ? "create.loadingVehicles" : vehicles.length ? "create.chooseVehicle" : "create.noVehicles")}</option>{vehicles.map((vehicle) => <option key={vehicle.public_ref} value={vehicle.public_ref}>{vehicle.public_ref} · {vehicle.make} {vehicle.model} · {vehicle.location}</option>)}</select></label>
</div>
<label className="check-card"><input type="checkbox" checked={requirementsComplete} onChange={(event) => setRequirementsComplete(event.target.checked)} /> <span>{t("create.requirementsComplete")}</span></label>
<div className="form-actions"><Link className="button button-secondary" to="/bookings">{t("create.cancel")}</Link><button className="button button-primary" type="submit" disabled={submitting || !windowValid || !customerRef || !vehicleRef}>{submitting ? t("create.saving") : t("create.save")}</button></div>
</form>
</div>
);
}
+30 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -10,9 +10,11 @@ import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns"]);
const { t } = useTranslation(["bookings", "returns", "errors"]);
const { formatDateTime, formatNumber } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const { manifest } = useDemoManifest();
@@ -20,6 +22,9 @@ export function BookingDetail() {
const [error, setError] = useState<string | null>(null);
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
const [canonicalOdometerKm, setCanonicalOdometerKm] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const load = useCallback(() => {
if (!publicRef) return;
@@ -56,6 +61,22 @@ export function BookingDetail() {
load();
}
async function cancelBooking(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
setCancelling(true);
setActionError(null);
try {
const updated = await api.post<Booking>(`/api/v1/bookings/${publicRef}/cancel`, { reason: cancelReason });
setBooking(updated);
setCancelReason("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.cancelFailed"));
} finally {
setCancelling(false);
}
}
if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label={t("detail.loading")} />;
@@ -73,6 +94,13 @@ export function BookingDetail() {
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section>
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
<h2>{t("detail.cancelAction")}</h2>
<ApiErrorNotice error={actionError} />
<label>{t("detail.cancelReason")}<textarea required minLength={3} maxLength={500} value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} placeholder={t("detail.cancelReasonPlaceholder")} /></label>
<div className="form-actions"><button className="button button-danger" type="submit" disabled={cancelling || cancelReason.trim().length < 3}>{cancelling ? t("detail.cancelling") : t("detail.confirmCancel")}</button></div>
</form>}
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
<Icon name="spark" />
+1 -1
View File
@@ -43,7 +43,7 @@ export function Bookings() {
return (
<div className="page">
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} />
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} actions={<Link className="button button-primary" to="/bookings/new">{t("list.create")}</Link>} />
<form className="filters" aria-label={t("list.title")}>
<label>
+6
View File
@@ -274,6 +274,12 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; }
.return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; }
.booking-create-form, .booking-cancel-form { padding: 22px; }
.booking-create-form .check-card { display: flex; align-items: center; gap: 9px; margin-bottom: 17px; }
.booking-cancel-form { margin-top: 18px; }
.booking-cancel-form h2 { margin: 0 0 14px; font-size: 1rem; }
.booking-cancel-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
.booking-cancel-form textarea { min-height: 92px; padding: 10px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); }
.review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; }