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%
19.7 KB · 397 lines tsx
Raw Blame History
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