M11: implement operational booking lifecycle
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user