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.
128 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|