"use client"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import type { FacilitySummary } from "@dci/core"; import { Badge, Button, EmptyState, Kbd, RelativeTime, SkeletonBlock, StatusBadge } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { DASH, fmtInt, fmtMw, placeLine } from "@/lib/format"; import { routes } from "@/lib/routes"; import type { AdminMatch, MatchCandidate, MatchSide } from "@/lib/admin-api"; import { ErrorNote, ExtLink, Mono, Pager, RefreshButton, ReviewBadge, Segmented, Select } from "./primitives"; import { AdminPageHeader } from "./shell"; import { useToast } from "./toast"; import { useAdminMutation, useAdminQuery } from "./use-admin-query"; import { useUrlState } from "./use-url-state"; /* ------------------------------------------------------------------------------------------ DUPLICATE RESOLUTION WORKBENCH (§55): every candidate pair side by side — name similarity, operator, distance, address, codes, external ids, source documents, score and reasons — with four decisions: MERGE · KEEP SEPARATE · MARK RELATED CAMPUS · DEFER. ------------------------------------------------------------------------------------------ */ const TABS = ["pending", "deferred", "approved", "rejected", "related_campus", "auto_merged", "auto_created", "all"] as const; type Tab = (typeof TABS)[number]; const TAB_LABEL: Record = { pending: "Pending", deferred: "Deferred", approved: "Merged", rejected: "Kept separate", related_campus: "Related campus", auto_merged: "Auto-merged", auto_created: "Auto-created", all: "All" }; const PER_PAGE = 20; type Decision = "approve" | "reject" | "related-campus" | "defer"; const DECIDED_STATUS: Record = { approve: "approved", reject: "rejected", "related-campus": "related_campus", defer: "deferred" }; /** "name:0.85" → 0.85 ; "distance:0.15km/0.25km" → { km: 0.15, radiusKm: 0.25 } */ function reasonValue(reasons: string[], key: string): string | null { const r = reasons.find((x) => x.startsWith(`${key}:`)); return r ? r.slice(key.length + 1) : null; } function fmtKm(km: number | null | undefined): string { if (km == null || !Number.isFinite(km)) return DASH; return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(km < 10 ? 2 : 0)} km`; } function coords(lat: number | null | undefined, lng: number | null | undefined, precision?: string | null): string { if (typeof lat !== "number" || typeof lng !== "number") return DASH; return `${lat.toFixed(4)}, ${lng.toFixed(4)}${precision ? ` (${precision})` : ""}`; } /** Candidate side from the API pair, falling back to the raw candidate payload (never persisted case). */ function candidateSide(m: AdminMatch): Partial { const p = m.pair?.candidate; if (p) return p; const c: MatchCandidate = m.candidate; return { id: m.candidateFacilityId ?? "", name: c.name ?? "", operator: c.operatorName ?? null, city: c.city ?? null, address: c.address ?? null, lat: c.geo?.lat ?? c.lat ?? null, lng: c.geo?.lng ?? c.lng ?? null, codes: [], externalIds: c.externalIds ?? {}, sourceCount: 0, status: c.status ?? "unknown", recordScope: "facility", parentFacilityId: null }; } function Row({ k, a, b, same, diff }: { k: string; a: React.ReactNode; b: React.ReactNode; same?: boolean; diff?: boolean }) { const cls = (v: React.ReactNode) => cn("min-w-0 overflow-hidden break-all text-ink [&_a]:max-w-full", same && "text-ok", diff && "text-warn", (v === null || v === undefined || v === DASH) && "text-ink-4"); return (
{k}
{a ?? DASH}
{b ?? DASH}
); } function Head({ f, side, label, id }: { f: FacilitySummary | null; side: Partial | null; label: string; id: string | null }) { const name = f?.name ?? side?.name; return (
{label}
{f ? ( {f.name} ) : name ? (
{name}
) : (
{id ? "facility not found" : "no linked facility"}
)} {side?.recordScope && side.recordScope !== "facility" && ( {side.recordScope} {side.parentFacilityId ? " · has parent" : ""} )}
{f && }
); } function ScoreBar({ score }: { score: number }) { return (
= 0.9 ? "bg-ok" : score >= 0.75 ? "bg-warn" : "bg-danger")} style={{ width: `${Math.min(100, score * 100)}%` }} />
{(score * 100).toFixed(0)}%
); } function externalIdsText(ids: Record | undefined): string | null { const e = Object.entries(ids ?? {}); if (!e.length) return null; return e .slice(0, 4) .map(([k, v]) => `${k}: ${String(v)}`) .join(" · ") + (e.length > 4 ? ` · +${e.length - 4}` : ""); } export function MatchesQueue() { const [params, setParams] = useUrlState(); const tab = (TABS.includes(params.status as Tab) ? params.status : "pending") as Tab; const page = Math.max(1, Number(params.page ?? 1) || 1); const connector = params.connector ?? ""; const q = useAdminQuery("matches", { query: { status: tab, connector: connector || undefined, page, per_page: PER_PAGE }, refreshMs: tab === "pending" ? 60_000 : null, timeoutMs: 30_000 }); const pendingCount = useAdminQuery("matches", { query: { status: "pending", per_page: 1 }, refreshMs: 60_000 }); const deferredCount = useAdminQuery("matches", { query: { status: "deferred", per_page: 1 }, refreshMs: 120_000 }); const { busy, run } = useAdminMutation(); const toast = useToast(); const [gone, setGone] = useState>({}); const [focus, setFocus] = useState(null); const listRef = useRef(null); const total = Number(q.meta?.total ?? 0); const connectors = useMemo(() => [...new Set((q.data ?? []).map((m) => m.connectorId))].sort(), [q.data]); const decide = async (m: AdminMatch, action: Decision) => { if (action === "approve" && !m.matchedFacilityId) return toast({ tone: "warn", title: "Nothing to merge into", description: "This match has no matched facility." }); if (action === "related-campus" && !m.candidateFacilityId) return toast({ tone: "warn", title: "Candidate not persisted", description: "The candidate was never written as a facility — merge or keep separate instead." }); setGone((g) => ({ ...g, [m.id]: DECIDED_STATUS[action] })); const r = await run<{ status: string; facilityId?: string; merged?: unknown }>(`${action}:${m.id}`, `matches/${encodeURIComponent(m.id)}/${action}`, { body: {} }); if (r.ok) { const name = m.candidate.name ?? m.pair?.candidate?.name ?? m.candidateKey; const msg: Record = { approve: `${name} → ${m.matched?.name ?? r.data.facilityId}${r.data.merged ? " (duplicate merged)" : ""}`, reject: `${name} stays a separate facility`, "related-campus": `${name} recorded as a building of ${m.matched?.name ?? m.matchedFacilityId}`, defer: `${name} moved to the Deferred tab`, }; toast({ tone: "ok", title: { approve: "Merged", reject: "Kept separate", "related-campus": "Marked related campus", defer: "Deferred" }[action], description: msg[action] }); setTimeout(() => { q.mutate((prev) => prev?.filter((x) => x.id !== m.id) ?? prev); void pendingCount.refresh(); void deferredCount.refresh(); }, 600); } else { setGone((g) => { const n = { ...g }; delete n[m.id]; return n; }); toast({ tone: "danger", title: `Could not ${action.replace("-", " ")}`, description: r.error }); } }; // keyboard: m / k / r / d on the focused card (pending + deferred only) useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!focus || e.metaKey || e.ctrlKey || e.altKey) return; const t = e.target as HTMLElement | null; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT")) return; const m = (q.data ?? []).find((x) => x.id === focus); if (!m || gone[m.id] || (m.status !== "pending" && m.status !== "deferred")) return; const map: Record = { m: "approve", k: "reject", r: "related-campus", d: "defer" }; const a = map[e.key.toLowerCase()]; if (!a) return; e.preventDefault(); void decide(m, a); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [focus, q.data, gone]); return (
void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} /> void q.refresh()} className="mb-3" />
setParams({ status: v === "pending" ? null : v, page: null })} options={TABS.map((t) => ({ value: t, label: TAB_LABEL[t], count: t === "pending" ? Number(pendingCount.meta?.total ?? (tab === "pending" ? total : 0)) : t === "deferred" ? Number(deferredCount.meta?.total ?? (tab === "deferred" ? total : 0)) : t === tab ? total : null }))} /> Focus a card, then m merge · k keep separate · r related campus · d defer
{q.loading && !q.data ? (
) : !q.data || q.data.length === 0 ? ( ) : (
{q.data.map((m) => { const a = candidateSide(m); const b = m.pair?.matched ?? null; const f = m.matched; const cf = m.candidateFacility; const decided = gone[m.id]; const score = m.score ?? 0; const nameSim = reasonValue(m.reasons, "name"); const dist = reasonValue(m.reasons, "distance"); const distKm = m.pair?.distanceKm ?? null; const sameCodes = m.pair?.sameCodes ?? []; const aCodes = a.codes ?? []; const bCodes = b?.codes ?? []; const sameOp = Boolean(a.operator && b?.operator && a.operator.toLowerCase() === b.operator.toLowerCase()); const diffOp = Boolean(a.operator && b?.operator && !sameOp); const open = m.status === "pending" || m.status === "deferred"; return (
setFocus(m.id)} onClick={() => setFocus(m.id)} aria-label={`Match ${m.candidateKey}`} className={cn("rounded-sm border bg-surface outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-accent", focus === m.id ? "border-hair-2" : "border-hair", decided && "opacity-40")} >
{m.reasons.map((r) => ( {r} ))}
{m.connectorId} {m.decidedBy && by {m.decidedBy}}
name {nameSim} : null} b={sameCodes.length ? codes match: {sameCodes.join(", ")} : aCodes.length && bCodes.length ? codes differ : null} /> {distKm != null ? fmtKm(distKm) : dist ? dist.replace("/", " / radius ") : DASH}} b={dist ? {`reason ${dist}`} : null} same={distKm != null && distKm < 0.25} /> {c}) : null} b={bCodes.length ? bCodes.map((c) => {c}) : null} /> : m.candidate.sourceId ? {m.candidate.sourceId} : null} b={b ? {fmtInt(b.sourceCount)} source{b.sourceCount === 1 ? "" : "s"}{f ? <> · confidence {f.confidence} · {f.completeness}% complete : null} : null} /> {m.candidateKey}} b={f ? {f.id} : m.matchedFacilityId ? {m.matchedFacilityId} : null} />
{open && !decided ? ( <> {m.status === "pending" && ( )} ) : (
{(decided ?? m.status).replace(/_/g, " ")} {m.decidedAt && }
)} {cf && ( )}
); })} setParams({ page: p === 1 ? null : p })} />

{fmtInt(total)} {TAB_LABEL[tab].toLowerCase()} matches. Green = the two sides agree; amber = they disagree. Decisions are recorded with `admin` as decider and are visible in the facility’s source history.

)}
); }