"use client"; import type { ProjectSummary } from "@dci/core"; import { FACILITY_STATUSES, PROJECT_CLASSES } from "@dci/core"; import Link from "next/link"; import { useMemo, useState } from "react"; import { Badge, Button, ConfidenceBadge, CountryFlag, EmptyState, EstimateMark, RelativeTime, ScopeBadge, SearchInput, SemanticsLabel, SkeletonRows, StatusBadge, Table, TBody, Td, Th, THead, Tr } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { DASH, fmtDate, fmtMw, fmtUsd, fmtValue } from "@/lib/format"; import { CLAIM_STATUS_LABEL, STATUS_LABEL } from "@/lib/labels"; import { routes } from "@/lib/routes"; import type { ClaimDTO } from "@/lib/admin-api"; import { ErrorNote, ExtLink, Mono, Pager, RefreshButton, 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"; /* ------------------------------------------------------------------------------------------ /admin/projects — project curation: hide / unhide / merge, and per-project CLAIMS REVIEW (set current · reject · mark for review). The list is the PUBLIC /api/v1/projects through the proxy (there is no admin list endpoint yet), so hidden projects are not listed — see the note. ------------------------------------------------------------------------------------------ */ const PER_PAGE = 30; const SORTS = ["updated", "mw", "announced", "opening"] as const; type Sort = (typeof SORTS)[number]; const EVIDENCE = ["", "strong", "weak", "none"] as const; const CLASS_LABEL: Record = { NEW_BUILD: "New build", EXPANSION: "Expansion", CONSTRUCTION_START: "Construction start", PERMIT: "Permit", LAND_ACQUISITION: "Land acquisition", GRID_CONNECTION: "Grid connection", POWER_AGREEMENT: "Power agreement", FINANCING: "Financing", ACQUISITION: "Acquisition", PARTNERSHIP: "Partnership", CUSTOMER_AGREEMENT: "Customer agreement", EXECUTIVE_APPOINTMENT: "Executive appointment", SUSTAINABILITY: "Sustainability", PRODUCT_NEWS: "Product news", GENERAL_COMPANY_NEWS: "General company news", UNKNOWN: "Unknown", }; const PHYSICAL = new Set(["NEW_BUILD", "EXPANSION", "CONSTRUCTION_START", "PERMIT", "LAND_ACQUISITION", "GRID_CONNECTION"]); function ClassBadge({ c }: { c: string | null | undefined }) { if (!c) return {DASH}; return ( {CLASS_LABEL[c] ?? c} ); } function EvidenceBadge({ e }: { e: ProjectSummary["evidenceLevel"] }) { if (!e) return {DASH}; return ( {e} ); } /** Claims of one project with status actions (POST claims/:id/status). */ function ClaimsReview({ project }: { project: ProjectSummary }) { const q = useAdminQuery("claims", { query: { subject_type: "project", subject_id: project.id, status: "all", per_page: 100 }, timeoutMs: 30_000 }); const { busy, run } = useAdminMutation(); const toast = useToast(); const set = async (c: ClaimDTO, status: "current" | "rejected" | "review") => { let reason: string | undefined; if (status !== "current") { const r = window.prompt(`${status === "rejected" ? "Reject" : "Mark for review"} — reason (optional)`, ""); if (r === null) return; reason = r.trim() || undefined; } const r = await run<{ claim: ClaimDTO; flagged: boolean }>(`claim:${c.id}`, `claims/${encodeURIComponent(c.id)}/status`, { body: { status, ...(reason ? { reason } : {}) } }); if (r.ok) { toast({ tone: "ok", title: `Claim ${CLAIM_STATUS_LABEL[status].toLowerCase()}`, description: r.data.flagged ? "It was backing a displayed value — a claim_rejected_backing_value flag was raised for the next reconciliation." : `${c.label}: ${c.value !== null ? fmtValue(c.value, c.unit === "USD" ? "usd" : "mw") : c.valueText ?? ""}` }); q.mutate((prev) => prev?.map((x) => (x.id === c.id ? r.data.claim : x)) ?? prev); } else toast({ tone: "danger", title: "Claim status not changed", description: r.error }); }; if (q.loading && !q.data) return ; if (q.error && !q.data) return void q.refresh()} />; const claims = q.data ?? []; if (!claims.length) return

No claim extracted for this project — its figures come from structured source fields, or none were published.

; return ( {claims.map((c) => ( ))}
Predicate Value Scope Status Source Evidence Actions
{c.isWinner && ( shown )}
{c.id}
{c.isEstimate && } {c.value !== null ? fmtValue(c.value, c.unit === "USD" ? "usd" : "mw") : c.valueText ?? DASH} {c.scopeReason &&
{c.scopeReason}
}
{CLAIM_STATUS_LABEL[c.status]} {c.rejectionReason &&
{c.rejectionReason}
}
{c.sourceName} {c.sourceKind} · tier {c.authorityTier} ·
{c.evidenceText ? {c.evidenceText} : no sentence}
{c.status !== "current" && ( )} {c.status !== "rejected" && ( )} {c.status !== "review" && ( )}
); } export function ProjectsAdmin() { const [params, setParams] = useUrlState(); const page = Math.max(1, Number(params.page ?? 1) || 1); const status = params.status ?? ""; const cls = params.project_class ?? ""; const evidence = params.evidence_level ?? ""; const sort = (SORTS.includes(params.sort as Sort) ? params.sort : "updated") as Sort; const search = params.q ?? ""; const q = useAdminQuery("v1/projects", { query: { page, per_page: PER_PAGE, status: status || undefined, project_class: cls || undefined, evidence_level: evidence || undefined, sort, q: search || undefined }, timeoutMs: 30_000 }); const { busy, run } = useAdminMutation(); const toast = useToast(); const [open, setOpen] = useState(null); const [hidden, setHidden] = useState>({}); const total = Number(q.meta?.total ?? 0); const rows = useMemo(() => q.data ?? [], [q.data]); const hide = async (p: ProjectSummary) => { const reason = window.prompt(`Hide “${p.name}” as a false positive? Reason (required, kept for audit):`, ""); if (reason === null) return; if (!reason.trim()) return toast({ tone: "warn", title: "A reason is required to hide a project" }); const r = await run<{ id: string; hidden: boolean; changed: boolean }>(`hide:${p.id}`, `projects/${encodeURIComponent(p.id)}/hide`, { body: { reason: reason.trim() } }); if (r.ok) { setHidden((h) => ({ ...h, [p.id]: true })); toast({ tone: "ok", title: "Project hidden", description: `${p.name} — kept for audit, never listed or summed.` }); } else toast({ tone: "danger", title: "Hide failed", description: r.error }); }; const unhide = async (p: ProjectSummary) => { const r = await run<{ id: string; hidden: boolean; changed: boolean }>(`unhide:${p.id}`, `projects/${encodeURIComponent(p.id)}/unhide`, { body: {} }); if (r.ok) { setHidden((h) => ({ ...h, [p.id]: false })); toast({ tone: "ok", title: "Project restored", description: p.name }); } else toast({ tone: "danger", title: "Unhide failed", description: r.error }); }; const merge = async (p: ProjectSummary) => { const into = window.prompt(`Merge “${p.name}” INTO which project? Enter the survivor's id (prj_…) or slug. Events, timeline, claims, provenance and keys move to the survivor.`, ""); if (!into || !into.trim()) return; if (!window.confirm(`Merge ${p.id} into ${into.trim()}? This cannot be undone from the console.`)) return; const r = await run<{ into: string; merged: string; moved: Record }>(`merge:${p.id}`, `projects/${encodeURIComponent(p.id)}/merge`, { body: { into: into.trim() } }); if (r.ok) { toast({ tone: "ok", title: "Projects merged", description: `${r.data.merged} → ${r.data.into} · moved ${Object.entries(r.data.moved).map(([k, v]) => `${v} ${k}`).join(", ")}` }); q.mutate((prev) => prev?.filter((x) => x.id !== p.id) ?? prev); } else toast({ tone: "danger", title: "Merge failed", description: r.error }); }; return (
void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />

Listed through the public /api/v1/projects (no admin list endpoint yet): projects already hidden by review are not listed here — a project hidden in this session shows a “Restore” button until you leave the page. Merged duplicates are excluded as well.

void q.refresh()} className="mb-3" />
setParams({ sort: v === "updated" ? null : v, page: null })} options={SORTS.map((s) => ({ value: s, label: s === "mw" ? "Planned MW" : s[0]!.toUpperCase() + s.slice(1) }))} />
setParams({ q: v || null, page: null })} placeholder="Search name, operator, city" wrapperClassName="min-w-[200px] flex-1" />
{q.loading && !q.data ? ( ) : rows.length === 0 ? ( ) : (
{rows.map((p) => { const isHidden = hidden[p.id] === true; const isOpen = open === p.id; return ( setOpen((o) => (o === p.id ? null : p.id))} onHide={() => void hide(p)} onUnhide={() => void unhide(p)} onMerge={() => void merge(p)} /> ); })}
Project Operator Country Status Class Evidence Planned MW Investment Announced Updated Actions
)} setParams({ page: p === 1 ? null : p })} />

