SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
20.1 KB · 319 lines tsx
Raw Blame History
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&rsquo;s source history.</p>314        </div>315      )}316    </div>317  );318}319