feat(emex): replace flat vehicle list with cascading dropdown filters
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Model → Engine → Gearbox dropdown chain narrows 4000+ vehicles down to a manageable selection. Parses options_raw key:value pairs for catalogs that have structured data (Renault). Falls back to engine/ transmission fields for others (BMW, Volvo, etc). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, Car } from "lucide-react";
|
||||
import { ArrowLeft, Car, ChevronRight } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
|
||||
component: EmexVehicleListPage,
|
||||
@@ -22,15 +23,144 @@ interface EmexVehicle {
|
||||
optionsRaw: string | null;
|
||||
}
|
||||
|
||||
/** Parse "Key: Value;Key2: Value2" into a map */
|
||||
function parseOptions(raw: string | null): Record<string, string> {
|
||||
if (!raw) return {};
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of raw.split(";")) {
|
||||
const idx = part.indexOf(":");
|
||||
if (idx > 0) {
|
||||
map[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Check if options_raw has key:value structure */
|
||||
function hasStructuredOptions(vehicles: EmexVehicle[]): boolean {
|
||||
const sample = vehicles.find((v) => v.optionsRaw);
|
||||
return sample?.optionsRaw?.includes(":") ?? false;
|
||||
}
|
||||
|
||||
/** Get distinct sorted values for a field across vehicles */
|
||||
function distinctValues(
|
||||
vehicles: EmexVehicle[],
|
||||
getter: (v: EmexVehicle) => string | null | undefined,
|
||||
): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const v of vehicles) {
|
||||
const val = getter(v);
|
||||
if (val) set.add(val);
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
function EmexVehicleListPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogCode } = Route.useParams();
|
||||
|
||||
const { data: vehicles, isLoading } = useQuery({
|
||||
queryKey: ["emex-vehicles", catalogCode],
|
||||
queryFn: () => api.get<EmexVehicle[]>(`/catalog/emex/brands/${catalogCode}/vehicles`),
|
||||
queryFn: () =>
|
||||
api.get<EmexVehicle[]>(`/catalog/emex/brands/${catalogCode}/vehicles`),
|
||||
});
|
||||
|
||||
// Distinct model names
|
||||
const modelNames = useMemo(
|
||||
() => (vehicles ? distinctValues(vehicles, (v) => v.name) : []),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [selectedEngine, setSelectedEngine] = useState("");
|
||||
const [selectedTransmission, setSelectedTransmission] = useState("");
|
||||
|
||||
// Vehicles matching selected model
|
||||
const modelVehicles = useMemo(
|
||||
() =>
|
||||
selectedModel && vehicles
|
||||
? vehicles.filter((v) => v.name === selectedModel)
|
||||
: [],
|
||||
[vehicles, selectedModel],
|
||||
);
|
||||
|
||||
// Determine filter options from model vehicles
|
||||
const structured = useMemo(
|
||||
() => (vehicles ? hasStructuredOptions(vehicles) : false),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
// Engine options for selected model
|
||||
const engineOptions = useMemo(() => {
|
||||
if (!selectedModel) return [];
|
||||
if (structured) {
|
||||
return distinctValues(modelVehicles, (v) => {
|
||||
const opts = parseOptions(v.optionsRaw);
|
||||
return opts["Engine type"] || v.engine || undefined;
|
||||
});
|
||||
}
|
||||
return distinctValues(modelVehicles, (v) => v.engine);
|
||||
}, [modelVehicles, selectedModel, structured]);
|
||||
|
||||
// Transmission options (filtered by engine if selected)
|
||||
const transmissionOptions = useMemo(() => {
|
||||
if (!selectedModel) return [];
|
||||
const base = selectedEngine
|
||||
? modelVehicles.filter((v) => {
|
||||
if (structured) {
|
||||
const opts = parseOptions(v.optionsRaw);
|
||||
return (opts["Engine type"] || v.engine) === selectedEngine;
|
||||
}
|
||||
return v.engine === selectedEngine;
|
||||
})
|
||||
: modelVehicles;
|
||||
if (structured) {
|
||||
return distinctValues(base, (v) => {
|
||||
const opts = parseOptions(v.optionsRaw);
|
||||
return opts["Gearbox type"] || v.transmission || undefined;
|
||||
});
|
||||
}
|
||||
return distinctValues(base, (v) => v.transmission);
|
||||
}, [modelVehicles, selectedModel, selectedEngine, structured]);
|
||||
|
||||
// Final filtered vehicles
|
||||
const filteredVehicles = useMemo(() => {
|
||||
let list = modelVehicles;
|
||||
if (selectedEngine) {
|
||||
list = list.filter((v) => {
|
||||
if (structured) {
|
||||
const opts = parseOptions(v.optionsRaw);
|
||||
return (opts["Engine type"] || v.engine) === selectedEngine;
|
||||
}
|
||||
return v.engine === selectedEngine;
|
||||
});
|
||||
}
|
||||
if (selectedTransmission) {
|
||||
list = list.filter((v) => {
|
||||
if (structured) {
|
||||
const opts = parseOptions(v.optionsRaw);
|
||||
return (
|
||||
(opts["Gearbox type"] || v.transmission) === selectedTransmission
|
||||
);
|
||||
}
|
||||
return v.transmission === selectedTransmission;
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [modelVehicles, selectedEngine, selectedTransmission, structured]);
|
||||
|
||||
// Reset downstream selections when upstream changes
|
||||
const handleModelChange = (name: string) => {
|
||||
setSelectedModel(name);
|
||||
setSelectedEngine("");
|
||||
setSelectedTransmission("");
|
||||
};
|
||||
|
||||
const handleEngineChange = (eng: string) => {
|
||||
setSelectedEngine(eng);
|
||||
setSelectedTransmission("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -40,43 +170,162 @@ function EmexVehicleListPage() {
|
||||
{t("catalog.backToBrands")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{decodeURIComponent(catalogCode)}</h1>
|
||||
<h1 className="text-xl font-bold">
|
||||
{decodeURIComponent(catalogCode)}
|
||||
</h1>
|
||||
{vehicles && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({vehicles.length} {t("catalog.models").toLowerCase()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`v-skel-${i}`} className="h-16 w-full rounded-lg" />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !vehicles || vehicles.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noModels")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vehicles.map((v) => (
|
||||
<Link
|
||||
key={v.id}
|
||||
to="/dashboard/catalog/emex/$catalogCode/$vehicleId"
|
||||
params={{ catalogCode, vehicleId: v.id }}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<Car className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{v.name || v.vehicleId}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
v.engine,
|
||||
v.yearFrom && v.yearTo ? `${v.yearFrom}-${v.yearTo}` : v.yearFrom,
|
||||
v.optionsRaw,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ")}
|
||||
</p>
|
||||
<>
|
||||
{/* Dropdown chain */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Model */}
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
{t("catalog.models")}
|
||||
</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("catalog.selectModel")}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Engine */}
|
||||
{selectedModel && engineOptions.length > 1 && (
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
{t("catalog.fordVariant.engine")}
|
||||
</label>
|
||||
<select
|
||||
value={selectedEngine}
|
||||
onChange={(e) => handleEngineChange(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("catalog.fordVariant.showAll")}</option>
|
||||
{engineOptions.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transmission */}
|
||||
{selectedModel && transmissionOptions.length > 1 && (
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
{t("catalog.fordVariant.gearbox")}
|
||||
</label>
|
||||
<select
|
||||
value={selectedTransmission}
|
||||
onChange={(e) => setSelectedTransmission(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("catalog.fordVariant.showAll")}</option>
|
||||
{transmissionOptions.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vehicle list */}
|
||||
{selectedModel ? (
|
||||
filteredVehicles.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t("catalog.noModels")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredVehicles.map((v) => (
|
||||
<VehicleRow
|
||||
key={v.id}
|
||||
vehicle={v}
|
||||
catalogCode={catalogCode}
|
||||
structured={structured}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("catalog.selectModel")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleRow({
|
||||
vehicle,
|
||||
catalogCode,
|
||||
structured,
|
||||
}: {
|
||||
vehicle: EmexVehicle;
|
||||
catalogCode: string;
|
||||
structured: boolean;
|
||||
}) {
|
||||
const opts = structured ? parseOptions(vehicle.optionsRaw) : null;
|
||||
|
||||
// Build descriptive subtitle
|
||||
const parts: string[] = [];
|
||||
if (opts) {
|
||||
if (opts["Engine type and suffix"]) parts.push(opts["Engine type and suffix"]);
|
||||
else if (opts["Engine type"]) parts.push(opts["Engine type"]);
|
||||
if (opts["Gearbox type"]) parts.push(opts["Gearbox type"]);
|
||||
if (opts["Vehicle type"]) parts.push(opts["Vehicle type"]);
|
||||
} else {
|
||||
if (vehicle.engine) parts.push(vehicle.engine);
|
||||
if (vehicle.transmission) parts.push(vehicle.transmission);
|
||||
if (vehicle.optionsRaw) parts.push(vehicle.optionsRaw);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/emex/$catalogCode/$vehicleId"
|
||||
params={{ catalogCode, vehicleId: vehicle.id }}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors hover:bg-accent"
|
||||
>
|
||||
<Car className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{parts.length > 0 ? (
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{parts.join(" · ")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{vehicle.vehicleId}</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user