1
0
Fork 0
polaris-task-force/src/components/admin/tech-tree/TechNode.tsx
Z8MB1E 89482798f4 feat(tech-tree): add admin tech tree editor view
Add a custom admin view over the technologies collection so staff can model
prerequisites as a left-to-right tree instead of a flat list.

- React Flow v12 canvas with category bands, OR-prerequisite edges, a
  lite side-panel editor, and a quick-create dialog.
- Propagating layout: a technology with prerequisites lands one column right
  of its parents at their average row; uncategorized roots fill a catalog
  grid keyed by each tech's rank among all uncategorized technologies, so
  promoting one into the tree leaves an empty slot instead of re-shuffling
  the grid.
- Stability contract: connecting or removing edges never moves a card (the
  connected node is pinned to its current spot), and a "Save positions"
  control commits every on-screen node so the arrangement survives reload;
  "Auto-arrange" clears manual positions and restores the ordered layout.
- Progression-path edge colors and per-source lanes keep overlapping edges
  readable. Wire the view and its nav entry into payload.config.ts and add
  dark React Flow / scrollbar styling.
2026-10-01 10:03:58 -04:00

128 lines
4.6 KiB
TypeScript

"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { TriangleAlertIcon } from "lucide-react";
import React from "react";
import type { TreeTech } from "./types";
export type TechNodeData = {
tech: TreeTech;
categoryName: string | null;
refLabel: string;
hasWarning: boolean;
connectable: boolean;
};
export type TechNodeModel = Node<TechNodeData, "techCard">;
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
in_progress: { label: "In Progress", className: "text-blue-400" },
ready_for_review: { label: "Ready for Review", className: "text-amber-400" },
approved: { label: "Approved", className: "text-emerald-400" },
rejected: { label: "Rejected", className: "text-red-400" },
revision_requested: { label: "Revision Requested", className: "text-orange-400" },
};
function CornerBrackets() {
const base = "pointer-events-none absolute size-2 border-white/25";
return (
<div aria-hidden className="pointer-events-none absolute inset-1">
<span className={`${base} top-0 left-0 border-t border-l`} />
<span className={`${base} top-0 right-0 border-t border-r`} />
<span className={`${base} bottom-0 left-0 border-b border-l`} />
<span className={`${base} right-0 bottom-0 border-r border-b`} />
</div>
);
}
/**
* Technology card node: tactical dossier styling (squared corners,
* classification strip, corner brackets, mono readouts).
*/
export function TechNodeCard({ data, selected }: NodeProps<TechNodeModel>) {
const { tech, categoryName, refLabel, hasWarning, connectable } = data;
const status = STATUS_LABELS[tech.approvalStatus] ?? {
label: tech.approvalStatus,
className: "text-white/60",
};
return (
<div
data-testid={`tech-node-${tech.id}`}
className="relative h-full w-full"
>
{/* Clipped card surface (the handles live outside this box) */}
<div
className={`flex h-full w-full flex-col overflow-hidden rounded-none border bg-black/60 backdrop-blur-sm transition-colors ${
selected
? "border-[#22d3ee]/70 shadow-[0_0_18px_rgba(34,211,238,0.15)]"
: "border-white/15 hover:border-white/40"
}`}
>
<CornerBrackets />
{/* Classification strip */}
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-white/15 bg-black/50 px-3.5 py-1.5">
<span className="truncate font-mono text-[8px] tracking-widest text-white/50 uppercase">
TECH // {categoryName ?? "UNFILED"}
</span>
<span className="shrink-0 font-mono text-[8px] tracking-widest text-white/40 uppercase">
{refLabel}
</span>
</div>
{/* Body */}
<div className="flex-1 px-3.5 py-2.5">
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-sm leading-snug font-semibold text-white">
{tech.name}
</span>
{hasWarning ? (
<TriangleAlertIcon
className="mt-0.5 size-3.5 shrink-0 text-amber-400"
aria-label="Timeline warning"
data-testid={`tech-warning-${tech.id}`}
/>
) : null}
</div>
<div className="mt-2 flex items-center gap-2">
<span className="rounded-sm border border-white/15 px-1 py-px font-mono text-[8px] tracking-widest text-white/60 uppercase">
{tech.type}
</span>
<span
className={`font-mono text-[9px] tracking-widest uppercase ${status.className}`}
>
{status.label}
</span>
</div>
</div>
{/* Footer readout */}
<div className="flex shrink-0 items-center justify-between border-t border-white/15 bg-black/60 px-3.5 py-1.5">
<span className="font-mono text-[9px] tracking-widest text-white/50 uppercase tabular-nums">
{tech.type === "asset" ? "UNLOCK" : "UPGRADE"}
</span>
<span className="font-mono text-[9px] tracking-widest text-white/35 uppercase">
{tech.categoryId ? "BAND" : "ZONE"}
</span>
</div>
</div>
<Handle
type="target"
position={Position.Left}
className="!bg-white/45 hover:!bg-[#22d3ee]"
style={{ width: 11, height: 11, border: "1px solid rgba(0,0,0,0.6)" }}
isConnectable={connectable}
/>
<Handle
type="source"
position={Position.Right}
className="!bg-white/45 hover:!bg-[#22d3ee]"
style={{ width: 11, height: 11, border: "1px solid rgba(0,0,0,0.6)" }}
isConnectable={connectable}
/>
</div>
);
}