spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { ProjectSummary } from "@dci/core";4import { FACILITY_STATUSES, PROJECT_CLASSES } from "@dci/core";5import Link from "next/link";6import { useMemo, useState } from "react";7import { Badge, Button, ConfidenceBadge, CountryFlag, EmptyState, EstimateMark, RelativeTime, ScopeBadge, SearchInput, SemanticsLabel, SkeletonRows, StatusBadge, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";8import { cn } from "@/components/ui/cn";9import { DASH, fmtDate, fmtMw, fmtUsd, fmtValue } from "@/lib/format";10import { CLAIM_STATUS_LABEL, STATUS_LABEL } from "@/lib/labels";11import { routes } from "@/lib/routes";12import type { ClaimDTO } from "@/lib/admin-api";13import { ErrorNote, ExtLink, Mono, Pager, RefreshButton, Segmented, Select } from "./primitives";14import { AdminPageHeader } from "./shell";15import { useToast } from "./toast";16import { useAdminMutation, useAdminQuery } from "./use-admin-query";17import { useUrlState } from "./use-url-state";1819/* ------------------------------------------------------------------------------------------20 /admin/projects — project curation: hide / unhide / merge, and per-project CLAIMS REVIEW21 (set current · reject · mark for review). The list is the PUBLIC /api/v1/projects through the22 proxy (there is no admin list endpoint yet), so hidden projects are not listed — see the note.23 ------------------------------------------------------------------------------------------ */2425const PER_PAGE = 30;26const SORTS = ["updated", "mw", "announced", "opening"] as const;27type Sort = (typeof SORTS)[number];28const EVIDENCE = ["", "strong", "weak", "none"] as const;2930const CLASS_LABEL: Record<string, string> = {31 NEW_BUILD: "New build",32 EXPANSION: "Expansion",33 CONSTRUCTION_START: "Construction start",34 PERMIT: "Permit",35 LAND_ACQUISITION: "Land acquisition",36 GRID_CONNECTION: "Grid connection",37 POWER_AGREEMENT: "Power agreement",38 FINANCING: "Financing",39 ACQUISITION: "Acquisition",40 PARTNERSHIP: "Partnership",41 CUSTOMER_AGREEMENT: "Customer agreement",42 EXECUTIVE_APPOINTMENT: "Executive appointment",43 SUSTAINABILITY: "Sustainability",44 PRODUCT_NEWS: "Product news",45 GENERAL_COMPANY_NEWS: "General company news",46 UNKNOWN: "Unknown",47};48const PHYSICAL = new Set(["NEW_BUILD", "EXPANSION", "CONSTRUCTION_START", "PERMIT", "LAND_ACQUISITION", "GRID_CONNECTION"]);4950function ClassBadge({ c }: { c: string | null | undefined }) {51 if (!c) return <span className="text-ink-4">{DASH}</span>;52 return (53 <Badge size="xs" tone={PHYSICAL.has(c) ? "ok" : c === "UNKNOWN" ? "outline" : "warn"} title={PHYSICAL.has(c) ? "physical development class" : "associated / non-physical class — should not have created a project"}>54 {CLASS_LABEL[c] ?? c}55 </Badge>56 );57}5859function EvidenceBadge({ e }: { e: ProjectSummary["evidenceLevel"] }) {60 if (!e) return <span className="text-ink-4">{DASH}</span>;61 return (62 <Badge size="xs" tone={e === "strong" ? "ok" : e === "weak" ? "warn" : "danger"} title="Evidence threshold: (explicit name or operator) + location + a development verb">63 {e}64 </Badge>65 );66}6768/** Claims of one project with status actions (POST claims/:id/status). */69function ClaimsReview({ project }: { project: ProjectSummary }) {70 const q = useAdminQuery<ClaimDTO[]>("claims", { query: { subject_type: "project", subject_id: project.id, status: "all", per_page: 100 }, timeoutMs: 30_000 });71 const { busy, run } = useAdminMutation();72 const toast = useToast();73 const set = async (c: ClaimDTO, status: "current" | "rejected" | "review") => {74 let reason: string | undefined;75 if (status !== "current") {76 const r = window.prompt(`${status === "rejected" ? "Reject" : "Mark for review"} — reason (optional)`, "");77 if (r === null) return;78 reason = r.trim() || undefined;79 }80 const r = await run<{ claim: ClaimDTO; flagged: boolean }>(`claim:${c.id}`, `claims/${encodeURIComponent(c.id)}/status`, { body: { status, ...(reason ? { reason } : {}) } });81 if (r.ok) {82 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 ?? ""}` });83 q.mutate((prev) => prev?.map((x) => (x.id === c.id ? r.data.claim : x)) ?? prev);84 } else toast({ tone: "danger", title: "Claim status not changed", description: r.error });85 };86 if (q.loading && !q.data) return <SkeletonRows rows={3} />;87 if (q.error && !q.data) return <ErrorNote error={q.error} onRetry={() => void q.refresh()} />;88 const claims = q.data ?? [];89 if (!claims.length) return <p className="text-[12px] text-ink-4">No claim extracted for this project — its figures come from structured source fields, or none were published.</p>;90 return (91 <Table className="text-[12.5px]" dense>92 <THead>93 <Tr>94 <Th>Predicate</Th>95 <Th numeric>Value</Th>96 <Th>Scope</Th>97 <Th>Status</Th>98 <Th>Source</Th>99 <Th className="hidden lg:table-cell">Evidence</Th>100 <Th>Actions</Th>101 </Tr>102 </THead>103 <TBody>104 {claims.map((c) => (105 <Tr key={c.id} className={cn(c.status !== "current" && "text-ink-3")}>106 <Td>107 <div className="flex flex-wrap items-center gap-1.5">108 <SemanticsLabel semantics={c.predicate} className="text-[12.5px] text-ink" />109 {c.isWinner && (110 <Badge size="xs" tone="accent" title="Backs the displayed value">111 shown112 </Badge>113 )}114 </div>115 <Mono className="text-[10.5px] text-ink-4">{c.id}</Mono>116 </Td>117 <Td numeric className="figure">118 {c.isEstimate && <EstimateMark className="mr-0.5" />}119 {c.value !== null ? fmtValue(c.value, c.unit === "USD" ? "usd" : "mw") : c.valueText ?? DASH}120 </Td>121 <Td>122 <ScopeBadge scope={c.scope} />123 {c.scopeReason && <div className="font-mono text-[10.5px] text-ink-4">{c.scopeReason}</div>}124 </Td>125 <Td>126 <Badge size="xs" tone={c.status === "current" ? "ok" : c.status === "rejected" ? "danger" : c.status === "review" ? "warn" : "outline"}>127 {CLAIM_STATUS_LABEL[c.status]}128 </Badge>129 {c.rejectionReason && <div className="text-[10.5px] text-ink-4">{c.rejectionReason}</div>}130 </Td>131 <Td>132 <div className="flex flex-col">133 <ExtLink href={c.url}>{c.sourceName}</ExtLink>134 <span className="text-[10.5px] text-ink-4">135 {c.sourceKind} · tier {c.authorityTier} · <ConfidenceBadge level={c.confidence} size="xs" />136 </span>137 </div>138 </Td>139 <Td className="hidden max-w-[360px] lg:table-cell">{c.evidenceText ? <span className="line-clamp-3 text-[12px] text-ink-2">{c.evidenceText}</span> : <span className="text-ink-4">no sentence</span>}</Td>140 <Td>141 <div className="flex flex-wrap gap-1">142 {c.status !== "current" && (143 <Button size="xs" variant="secondary" onClick={() => void set(c, "current")} disabled={busy !== null}>144 Set current145 </Button>146 )}147 {c.status !== "rejected" && (148 <Button size="xs" variant="danger" onClick={() => void set(c, "rejected")} disabled={busy !== null}>149 Reject150 </Button>151 )}152 {c.status !== "review" && (153 <Button size="xs" variant="ghost" onClick={() => void set(c, "review")} disabled={busy !== null}>154 Review155 </Button>156 )}157 </div>158 </Td>159 </Tr>160 ))}161 </TBody>162 </Table>163 );164}165166export function ProjectsAdmin() {167 const [params, setParams] = useUrlState();168 const page = Math.max(1, Number(params.page ?? 1) || 1);169 const status = params.status ?? "";170 const cls = params.project_class ?? "";171 const evidence = params.evidence_level ?? "";172 const sort = (SORTS.includes(params.sort as Sort) ? params.sort : "updated") as Sort;173 const search = params.q ?? "";174 const q = useAdminQuery<ProjectSummary[]>("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 });175 const { busy, run } = useAdminMutation();176 const toast = useToast();177 const [open, setOpen] = useState<string | null>(null);178 const [hidden, setHidden] = useState<Record<string, boolean>>({});179 const total = Number(q.meta?.total ?? 0);180 const rows = useMemo(() => q.data ?? [], [q.data]);181182 const hide = async (p: ProjectSummary) => {183 const reason = window.prompt(`Hide “${p.name}” as a false positive? Reason (required, kept for audit):`, "");184 if (reason === null) return;185 if (!reason.trim()) return toast({ tone: "warn", title: "A reason is required to hide a project" });186 const r = await run<{ id: string; hidden: boolean; changed: boolean }>(`hide:${p.id}`, `projects/${encodeURIComponent(p.id)}/hide`, { body: { reason: reason.trim() } });187 if (r.ok) {188 setHidden((h) => ({ ...h, [p.id]: true }));189 toast({ tone: "ok", title: "Project hidden", description: `${p.name} — kept for audit, never listed or summed.` });190 } else toast({ tone: "danger", title: "Hide failed", description: r.error });191 };192 const unhide = async (p: ProjectSummary) => {193 const r = await run<{ id: string; hidden: boolean; changed: boolean }>(`unhide:${p.id}`, `projects/${encodeURIComponent(p.id)}/unhide`, { body: {} });194 if (r.ok) {195 setHidden((h) => ({ ...h, [p.id]: false }));196 toast({ tone: "ok", title: "Project restored", description: p.name });197 } else toast({ tone: "danger", title: "Unhide failed", description: r.error });198 };199 const merge = async (p: ProjectSummary) => {200 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.`, "");201 if (!into || !into.trim()) return;202 if (!window.confirm(`Merge ${p.id} into ${into.trim()}? This cannot be undone from the console.`)) return;203 const r = await run<{ into: string; merged: string; moved: Record<string, number> }>(`merge:${p.id}`, `projects/${encodeURIComponent(p.id)}/merge`, { body: { into: into.trim() } });204 if (r.ok) {205 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(", ")}` });206 q.mutate((prev) => prev?.filter((x) => x.id !== p.id) ?? prev);207 } else toast({ tone: "danger", title: "Merge failed", description: r.error });208 };209210 return (211 <div>212 <AdminPageHeader213 title="Projects"214 description="Curate project records: hide false positives (kept for audit, never listed), restore, merge duplicates, and review the claims behind each figure."215 aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />}216 />217 <p className="mb-3 rounded-xs border border-dashed border-hair-2 px-2.5 py-2 text-[11.5px] text-ink-3">218 Listed through the public <Mono>/api/v1/projects</Mono> (no admin list endpoint yet): projects already <strong className="text-ink-2">hidden</strong> 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.219 </p>220 <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />221222 <div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center">223 <Segmented label="Sort" value={sort} onChange={(v) => 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) }))} />224 <div className="flex flex-1 flex-wrap gap-2">225 <Select value={status} onChange={(e) => setParams({ status: e.target.value || null, page: null })} className="w-[170px]" aria-label="Status">226 <option value="">All statuses</option>227 {FACILITY_STATUSES.map((s) => (228 <option key={s} value={s}>229 {STATUS_LABEL[s]}230 </option>231 ))}232 </Select>233 <Select value={cls} onChange={(e) => setParams({ project_class: e.target.value || null, page: null })} className="w-[190px]" aria-label="Class">234 <option value="">All classes</option>235 {PROJECT_CLASSES.map((c) => (236 <option key={c} value={c}>237 {CLASS_LABEL[c] ?? c}238 </option>239 ))}240 </Select>241 <Select value={evidence} onChange={(e) => setParams({ evidence_level: e.target.value || null, page: null })} className="w-[150px]" aria-label="Evidence level">242 {EVIDENCE.map((e) => (243 <option key={e} value={e}>244 {e ? `Evidence: ${e}` : "All evidence levels"}245 </option>246 ))}247 </Select>248 <SearchInput size="sm" value={search} onChange={(v) => setParams({ q: v || null, page: null })} placeholder="Search name, operator, city" wrapperClassName="min-w-[200px] flex-1" />249 </div>250 </div>251252 {q.loading && !q.data ? (253 <SkeletonRows rows={10} />254 ) : rows.length === 0 ? (255 <EmptyState compact title="No project matches" description="Adjust the filters or the search." />256 ) : (257 <div className="max-w-full overflow-x-auto rounded-sm border border-hair bg-surface">258 <Table className="min-w-[1240px] text-[12.5px] sm:[&_td]:py-1.5" responsive={false}>259 <THead>260 <Tr>261 <Th className="pl-3">Project</Th>262 <Th>Operator</Th>263 <Th>Country</Th>264 <Th>Status</Th>265 <Th>Class</Th>266 <Th>Evidence</Th>267 <Th numeric>Planned MW</Th>268 <Th numeric>Investment</Th>269 <Th>Announced</Th>270 <Th>Updated</Th>271 <Th className="pr-3">Actions</Th>272 </Tr>273 </THead>274 <TBody>275 {rows.map((p) => {276 const isHidden = hidden[p.id] === true;277 const isOpen = open === p.id;278 return (279 <ProjectRow key={p.id} p={p} isHidden={isHidden} isOpen={isOpen} busy={busy !== null} onToggle={() => setOpen((o) => (o === p.id ? null : p.id))} onHide={() => void hide(p)} onUnhide={() => void unhide(p)} onMerge={() => void merge(p)} />280 );281 })}282 </TBody>283 </Table>284 </div>285 )}286 <Pager page={page} perPage={PER_PAGE} total={total} onPage={(p) => setParams({ page: p === 1 ? null : p })} />287 <p className="text-[11px] text-ink-4">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.</p>288 </div>289 );290}291292function 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 }) {293 return (294 <>295 <Tr className={cn(isHidden && "opacity-50", isOpen && "bg-surface-2/60")}>296 <Td primary className="pl-3">297 <div className="flex items-center gap-1.5">298 <Link href={routes.project(p.slug)} target="_blank" className="max-w-[320px] truncate font-medium text-ink hover:text-accent hover:underline" title={p.name}>299 {p.name}300 </Link>301 {isHidden && (302 <Badge size="xs" tone="warn">303 hidden304 </Badge>305 )}306 {p.isAi && (307 <Badge size="xs" tone="accent">308 AI309 </Badge>310 )}311 </div>312 <div className="text-[11px] text-ink-4">313 <Mono>{p.id}</Mono>314 {p.city && <span> · {p.city}</span>}315 </div>316 </Td>317 <Td muted>318 {p.operator ? (319 <Link href={routes.operator(p.operator.slug)} target="_blank" className="hover:text-ink hover:underline">320 {p.operator.name}321 </Link>322 ) : (323 <span className="text-ink-4">{DASH}</span>324 )}325 </Td>326 <Td>{p.countryIso2 ? <CountryFlag iso2={p.countryIso2} withName className="max-w-[140px] text-ink-2" /> : <span className="text-ink-4">{DASH}</span>}</Td>327 <Td>328 <StatusBadge status={p.status} short size="xs" />329 </Td>330 <Td>331 <ClassBadge c={p.projectClass} />332 </Td>333 <Td>334 <EvidenceBadge e={p.evidenceLevel} />335 </Td>336 <Td numeric className="figure">337 {p.plannedMw === null ? (338 <span className="text-ink-4">{DASH}</span>339 ) : (340 <span title={p.capacityScope ? `scope ${p.capacityScope}${p.capacitySemantics ? ` · ${p.capacitySemantics}` : ""}` : undefined}>341 <EstimateMark className="mr-0.5" />342 {fmtMw(p.plannedMw)}343 {p.capacityScope && <span className="ml-1 text-[10.5px] text-ink-4">{p.capacityScope}</span>}344 </span>345 )}346 </Td>347 <Td numeric className="figure">348 {p.investmentUsd === null ? (349 <span className="text-ink-4">{DASH}</span>350 ) : (351 <span title={p.investmentScope ? `scope ${p.investmentScope}` : undefined}>352 {fmtUsd(p.investmentUsd)}353 {p.investmentScope && <span className="ml-1 text-[10.5px] text-ink-4">{p.investmentScope}</span>}354 </span>355 )}356 </Td>357 <Td muted className="figure">358 {p.announcedOn ? fmtDate(p.announcedOn) : DASH}359 </Td>360 <Td muted>361 <RelativeTime iso={p.lastUpdate} />362 </Td>363 <Td className="pr-3">364 <div className="flex items-center gap-1">365 <Button size="xs" variant={isOpen ? "primary" : "secondary"} onClick={onToggle} aria-expanded={isOpen}>366 Claims367 </Button>368 {isHidden ? (369 <Button size="xs" variant="secondary" onClick={onUnhide} disabled={busy}>370 Restore371 </Button>372 ) : (373 <Button size="xs" variant="danger" onClick={onHide} disabled={busy} title="Hide as a false positive (reason required)">374 Hide375 </Button>376 )}377 <Button size="xs" variant="ghost" onClick={onMerge} disabled={busy || isHidden} title="Merge this duplicate into another project">378 Merge into…379 </Button>380 </div>381 </Td>382 </Tr>383 {isOpen && (384 <tr className="bg-surface-2/60">385 <td colSpan={11} className="px-3 pb-3 pt-1">386 <div className="mb-1 flex items-center gap-2">387 <span className="label-caps">Claims review</span>388 <span className="text-[11px] text-ink-4">Set current / Reject / Review never rewrite the project column directly; reconciliation re-derives it on the next run.</span>389 </div>390 <ClaimsReview project={p} />391 </td>392 </tr>393 )}394 </>395 );396}397