feat: add service test page for individual VIN decode testing
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
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:
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
302
apps/web/src/routes/dashboard/service-test.tsx
Normal file
302
apps/web/src/routes/dashboard/service-test.tsx
Normal 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 →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user