← 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">&#10003;</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>
  );
}