"use client"; import Link from "next/link"; import { useMemo, useState } from "react"; import type { FacilityDetail, OperatorSummary, SearchResponse } from "@dci/core"; import { FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS } from "@dci/core"; import { Badge, Button, ConfidenceBadge, EmptyState, PrecisionBadge, SkeletonBlock, StatusBadge } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { DASH, fmtDateTimeUtc, fmtValue, placeLine } from "@/lib/format"; import { PRECISION_LABEL, STATUS_LABEL, TYPE_LABEL } from "@/lib/labels"; import { routes } from "@/lib/routes"; import { adminFetch, type FacilityPatch, type FacilityPatchResult } from "@/lib/admin-api"; import { useDebounced } from "@/lib/hooks"; import { Checkbox, ErrorNote, Field, Input, KV, Mono, Panel, Select, Textarea } from "./primitives"; import { AdminPageHeader } from "./shell"; import { useToast } from "./toast"; import { useAdminMutation, useAdminQuery } from "./use-admin-query"; interface FormState { name: string; status: string; facility_type: string; lat: string; lng: string; geo_precision: string; it_capacity_mw: string; total_power_mw: string; planned_power_mw: string; mw_is_estimate: boolean; opened_on: string; operator_id: string; operator_name: string; is_ai: boolean; is_hyperscale: boolean; description: string; note: string; } function fromDetail(f: FacilityDetail): FormState { return { name: f.name, status: f.status, facility_type: f.facilityType, lat: f.lat != null ? String(f.lat) : "", lng: f.lng != null ? String(f.lng) : "", geo_precision: f.geoPrecision, it_capacity_mw: f.itCapacityMw != null ? String(f.itCapacityMw) : "", total_power_mw: f.totalPowerMw != null ? String(f.totalPowerMw) : "", planned_power_mw: f.plannedPowerMw != null ? String(f.plannedPowerMw) : "", mw_is_estimate: f.mwIsEstimate ?? false, opened_on: f.openedOn ?? "", operator_id: f.operator?.id ?? "", operator_name: f.operator?.name ?? "", is_ai: f.isAi, is_hyperscale: f.isHyperscale, description: f.description ?? "", note: "", }; } const numOrNull = (s: string): number | null | "bad" => { if (s.trim() === "") return null; const n = Number(s); return Number.isFinite(n) ? n : "bad"; }; /** Build the PATCH body from the diff between the initial and the current form. Returns validation errors per field. */ function buildPatch(init: FormState, cur: FormState): { patch: FacilityPatch; errors: Record } { const patch: FacilityPatch = {}; const errors: Record = {}; if (cur.name !== init.name) { if (cur.name.trim().length < 2) errors.name = "at least 2 characters"; else patch.name = cur.name.trim(); } if (cur.status !== init.status) patch.status = cur.status; if (cur.facility_type !== init.facility_type) patch.facility_type = cur.facility_type; const lat = numOrNull(cur.lat); const lng = numOrNull(cur.lng); if (lat === "bad") errors.lat = "not a number"; if (lng === "bad") errors.lng = "not a number"; if (lat !== "bad" && lng !== "bad" && (cur.lat !== init.lat || cur.lng !== init.lng)) { if ((lat == null) !== (lng == null)) errors.lat = "lat and lng must be set together"; else if (lat != null && (lat < -90 || lat > 90)) errors.lat = "−90 … 90"; else if (lng != null && (lng < -180 || lng > 180)) errors.lng = "−180 … 180"; else { patch.lat = lat; patch.lng = lng; } } if (cur.geo_precision !== init.geo_precision || (patch.lat != null && cur.geo_precision === "exact")) patch.geo_precision = cur.geo_precision; for (const k of ["it_capacity_mw", "total_power_mw", "planned_power_mw"] as const) { if (cur[k] === init[k]) continue; const v = numOrNull(cur[k]); if (v === "bad") errors[k] = "not a number"; else if (v != null && v <= 0) errors[k] = "must be positive"; else if (v != null && v > (k === "planned_power_mw" ? 20000 : 10000)) errors[k] = "too large"; else patch[k] = v; } if (cur.mw_is_estimate !== init.mw_is_estimate || (cur.mw_is_estimate && (patch.it_capacity_mw !== undefined || patch.total_power_mw !== undefined || patch.planned_power_mw !== undefined))) patch.mw_is_estimate = cur.mw_is_estimate; if (cur.opened_on !== init.opened_on) { const v = cur.opened_on.trim(); if (v && !/^\d{4}(-\d{2}(-\d{2})?|-[QH]\d)?$/.test(v)) errors.opened_on = "YYYY, YYYY-MM, YYYY-MM-DD, YYYY-Qn or YYYY-Hn"; else patch.opened_on = v || null; } if (cur.operator_id !== init.operator_id) patch.operator_id = cur.operator_id || null; if (cur.is_ai !== init.is_ai) patch.is_ai = cur.is_ai; if (cur.is_hyperscale !== init.is_hyperscale) patch.is_hyperscale = cur.is_hyperscale; if (cur.description !== init.description) patch.description = cur.description.trim() || null; if (cur.note.trim()) patch.note = cur.note.trim(); return { patch, errors }; } function OperatorPicker({ value, name, onPick }: { value: string; name: string; onPick: (id: string, name: string) => void }) { const [q, setQ] = useState(""); const dq = useDebounced(q, 250); const res = useAdminQuery(dq.trim().length >= 2 ? "v1/operators" : null, { query: { q: dq.trim(), per_page: 8 } }); return (
{value ? ( {name || value} ) : ( no operator )} {value && ( )}
setQ(e.target.value)} placeholder="Search operators…" /> {dq.trim().length >= 2 && (
    {res.loading &&
  • Searching…
  • } {res.data?.map((o) => (
  • ))} {res.data && res.data.length === 0 &&
  • No operator matches.
  • }
)}
); } function MergePicker({ facilityId, onMerged }: { facilityId: string; onMerged: (survivor: string) => void }) { const [q, setQ] = useState(""); const dq = useDebounced(q, 250); const res = useAdminQuery(dq.trim().length >= 2 ? "v1/search" : null, { query: { q: dq.trim() } }); const [target, setTarget] = useState<{ id: string; slug: string; title: string; subtitle: string | null } | null>(null); const { busy, run } = useAdminMutation(); const toast = useToast(); const hits = (res.data?.hits ?? []).filter((h) => h.type === "facility" && h.id !== facilityId).slice(0, 8); const merge = async () => { if (!target) return; if (!window.confirm(`Merge this facility INTO "${target.title}"? Aliases, provenance, tenants, keys, projects and events move to the survivor; this record becomes a redirect. This cannot be undone from the console.`)) return; const r = await run<{ survivor?: string; into?: string; merged?: string; [k: string]: unknown }>("merge", `facilities/${encodeURIComponent(facilityId)}/merge`, { body: { into: target.id } }); if (r.ok) { toast({ tone: "ok", title: "Merged", description: `→ ${target.title}` }); onMerged(target.id); } else toast({ tone: "danger", title: "Merge failed", description: r.error }); }; return (
setQ(e.target.value)} placeholder="Search the surviving facility…" /> {dq.trim().length >= 2 && !target && (
    {res.loading &&
  • Searching…
  • } {hits.map((h) => (
  • ))} {res.data && hits.length === 0 &&
  • No other facility matches.
  • }
)} {target && (
Merge into {target.title} {target.subtitle} {target.id} view
)}
); } export function FacilityEditView({ id }: { id: string }) { const q = useAdminQuery(`v1/datacenters/${encodeURIComponent(id)}`); const f = q.data; // initial form = derived from the API record; `edits` holds the user's changes (null = untouched) const init = useMemo(() => (f ? fromDetail(f) : null), [f]); const [edits, setEdits] = useState(null); const form = edits ?? init; const [result, setResult] = useState(null); const [savedNote, setSavedNote] = useState(null); const [serverError, setServerError] = useState(null); const [saving, setSaving] = useState(false); const toast = useToast(); const { patch, errors } = useMemo(() => (init && form ? buildPatch(init, form) : { patch: {}, errors: {} }), [init, form]); const dirty = Object.keys(patch).filter((k) => k !== "note").length > 0; const set = (k: K, v: FormState[K]) => setEdits((p) => { const base = p ?? init; return base ? { ...base, [k]: v } : p; }); const save = async () => { if (!form || !f) return; if (Object.keys(errors).length) return; setSaving(true); setServerError(null); const r = await adminFetch(`facilities/${encodeURIComponent(f.id)}`, { method: "PATCH", body: patch }); setSaving(false); if (r.ok) { setResult(r.data); setSavedNote(patch.note ?? null); toast({ tone: "ok", title: r.data.changed.length ? `Saved ${r.data.changed.length} field${r.data.changed.length > 1 ? "s" : ""}` : "No changes", description: r.data.changed.map((c) => c.field).join(", ") || r.data.message }); setEdits(null); if (r.data.facility) q.mutate(() => r.data.facility); else void q.refresh(); } else { const details = Array.isArray(r.details) ? (r.details as Array<{ path?: string; message?: string }>).map((d) => `${d.path ?? ""}: ${d.message ?? ""}`).join("; ") : ""; setServerError(`${r.error}${details ? ` — ${details}` : ""}`); toast({ tone: "danger", title: "Save failed", description: r.error }); } }; if (q.error && !f) { return (
{id}} /> void q.refresh()} />
); } if (!f || !form || !init) return ; return (
{placeLine({ city: f.city, regionName: f.regionName, countryIso2: f.countryIso2, countryName: f.countryName ?? undefined })} {f.id} public page } aside={ <> } /> {result && result.changed.length > 0 && (
    {result.changed.map((c) => (
  • {c.field} {fmtValue(c.oldValue)} → {fmtValue(c.newValue)}
  • ))}

source src_manual · url https://www.datacenterindex.io/admin/facilities/{result.id} ·{" "} {savedNote ? <>note “{savedNote}” : no note given} · last_verified set to now · confidence raised to high when status or MW changed.

)}
set("name", e.target.value)} /> setEdits((p) => { const base = p ?? init; return base ? { ...base, operator_id: oid, operator_name: oname } : p; }) } />
set("is_ai", e.target.checked)} /> set("is_hyperscale", e.target.checked)} />
set("opened_on", e.target.value)} placeholder="YYYY-MM" mono />
set("lat", e.target.value)} placeholder="45.5017" mono inputMode="decimal" /> set("lng", e.target.value)} placeholder="-73.5673" mono inputMode="decimal" />
{form.lat && form.lng && !errors.lat && !errors.lng && (

Check on{" "} OpenStreetMap . Address on record: {f.address ?? DASH}

)}
set("it_capacity_mw", e.target.value)} mono inputMode="decimal" placeholder="—" /> set("total_power_mw", e.target.value)} mono inputMode="decimal" placeholder="—" /> set("planned_power_mw", e.target.value)} mono inputMode="decimal" placeholder="—" />
set("mw_is_estimate", e.target.checked)} />