← back to Omega Watches 2
src/app/(dashboard)/entity-resolution/page.tsx
286 lines
"use client";
import { useEffect, useState } from "react";
import { formatDateTime, formatCurrency } from "@/lib/utils";
interface UnmatchedEvent {
event_id: number;
source_name: string;
source_listing_id: string;
event_type: string;
reference_number_raw: string;
sale_date: string;
price_amount: string;
currency: string;
condition_raw: string;
condition_normalized: string;
source_url: string;
created_at: string;
}
interface MatchSuggestion {
id: string;
reference_number: string;
collection: string;
model_name: string;
score: number;
}
export default function EntityResolutionPage() {
const [unmatched, setUnmatched] = useState<UnmatchedEvent[]>([]);
const [suggestions, setSuggestions] = useState<Record<string, MatchSuggestion[]>>({});
const [references, setReferences] = useState<any[]>([]);
const [stats, setStats] = useState<any>(null);
const [error, setError] = useState("");
const [linking, setLinking] = useState<number | null>(null);
const [manualRef, setManualRef] = useState("");
useEffect(() => {
loadData();
}, []);
async function loadData() {
try {
const token = localStorage.getItem("omega_token");
const res = await fetch("/api/entity-resolution", {
headers: { Authorization: `Bearer ${token}` },
});
const json = await res.json();
if (json.success) {
setUnmatched(json.data.unmatched);
setSuggestions(json.data.suggestions);
setReferences(json.data.references);
setStats(json.data.stats);
}
} catch (e: any) {
setError(e.message);
}
}
async function linkEvent(eventId: number, referenceNumber: string) {
try {
const token = localStorage.getItem("omega_token");
const res = await fetch("/api/entity-resolution", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ event_id: eventId, reference_number: referenceNumber }),
});
const json = await res.json();
if (json.success) {
setUnmatched((prev) => prev.filter((e) => e.event_id !== eventId));
setLinking(null);
setManualRef("");
}
} catch (e: any) {
setError(e.message);
}
}
const matchRate = stats
? Math.round((parseInt(stats.matched) / parseInt(stats.total)) * 100)
: 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-100">Entity Resolution Workbench</h1>
<button onClick={loadData} className="btn-secondary text-xs">
Refresh
</button>
</div>
{error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
{/* Stats */}
{stats && (
<div className="grid gap-4 md:grid-cols-4">
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-gray-200">{stats.total}</div>
<div className="text-xs text-gray-500">Total Events</div>
</div>
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-green-400">{stats.matched}</div>
<div className="text-xs text-gray-500">Matched</div>
</div>
<div className="card border-navy-700 text-center">
<div className={`text-3xl font-bold ${parseInt(stats.unmatched) > 0 ? "text-yellow-400" : "text-green-400"}`}>
{stats.unmatched}
</div>
<div className="text-xs text-gray-500">Unmatched</div>
</div>
<div className="card border-navy-700 text-center">
<div className={`text-3xl font-bold ${matchRate >= 95 ? "text-green-400" : matchRate >= 80 ? "text-yellow-400" : "text-red-400"}`}>
{matchRate}%
</div>
<div className="text-xs text-gray-500">Match Rate</div>
</div>
</div>
)}
{/* Dedup Pipeline Info */}
<div className="grid gap-4 md:grid-cols-2">
<div className="card border-navy-700">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
Deduplication Pipeline
</h2>
<div className="space-y-2">
{[
{ stage: "Hard Keys", desc: "(source_name, source_listing_id) unique", status: "Active" },
{ stage: "Strong Identity", desc: "(reference_number, serial_number)", status: "Active" },
{ stage: "Fuzzy Match", desc: "Levenshtein similarity scoring", status: "Active" },
{ stage: "Image pHash", desc: "Perceptual hashing for near-duplicates", status: "Pending" },
].map((s) => (
<div key={s.stage} className="flex items-center gap-3 rounded bg-navy-900 p-2">
<span className={`badge text-xs ${s.status === "Active" ? "badge-success" : "badge-warning"}`}>
{s.status}
</span>
<div>
<span className="text-sm text-gray-200">{s.stage}</span>
<span className="ml-2 text-xs text-gray-500">{s.desc}</span>
</div>
</div>
))}
</div>
</div>
<div className="card border-navy-700">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
Validation Rules
</h2>
<div className="space-y-2">
{[
"Schema Constraints: Currency codes, numeric ranges, required fields",
"Cross-Field Consistency: Condition category vs description",
"Venue Semantics: Auction fee inclusion from definitions",
"Price Plausibility: 10x jumps, negatives, currency switches",
"Temporal Logic: Sale date before scrape date",
].map((v, i) => (
<div key={i} className="flex items-center gap-2 rounded bg-navy-900 p-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-400" />
<span className="text-xs text-gray-400">{v}</span>
</div>
))}
</div>
</div>
</div>
{/* Unmatched Events */}
<div className="card border-navy-700">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400">
Unmatched Market Events ({unmatched.length})
</h2>
{unmatched.length === 0 ? (
<div className="p-8 text-center">
<div className="text-4xl text-green-400">✓</div>
<div className="mt-2 text-lg font-medium text-green-400">All Resolved</div>
<div className="text-sm text-gray-500">Every market event is linked to a known reference.</div>
</div>
) : (
<div className="space-y-3">
{unmatched.map((event) => {
const eventSuggestions = suggestions[event.event_id] || [];
const isLinking = linking === event.event_id;
return (
<div key={event.event_id} className="rounded-lg border border-navy-700 bg-navy-900/50 p-4">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-yellow-400">{event.reference_number_raw}</span>
<span className={`badge text-xs ${
event.event_type === "auction_realized" ? "badge-success" :
event.event_type === "marketplace_sold" ? "badge-info" : "badge-warning"
}`}>
{event.event_type.replace(/_/g, " ")}
</span>
<span className="text-xs text-gray-500">{event.source_name}</span>
</div>
<div className="mt-1 flex items-center gap-4 text-xs text-gray-400">
{event.price_amount && (
<span>{formatCurrency(parseFloat(event.price_amount))}</span>
)}
{event.sale_date && <span>{event.sale_date}</span>}
{event.condition_raw && <span>{event.condition_raw}</span>}
</div>
</div>
<span className="text-xs text-gray-600">#{event.event_id}</span>
</div>
{/* Fuzzy match suggestions */}
{eventSuggestions.length > 0 && (
<div className="mt-3">
<div className="text-xs text-gray-500 mb-1">Suggested matches:</div>
<div className="flex flex-wrap gap-2">
{eventSuggestions.map((s) => (
<button
key={s.id}
onClick={() => linkEvent(event.event_id, s.reference_number)}
className="flex items-center gap-1 rounded border border-navy-600 bg-navy-800 px-2 py-1 text-xs transition-colors hover:border-omega-500 hover:bg-navy-700"
>
<span className="font-mono text-omega-400">{s.reference_number}</span>
<span className="text-gray-400">{s.collection}</span>
<span className={`ml-1 rounded px-1 text-[10px] ${
s.score > 0.8 ? "bg-green-900 text-green-400" :
s.score > 0.5 ? "bg-yellow-900 text-yellow-400" : "bg-gray-800 text-gray-400"
}`}>
{Math.round(s.score * 100)}%
</span>
</button>
))}
</div>
</div>
)}
{/* Manual link */}
<div className="mt-2 flex items-center gap-2">
{isLinking ? (
<>
<select
className="input w-72 text-xs"
value={manualRef}
onChange={(e) => setManualRef(e.target.value)}
>
<option value="">Select reference...</option>
{references.map((r: any) => (
<option key={r.id} value={r.reference_number}>
{r.reference_number} — {r.collection} {r.model_name}
</option>
))}
</select>
<button
onClick={() => manualRef && linkEvent(event.event_id, manualRef)}
disabled={!manualRef}
className="btn-primary text-xs disabled:opacity-50"
>
Link
</button>
<button
onClick={() => { setLinking(null); setManualRef(""); }}
className="btn-secondary text-xs"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setLinking(event.event_id)}
className="text-xs text-gray-500 hover:text-gray-300"
>
Manual link...
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}