128 lines
6.7 KiB
TypeScript
128 lines
6.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api } from "../api/client";
|
|
import type { Page, Vehicle } from "../api/types";
|
|
import { useLocaleFormat } from "../i18n/format";
|
|
import { StatusBadge } from "../components/Badge";
|
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
|
import { Pagination } from "../components/Pagination";
|
|
|
|
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
|
|
|
|
export function Vehicles() {
|
|
const { t } = useTranslation("fleet");
|
|
const { formatDateTime, formatNumber } = useLocaleFormat();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const status = searchParams.get("status") ?? "";
|
|
const attentionOnly = searchParams.get("attention_only") === "true";
|
|
const query = searchParams.get("q") ?? "";
|
|
const location = searchParams.get("location") ?? "";
|
|
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
|
|
|
function updateFilters(updates: Record<string, string | boolean | number | null>) {
|
|
const next = new URLSearchParams(searchParams);
|
|
Object.entries(updates).forEach(([key, value]) => {
|
|
if (value === null || value === "" || value === false) next.delete(key);
|
|
else next.set(key, String(value));
|
|
});
|
|
setSearchParams(next, { replace: true });
|
|
}
|
|
|
|
useEffect(() => {
|
|
setVehicles(null);
|
|
setError(null);
|
|
const params = new URLSearchParams();
|
|
if (status) params.set("status", status);
|
|
if (attentionOnly) params.set("attention_only", "true");
|
|
if (query) params.set("query", query);
|
|
if (location) params.set("location", location);
|
|
params.set("page", String(page));
|
|
params.set("page_size", "25");
|
|
const controller = new AbortController();
|
|
api
|
|
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`, { signal: controller.signal })
|
|
.then(setVehicles)
|
|
.catch(() => {
|
|
if (!controller.signal.aborted) setError(t("list.unavailable"));
|
|
});
|
|
return () => controller.abort();
|
|
}, [status, attentionOnly, query, location, page, t]);
|
|
|
|
return (
|
|
<div className="page">
|
|
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} />
|
|
|
|
<form className="filters" aria-label={t("list.title")}>
|
|
<label>
|
|
{t("list.searchLabel")}
|
|
<input type="search" value={query} onChange={(e) => updateFilters({ q: e.target.value, page: 1 })} placeholder={t("list.searchPlaceholder")} />
|
|
</label>
|
|
<label>
|
|
{t("list.statusLabel")}
|
|
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
|
|
<option value="">{t("list.statusAll")}</option>
|
|
{STATUS_OPTIONS.map((s) => (
|
|
<option key={s} value={s}>
|
|
{t(`statuses.${s}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="checkbox-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={attentionOnly}
|
|
onChange={(e) => updateFilters({ attention_only: e.target.checked, page: 1 })}
|
|
/>
|
|
{t("list.attentionOnly")}
|
|
</label>
|
|
<label>{t("list.locationFilter")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
|
|
</form>
|
|
|
|
{error && <ErrorState message={error} />}
|
|
{!error && !vehicles && <LoadingState label={t("list.loading")} />}
|
|
{vehicles && vehicles.items.length === 0 && <EmptyState icon={query ? "search" : "fleet"} title={query ? t("list.noMatch") : t("list.empty")} detail={query ? t("list.noMatchDetail") : t("list.emptyDetail")} />}
|
|
|
|
{vehicles && vehicles.items.length > 0 && <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: vehicles.total })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
|
|
<caption className="visually-hidden">{t("list.title")}</caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">{t("list.columns.reference")}</th>
|
|
<th scope="col">{t("list.columns.makeModel")}</th>
|
|
<th scope="col">{t("list.columns.location")}</th>
|
|
<th scope="col">{t("list.columns.status")}</th>
|
|
<th scope="col">{t("list.columns.odometer")}</th>
|
|
<th scope="col">{t("list.columns.service")}</th>
|
|
<th scope="col">{t("list.columns.nextBooking")}</th>
|
|
<th scope="col">{t("list.columns.attention")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{vehicles.items.map((v) => (
|
|
<tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}>
|
|
<th scope="row" data-label={t("list.columns.reference")}>
|
|
{v.public_ref}
|
|
<Link className="row-link" to={`/vehicles/${v.public_ref}`}><span className="visually-hidden">{v.public_ref}</span></Link>
|
|
</th>
|
|
<td data-label={t("list.columns.makeModel")}>
|
|
{v.make} {v.model} ({v.model_year})
|
|
</td>
|
|
<td data-label={t("list.columns.location")}>{v.location}</td>
|
|
<td data-label={t("list.columns.status")}>
|
|
<StatusBadge status={v.operational_status} label={t(`statuses.${v.operational_status}`, { defaultValue: v.operational_status })} />
|
|
</td>
|
|
<td data-label={t("list.columns.odometer")}>{formatNumber(v.odometer_km)}</td>
|
|
<td data-label={t("list.columns.service")} className={v.service_remaining_km <= 0 ? "is-overdue" : ""}>{v.service_remaining_km <= 0 ? t("list.serviceDue") : t("list.serviceRemaining", { count: formatNumber(v.service_remaining_km) })}</td>
|
|
<td data-label={t("list.columns.nextBooking")}>{v.next_booking_ref && v.next_booking_at ? <Link className="cell-link" to={`/bookings/${v.next_booking_ref}`}>{v.next_booking_ref}<span className="table-secondary">{formatDateTime(v.next_booking_at)}</span></Link> : "—"}</td>
|
|
<td data-label={t("list.columns.attention")}>{v.attention && v.attention_reason ? <span className="attention-flag">{t(`list.attentionReasons.${v.attention_reason}`)}</span> : "—"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table><Pagination page={vehicles.page} totalPages={vehicles.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
|
|
</div>
|
|
);
|
|
}
|