spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import Link from "next/link";4import { useEffect, useMemo, useRef, useState } from "react";5import type { FacilitySummary } from "@dci/core";6import { Badge, Button, EmptyState, Kbd, RelativeTime, SkeletonBlock, StatusBadge } from "@/components/ui";7import { cn } from "@/components/ui/cn";8import { DASH, fmtInt, fmtMw, placeLine } from "@/lib/format";9import { routes } from "@/lib/routes";10import type { AdminMatch, MatchCandidate, MatchSide } from "@/lib/admin-api";11import { ErrorNote, ExtLink, Mono, Pager, RefreshButton, ReviewBadge, Segmented, Select } from "./primitives";12import { AdminPageHeader } from "./shell";13import { useToast } from "./toast";14import { useAdminMutation, useAdminQuery } from "./use-admin-query";15import { useUrlState } from "./use-url-state";1617/* ------------------------------------------------------------------------------------------18 DUPLICATE RESOLUTION WORKBENCH (§55): every candidate pair side by side — name similarity,19 operator, distance, address, codes, external ids, source documents, score and reasons — with20 four decisions: MERGE · KEEP SEPARATE · MARK RELATED CAMPUS · DEFER.21 ------------------------------------------------------------------------------------------ */2223const TABS = ["pending", "deferred", "approved", "rejected", "related_campus", "auto_merged", "auto_created", "all"] as const;24type Tab = (typeof TABS)[number];25const TAB_LABEL: Record<Tab, string> = { pending: "Pending", deferred: "Deferred", approved: "Merged", rejected: "Kept separate", related_campus: "Related campus", auto_merged: "Auto-merged", auto_created: "Auto-created", all: "All" };26const PER_PAGE = 20;27type Decision = "approve" | "reject" | "related-campus" | "defer";28const DECIDED_STATUS: Record<Decision, string> = { approve: "approved", reject: "rejected", "related-campus": "related_campus", defer: "deferred" };2930/** "name:0.85" → 0.85 ; "distance:0.15km/0.25km" → { km: 0.15, radiusKm: 0.25 } */31function reasonValue(reasons: string[], key: string): string | null {32 const r = reasons.find((x) => x.startsWith(`${key}:`));33 return r ? r.slice(key.length + 1) : null;34}35function fmtKm(km: number | null | undefined): string {36 if (km == null || !Number.isFinite(km)) return DASH;37 return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(km < 10 ? 2 : 0)} km`;38}39function coords(lat: number | null | undefined, lng: number | null | undefined, precision?: string | null): string {40 if (typeof lat !== "number" || typeof lng !== "number") return DASH;41 return `${lat.toFixed(4)}, ${lng.toFixed(4)}${precision ? ` (${precision})` : ""}`;42}4344/** Candidate side from the API pair, falling back to the raw candidate payload (never persisted case). */45function candidateSide(m: AdminMatch): Partial<MatchSide> {46 const p = m.pair?.candidate;47 if (p) return p;48 const c: MatchCandidate = m.candidate;49 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 };50}5152function Row({ k, a, b, same, diff }: { k: string; a: React.ReactNode; b: React.ReactNode; same?: boolean; diff?: boolean }) {53 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");54 return (55 <div className="grid grid-cols-[92px_1fr] gap-x-2 gap-y-0.5 border-b border-hair/70 py-1.5 text-[12.5px] last:border-0 sm:grid-cols-[92px_1fr_1fr]">56 <div className="text-ink-3">{k}</div>57 <div className={cls(a)}>{a ?? DASH}</div>58 <div className={cn("col-start-2 sm:col-start-3", cls(b))}>{b ?? DASH}</div>59 </div>60 );61}6263function Head({ f, side, label, id }: { f: FacilitySummary | null; side: Partial<MatchSide> | null; label: string; id: string | null }) {64 const name = f?.name ?? side?.name;65 return (66 <div className="mb-1 flex items-start justify-between gap-2">67 <div className="min-w-0">68 <div className="label-caps">{label}</div>69 {f ? (70 <Link href={routes.facility(f.slug)} target="_blank" className="block truncate text-[13.5px] font-semibold text-ink hover:text-accent hover:underline">71 {f.name}72 </Link>73 ) : name ? (74 <div className="truncate text-[13.5px] font-semibold text-ink">{name}</div>75 ) : (76 <div className="text-[13px] text-ink-3">{id ? "facility not found" : "no linked facility"}</div>77 )}78 {side?.recordScope && side.recordScope !== "facility" && (79 <Badge size="xs" tone="outline" className="mt-1">80 {side.recordScope}81 {side.parentFacilityId ? " · has parent" : ""}82 </Badge>83 )}84 </div>85 {f && <StatusBadge status={f.status} short size="xs" />}86 </div>87 );88}8990function ScoreBar({ score }: { score: number }) {91 return (92 <div className="flex items-center gap-1.5" title={`match score ${score.toFixed(3)} — ≥ 0.92 auto-merged, 0.60–0.92 reviewed here`}>93 <div className="h-1.5 w-24 rounded-full bg-surface-3">94 <div className={cn("h-full rounded-full", score >= 0.9 ? "bg-ok" : score >= 0.75 ? "bg-warn" : "bg-danger")} style={{ width: `${Math.min(100, score * 100)}%` }} />95 </div>96 <span className="figure text-[12.5px] text-ink">{(score * 100).toFixed(0)}%</span>97 </div>98 );99}100101function externalIdsText(ids: Record<string, string | number> | undefined): string | null {102 const e = Object.entries(ids ?? {});103 if (!e.length) return null;104 return e105 .slice(0, 4)106 .map(([k, v]) => `${k}: ${String(v)}`)107 .join(" · ") + (e.length > 4 ? ` · +${e.length - 4}` : "");108}109110export function MatchesQueue() {111 const [params, setParams] = useUrlState();112 const tab = (TABS.includes(params.status as Tab) ? params.status : "pending") as Tab;113 const page = Math.max(1, Number(params.page ?? 1) || 1);114 const connector = params.connector ?? "";115 const q = useAdminQuery<AdminMatch[]>("matches", { query: { status: tab, connector: connector || undefined, page, per_page: PER_PAGE }, refreshMs: tab === "pending" ? 60_000 : null, timeoutMs: 30_000 });116 const pendingCount = useAdminQuery<AdminMatch[]>("matches", { query: { status: "pending", per_page: 1 }, refreshMs: 60_000 });117 const deferredCount = useAdminQuery<AdminMatch[]>("matches", { query: { status: "deferred", per_page: 1 }, refreshMs: 120_000 });118 const { busy, run } = useAdminMutation();119 const toast = useToast();120 const [gone, setGone] = useState<Record<string, string>>({});121 const [focus, setFocus] = useState<string | null>(null);122 const listRef = useRef<HTMLDivElement>(null);123 const total = Number(q.meta?.total ?? 0);124 const connectors = useMemo(() => [...new Set((q.data ?? []).map((m) => m.connectorId))].sort(), [q.data]);125126 const decide = async (m: AdminMatch, action: Decision) => {127 if (action === "approve" && !m.matchedFacilityId) return toast({ tone: "warn", title: "Nothing to merge into", description: "This match has no matched facility." });128 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." });129 setGone((g) => ({ ...g, [m.id]: DECIDED_STATUS[action] }));130 const r = await run<{ status: string; facilityId?: string; merged?: unknown }>(`${action}:${m.id}`, `matches/${encodeURIComponent(m.id)}/${action}`, { body: {} });131 if (r.ok) {132 const name = m.candidate.name ?? m.pair?.candidate?.name ?? m.candidateKey;133 const msg: Record<Decision, string> = {134 approve: `${name} → ${m.matched?.name ?? r.data.facilityId}${r.data.merged ? " (duplicate merged)" : ""}`,135 reject: `${name} stays a separate facility`,136 "related-campus": `${name} recorded as a building of ${m.matched?.name ?? m.matchedFacilityId}`,137 defer: `${name} moved to the Deferred tab`,138 };139 toast({ tone: "ok", title: { approve: "Merged", reject: "Kept separate", "related-campus": "Marked related campus", defer: "Deferred" }[action], description: msg[action] });140 setTimeout(() => {141 q.mutate((prev) => prev?.filter((x) => x.id !== m.id) ?? prev);142 void pendingCount.refresh();143 void deferredCount.refresh();144 }, 600);145 } else {146 setGone((g) => {147 const n = { ...g };148 delete n[m.id];149 return n;150 });151 toast({ tone: "danger", title: `Could not ${action.replace("-", " ")}`, description: r.error });152 }153 };154155 // keyboard: m / k / r / d on the focused card (pending + deferred only)156 useEffect(() => {157 const onKey = (e: KeyboardEvent) => {158 if (!focus || e.metaKey || e.ctrlKey || e.altKey) return;159 const t = e.target as HTMLElement | null;160 if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT")) return;161 const m = (q.data ?? []).find((x) => x.id === focus);162 if (!m || gone[m.id] || (m.status !== "pending" && m.status !== "deferred")) return;163 const map: Record<string, Decision> = { m: "approve", k: "reject", r: "related-campus", d: "defer" };164 const a = map[e.key.toLowerCase()];165 if (!a) return;166 e.preventDefault();167 void decide(m, a);168 };169 window.addEventListener("keydown", onKey);170 return () => window.removeEventListener("keydown", onKey);171 // eslint-disable-next-line react-hooks/exhaustive-deps172 }, [focus, q.data, gone]);173174 return (175 <div>176 <AdminPageHeader177 title="Duplicate resolution workbench"178 description="Candidates that scored 0.60–0.92 against an existing facility. MERGE = same site (keys repointed, duplicate merged). KEEP SEPARATE = different sites. RELATED CAMPUS = the candidate is a building of the matched campus (both rows stay, never both counted). DEFER = decide later."179 aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />}180 />181 <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />182 <div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center">183 <Segmented184 label="Status"185 value={tab}186 onChange={(v) => setParams({ status: v === "pending" ? null : v, page: null })}187 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 }))}188 />189 <Select value={connector} onChange={(e) => setParams({ connector: e.target.value || null, page: null })} className="sm:w-[200px]" aria-label="Connector">190 <option value="">All connectors</option>191 {connector && !connectors.includes(connector) && <option value={connector}>{connector}</option>}192 {connectors.map((c) => (193 <option key={c} value={c}>194 {c}195 </option>196 ))}197 </Select>198 <span className="hidden text-[11.5px] text-ink-4 lg:inline">199 Focus a card, then <Kbd>m</Kbd> merge · <Kbd>k</Kbd> keep separate · <Kbd>r</Kbd> related campus · <Kbd>d</Kbd> defer200 </span>201 </div>202203 {q.loading && !q.data ? (204 <div className="space-y-3">205 <SkeletonBlock className="h-48" />206 <SkeletonBlock className="h-48" />207 </div>208 ) : !q.data || q.data.length === 0 ? (209 <EmptyState title={tab === "pending" ? "Queue is empty" : `No ${TAB_LABEL[tab].toLowerCase()} matches`} description={tab === "pending" ? "New candidates appear here after connector runs when a candidate scores close to an existing facility." : undefined} />210 ) : (211 <div className="space-y-3" ref={listRef}>212 {q.data.map((m) => {213 const a = candidateSide(m);214 const b = m.pair?.matched ?? null;215 const f = m.matched;216 const cf = m.candidateFacility;217 const decided = gone[m.id];218 const score = m.score ?? 0;219 const nameSim = reasonValue(m.reasons, "name");220 const dist = reasonValue(m.reasons, "distance");221 const distKm = m.pair?.distanceKm ?? null;222 const sameCodes = m.pair?.sameCodes ?? [];223 const aCodes = a.codes ?? [];224 const bCodes = b?.codes ?? [];225 const sameOp = Boolean(a.operator && b?.operator && a.operator.toLowerCase() === b.operator.toLowerCase());226 const diffOp = Boolean(a.operator && b?.operator && !sameOp);227 const open = m.status === "pending" || m.status === "deferred";228 return (229 <article230 key={m.id}231 tabIndex={0}232 onFocus={() => setFocus(m.id)}233 onClick={() => setFocus(m.id)}234 aria-label={`Match ${m.candidateKey}`}235 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")}236 >237 <div className="flex flex-wrap items-center gap-2 border-b border-hair px-3 py-2">238 <ReviewBadge status={decided ?? m.status} />239 <ScoreBar score={score} />240 <div className="flex flex-wrap gap-1">241 {m.reasons.map((r) => (242 <Badge key={r} size="xs" mono tone={r.startsWith("rule:") ? "accent" : "outline"}>243 {r}244 </Badge>245 ))}246 </div>247 <div className="ml-auto flex items-center gap-2 text-[11.5px] text-ink-3">248 <Link href={`/admin/connectors/${encodeURIComponent(m.connectorId)}`} className="font-mono hover:text-accent">249 {m.connectorId}250 </Link>251 <RelativeTime iso={m.createdAt} />252 {m.decidedBy && <span>by {m.decidedBy}</span>}253 </div>254 </div>255 <div className="grid gap-3 px-3 py-2.5 lg:grid-cols-[1fr_auto]">256 <div className="min-w-0">257 <div className="grid grid-cols-[92px_1fr] gap-x-2 sm:grid-cols-[92px_1fr_1fr]">258 <div />259 <div className="min-w-0">260 <Head f={cf} side={a} label="Candidate (from source)" id={m.candidateFacilityId} />261 </div>262 <div className="col-start-2 min-w-0 sm:col-start-3">263 <Head f={f} side={b} label="Matched facility (in index)" id={m.matchedFacilityId} />264 </div>265 </div>266 <Row k="Name" a={a.name || null} b={b?.name ?? f?.name} same={Boolean(a.name && (b?.name ?? f?.name) && a.name.toLowerCase() === (b?.name ?? f?.name)!.toLowerCase())} />267 <Row k="Similarity" a={nameSim ? <span className="figure">name {nameSim}</span> : null} b={sameCodes.length ? <span className="figure text-ok">codes match: {sameCodes.join(", ")}</span> : aCodes.length && bCodes.length ? <span className="figure text-warn">codes differ</span> : null} />268 <Row k="Operator" a={a.operator ?? null} b={b?.operator ?? f?.operator?.name} same={sameOp} diff={diffOp} />269 <Row k="Address" a={a.address ?? (a.city ? placeLine({ city: a.city }) : null)} b={b?.address ?? (f ? placeLine({ city: f.city, regionName: f.regionName, countryIso2: f.countryIso2, countryName: f.countryName ?? undefined }) : null)} />270 <Row k="Coordinates" a={coords(a.lat, a.lng, m.candidate.geo?.precision)} b={b ? coords(b.lat, b.lng) : f ? coords(f.lat, f.lng, f.geoPrecision) : null} />271 <Row k="Distance" a={<span className="figure">{distKm != null ? fmtKm(distKm) : dist ? dist.replace("/", " / radius ") : DASH}</span>} b={dist ? <span className="text-ink-3">{`reason ${dist}`}</span> : null} same={distKm != null && distKm < 0.25} />272 <Row k="Codes" a={aCodes.length ? aCodes.map((c) => <Badge key={c} size="xs" mono tone={sameCodes.includes(c) ? "ok" : "outline"} className="mr-1">{c}</Badge>) : null} b={bCodes.length ? bCodes.map((c) => <Badge key={c} size="xs" mono tone={sameCodes.includes(c) ? "ok" : "outline"} className="mr-1">{c}</Badge>) : null} />273 <Row k="External ids" a={externalIdsText(a.externalIds)} b={externalIdsText(b?.externalIds)} />274 <Row k="Capacity" a={m.candidate.itCapacityMw ?? m.candidate.totalPowerMw ?? m.candidate.plannedPowerMw ? `${fmtMw(m.candidate.itCapacityMw ?? m.candidate.totalPowerMw ?? m.candidate.plannedPowerMw)}${m.candidate.itCapacityMw ? " IT" : m.candidate.totalPowerMw ? " total" : " planned"}` : null} b={f && (f.itCapacityMw ?? f.totalPowerMw ?? f.plannedPowerMw) != null ? `${fmtMw(f.itCapacityMw ?? f.totalPowerMw ?? f.plannedPowerMw)}${f.itCapacityMw ? " IT" : f.totalPowerMw ? " total" : " planned"}` : null} />275 <Row k="Status" a={a.status ?? null} b={b?.status ?? f?.status} />276 <Row k="Sources" a={m.candidate.url ? <ExtLink href={m.candidate.url} /> : m.candidate.sourceId ? <Mono>{m.candidate.sourceId}</Mono> : null} b={b ? <span>{fmtInt(b.sourceCount)} source{b.sourceCount === 1 ? "" : "s"}{f ? <> · confidence {f.confidence} · {f.completeness}% complete</> : null}</span> : null} />277 <Row k="Key / id" a={<Mono>{m.candidateKey}</Mono>} b={f ? <Mono>{f.id}</Mono> : m.matchedFacilityId ? <Mono>{m.matchedFacilityId}</Mono> : null} />278 </div>279 <div className="flex flex-row flex-wrap gap-2 lg:w-[170px] lg:flex-col lg:justify-center">280 {open && !decided ? (281 <>282 <Button variant="primary" size="md" className="flex-1" onClick={() => void decide(m, "approve")} disabled={busy !== null || !m.matchedFacilityId} title="Same site: repoint keys, merge the duplicate (m)">283 Merge284 </Button>285 <Button variant="secondary" size="md" className="flex-1" onClick={() => void decide(m, "reject")} disabled={busy !== null} title="Different sites (k)">286 Keep separate287 </Button>288 <Button variant="secondary" size="md" className="flex-1" onClick={() => void decide(m, "related-campus")} disabled={busy !== null || !m.candidateFacilityId || !m.matchedFacilityId} title="Candidate is a building of the matched campus (r)">289 Related campus290 </Button>291 {m.status === "pending" && (292 <Button variant="ghost" size="md" className="flex-1" onClick={() => void decide(m, "defer")} disabled={busy !== null} title="Decide later (d)">293 Defer294 </Button>295 )}296 </>297 ) : (298 <div className="text-[12px] text-ink-3">299 {(decided ?? m.status).replace(/_/g, " ")} {m.decidedAt && <RelativeTime iso={m.decidedAt} />}300 </div>301 )}302 {cf && (303 <Button size="xs" variant="ghost" href={`/admin/facilities/${encodeURIComponent(cf.id)}/edit`}>304 Curate candidate305 </Button>306 )}307 </div>308 </div>309 </article>310 );311 })}312 <Pager page={page} perPage={PER_PAGE} total={total} onPage={(p) => setParams({ page: p === 1 ? null : p })} />313 <p className="text-[11.5px] text-ink-4">{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.</p>314 </div>315 )}316 </div>317 );318}319