← back to Omega Watches 2
src/app/(dashboard)/models/page.tsx
347 lines
"use client";
import { useEffect, useState, useMemo } from "react";
import { api } from "@/lib/api-client";
import { formatCurrency, formatDate } from "@/lib/utils";
import dynamic from "next/dynamic";
const PriceTimeSeries = dynamic(() => import("@/components/charts/PriceTimeSeries"), { ssr: false });
const ConditionBreakdown = dynamic(() => import("@/components/charts/ConditionBreakdown"), { ssr: false });
interface WatchRef {
id: string;
brand: string;
collection: string;
model_name: string;
reference_number: string;
calibre: string;
case_material: string;
case_diameter_mm: string;
dial_color: string;
year_introduced: number;
notes: string;
event_count: string;
msrp_count: string;
latest_msrp: string | null;
avg_market_price: string | null;
latest_sale_price: string | null;
latest_sale_date: string | null;
}
interface Summary {
total_references: string;
total_events: string;
total_snapshots: string;
collection_count: string;
}
export default function ModelDashboardPage() {
const [references, setReferences] = useState<WatchRef[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [collection, setCollection] = useState("");
const [selected, setSelected] = useState<WatchRef | null>(null);
const [detail, setDetail] = useState<any>(null);
useEffect(() => {
load();
}, [collection]);
async function load() {
try {
const params: any = {};
if (collection) params.collection = collection;
const token = localStorage.getItem("omega_token");
const qs = new URLSearchParams(params).toString();
const res = await fetch(`/api/references${qs ? `?${qs}` : ""}`, {
headers: { Authorization: `Bearer ${token}` },
});
const json = await res.json();
if (json.success) {
setReferences(json.data);
setSummary(json.summary);
}
} catch (e: any) {
setError(e.message);
}
}
async function loadDetail(ref: WatchRef) {
setSelected(ref);
try {
const [msrp, ts] = await Promise.all([
api.getMSRP(ref.reference_number),
api.getTimeseries(ref.reference_number, { include_asks: "true" }),
]);
setDetail({ msrp, timeseries: ts });
} catch {
setDetail(null);
}
}
// Build chart data from detail
const chartData = useMemo(() => {
if (!detail) return [];
const points: any[] = [];
// Add MSRP points
(detail.timeseries?.msrp_history || []).forEach((m: any) => {
points.push({ date: m.date, msrp: parseFloat(m.value), source: "MSRP" });
});
// Add market event points
(detail.timeseries?.market_events || []).forEach((e: any) => {
points.push({
date: e.date,
price: parseFloat(e.value),
event_type: e.event_type,
source: e.source,
});
});
// Sort by date and merge same-date MSRP+price
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const merged: any[] = [];
let lastMsrp: number | undefined;
for (const p of points) {
if (p.msrp) lastMsrp = p.msrp;
merged.push({ ...p, msrp: p.msrp || lastMsrp });
}
return merged;
}, [detail]);
// Build condition breakdown from events
const conditionData = useMemo(() => {
if (!detail?.timeseries?.market_events) return [];
const counts: Record<string, { count: number; total: number }> = {};
for (const e of detail.timeseries.market_events) {
const cond = e.condition || "unknown";
if (!counts[cond]) counts[cond] = { count: 0, total: 0 };
counts[cond].count++;
counts[cond].total += parseFloat(e.value) || 0;
}
return Object.entries(counts).map(([condition, { count, total }]) => ({
condition,
count,
avg_price: Math.round(total / count),
}));
}, [detail]);
const collections = [...new Set(references.map((r) => r.collection))];
const filtered = search
? references.filter(
(r) =>
r.reference_number.toLowerCase().includes(search.toLowerCase()) ||
r.model_name.toLowerCase().includes(search.toLowerCase()) ||
r.collection.toLowerCase().includes(search.toLowerCase())
)
: references;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-100">Watch References</h1>
<div className="flex gap-2">
<select
className="input w-40"
value={collection}
onChange={(e) => setCollection(e.target.value)}
>
<option value="">All Collections</option>
{collections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<input
type="text"
placeholder="Search references..."
className="input w-64"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
{summary && (
<div className="grid gap-4 md:grid-cols-4">
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-omega-400">{summary.total_references}</div>
<div className="text-sm text-gray-500">Watch References</div>
</div>
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-blue-400">{summary.total_events}</div>
<div className="text-sm text-gray-500">Market Events</div>
</div>
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-green-400">{summary.total_snapshots}</div>
<div className="text-sm text-gray-500">MSRP Snapshots</div>
</div>
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-purple-400">{summary.collection_count}</div>
<div className="text-sm text-gray-500">Collections</div>
</div>
</div>
)}
<div className="card border-navy-700">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
Reference Catalog ({filtered.length})
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
<th className="pb-2 pr-3">Reference</th>
<th className="pb-2 pr-3">Collection</th>
<th className="pb-2 pr-3">Model</th>
<th className="pb-2 pr-3">Calibre</th>
<th className="pb-2 pr-3">Size</th>
<th className="pb-2 pr-3">Dial</th>
<th className="pb-2 pr-3 text-right">MSRP</th>
<th className="pb-2 pr-3 text-right">Avg Market</th>
<th className="pb-2 pr-3 text-right">Events</th>
<th className="pb-2 text-right">Year</th>
</tr>
</thead>
<tbody>
{filtered.map((ref) => {
const msrp = ref.latest_msrp ? parseFloat(ref.latest_msrp) : null;
const avg = ref.avg_market_price ? parseFloat(ref.avg_market_price) : null;
const premium = msrp && avg ? ((avg - msrp) / msrp) * 100 : null;
return (
<tr
key={ref.id}
className={`cursor-pointer border-b border-navy-800 transition-colors hover:bg-navy-900 ${
selected?.id === ref.id ? "bg-navy-900" : ""
}`}
onClick={() => loadDetail(ref)}
>
<td className="py-2.5 pr-3 font-mono text-xs text-omega-400">
{ref.reference_number}
</td>
<td className="py-2.5 pr-3">
<span className="badge badge-info">{ref.collection}</span>
</td>
<td className="py-2.5 pr-3 font-medium text-gray-200">{ref.model_name}</td>
<td className="py-2.5 pr-3 text-gray-400">{ref.calibre}</td>
<td className="py-2.5 pr-3 text-gray-400">{ref.case_diameter_mm}mm</td>
<td className="py-2.5 pr-3 text-gray-400">{ref.dial_color}</td>
<td className="py-2.5 pr-3 text-right text-gray-200">
{msrp ? formatCurrency(msrp) : "—"}
</td>
<td className="py-2.5 pr-3 text-right">
{avg ? (
<span className={premium && premium > 0 ? "text-green-400" : premium && premium < -5 ? "text-red-400" : "text-gray-200"}>
{formatCurrency(avg)}
{premium !== null && (
<span className="ml-1 text-xs">
({premium > 0 ? "+" : ""}{premium.toFixed(0)}%)
</span>
)}
</span>
) : "—"}
</td>
<td className="py-2.5 pr-3 text-right text-gray-400">{ref.event_count}</td>
<td className="py-2.5 text-right text-gray-500">{ref.year_introduced}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Detail panel with charts */}
{selected && detail && (
<>
{/* Price Chart */}
{chartData.length > 0 && (
<PriceTimeSeries
data={chartData}
title={`${selected.model_name} — Price History`}
showMSRP={true}
/>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* Condition breakdown donut */}
{conditionData.length > 0 && (
<ConditionBreakdown
data={conditionData}
title="Condition Distribution"
/>
)}
{/* Market Events list */}
<div className="card border-navy-700">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
Recent Market Events ({detail.timeseries?.market_events?.length || 0})
</h3>
{detail.timeseries?.market_events?.length > 0 ? (
<div className="space-y-2 max-h-80 overflow-y-auto">
{detail.timeseries.market_events.map((ev: any, i: number) => (
<div key={i} className="flex items-center justify-between rounded bg-navy-900 p-2">
<div>
<span className={`badge text-xs ${
ev.event_type === "auction_realized" ? "badge-success" :
ev.event_type === "marketplace_sold" ? "badge-info" :
ev.event_type === "dealer_ask" ? "badge-warning" : "badge-info"
}`}>
{ev.event_type.replace(/_/g, " ")}
</span>
<span className="ml-2 text-xs text-gray-500">{ev.source}</span>
</div>
<div className="text-right">
<div className="font-mono text-gray-200">
{ev.value ? formatCurrency(parseFloat(ev.value)) : "—"}
</div>
<div className="text-xs text-gray-500">
{ev.date ? formatDate(ev.date) : ""}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-sm text-gray-600">No market events recorded</div>
)}
</div>
</div>
{/* MSRP detail */}
<div className="card border-navy-700">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
MSRP Snapshots — {selected.reference_number}
</h3>
{detail.msrp?.history?.length > 0 ? (
<div className="grid gap-2 md:grid-cols-5">
{detail.msrp.history.map((h: any, i: number) => {
const prev = i > 0 ? parseFloat(detail.msrp.history[i - 1].msrp_amount) : null;
const curr = parseFloat(h.msrp_amount);
const change = prev ? curr - prev : 0;
return (
<div key={i} className="rounded bg-navy-900 p-3 text-center">
<div className="text-xs text-gray-500">{formatDate(h.captured_date)}</div>
<div className="mt-1 text-lg font-bold text-gray-200">{formatCurrency(curr)}</div>
{change !== 0 && (
<div className={`text-xs ${change > 0 ? "text-green-400" : "text-red-400"}`}>
{change > 0 ? "+" : ""}{formatCurrency(change)}
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center text-sm text-gray-600">
{selected.year_introduced < 2000 ? "Vintage/discontinued — no current MSRP" : "No MSRP snapshots yet"}
</div>
)}
</div>
</>
)}
</div>
);
}