Files
nextcraft/apps/web/components/admin/competency-graph.tsx
T
CIAgent 810485ecc8 docs(milestone): complete v0.1-nextcraft-ui-prototype
---ci---
phase: 7
milestone: v0.1
status: complete
requirements:
  covered: [REQ-001, REQ-002, REQ-003, REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027, REQ-028]
  partial: []
---/ci---

Milestone v0.1 (nextcraft-ui-prototype) complete.

Summary:
- 7 phases (P0 pre-execution + P1-P6 execution + P7 final review)
- 28 requirements covered (all complete)
- 4 surfaces: Learner (7 pages), Marketplace (5), Employer Dashboard (4), Admin (4)
- 19 routes, 104 TypeScript files
- Shared component library (5 primitives + design tokens)
- Mock data: 5 competency stacks (70 competencies), 20 jobs, 15 candidates, 10 employers
- Tech: Next.js 15, Tailwind CSS v4, lucide-react, recharts, @xyflow/react, Inter font
- Storybook with 6 stories
- Dark mode, breadcrumbs, role switcher, responsive design
- Build passes, typecheck passes, Storybook build passes

Phases:
  P0 pre-execution     → v0.0.1
  P1 project-scaffolding → v0.0.2
  P2 learner-surface   → v0.0.3
  P3 marketplace-surface → v0.0.4
  P4 employer-dashboard → v0.0.5
  P5 admin-surface     → v0.0.6
  P6 polish-integration → v0.0.7
  P7 final-review-ship → v0.1.0 (milestone release)
2026-09-10 22:47:56 +00:00

345 lines
12 KiB
TypeScript

