Files
MobilityOps/frontend/src/pages/BookingDetail.tsx
T

174 lines
9.4 KiB
TypeScript

import { FormEvent, useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Booking, CheckoutBookingResult, RegisterReturnResult, VehicleDetail } from "../api/types";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
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";
import { CheckoutForm } from "../components/CheckoutForm";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
const { formatDateTime, formatNumber } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const { manifest, loading: manifestLoading } = useDemoManifest();
const [booking, setBooking] = useState<Booking | null>(null);
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 [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
const load = useCallback(() => {
if (!publicRef) return;
api
.get<Booking>(`/api/v1/bookings/${publicRef}`)
.then(setBooking)
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
useEffect(() => {
setBooking(null);
setError(null);
setReturnResult(null);
load();
}, [load]);
// The odometer-regression demo scenario supplies its own suspicious reading (per the
// brief: never ask a demo visitor to invent one) -- only fetched for that one known
// scenario booking, not for every return.
const isReturnAnomalyScenario =
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path === `/bookings/${publicRef}`;
useEffect(() => {
setCanonicalOdometerKm(null);
if (!isReturnAnomalyScenario || !booking) return;
api
.get<VehicleDetail>(`/api/v1/vehicles/${booking.vehicle_ref}`)
.then((vehicle) => setCanonicalOdometerKm(vehicle.odometer_km))
.catch(() => setCanonicalOdometerKm(null));
}, [isReturnAnomalyScenario, booking]);
function handleRegistered(result: RegisterReturnResult) {
setReturnResult(result);
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);
}
}
function handleCheckout(result: CheckoutBookingResult) {
setCheckoutResult(result);
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")} />;
return (
<div className="page">
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={t("detail.eyebrow")} title={booking.public_ref} description={`${booking.customer_name} · ${booking.vehicle_ref}`} actions={<StatusBadge status={booking.status} label={t(`statuses.${booking.status}`, { defaultValue: booking.status })} />} />
<section className="record-surface" aria-label={t("detail.eyebrow")}><dl className="detail-grid">
<div><dt>{t("detail.customer")}</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
<div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
<div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div>
<div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div>
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : t("detail.startOdometerPending")}</dd></div>
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : t("detail.endOdometerPending")}</dd></div>
<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} />
<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>}
{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" && 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")}>
<Icon name="spark" />
<div>
<strong>{t("returns:scenario.title")}</strong>
<p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm), productName: PRODUCT_NAME })}</p>
</div>
</section>
)}
{returnResult && <ReturnResultPanel result={returnResult} />}
{/* Wait for the scenario's canonical odometer before mounting the form at all when
this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct
from its very first render -- never updated asynchronously after mount, which
previously raced with anyone already typing into the field. */}
{!returnResult && booking.status === "active" && (manifestLoading || (isReturnAnomalyScenario && canonicalOdometerKm === null)) && (
<LoadingState label={t("returns:scenario.preparing")} />
)}
{!returnResult && booking.status === "active" && !manifestLoading && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
<ReturnForm
bookingRef={booking.public_ref}
onRegistered={handleRegistered}
suggestedOdometerKm={isReturnAnomalyScenario && canonicalOdometerKm !== null ? Math.max(0, canonicalOdometerKm - 50) : undefined}
/>
)}
</div>
);
}