130 lines
7.1 KiB
TypeScript
130 lines
7.1 KiB
TypeScript
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";
|
|
import { brusselsDateTimeFromNow, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
|
|
|
|
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(() => brusselsDateTimeFromNow(2));
|
|
const [endsAt, setEndsAt] = useState(() => brusselsDateTimeFromNow(26));
|
|
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
|
|
const [vehicleRef, setVehicleRef] = useState("");
|
|
const [vehicleQuery, setVehicleQuery] = useState("");
|
|
const [loadingVehicles, setLoadingVehicles] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
|
|
|
const startsAtIso = useMemo(() => startsAt ? tryBrusselsLocalToIso(startsAt) : null, [startsAt]);
|
|
const endsAtIso = useMemo(() => endsAt ? tryBrusselsLocalToIso(endsAt) : null, [endsAt]);
|
|
const windowValid = Boolean(startsAtIso && endsAtIso && endsAtIso > startsAtIso);
|
|
const localTimeInvalid = Boolean(
|
|
(startsAt && !startsAtIso) || (endsAt && !endsAtIso),
|
|
);
|
|
const windowOrderInvalid = Boolean(startsAtIso && endsAtIso && endsAtIso <= startsAtIso);
|
|
|
|
useEffect(() => {
|
|
if (customerQuery.trim().length < 2) {
|
|
setCustomers([]);
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
const timeout = window.setTimeout(() => {
|
|
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) => {
|
|
if (!controller.signal.aborted) setError(describeApiError(t, err));
|
|
});
|
|
}, 250);
|
|
return () => {
|
|
window.clearTimeout(timeout);
|
|
controller.abort();
|
|
};
|
|
}, [customerQuery, customerRef, t]);
|
|
|
|
useEffect(() => {
|
|
if (!windowValid) {
|
|
setVehicles([]);
|
|
setVehicleRef("");
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
const timeout = window.setTimeout(() => {
|
|
setLoadingVehicles(true);
|
|
const params = new URLSearchParams({
|
|
starts_at: startsAtIso as string,
|
|
ends_at: endsAtIso as string,
|
|
});
|
|
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();
|
|
};
|
|
}, [endsAtIso, startsAtIso, t, vehicleQuery, windowValid]);
|
|
|
|
async function submit(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!startsAtIso || !endsAtIso || endsAtIso <= startsAtIso) return;
|
|
setError(null);
|
|
setSubmitting(true);
|
|
try {
|
|
const booking = await api.post<Booking>("/api/v1/bookings", {
|
|
customer_ref: customerRef,
|
|
vehicle_ref: vehicleRef,
|
|
starts_at: startsAtIso,
|
|
ends_at: endsAtIso,
|
|
});
|
|
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 aria-invalid={Boolean(startsAt && !startsAtIso)} value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
|
|
<label>{t("create.endsAt")}<input type="datetime-local" required aria-invalid={Boolean((endsAt && !endsAtIso) || windowOrderInvalid)} 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.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>
|
|
{localTimeInvalid && <p className="error" role="alert">{t("create.invalidLocalTime")}</p>}
|
|
{!localTimeInvalid && windowOrderInvalid && <p className="error" role="alert">{t("create.endAfterStart")}</p>}
|
|
<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>
|
|
);
|
|
}
|