Class: green = physical development (may create a project); amber = associated news (power agreement, financing, acquisition, partnership, customer) or non-physical (appointment, sustainability, product, general) — these should not have created a project and are hide candidates. Planned MW / investment are site-scoped published figures; portfolio and company figures stay as claims.

); } function ProjectRow({ p, isHidden, isOpen, busy, onToggle, onHide, onUnhide, onMerge }: { p: ProjectSummary; isHidden: boolean; isOpen: boolean; busy: boolean; onToggle: () => void; onHide: () => void; onUnhide: () => void; onMerge: () => void }) { return ( <>
{p.name} {isHidden && ( hidden )} {p.isAi && ( AI )}
{p.id} {p.city && · {p.city}}
{p.operator ? ( {p.operator.name} ) : ( {DASH} )} {p.countryIso2 ? : {DASH}} {p.plannedMw === null ? ( {DASH} ) : ( {fmtMw(p.plannedMw)} {p.capacityScope && {p.capacityScope}} )} {p.investmentUsd === null ? ( {DASH} ) : ( {fmtUsd(p.investmentUsd)} {p.investmentScope && {p.investmentScope}} )} {p.announcedOn ? fmtDate(p.announcedOn) : DASH}
{isHidden ? ( ) : ( )}
{isOpen && (
Claims review Set current / Reject / Review never rewrite the project column directly; reconciliation re-derives it on the next run.
)} ); }