feat: add service test page for individual VIN decode testing
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

Admin-only page at /dashboard/service-test with dropdown to test
each VIN decode service (Corgi, PartsCatalogs, PL24, EMEX, VIN API)
individually or the full cascade. Results shown as JSON on page,
no DB writes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:12:50 +00:00
parent 4e838fdace
commit 03353a703a
4 changed files with 384 additions and 0 deletions

View File

@@ -27,6 +27,7 @@ import {
Moon,
Copy,
Library,
FlaskConical,
} from "lucide-react";
import { useState } from "react";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
@@ -63,6 +64,7 @@ const adminItems = [
{ to: "/dashboard/admin/analytics", label: "Analitik", icon: BarChart3 },
{ to: "/dashboard/admin/copy-logs", label: "OEM Kopyalama", icon: Copy },
{ to: "/dashboard/admin/referrals", label: "Referanslar", icon: Share2 },
{ to: "/dashboard/service-test", label: "Servis Test", icon: FlaskConical },
] as const;
// ─── HELPERS ──────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,302 @@
import { useState, useEffect, useRef } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Button, Input, Badge, Separator } from "@sase/ui";
import {
FlaskConical,
Search,
Loader2,
AlertCircle,
Copy,
Check,
} from "lucide-react";
import { api, ApiError } from "@/lib/api-client";
import { toast } from "@/lib/toast";
// ─── HELPERS ──────────────────────────────────────────────────────────────────
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
function isValidVin(vin: string): boolean {
if (!vin || vin.length !== 17) return false;
return VIN_REGEX.test(vin.toUpperCase());
}
function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
const corrections: string[] = [];
const cleaned = raw.replace(/[IOQioq]/g, (ch) => {
const upper = ch.toUpperCase();
if (upper === "I") { corrections.push("I→1"); return "1"; }
if (upper === "O") { corrections.push("O→0"); return "0"; }
corrections.push("Q→9"); return "9";
});
return { cleaned, corrections };
}
const SERVICE_OPTIONS = [
{ value: "all", label: "Normal Akış (Cascade)" },
{ value: "corgi", label: "Corgi (Offline WMI)" },
{ value: "parts-catalogs", label: "PartsCatalogs" },
{ value: "pl24", label: "PL24 (PartsLink24)" },
{ value: "emex", label: "EMEX" },
{ value: "vin-api", label: "VIN API (NHTSA)" },
] as const;
// ─── ROUTE ────────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/dashboard/service-test")({
component: ServiceTestPage,
});
function ServiceTestPage() {
const inputRef = useRef<HTMLInputElement>(null);
const [vin, setVin] = useState("");
const [service, setService] = useState("all");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{
service: string;
success: boolean;
responseTimeMs: number;
data: any;
error?: string;
} | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
inputRef.current?.focus();
}, []);
function handleVinChange(raw: string) {
const upper = raw.toUpperCase();
const { cleaned, corrections } = sanitizeVin(upper);
setVin(cleaned);
setError(null);
if (corrections.length > 0) {
const unique = [...new Set(corrections)];
toast.info(`Otomatik düzeltildi: ${unique.join(", ")}`, {
description: "Şase numarasında I, O, Q harfleri kullanılamaz",
duration: 2500,
});
}
}
async function handleTest(e: React.FormEvent) {
e.preventDefault();
setError(null);
setResult(null);
setCopied(false);
const cleanVin = vin.toUpperCase().trim();
if (!isValidVin(cleanVin)) {
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
setLoading(true);
try {
const data = await api.post<any>("/vehicles/service-test", {
vin: cleanVin,
service: service === "all" ? undefined : service,
});
setResult(data);
} catch (err) {
const message = err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
setError(message);
toast.error("Servis testi başarısız");
} finally {
setLoading(false);
}
}
async function handleCopyJson() {
if (!result) return;
try {
await navigator.clipboard.writeText(JSON.stringify(result, null, 2));
setCopied(true);
toast.success("JSON kopyalandı");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Kopyalama başarısız");
}
}
function fillExampleVin() {
setVin("WVWZZZ1JZ3W597935");
inputRef.current?.focus();
}
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* ─── SECTION 1: Hero Input Card ─────────────────────────────────── */}
<div className="rounded-2xl border border-border bg-background p-6 sm:p-8">
{/* Header */}
<div className="flex flex-col items-center text-center">
<div className="inline-flex size-14 items-center justify-center rounded-2xl bg-muted">
<FlaskConical className="size-6 text-muted-foreground" />
</div>
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
Servis Test
</h2>
<p className="mt-1 text-sm text-muted-foreground">
VIN decode servislerini tek tek test edin
</p>
</div>
<Separator className="my-6 bg-border" />
{/* Form */}
<form onSubmit={handleTest} className="space-y-4">
{/* Service Selector */}
<div>
<label
htmlFor="service-select"
className="mb-1.5 block text-sm font-medium text-foreground"
>
Servis
</label>
<select
id="service-select"
value={service}
onChange={(e) => setService(e.target.value)}
className="h-12 w-full rounded-xl border border-border bg-muted/50 px-4 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring"
>
{SERVICE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* VIN Input */}
<div className="relative">
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Şase numarasını girin (17 karakter)"
value={vin}
onChange={(e) => handleVinChange(e.target.value)}
maxLength={17}
className={`h-14 rounded-xl bg-muted/50 pl-12 font-mono tracking-wider ${vin.length === 0 ? "pr-24" : "pr-4"}`}
/>
{vin.length === 0 && (
<div className="pointer-events-none absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-1 text-xs text-muted-foreground">
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-[10px]">
Ctrl
</kbd>
<span>+</span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-[10px]">
K
</kbd>
</div>
)}
</div>
{/* 17-segment progress bar */}
<div className="flex gap-0.5">
{Array.from({ length: 17 }).map((_, i) => (
<div
key={i}
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-muted"
}`}
/>
))}
</div>
{/* Counter + Example VIN */}
<div className="flex items-center justify-between text-sm">
<span className="tabular-nums text-muted-foreground">
{vin.length}/17 karakter
</span>
<button
type="button"
onClick={fillExampleVin}
className="text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
Örnek şase deneyin &rarr;
</button>
</div>
{/* Submit button */}
<Button
type="submit"
disabled={loading || vin.length !== 17}
className="h-12 w-full rounded-xl"
>
{loading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<FlaskConical className="mr-2 size-4" />
)}
Test Et
</Button>
{/* Error card */}
{error && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
</form>
</div>
{/* ─── SECTION 2: Result Card ──────────────────────────────────────── */}
{result && (
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
{/* Meta badges */}
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="default"
className="bg-blue-600 text-xs text-white"
>
{result.service}
</Badge>
<Badge variant="secondary" className="text-xs">
{result.responseTimeMs.toLocaleString("tr-TR")}ms
</Badge>
<Badge
variant={result.success ? "default" : "destructive"}
className={`text-xs ${result.success ? "bg-emerald-600 text-white" : ""}`}
>
{result.success ? "Başarılı" : "Başarısız"}
</Badge>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={handleCopyJson}
className="h-8 gap-1.5 rounded-lg text-xs"
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "Kopyalandı" : "JSON Kopyala"}
</Button>
</div>
{/* Error message */}
{result.error && (
<div className="mt-4 flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-sm text-destructive">{result.error}</p>
</div>
)}
{/* JSON output */}
{result.data !== null && (
<>
<Separator className="my-4 bg-border" />
<pre className="max-h-[600px] overflow-auto rounded-xl border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed">
{JSON.stringify(result.data, null, 2)}
</pre>
</>
)}
</div>
)}
</div>
);
}