130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { BriefSummary } from "@tasks/shared/orchestrator";
|
||
|
|
|
||
|
|
interface ConvoyDagProps {
|
||
|
|
briefs: Array<{
|
||
|
|
id: string;
|
||
|
|
briefNumber: number;
|
||
|
|
sliceDependencies: number[];
|
||
|
|
status: string | null;
|
||
|
|
}>;
|
||
|
|
completedBriefIds: Set<string>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ConvoyDag({ briefs, completedBriefIds }: ConvoyDagProps) {
|
||
|
|
if (briefs.length === 0) return null;
|
||
|
|
|
||
|
|
const briefByNum = new Map(briefs.map((b) => [b.briefNumber, b]));
|
||
|
|
|
||
|
|
const nodeSize = 60;
|
||
|
|
const spacing = 180;
|
||
|
|
const startX = 80;
|
||
|
|
const startY = 80;
|
||
|
|
|
||
|
|
const getPosition = (num: number) => ({
|
||
|
|
x: startX + ((num - 1) % 4) * spacing,
|
||
|
|
y: startY + Math.floor((num - 1) / 4) * 140,
|
||
|
|
});
|
||
|
|
|
||
|
|
const briefsSorted = [...briefs].sort((a, b) => a.briefNumber - b.briefNumber);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="w-full h-[300px] overflow-auto bg-muted/30 rounded-lg p-4 relative">
|
||
|
|
<svg
|
||
|
|
className="w-full h-full"
|
||
|
|
style={{ width: "100%", height: "100%" }}
|
||
|
|
viewBox="0 0 800 600"
|
||
|
|
>
|
||
|
|
<defs>
|
||
|
|
<marker
|
||
|
|
id="arrowhead"
|
||
|
|
markerWidth="10"
|
||
|
|
markerHeight="7"
|
||
|
|
refX="9"
|
||
|
|
refY="3.5"
|
||
|
|
orient="auto"
|
||
|
|
>
|
||
|
|
<path d="M0,0 L10,3.5 L0,7 Z" fill="currentColor" />
|
||
|
|
</marker>
|
||
|
|
</defs>
|
||
|
|
|
||
|
|
{briefsSorted.map((brief) => {
|
||
|
|
const pos = getPosition(brief.briefNumber);
|
||
|
|
const isComplete = completedBriefIds.has(brief.id);
|
||
|
|
const isEligible = !isComplete && brief.sliceDependencies.every((dep) => {
|
||
|
|
const depBrief = briefByNum.get(dep);
|
||
|
|
return depBrief && completedBriefIds.has(depBrief.id);
|
||
|
|
});
|
||
|
|
|
||
|
|
brief.sliceDependencies.forEach((depNum) => {
|
||
|
|
const dep = briefByNum.get(depNum);
|
||
|
|
if (dep) {
|
||
|
|
const depPos = getPosition(depNum);
|
||
|
|
return (
|
||
|
|
<line
|
||
|
|
key={`edge-${depNum}-${brief.briefNumber}`}
|
||
|
|
x1={depPos.x + nodeSize}
|
||
|
|
y1={depPos.y + nodeSize / 2}
|
||
|
|
x2={pos.x}
|
||
|
|
y2={pos.y + nodeSize / 2}
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth={2}
|
||
|
|
markerEnd="url(#arrowhead)"
|
||
|
|
opacity={0.4}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return (
|
||
|
|
<g key={`node-${brief.briefNumber}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||
|
|
<rect
|
||
|
|
width={nodeSize}
|
||
|
|
height={nodeSize}
|
||
|
|
rx={8}
|
||
|
|
fill={isComplete ? "var(--green-3)" : isEligible ? "var(--blue-3)" : "var(--gray-4)"}
|
||
|
|
stroke={isComplete ? "var(--green-7)" : isEligible ? "var(--blue-7)" : "var(--gray-7)"}
|
||
|
|
strokeWidth={2}
|
||
|
|
/>
|
||
|
|
<text
|
||
|
|
x={nodeSize / 2}
|
||
|
|
y={nodeSize / 2 + 4}
|
||
|
|
textAnchor="middle"
|
||
|
|
dominantBaseline="middle"
|
||
|
|
fill="var(--white)"
|
||
|
|
fontSize={14}
|
||
|
|
fontWeight={600}
|
||
|
|
>
|
||
|
|
B{brief.briefNumber}
|
||
|
|
</text>
|
||
|
|
{isComplete && (
|
||
|
|
<circle
|
||
|
|
cx={nodeSize - 12}
|
||
|
|
cy={12}
|
||
|
|
r={10}
|
||
|
|
fill="var(--green-7)"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</g>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</svg>
|
||
|
|
|
||
|
|
<p className="mt-4 text-xs text-muted-foreground">
|
||
|
|
Green = Complete | Blue = Ready | Gray = Blocked
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getPosition(num: number) {
|
||
|
|
const nodeSize = 60;
|
||
|
|
const spacing = 180;
|
||
|
|
const startX = 80;
|
||
|
|
const startY = 80;
|
||
|
|
return {
|
||
|
|
x: 80 + ((num - 1) % 4) * 180,
|
||
|
|
y: 80 + Math.floor((num - 1) / 4) * 140,
|
||
|
|
};
|
||
|
|
}
|