UX: implement visual product roadmap

This commit is contained in:
NuklearRabbit
2026-08-10 01:05:07 +02:00
parent 13ad2ba6a3
commit f2cdad194c
40 changed files with 1010 additions and 281 deletions
+30 -18
View File
@@ -1,22 +1,34 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Vehicle } from "../api/types";
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 { formatNumber } = useLocaleFormat();
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [attentionOnly, setAttentionOnly] = useState(false);
const [query, setQuery] = useState("");
const status = searchParams.get("status") ?? "";
const attentionOnly = searchParams.get("attention_only") === "true";
const query = searchParams.get("q") ?? "";
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);
}
useEffect(() => {
setVehicles(null);
@@ -24,11 +36,14 @@ export function Vehicles() {
const params = new URLSearchParams();
if (status) params.set("status", status);
if (attentionOnly) params.set("attention_only", "true");
if (query) params.set("query", query);
params.set("page", String(page));
params.set("page_size", "25");
api
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles)
.catch(() => setError(t("list.unavailable")));
}, [status, attentionOnly]);
}, [status, attentionOnly, query, page, t]);
return (
<div className="page">
@@ -37,11 +52,11 @@ export function Vehicles() {
<form className="filters" aria-label={t("list.title")}>
<label>
{t("list.searchLabel")}
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("list.searchPlaceholder")} />
<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) => setStatus(e.target.value)}>
<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}>
@@ -54,7 +69,7 @@ export function Vehicles() {
<input
type="checkbox"
checked={attentionOnly}
onChange={(e) => setAttentionOnly(e.target.checked)}
onChange={(e) => updateFilters({ attention_only: e.target.checked, page: 1 })}
/>
{t("list.attentionOnly")}
</label>
@@ -62,11 +77,9 @@ export function Vehicles() {
{error && <ErrorState message={error} />}
{!error && !vehicles && <LoadingState label={t("list.loading")} />}
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title={t("list.empty")} detail={t("list.emptyDetail")} />}
{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.length > 0 && (() => {
const filtered = vehicles.filter((v) => `${v.public_ref} ${v.make} ${v.model} ${v.location}`.toLowerCase().includes(query.toLowerCase()));
return filtered.length === 0 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
{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>
@@ -79,7 +92,7 @@ export function Vehicles() {
</tr>
</thead>
<tbody>
{filtered.map((v) => (
{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}
@@ -97,8 +110,7 @@ export function Vehicles() {
</tr>
))}
</tbody>
</table></div>;
})()}
</table><Pagination page={vehicles.page} totalPages={vehicles.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
</div>
);
}