← back to Omega Watches 2
src/app/(dashboard)/condition-mappings/page.tsx
248 lines
"use client";
import { useState, useEffect } from "react";
import { formatDateTime } from "@/lib/utils";
interface ConditionMapping {
id: number;
source_name: string;
source_category: string;
normalized_grade: string;
score_0_100: number;
version: number;
created_at: string;
}
const GRADES = [
"new_unworn", "excellent", "very_good", "good", "fair", "poor", "parts_only", "unknown",
];
const gradeColor: Record<string, string> = {
new_unworn: "bg-green-900/30 text-green-300",
excellent: "bg-blue-900/30 text-blue-300",
very_good: "bg-cyan-900/30 text-cyan-300",
good: "bg-yellow-900/30 text-yellow-300",
fair: "bg-orange-900/30 text-orange-300",
poor: "bg-red-900/30 text-red-300",
parts_only: "bg-gray-800 text-gray-400",
unknown: "bg-navy-800 text-gray-500",
};
export default function ConditionMappingsPage() {
const [mappings, setMappings] = useState<ConditionMapping[]>([]);
const [bySource, setBySource] = useState<Record<string, ConditionMapping[]>>({});
const [stats, setStats] = useState({ total_mappings: 0, sources: 0, grades: [] as string[] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
const [successMsg, setSuccessMsg] = useState("");
const [sourceName, setSourceName] = useState("");
const [sourceCategory, setSourceCategory] = useState("");
const [grade, setGrade] = useState("unknown");
const [score, setScore] = useState(50);
function getHeaders() {
const token = typeof window !== "undefined" ? localStorage.getItem("omega_token") : null;
const h: Record<string, string> = { "Content-Type": "application/json" };
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
async function loadMappings() {
try {
const res = await fetch("/api/condition-mappings", { headers: getHeaders() });
const json = await res.json();
if (json.success) {
setMappings(json.data.mappings);
setBySource(json.data.by_source);
setStats(json.data.stats);
if (expanded.size === 0) {
setExpanded(new Set(Object.keys(json.data.by_source)));
}
} else {
setError(json.error?.message || "Failed to load");
}
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}
useEffect(() => { loadMappings(); }, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!sourceName || !sourceCategory) return;
setSubmitting(true);
try {
const res = await fetch("/api/condition-mappings", {
method: "POST",
headers: getHeaders(),
body: JSON.stringify({
source_name: sourceName.trim().toLowerCase(),
source_category: sourceCategory.trim(),
normalized_grade: grade,
score_0_100: score,
}),
});
const json = await res.json();
if (json.success) {
setSourceName("");
setSourceCategory("");
setGrade("unknown");
setScore(50);
setSuccessMsg("Mapping saved");
setTimeout(() => setSuccessMsg(""), 3000);
loadMappings();
} else {
setError(json.error?.message || "Failed to save");
}
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
}
function toggleSource(src: string) {
const next = new Set(expanded);
next.has(src) ? next.delete(src) : next.add(src);
setExpanded(next);
}
if (loading) {
return <div className="flex h-full items-center justify-center"><div className="text-gray-400">Loading mappings...</div></div>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-100">Condition Mappings</h1>
<div className="flex gap-2 text-xs">
<button onClick={() => setExpanded(new Set(Object.keys(bySource)))}
className="text-omega-400 hover:text-omega-300">Expand All</button>
<span className="text-gray-600">|</span>
<button onClick={() => setExpanded(new Set())}
className="text-omega-400 hover:text-omega-300">Collapse All</button>
</div>
</div>
{error && (
<div className="rounded bg-red-900/30 p-3 text-red-300">
{error}
<button onClick={() => setError("")} className="ml-2 text-xs underline">Dismiss</button>
</div>
)}
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-omega-400">{stats.total_mappings}</div>
<div className="text-xs text-gray-500">Total Mappings</div>
</div>
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-omega-400">{stats.sources}</div>
<div className="text-xs text-gray-500">Sources</div>
</div>
<div className="card border-navy-700">
<div className="text-xs text-gray-500 mb-2">Grades Used</div>
<div className="flex flex-wrap gap-1">
{stats.grades.map((g) => (
<span key={g} className={`rounded px-2 py-0.5 text-xs ${gradeColor[g] || "bg-navy-800 text-gray-500"}`}>
{g}
</span>
))}
</div>
</div>
</div>
{/* Add Form */}
<div className="card border-navy-700">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">Add / Update Mapping</h2>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-400">Source Name</label>
<input type="text" className="input" placeholder="e.g., chrono24"
value={sourceName} onChange={(e) => setSourceName(e.target.value)} required />
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Source Category</label>
<input type="text" className="input" placeholder="e.g., Very good"
value={sourceCategory} onChange={(e) => setSourceCategory(e.target.value)} required />
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Normalized Grade</label>
<select className="input" value={grade} onChange={(e) => setGrade(e.target.value)}>
{GRADES.map((g) => <option key={g} value={g}>{g}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Score (0-100)</label>
<input type="number" min={0} max={100} className="input"
value={score} onChange={(e) => setScore(parseInt(e.target.value) || 0)} required />
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<button type="submit" disabled={submitting} className="btn-primary disabled:opacity-50">
{submitting ? "Saving..." : "Save Mapping"}
</button>
{successMsg && <span className="text-sm text-green-400">{successMsg}</span>}
</div>
</form>
</div>
{/* Grouped Mappings */}
<div className="space-y-3">
{Object.entries(bySource).sort(([a], [b]) => a.localeCompare(b)).map(([src, items]) => (
<div key={src} className="card border-navy-700 overflow-hidden p-0">
<button onClick={() => toggleSource(src)}
className="flex w-full items-center justify-between px-4 py-3 hover:bg-navy-800 transition-colors">
<div className="flex items-center gap-3">
<span className={`text-gray-400 transition-transform ${expanded.has(src) ? "rotate-90" : ""}`}>
▶
</span>
<span className="text-sm font-semibold text-gray-100">{src}</span>
<span className="text-xs text-gray-500">({items.length} mappings)</span>
</div>
</button>
{expanded.has(src) && (
<div className="border-t border-navy-700">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
<th className="px-4 py-2">Source Category</th>
<th className="px-4 py-2">Normalized Grade</th>
<th className="px-4 py-2">Score</th>
<th className="px-4 py-2">Version</th>
<th className="px-4 py-2">Created</th>
</tr>
</thead>
<tbody>
{(items as ConditionMapping[]).map((m) => (
<tr key={m.id} className="border-b border-navy-800 hover:bg-navy-800/50">
<td className="px-4 py-2 text-gray-200">{m.source_category}</td>
<td className="px-4 py-2">
<span className={`rounded px-2 py-0.5 text-xs ${gradeColor[m.normalized_grade] || "bg-navy-800 text-gray-500"}`}>
{m.normalized_grade}
</span>
</td>
<td className="px-4 py-2 text-gray-300">{m.score_0_100}</td>
<td className="px-4 py-2 text-gray-400">v{m.version}</td>
<td className="px-4 py-2 text-gray-500 text-xs">{formatDateTime(m.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
</div>
);
}