feat(emex): dynamic cascading filters for vehicle selection
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Dropdowns now auto-detect which fields have data (engine, body type, transmission, drive type, fuel type, variant) and show only relevant filters. Each dropdown cascades — selecting a value narrows options for subsequent filters. Handles both structured options_raw (Renault) and standard DB fields (Fiat, BMW, Volvo, etc). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -18,11 +18,22 @@ interface EmexVehicle {
|
||||
engineCode: string | null;
|
||||
bodyType: string | null;
|
||||
transmission: string | null;
|
||||
driveType: string | null;
|
||||
fuelType: string | null;
|
||||
yearFrom: number | null;
|
||||
yearTo: number | null;
|
||||
optionsRaw: string | null;
|
||||
}
|
||||
|
||||
// ── Filter definition ────────────────────────────────
|
||||
|
||||
interface FilterDef {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Extract the filterable value from a vehicle */
|
||||
getter: (v: EmexVehicle) => string | null | undefined;
|
||||
}
|
||||
|
||||
/** Parse "Key: Value;Key2: Value2" into a map */
|
||||
function parseOptions(raw: string | null): Record<string, string> {
|
||||
if (!raw) return {};
|
||||
@@ -42,7 +53,83 @@ function hasStructuredOptions(vehicles: EmexVehicle[]): boolean {
|
||||
return sample?.optionsRaw?.includes(":") ?? false;
|
||||
}
|
||||
|
||||
/** Get distinct sorted values for a field across vehicles */
|
||||
/** Build the list of filter definitions based on what data actually exists */
|
||||
function buildFilterDefs(
|
||||
vehicles: EmexVehicle[],
|
||||
structured: boolean,
|
||||
): FilterDef[] {
|
||||
const defs: FilterDef[] = [];
|
||||
|
||||
if (structured) {
|
||||
// Renault-style: extract filters from parsed options_raw
|
||||
const optionKeys = new Map<string, number>();
|
||||
for (const v of vehicles) {
|
||||
const opts = parseOptions(v.optionsRaw);
|
||||
for (const k of Object.keys(opts)) {
|
||||
optionKeys.set(k, (optionKeys.get(k) || 0) + 1);
|
||||
}
|
||||
}
|
||||
// "Engine type" and "Engine type and suffix" — use "Engine type" as filter
|
||||
if (optionKeys.has("Engine type")) {
|
||||
defs.push({
|
||||
key: "engineType",
|
||||
label: "engine",
|
||||
getter: (v) => parseOptions(v.optionsRaw)["Engine type"] || v.engine,
|
||||
});
|
||||
}
|
||||
if (optionKeys.has("Gearbox type")) {
|
||||
defs.push({
|
||||
key: "gearboxType",
|
||||
label: "gearbox",
|
||||
getter: (v) =>
|
||||
parseOptions(v.optionsRaw)["Gearbox type"] || v.transmission,
|
||||
});
|
||||
}
|
||||
if (optionKeys.has("Vehicle type")) {
|
||||
defs.push({
|
||||
key: "vehicleType",
|
||||
label: "vehicleType",
|
||||
getter: (v) => parseOptions(v.optionsRaw)["Vehicle type"],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Standard fields — add in a sensible order
|
||||
defs.push({
|
||||
key: "engine",
|
||||
label: "engine",
|
||||
getter: (v) => v.engine,
|
||||
});
|
||||
defs.push({
|
||||
key: "bodyType",
|
||||
label: "body",
|
||||
getter: (v) => v.bodyType,
|
||||
});
|
||||
defs.push({
|
||||
key: "transmission",
|
||||
label: "gearbox",
|
||||
getter: (v) => v.transmission,
|
||||
});
|
||||
defs.push({
|
||||
key: "driveType",
|
||||
label: "drive",
|
||||
getter: (v) => v.driveType,
|
||||
});
|
||||
defs.push({
|
||||
key: "fuelType",
|
||||
label: "fuel",
|
||||
getter: (v) => v.fuelType,
|
||||
});
|
||||
defs.push({
|
||||
key: "optionsRaw",
|
||||
label: "variant",
|
||||
getter: (v) => v.optionsRaw,
|
||||
});
|
||||
}
|
||||
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** Get distinct sorted values for a getter */
|
||||
function distinctValues(
|
||||
vehicles: EmexVehicle[],
|
||||
getter: (v: EmexVehicle) => string | null | undefined,
|
||||
@@ -55,6 +142,20 @@ function distinctValues(
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ── Label map ────────────────────────────────────────
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
engine: "Motor",
|
||||
gearbox: "Şanzıman",
|
||||
body: "Kasa Tipi",
|
||||
drive: "Sürüş",
|
||||
fuel: "Yakıt",
|
||||
variant: "Varyant",
|
||||
vehicleType: "Araç Tipi",
|
||||
};
|
||||
|
||||
// ── Component ────────────────────────────────────────
|
||||
|
||||
function EmexVehicleListPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogCode } = Route.useParams();
|
||||
@@ -65,6 +166,17 @@ function EmexVehicleListPage() {
|
||||
api.get<EmexVehicle[]>(`/catalog/emex/brands/${catalogCode}/vehicles`),
|
||||
});
|
||||
|
||||
const structured = useMemo(
|
||||
() => (vehicles ? hasStructuredOptions(vehicles) : false),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
// All possible filter definitions for this catalog
|
||||
const allFilterDefs = useMemo(
|
||||
() => (vehicles ? buildFilterDefs(vehicles, structured) : []),
|
||||
[vehicles, structured],
|
||||
);
|
||||
|
||||
// Distinct model names
|
||||
const modelNames = useMemo(
|
||||
() => (vehicles ? distinctValues(vehicles, (v) => v.name) : []),
|
||||
@@ -72,8 +184,8 @@ function EmexVehicleListPage() {
|
||||
);
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [selectedEngine, setSelectedEngine] = useState("");
|
||||
const [selectedTransmission, setSelectedTransmission] = useState("");
|
||||
// Dynamic filter selections: { filterKey: selectedValue }
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
|
||||
// Vehicles matching selected model
|
||||
const modelVehicles = useMemo(
|
||||
@@ -84,82 +196,63 @@ function EmexVehicleListPage() {
|
||||
[vehicles, selectedModel],
|
||||
);
|
||||
|
||||
// Determine filter options from model vehicles
|
||||
const structured = useMemo(
|
||||
() => (vehicles ? hasStructuredOptions(vehicles) : false),
|
||||
[vehicles],
|
||||
);
|
||||
// For each filter def, compute available options (cascading: each filter
|
||||
// narrows the pool for subsequent filters)
|
||||
const filterChain = useMemo(() => {
|
||||
const chain: {
|
||||
def: FilterDef;
|
||||
options: string[];
|
||||
}[] = [];
|
||||
|
||||
// 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;
|
||||
});
|
||||
let pool = modelVehicles;
|
||||
|
||||
for (const def of allFilterDefs) {
|
||||
const opts = distinctValues(pool, def.getter);
|
||||
// Only show filter if there are >1 options
|
||||
if (opts.length > 1) {
|
||||
chain.push({ def, options: opts });
|
||||
}
|
||||
// If user selected a value for this filter, narrow the pool
|
||||
const selected = filters[def.key];
|
||||
if (selected) {
|
||||
pool = pool.filter((v) => def.getter(v) === selected);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
return chain;
|
||||
}, [modelVehicles, allFilterDefs, filters]);
|
||||
|
||||
// Final filtered vehicles
|
||||
// Final filtered vehicles (apply all selected filters)
|
||||
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;
|
||||
});
|
||||
for (const def of allFilterDefs) {
|
||||
const selected = filters[def.key];
|
||||
if (selected) {
|
||||
list = list.filter((v) => def.getter(v) === selected);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [modelVehicles, selectedEngine, selectedTransmission, structured]);
|
||||
}, [modelVehicles, allFilterDefs, filters]);
|
||||
|
||||
// Reset downstream selections when upstream changes
|
||||
const handleModelChange = (name: string) => {
|
||||
const handleModelChange = useCallback((name: string) => {
|
||||
setSelectedModel(name);
|
||||
setSelectedEngine("");
|
||||
setSelectedTransmission("");
|
||||
};
|
||||
setFilters({});
|
||||
}, []);
|
||||
|
||||
const handleEngineChange = (eng: string) => {
|
||||
setSelectedEngine(eng);
|
||||
setSelectedTransmission("");
|
||||
};
|
||||
const handleFilterChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
// Clear this filter and all downstream filters
|
||||
const idx = allFilterDefs.findIndex((d) => d.key === key);
|
||||
const newFilters: Record<string, string> = {};
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const k = allFilterDefs[i].key;
|
||||
if (filters[k]) newFilters[k] = filters[k];
|
||||
}
|
||||
if (value) newFilters[key] = value;
|
||||
setFilters(newFilters);
|
||||
},
|
||||
[allFilterDefs, filters],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -194,66 +287,27 @@ function EmexVehicleListPage() {
|
||||
<>
|
||||
{/* 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>
|
||||
{/* Model — always visible */}
|
||||
<FilterSelect
|
||||
label={t("catalog.models")}
|
||||
placeholder={t("catalog.selectModel")}
|
||||
value={selectedModel}
|
||||
options={modelNames}
|
||||
onChange={handleModelChange}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Dynamic filters — only shown after model selection */}
|
||||
{selectedModel &&
|
||||
filterChain.map(({ def, options }) => (
|
||||
<FilterSelect
|
||||
key={def.key}
|
||||
label={FILTER_LABELS[def.label] || def.label}
|
||||
placeholder={t("catalog.fordVariant.showAll")}
|
||||
value={filters[def.key] || ""}
|
||||
options={options}
|
||||
onChange={(val) => handleFilterChange(def.key, val)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Vehicle list */}
|
||||
@@ -264,6 +318,9 @@ function EmexVehicleListPage() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filteredVehicles.length} varyant
|
||||
</p>
|
||||
{filteredVehicles.map((v) => (
|
||||
<VehicleRow
|
||||
key={v.id}
|
||||
@@ -285,6 +342,44 @@ function EmexVehicleListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reusable select ──────────────────────────────────
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(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="">{placeholder}</option>
|
||||
{options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vehicle row ──────────────────────────────────────
|
||||
|
||||
function VehicleRow({
|
||||
vehicle,
|
||||
catalogCode,
|
||||
@@ -299,16 +394,28 @@ function VehicleRow({
|
||||
// Build descriptive subtitle
|
||||
const parts: string[] = [];
|
||||
if (opts) {
|
||||
if (opts["Engine type and suffix"]) parts.push(opts["Engine type and suffix"]);
|
||||
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.bodyType) parts.push(vehicle.bodyType);
|
||||
if (vehicle.transmission) parts.push(vehicle.transmission);
|
||||
if (vehicle.driveType) parts.push(vehicle.driveType);
|
||||
if (vehicle.fuelType) parts.push(vehicle.fuelType);
|
||||
if (vehicle.optionsRaw) parts.push(vehicle.optionsRaw);
|
||||
}
|
||||
|
||||
// Deduplicate (optionsRaw may repeat other fields)
|
||||
const seen = new Set<string>();
|
||||
const unique = parts.filter((p) => {
|
||||
if (seen.has(p)) return false;
|
||||
seen.add(p);
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/emex/$catalogCode/$vehicleId"
|
||||
@@ -317,10 +424,8 @@ function VehicleRow({
|
||||
>
|
||||
<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>
|
||||
{unique.length > 0 ? (
|
||||
<p className="truncate text-sm">{unique.join(" · ")}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{vehicle.vehicleId}</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user