'use client';
import { useCallback, useMemo, useState } from 'react';
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useNodesState, useEdgesState, type Node, type Edge, type NodeMouseHandler } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { competencyStacks, allCompetencies } from '@nextcraft/mock-data';
import type { CompetencyStack, Competency } from '@nextcraft/types';
import { colors } from '@nextcraft/ui';
// Stack color → hex used for node fills + minimap. Sourced from the design
// tokens (colors.*) rather than inline literals.
const STACK_HEX: Record<string, string> = {
indigo: colors.primary[500],
rose: colors.rose[500],
violet: colors.violet[500],
emerald: colors.accent[500],
cyan: colors.cyan[500],
};
const STACK_BG: Record<string, string> = {
indigo: colors.primary[50],
rose: '#fff1f2',
violet: '#f5f3ff',
emerald: colors.accent[50],
cyan: '#ecfeff',
};
interface StackNodeData {
label: string;
kind: 'stack';
stackId: string;
description: string;
competencyCount: number;
targetRoles: string;
stackColor: string;
[key: string]: unknown;
}
interface CompetencyNodeData {
label: string;
kind: 'competency';
stackId: string;
description: string;
stackColor: string;
prerequisites: string[];
learnersMastering: number;
[key: string]: unknown;
}
type GraphData = StackNodeData | CompetencyNodeData;
type GraphNode = Node<GraphData>;
function buildGraph(): { nodes: GraphNode[]; edges: Edge[] } {
const nodes: GraphNode[] = [];
const edges: Edge[] = [];
// Place stacks in a horizontal row at the top.
const stackCount = competencyStacks.length;
const stackGap = 280;
const stackY = 0;
const startX = -((stackCount - 1) * stackGap) / 2;
competencyStacks.forEach((stack: CompetencyStack, sIdx) => {
const x = startX + sIdx * stackGap;
const color = STACK_HEX[stack.color] ?? colors.primary[500];
nodes.push({
id: stack.id,
type: 'default',
position: { x, y: stackY },
data: {
label: stack.name,
kind: 'stack',
stackId: stack.id,
description: stack.description,
competencyCount: stack.competencies.length,
targetRoles: stack.targetRoles,
stackColor: color,
},
style: {
background: STACK_BG[stack.color] ?? colors.primary[50],
border: `2px solid ${color}`,
color: colors.neutral[900],
borderRadius: 12,
padding: 10,
fontWeight: 600,
fontSize: 13,
width: 220,
textAlign: 'center',
},
});
});
// Place competencies below their stack in a vertical column.
competencyStacks.forEach((stack, sIdx) => {
const stackX = startX + sIdx * stackGap;
const color = STACK_HEX[stack.color] ?? colors.primary[500];
stack.competencies.forEach((comp: Competency, cIdx) => {
// Show up to 3 competencies per stack in the graph (10-15 total).
if (cIdx >= 3) return;
const compId = comp.id;
const y = 160 + cIdx * 90;
nodes.push({
id: compId,
type: 'default',
position: { x: stackX + 30, y },
data: {
label: comp.name,
kind: 'competency',
stackId: stack.id,
description: comp.description,
stackColor: color,
prerequisites: comp.prerequisites,
learnersMastering: 12 + ((cIdx + sIdx) % 40),
},
style: {
background: '#ffffff',
border: `1.5px solid ${color}`,
color: colors.neutral[700],
borderRadius: 8,
padding: 8,
fontSize: 11,
width: 200,
},
});
// Edge: competency → parent stack.
edges.push({
id: `e-${compId}-${stack.id}`,
source: compId,
target: stack.id,
style: { stroke: color, strokeWidth: 1.5 },
type: 'smoothstep',
});
});
});
// A few cross-stack dependency edges between competencies.
const crossDeps: Array<{ from: string; to: string }> = [
{ from: 'stack-orchestration-c001', to: 'stack-safety-c001' },
{ from: 'stack-orchestration-c005', to: 'stack-science-c001' },
{ from: 'stack-designer-c001', to: 'stack-orchestration-c001' },
];
crossDeps.forEach((d, i) => {
const fromComp = allCompetencies.find((c) => c.id === d.from);
const toComp = allCompetencies.find((c) => c.id === d.to);
if (!fromComp || !toComp) return;
if (!nodes.find((n) => n.id === d.from) || !nodes.find((n) => n.id === d.to)) return;
edges.push({
id: `cross-${i}`,
source: d.from,
target: d.to,
style: { stroke: colors.neutral[400], strokeWidth: 1.5, strokeDasharray: '4 3' },
type: 'smoothstep',
animated: true,
});
});
return { nodes, edges };
}
interface SelectedStackInfo {
kind: 'stack';
label: string;
description: string;
competencyCount: number;
targetRoles: string;
stackColor: string;
}
interface SelectedCompetencyInfo {
kind: 'competency';
label: string;
description: string;
stackId: string;
stackName: string;
stackColor: string;
prerequisites: string[];
learnersMastering: number;
}
type SelectedInfo = SelectedStackInfo | SelectedCompetencyInfo | null;
function GraphInner() {
const initial = useMemo(() => buildGraph(), []);
const [nodes, , onNodesChange] = useNodesState<GraphNode>(initial.nodes);
const [edges, , onEdgesChange] = useEdgesState(initial.edges);
const [selected, setSelected] = useState<SelectedInfo>(null);
const handleNodeClick: NodeMouseHandler = useCallback((_evt, node) => {
const data = node.data as StackNodeData | CompetencyNodeData;
if (data.kind === 'stack') {
setSelected({
kind: 'stack',
label: data.label,
description: data.description,
competencyCount: data.competencyCount,
targetRoles: data.targetRoles,
stackColor: data.stackColor,
});
} else {
const stackName =
competencyStacks.find((s) => s.id === data.stackId)?.name ?? data.stackId;
setSelected({
kind: 'competency',
label: data.label,
description: data.description,
stackId: data.stackId,
stackName,
stackColor: data.stackColor,
prerequisites: data.prerequisites,
learnersMastering: data.learnersMastering,
});
}
}, []);
return (
<div className="flex h-[640px] w-full flex-col gap-4 lg:flex-row">
<div className="relative h-[400px] flex-1 overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900 lg:h-auto">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
<Controls showInteractive={false} />
<MiniMap
pannable
zoomable
nodeColor={(n) => {
const d = n.data as StackNodeData | CompetencyNodeData;
return d.stackColor ?? colors.neutral[400];
}}
maskColor="rgba(241, 245, 249, 0.7)"
/>
</ReactFlow>
</div>
{/* Detail panel */}
<aside className="w-full shrink-0 lg:w-80">
<div className="flex h-full flex-col gap-3 rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
{!selected ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-slate-500 dark:text-slate-400">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="2" />
<path d="M5 7h14M5 12h14M5 17h14" />
</svg>
</span>
<p>Select a node to view details.</p>
</div>
) : selected.kind === 'stack' ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ background: selected.stackColor }}
aria-hidden
/>
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Stack
</span>
</div>
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{selected.label}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
{selected.description}
</p>
<dl className="flex flex-col gap-2 border-t border-slate-200 pt-3 text-sm dark:border-slate-800">
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Competencies</dt>
<dd className="font-semibold text-slate-900 dark:text-slate-100">
{selected.competencyCount}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-slate-500 dark:text-slate-400">Target roles</dt>
<dd className="text-slate-800 dark:text-slate-200">
{selected.targetRoles}
</dd>
</div>
</dl>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ background: selected.stackColor }}
aria-hidden
/>
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Competency
</span>
</div>
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{selected.label}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
{selected.description}
</p>
<dl className="flex flex-col gap-2 border-t border-slate-200 pt-3 text-sm dark:border-slate-800">
<div className="flex flex-col gap-1">
<dt className="text-slate-500 dark:text-slate-400">Stack</dt>
<dd className="text-slate-800 dark:text-slate-200">
{selected.stackName}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Learners mastering</dt>
<dd className="font-semibold text-slate-900 dark:text-slate-100">
{selected.learnersMastering}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-slate-500 dark:text-slate-400">Prerequisites</dt>
<dd className="text-slate-800 dark:text-slate-200">
{selected.prerequisites.length === 0
? 'None'
: `${selected.prerequisites.length} prerequisite(s)`}
</dd>
</div>
</dl>
</div>
)}
</div>
</aside>
</div>
);
}
export function CompetencyGraph() {
return (
<ReactFlowProvider>
<GraphInner />
</ReactFlowProvider>
);
}