M33: enforce booking readiness workflow

This commit is contained in:
NuklearRabbit
2026-08-10 22:23:11 +02:00
parent be33b46228
commit 82a933f6cd
12 changed files with 273 additions and 43 deletions
+45 -30
View File
@@ -6,13 +6,7 @@ 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);
}
import { brusselsDateTimeFromNow, brusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingCreate() {
const { t } = useTranslation(["bookings", "errors"]);
@@ -20,17 +14,18 @@ export function BookingCreate() {
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 [startsAt, setStartsAt] = useState(() => brusselsDateTimeFromNow(2));
const [endsAt, setEndsAt] = useState(() => brusselsDateTimeFromNow(26));
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
const [vehicleRef, setVehicleRef] = useState("");
const [requirementsComplete, setRequirementsComplete] = useState(true);
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [vehicleQuery, setVehicleQuery] = useState("");
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)),
() => Boolean(startsAt && endsAt && brusselsLocalToIso(endsAt) > brusselsLocalToIso(startsAt)),
[endsAt, startsAt],
);
@@ -39,15 +34,21 @@ export function BookingCreate() {
setCustomers([]);
return;
}
const controller = new AbortController();
const timeout = window.setTimeout(() => {
api.get<CustomerOption[]>(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`)
api.get<CustomerOption[]>(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`, { signal: controller.signal })
.then((result) => {
setCustomers(result);
if (!result.some((customer) => customer.public_ref === customerRef)) setCustomerRef("");
})
.catch((err) => setError(describeApiError(t, err)));
.catch((err) => {
if (!controller.signal.aborted) setError(describeApiError(t, err));
});
}, 250);
return () => window.clearTimeout(timeout);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [customerQuery, customerRef, t]);
useEffect(() => {
@@ -56,19 +57,32 @@ export function BookingCreate() {
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]);
const controller = new AbortController();
const timeout = window.setTimeout(() => {
setLoadingVehicles(true);
const params = new URLSearchParams({
starts_at: brusselsLocalToIso(startsAt),
ends_at: brusselsLocalToIso(endsAt),
});
if (vehicleQuery.trim()) params.set("query", vehicleQuery.trim());
params.set("limit", "50");
api.get<AvailableVehicle[]>(`/api/v1/bookings/availability?${params.toString()}`, { signal: controller.signal })
.then((result) => {
setVehicles(result);
setVehicleRef((current) => result.some((vehicle) => vehicle.public_ref === current) ? current : "");
})
.catch((err) => {
if (!controller.signal.aborted) setError(describeApiError(t, err));
})
.finally(() => {
if (!controller.signal.aborted) setLoadingVehicles(false);
});
}, 250);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [endsAt, startsAt, t, vehicleQuery, windowValid]);
async function submit(event: FormEvent) {
event.preventDefault();
@@ -78,8 +92,8 @@ export function BookingCreate() {
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(),
starts_at: brusselsLocalToIso(startsAt),
ends_at: brusselsLocalToIso(endsAt),
requirements_complete: requirementsComplete,
});
navigate(`/bookings/${booking.public_ref}`);
@@ -101,7 +115,8 @@ export function BookingCreate() {
<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>
<label>{t("create.vehicleSearch")}<input type="search" value={vehicleQuery} onChange={(event) => setVehicleQuery(event.target.value)} placeholder={t("create.vehiclePlaceholder")} /></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 || t("create.locationUnknown")}</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>
+33 -1
View File
@@ -27,6 +27,8 @@ export function BookingDetail() {
const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
const load = useCallback(() => {
if (!publicRef) return;
@@ -84,6 +86,24 @@ export function BookingDetail() {
load();
}
async function confirmRequirements(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
setConfirmingRequirements(true);
setActionError(null);
try {
const updated = await api.post<Booking>(`/api/v1/bookings/${publicRef}/complete-requirements`, {
confirmation: requirementsConfirmation,
});
setBooking(updated);
setRequirementsConfirmation("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed"));
} finally {
setConfirmingRequirements(false);
}
}
if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label={t("detail.loading")} />;
@@ -101,6 +121,18 @@ 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" && !booking.requirements_complete ? (
<form className="record-surface requirements-confirmation" onSubmit={confirmRequirements}>
<div>
<h2>{t("detail.requirementsAction")}</h2>
<p>{t("detail.requirementsActionDetail")}</p>
</div>
<ApiErrorNotice error={actionError} />
<label>{t("detail.requirementsConfirmation")}<textarea required minLength={3} maxLength={500} value={requirementsConfirmation} onChange={(event) => setRequirementsConfirmation(event.target.value)} placeholder={t("detail.requirementsConfirmationPlaceholder")} /></label>
<div className="form-actions"><button className="button button-primary" type="submit" disabled={confirmingRequirements || requirementsConfirmation.trim().length < 3}>{confirmingRequirements ? t("detail.requirementsSaving") : t("detail.requirementsConfirm")}</button></div>
</form>
) : null}
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
<h2>{t("detail.cancelAction")}</h2>
<ApiErrorNotice error={actionError} />
@@ -109,7 +141,7 @@ export function BookingDetail() {
</form>}
{checkoutResult && <section className={`record-surface checkout-result ${checkoutResult.activated ? "success" : "warning"}`} role="status"><h2>{t(checkoutResult.activated ? "checkout.activatedTitle" : "checkout.blockedTitle")}</h2><p>{t(checkoutResult.activated ? "checkout.activatedDetail" : "checkout.blockedDetail", { inspection: checkoutResult.inspection_ref })}</p></section>}
{!checkoutResult && booking.status === "reserved" && <CheckoutForm bookingRef={booking.public_ref} vehicleRef={booking.vehicle_ref} onRecorded={handleCheckout} />}
{!checkoutResult && booking.status === "reserved" && booking.requirements_complete && <CheckoutForm bookingRef={booking.public_ref} vehicleRef={booking.vehicle_ref} onRecorded={handleCheckout} />}
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>