refactor(web): type the vehicle detail page — drop the any-soup

Replace `vehicle: any` in the route + every helper (getVehicleAttributes,
fromPcat, fromEmex, fromVinApi, getFallbackAttributes, getDisplayAttributes,
getEngineCode, getEquipment, VehicleSummary, VehicleAttributes,
VehicleEquipment) with the shared Vehicle type, and type the two queries
as Vehicle / CategoryNode[]. The opaque rawData blob now goes through a
small RawData interface that captures the four source-specific shapes
(pl24, parts-catalogs, emex, vin-api) instead of `any` index access, so
the per-source helpers stay readable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:35:37 +03:00
parent 74f32b82e5
commit 73fe3e33c1

View File

@@ -9,6 +9,7 @@ import { ApiError, api } from "@/lib/api-client";
import { setChatwootVehicle } from "@/lib/chatwoot";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { cleanModelName } from "@/lib/vehicle";
import type { CategoryNode, Vehicle } from "@sase/shared";
import {
Accordion,
AccordionContent,
@@ -65,7 +66,7 @@ function VehicleDetailPage() {
isFetching: vehicleFetching,
} = useQuery({
queryKey: ["vehicle", id],
queryFn: () => api.get<any>(`/vehicles/${id}`),
queryFn: () => api.get<Vehicle>(`/vehicles/${id}`),
enabled: !!id,
// A missing vehicle (404) is final — don't burn retries before showing the
// not-found state. Transient errors still retry.
@@ -74,12 +75,12 @@ function VehicleDetailPage() {
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
queryKey: ["category-tree", id],
queryFn: () => api.get<any[]>(`/categories/tree/${id}`),
queryFn: () => api.get<CategoryNode[]>(`/categories/tree/${id}`),
enabled: !!id,
});
const vehicleLabel = vehicle?.brandName
? `${vehicle.brandName}${cleanModelName(vehicle?.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
const vehicleLabel: string = vehicle?.brandName
? `${vehicle.brandName}${cleanModelName(vehicle.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
: "Araç";
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
@@ -181,6 +182,10 @@ function VehicleDetailPage() {
);
}
// After the loading and error guards above, the query has resolved; this
// catches the edge case where the request succeeded with no payload.
if (!vehicle) return null;
return (
<div className="space-y-6">
<CategoryBreadcrumb vehicleId={id} vehicleLabel={vehicleLabel} />
@@ -275,6 +280,45 @@ function VehicleDetailPage() {
);
}
// Source-specific shape of vehicle.rawData. The DB stores it as
// Record<string, unknown>; the per-source helpers below cast to this interface
// rather than reaching through `any`. Each field is optional because only one
// source populates any given branch.
interface PcatParam {
key?: string;
name?: string;
value?: unknown;
sortOrder?: number;
}
interface VinfoRecord {
values?: { description?: string; value?: string };
}
interface PrNrRecord {
values?: { col1?: string; col2?: string; col3?: string };
}
interface RawData {
// pl24
engineCode?: string;
driveType?: string;
raw?: {
segments?: {
vinfoBasic?: { records?: VinfoRecord[] };
prNr?: { records?: PrNrRecord[] };
};
};
// parts-catalogs
pcatCar?: { parameters?: PcatParam[] };
// emex
parsedOptions?: Record<string, string | undefined>;
// vin-api (flat fields)
engineModel?: string;
bodyClass?: string;
transmissionStyle?: string;
plantCountry?: string;
// misc — keep open for shape-specific lookups via index access
[k: string]: unknown;
}
/** Known PL24 raw labels (missing Turkish chars) → clean Turkish. Unknown
* labels pass through unchanged. */
const LABEL_OVERRIDES: Record<string, string> = {
@@ -289,25 +333,26 @@ const LABEL_OVERRIDES: Record<string, string> = {
const HEADER_LABELS = new Set(["model", "model yili", "model yılı"]);
/** Extract vinfoBasic records from rawData, dropping VIN and header dupes. */
function getVehicleAttributes(vehicle: any): Array<{ label: string; value: string }> {
const records = vehicle?.rawData?.raw?.segments?.vinfoBasic?.records;
function getVehicleAttributes(vehicle: Vehicle): Array<{ label: string; value: string }> {
const rd = vehicle.rawData as RawData | null;
const records = rd?.raw?.segments?.vinfoBasic?.records;
if (!Array.isArray(records)) return [];
const vinLower = (vehicle?.vin || "").toLowerCase();
const vinLower = (vehicle.vin || "").toLowerCase();
return records
.map((r: any) => ({
.map((r) => ({
label: (r.values?.description || "").trim(),
value: (r.values?.value || "").replace(/\r?\n/g, " ").trim(),
}))
.filter(
(attr: { label: string; value: string }) =>
(attr) =>
attr.label &&
attr.value &&
attr.value.toLowerCase() !== vinLower &&
!HEADER_LABELS.has(attr.label.toLowerCase()),
)
.map((attr: { label: string; value: string }) => ({
.map((attr) => ({
label: LABEL_OVERRIDES[attr.label] ?? attr.label,
value: attr.value,
}));
@@ -349,16 +394,17 @@ const PCAT_LABELS: Record<string, string> = {
"Car name": "Model",
};
function fromPcat(vehicle: any): Attr[] {
const params = vehicle?.rawData?.pcatCar?.parameters;
function fromPcat(vehicle: Vehicle): Attr[] {
const rd = vehicle.rawData as RawData | null;
const params = rd?.pcatCar?.parameters;
if (!Array.isArray(params)) return [];
return [...params]
.sort((a: any, b: any) => (a?.sortOrder ?? 999) - (b?.sortOrder ?? 999))
.map((p: any) => ({
label: PCAT_LABELS[p?.name] ?? String(p?.name ?? "").trim(),
.sort((a, b) => (a?.sortOrder ?? 999) - (b?.sortOrder ?? 999))
.map((p) => ({
label: (p?.name && PCAT_LABELS[p.name]) ?? String(p?.name ?? "").trim(),
value: String(p?.value ?? "").trim(),
}))
.filter((a: Attr) => Boolean(a.label && a.value));
.filter((a) => Boolean(a.label && a.value));
}
/** emex parsedOptions — bare codes; map the snake_case keys to Turkish. */
@@ -368,8 +414,9 @@ const EMEX_LABELS: Record<string, string> = {
vehicle_type: "Araç tipi",
};
function fromEmex(vehicle: any): Attr[] {
const opts = vehicle?.rawData?.parsedOptions;
function fromEmex(vehicle: Vehicle): Attr[] {
const rd = vehicle.rawData as RawData | null;
const opts = rd?.parsedOptions;
if (!opts || typeof opts !== "object") return [];
return Object.entries(EMEX_LABELS)
.map(([key, label]) => ({ label, value: String(opts[key] ?? "").trim() }))
@@ -384,8 +431,8 @@ const VINAPI_FIELDS: Array<[string, string]> = [
["plantCountry", "Üretim ülkesi"],
];
function fromVinApi(vehicle: any): Attr[] {
const rd = vehicle?.rawData;
function fromVinApi(vehicle: Vehicle): Attr[] {
const rd = vehicle.rawData as RawData | null;
if (!rd) return [];
return VINAPI_FIELDS.map(([key, label]) => ({
label,
@@ -394,49 +441,52 @@ function fromVinApi(vehicle: any): Attr[] {
}
/** Plain DB columns — last resort when no source payload is recognised. */
function getFallbackAttributes(vehicle: any): Attr[] {
function getFallbackAttributes(vehicle: Vehicle): Attr[] {
return [
{ label: "Marka", value: vehicle?.brandName },
{ label: "Model", value: vehicle?.model },
{ label: "Yıl", value: vehicle?.year },
{ label: "Motor", value: vehicle?.engine },
{ label: "Vites", value: vehicle?.transmission },
{ label: "Kasa", value: vehicle?.bodyType },
{ label: "Marka", value: vehicle.brandName },
{ label: "Model", value: vehicle.model },
{ label: "Yıl", value: vehicle.year },
{ label: "Motor", value: vehicle.engine },
{ label: "Vites", value: vehicle.transmission },
{ label: "Kasa", value: vehicle.bodyType },
].filter(isAttr);
}
/** Resolve the attribute list for the vehicle's decode source. Each catalog
* ships its detail in a different shape; surface the richest one available. */
function getDisplayAttributes(vehicle: any): Attr[] {
function getDisplayAttributes(vehicle: Vehicle): Attr[] {
const rd = vehicle.rawData as RawData | null;
const source: string = vehicle.source;
// PL24 — readable extras lead, then the decoded VIN codes.
const vinfo = getVehicleAttributes(vehicle);
if (vinfo.length > 0) {
const extras = [
{ label: "Motor", value: vehicle?.engine },
{ label: "Çekiş", value: vehicle?.rawData?.driveType },
{ label: "Kasa", value: vehicle?.bodyType },
{ label: "Motor", value: vehicle.engine },
{ label: "Çekiş", value: rd?.driveType },
{ label: "Kasa", value: vehicle.bodyType },
].filter(isAttr);
return [...extras, ...vinfo];
}
// parts-catalogs — structured parameter list.
if (vehicle?.source === "parts-catalogs") {
if (source === "parts-catalogs") {
const a = dropHeaderDupes(fromPcat(vehicle));
if (a.length > 0) return a;
}
// emex — lead with any readable DB columns, then the option codes.
if (vehicle?.source === "emex") {
if (source === "emex") {
const dbExtras = [
{ label: "Vites", value: vehicle?.transmission },
{ label: "Kasa", value: vehicle?.bodyType },
{ label: "Vites", value: vehicle.transmission },
{ label: "Kasa", value: vehicle.bodyType },
].filter(isAttr);
const a = dropHeaderDupes([...dbExtras, ...fromEmex(vehicle)]);
if (a.length > 0) return a;
}
// vin-api — flat readable fields.
if (vehicle?.source === "vin-api") {
if (source === "vin-api") {
const a = dropHeaderDupes(fromVinApi(vehicle));
if (a.length > 0) return a;
}
@@ -444,7 +494,7 @@ function getDisplayAttributes(vehicle: any): Attr[] {
return dropHeaderDupes(getFallbackAttributes(vehicle));
}
function VehicleAttributes({ vehicle }: { vehicle: any }) {
function VehicleAttributes({ vehicle }: { vehicle: Vehicle }) {
const attrs = getDisplayAttributes(vehicle);
if (attrs.length === 0) {
@@ -474,23 +524,21 @@ function VehicleAttributes({ vehicle }: { vehicle: any }) {
}
/** The engine code lives in a different spot per decode source. */
function getEngineCode(vehicle: any): string | undefined {
const rd = vehicle?.rawData;
function getEngineCode(vehicle: Vehicle): string | undefined {
const rd = vehicle.rawData as RawData | null;
if (rd?.engineCode) return rd.engineCode; // pl24
if (rd?.parsedOptions?.engine_type) return rd.parsedOptions.engine_type; // emex
if (rd?.engineModel) return rd.engineModel; // vin-api
// pcat — the "engine" parameter.
const param = rd?.pcatCar?.parameters?.find(
(p: any) => p?.key === "engine" || p?.name === "Engine",
);
const param = rd?.pcatCar?.parameters?.find((p) => p?.key === "engine" || p?.name === "Engine");
return param?.value ? String(param.value) : undefined;
}
/** Compact at-a-glance fields shown on the collapsed Araç Bilgileri header. */
function VehicleSummary({ vehicle }: { vehicle: any }) {
function VehicleSummary({ vehicle }: { vehicle: Vehicle }) {
const fields = [
{ label: "Model", value: cleanModelName(vehicle?.model) },
{ label: "Model yılı", value: vehicle?.year },
{ label: "Model", value: cleanModelName(vehicle.model) },
{ label: "Model yılı", value: vehicle.year },
{ label: "Motor kodu", value: getEngineCode(vehicle) },
].filter((f) => f.value != null && f.value !== "");
@@ -521,21 +569,22 @@ interface Equipment {
/** Decode the prNr (PR-code / equipment) segment — PL24 already ships a
* readable description per code, so this is the equipment "dictionary". */
function getEquipment(vehicle: any): Equipment[] {
const records = vehicle?.rawData?.raw?.segments?.prNr?.records;
function getEquipment(vehicle: Vehicle): Equipment[] {
const rd = vehicle.rawData as RawData | null;
const records = rd?.raw?.segments?.prNr?.records;
if (!Array.isArray(records)) return [];
return records
.map((r: any) => ({
.map((r) => ({
code: (r.values?.col2 || "").trim(),
group: cleanText(r.values?.col1 || ""),
desc: cleanText(r.values?.col3 || ""),
}))
.filter((e: Equipment) => e.code || e.desc);
.filter((e) => e.code || e.desc);
}
/** Full decoded equipment / PR-code list, shown inside the expanded card. */
const EQUIPMENT_PREVIEW = 8;
function VehicleEquipment({ vehicle }: { vehicle: any }) {
function VehicleEquipment({ vehicle }: { vehicle: Vehicle }) {
const items = getEquipment(vehicle);
const [expanded, setExpanded] = useState(false);
if (items.length === 0) return null;