810485ecc8
---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)
345 lines
12 KiB
TypeScript
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>
|
|
);
|
|
} |