docs(P05): complete admin-surface phase

---ci---
phase: 5
milestone: v0.1
status: complete
requirements:
  covered: [REQ-022, REQ-023, REQ-024, REQ-025]
  partial: []
---/ci---

Phase 5 (admin-surface) complete:
- Admin overview: 5 metric cards, activity feed (10 events), system health (6 services)
- Learner management: searchable table with detail panel (15 mock learners)
- Competency graph viewer: interactive @xyflow/react graph (20 nodes, clickable, minimap)
- Marketplace moderation: 3 tabbed queues with action buttons
- 4 client components: learner-table, learner-detail-panel, competency-graph, moderation-tabs
- packages/mock-data/admin.ts with all admin mock data
- All pages responsive, dark mode supported, build passes
This commit is contained in:
CIAgent
2026-09-10 22:29:32 +00:00
parent a38512f493
commit e9ba886dbb
12 changed files with 1849 additions and 15 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"phase": 4,
"phase": 5,
"stage": "execute",
"milestone": "v0.1",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-10T22:10:00Z"
"updated_at": "2026-09-10T22:20:00Z"
}
+17
View File
@@ -0,0 +1,17 @@
import { CompetencyGraph } from '../../../../components/admin/competency-graph';
export default function CompetencyGraphPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Competency Graph
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Visualize stack relationships and dependencies
</p>
</header>
<CompetencyGraph />
</div>
);
}
@@ -0,0 +1,18 @@
import { LearnerTable } from '../../../../components/admin/learner-table';
import { adminLearners } from '@nextcraft/mock-data';
export default function LearnerManagementPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Learner Management
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Track and manage learner progress
</p>
</header>
<LearnerTable learners={adminLearners} />
</div>
);
}
@@ -0,0 +1,17 @@
import { ModerationTabs } from '../../../../components/admin/moderation-tabs';
export default function MarketplaceModerationPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Marketplace Moderation
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Review and manage marketplace content
</p>
</header>
<ModerationTabs />
</div>
);
}
+239 -10
View File
@@ -1,15 +1,244 @@
import { Card, CardBody, Badge } from '@nextcraft/ui';
import {
Activity,
AlertTriangle,
Briefcase,
Building2,
CheckCircle,
Clock,
Cpu,
Database,
GraduationCap,
Server,
ThumbsUp,
Users,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@nextcraft/ui';
import {
activityFeed,
platformMetrics,
serviceHealth,
systemStats,
uptimeBars,
} from '@nextcraft/mock-data';
export default function AdminHome() {
const ICONS: Record<string, LucideIcon> = {
Users,
Building2,
Briefcase,
GraduationCap,
ThumbsUp,
Activity,
Server,
Database,
Cpu,
};
const TONE_CLASSES: Record<string, string> = {
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
rose: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
cyan: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
};
function statusDot(status: string): string {
if (status === 'operational') return 'bg-emerald-500';
if (status === 'degraded') return 'bg-amber-500';
return 'bg-rose-500';
}
function statusLabel(status: string): string {
if (status === 'operational') return 'Operational';
if (status === 'degraded') return 'Degraded';
return 'Down';
}
export default function AdminOverviewPage() {
return (
<Card>
<CardBody className="flex flex-col gap-3">
<Badge variant="error">Admin surface</Badge>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Admin overview</h1>
<p className="text-slate-600 dark:text-slate-400">
Placeholder cohort oversight, content governance, and system health arrive in Phase 5.
<div className="flex flex-col gap-8">
{/* Page header */}
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Admin Dashboard
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Platform overview
</p>
</CardBody>
</Card>
</header>
{/* Metric cards */}
<section
aria-label="Platform metrics"
className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-5"
>
{platformMetrics.map((m) => {
const Icon = ICONS[m.icon] ?? Activity;
const positive = m.trendPct >= 0;
return (
<Card key={m.id}>
<CardBody className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span
className={`inline-flex h-9 w-9 items-center justify-center rounded-full ${TONE_CLASSES[m.tone]}`}
aria-hidden
>
<Icon className="h-4 w-4" />
</span>
<span
className={`text-xs font-medium ${positive ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'}`}
>
{positive ? '+' : ''}
{m.trendPct}%
</span>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{m.label}
</p>
<p className="mt-0.5 text-2xl font-bold text-slate-900 dark:text-slate-100">
{m.value.toLocaleString()}
{m.suffix}
</p>
</div>
</CardBody>
</Card>
);
})}
</section>
{/* Activity + System health */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Activity feed — 2/3 width */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-primary-600" aria-hidden />
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
Activity Feed
</h2>
</div>
</CardHeader>
<CardBody className="p-0">
<ol className="flex flex-col">
{activityFeed.map((evt, i) => {
const Icon = ICONS[evt.icon] ?? Activity;
const last = i === activityFeed.length - 1;
return (
<li
key={evt.id}
className={`flex items-start gap-3 px-6 py-3 ${last ? '' : 'border-b border-slate-100 dark:border-slate-800'}`}
>
<span
className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${TONE_CLASSES[evt.tone]}`}
aria-hidden
>
<Icon className="h-4 w-4" />
</span>
<div className="flex flex-1 flex-col gap-0.5">
<p className="text-sm text-slate-700 dark:text-slate-200">
{evt.message}
</p>
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Clock className="h-3 w-3" aria-hidden />
{evt.relativeTime}
</span>
</div>
</li>
);
})}
</ol>
</CardBody>
</Card>
</div>
{/* System health — 1/3 width */}
<div>
<Card className="h-full">
<CardHeader>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-primary-600" aria-hidden />
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
System Health
</h2>
</div>
</CardHeader>
<CardBody className="flex flex-col gap-5">
<ul className="flex flex-col gap-2.5">
{serviceHealth.map((svc) => (
<li
key={svc.id}
className="flex items-center justify-between gap-2"
>
<span className="text-sm text-slate-700 dark:text-slate-200">
{svc.name}
</span>
<span className="flex items-center gap-2">
<span
className={`h-2.5 w-2.5 rounded-full ${statusDot(svc.status)}`}
aria-hidden
/>
<span
className={`text-xs font-medium ${svc.status === 'operational' ? 'text-emerald-600 dark:text-emerald-400' : svc.status === 'degraded' ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'}`}
>
{statusLabel(svc.status)}
</span>
</span>
</li>
))}
</ul>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Uptime (30d)
</h3>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">
99.96%
</span>
</div>
<div className="flex h-8 items-end gap-0.5" aria-hidden>
{uptimeBars.map((u, idx) => {
const h = Math.max(20, Math.round((u - 99.8) * 100 * 4));
const degraded = u < 99.95;
return (
<span
key={idx}
className={`flex-1 rounded-sm ${degraded ? 'bg-amber-400' : 'bg-emerald-400'}`}
style={{ height: `${Math.min(100, h)}%` }}
title={`${u.toFixed(2)}%`}
/>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t border-slate-200 pt-4 dark:border-slate-800">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-slate-500 dark:text-slate-400">
Error Rate
</span>
<span className="flex items-center gap-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-hidden />
{systemStats.errorRate}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-slate-500 dark:text-slate-400">
Avg Response Time
</span>
<span className="flex items-center gap-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
<CheckCircle className="h-3.5 w-3.5 text-emerald-500" aria-hidden />
{systemStats.avgResponseMs}ms
</span>
</div>
</div>
</CardBody>
</Card>
</div>
</section>
</div>
);
}
+3 -2
View File
@@ -4,8 +4,9 @@ import { Shield } from 'lucide-react';
const LINKS = [
{ label: 'Overview', href: '/admin' },
{ label: 'Cohorts', href: '/admin/cohorts' },
{ label: 'Content', href: '/admin/content' },
{ label: 'Learners', href: '/admin/learners' },
{ label: 'Competency Graph', href: '/admin/graph' },
{ label: 'Moderation', href: '/admin/moderation' },
];
export default function AdminLayout({ children }: { children: ReactNode }) {
@@ -0,0 +1,343 @@
'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';
// Stack color → tailwind-friendly hex (used for node fills + minimap).
const STACK_HEX: Record<string, string> = {
indigo: '#6366f1',
rose: '#f43f5e',
violet: '#8b5cf6',
emerald: '#10b981',
cyan: '#06b6d4',
};
const STACK_BG: Record<string, string> = {
indigo: '#eef2ff',
rose: '#fff1f2',
violet: '#f5f3ff',
emerald: '#ecfdf5',
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] ?? '#6366f1';
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] ?? '#eef2ff',
border: `2px solid ${color}`,
color: '#0f172a',
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] ?? '#6366f1';
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: '#334155',
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: '#94a3b8', 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 ?? '#94a3b8';
}}
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>
);
}
@@ -0,0 +1,197 @@
'use client';
import { X, Mail, CalendarDays, GraduationCap, Award, CheckCircle, Circle, Lock } from 'lucide-react';
import { Badge } from '@nextcraft/ui';
import type { AdminLearner } from '@nextcraft/mock-data';
export interface LearnerDetailPanelProps {
learner: AdminLearner | null;
onClose: () => void;
}
const STATUS_CLASSES: Record<string, string> = {
mastered: 'text-emerald-600 dark:text-emerald-400',
in_progress: 'text-amber-600 dark:text-amber-400',
available: 'text-slate-500 dark:text-slate-400',
locked: 'text-slate-300 dark:text-slate-600',
};
const STATUS_LABEL: Record<string, string> = {
mastered: 'Mastered',
in_progress: 'In Progress',
available: 'Available',
locked: 'Locked',
};
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
export function LearnerDetailPanel({ learner, onClose }: LearnerDetailPanelProps) {
if (!learner) return null;
const masteredCount = learner.masteredCount;
const total = learner.totalCompetencies;
const pct = learner.progressPct;
return (
<div
className="fixed inset-0 z-50 flex justify-end bg-slate-900/40 backdrop-blur-sm md:inset-y-0 md:right-0 md:w-[480px]"
role="dialog"
aria-modal="true"
aria-label={`Learner details for ${learner.name}`}
>
<div className="flex h-full w-full flex-col overflow-y-auto bg-white shadow-2xl dark:bg-slate-900 md:w-[480px]">
{/* Header */}
<div className="flex items-start justify-between border-b border-slate-200 p-5 dark:border-slate-800">
<div className="flex items-center gap-3">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200">
{initials(learner.name)}
</span>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{learner.name}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{learner.stackName}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close detail panel"
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="flex flex-col gap-6 p-5">
{/* Profile */}
<section className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Profile
</h3>
<div className="grid grid-cols-1 gap-2 text-sm">
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-200">
<Mail className="h-4 w-4 text-slate-400" aria-hidden />
<span>{learner.email}</span>
</div>
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-200">
<CalendarDays className="h-4 w-4 text-slate-400" aria-hidden />
<span>Joined {formatDate(learner.joinedAt)}</span>
</div>
</div>
</section>
{/* Progress */}
<section className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Progress Tracking
</h3>
<div className="rounded-lg border border-slate-200 p-4 dark:border-slate-800">
<div className="mb-2 flex items-end justify-between">
<span className="text-sm text-slate-600 dark:text-slate-300">
{masteredCount} of {total} competencies mastered
</span>
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{pct}%
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className="h-full rounded-full bg-primary-600 transition-all"
style={{ width: `${pct}%` }}
aria-label={`${pct}% mastered`}
/>
</div>
</div>
</section>
{/* Competencies */}
<section className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Competency Completion
</h3>
<ul className="flex flex-col divide-y divide-slate-100 rounded-lg border border-slate-200 dark:divide-slate-800 dark:border-slate-800">
{learner.competencies.map((c) => {
const Icon =
c.status === 'mastered'
? CheckCircle
: c.status === 'locked'
? Lock
: Circle;
return (
<li
key={c.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<Icon
className={`h-4 w-4 shrink-0 ${STATUS_CLASSES[c.status]}`}
aria-hidden
/>
<span className="flex-1 text-sm text-slate-700 dark:text-slate-200">
{c.name}
</span>
<span className={`text-xs font-medium ${STATUS_CLASSES[c.status]}`}>
{STATUS_LABEL[c.status]}
</span>
</li>
);
})}
</ul>
</section>
{/* Credentials */}
<section className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Credential Issuance Log
</h3>
{learner.credentials.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-300 p-4 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
No microcredentials issued yet.
</p>
) : (
<ul className="flex flex-col gap-2">
{learner.credentials.map((cr) => (
<li
key={cr.id}
className="flex items-start gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800"
>
<span className="mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
<Award className="h-3.5 w-3.5" aria-hidden />
</span>
<div className="flex flex-1 flex-col gap-0.5">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{cr.name}
</p>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<GraduationCap className="h-3 w-3" aria-hidden />
Issued {formatDate(cr.issuedAt)}
</span>
<Badge variant="success">{cr.score}/100</Badge>
</div>
</div>
</li>
))}
</ul>
)}
</section>
</div>
</div>
</div>
);
}
function initials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('');
}
+229
View File
@@ -0,0 +1,229 @@
'use client';
import { useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { Badge, Input } from '@nextcraft/ui';
import type { AdminLearner, LearnerStatus } from '@nextcraft/mock-data';
import { competencyStacks } from '@nextcraft/mock-data';
import { LearnerDetailPanel } from './learner-detail-panel';
export interface LearnerTableProps {
learners: AdminLearner[];
}
const STATUS_VARIANT: Record<LearnerStatus, 'success' | 'default' | 'warning'> = {
active: 'success',
completed: 'default',
paused: 'warning',
};
const STATUS_LABEL: Record<LearnerStatus, string> = {
active: 'Active',
completed: 'Completed',
paused: 'Paused',
};
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function initials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('');
}
export function LearnerTable({ learners }: LearnerTableProps) {
const [query, setQuery] = useState('');
const [stackFilter, setStackFilter] = useState<string>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return learners.filter((l) => {
if (q && !`${l.name} ${l.email}`.toLowerCase().includes(q)) return false;
if (stackFilter !== 'all' && l.stackId !== stackFilter) return false;
return true;
});
}, [learners, query, stackFilter]);
const selected = learners.find((l) => l.id === selectedId) ?? null;
return (
<div className="flex flex-col gap-4">
{/* Top bar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="sm:max-w-xs sm:flex-1">
<Input
name="learner-search"
placeholder="Search by name or email..."
icon={<Search className="h-4 w-4" />}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search learners"
/>
</div>
<div className="flex items-center gap-2">
<label htmlFor="stack-filter" className="text-sm text-slate-600 dark:text-slate-300">
Stack
</label>
<select
id="stack-filter"
aria-label="Filter by competency stack"
value={stackFilter}
onChange={(e) => setStackFilter(e.target.value)}
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 sm:w-auto"
>
<option value="all">All stacks</option>
{competencyStacks.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">
Showing{' '}
<span className="font-semibold text-slate-700 dark:text-slate-200">
{filtered.length}
</span>{' '}
of {learners.length} learners
</p>
{/* Desktop table */}
<div className="hidden overflow-hidden rounded-lg border border-slate-200 dark:border-slate-800 md:block">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 dark:bg-slate-800/60 dark:text-slate-400">
<tr>
<th className="px-4 py-3 text-left font-semibold">Name</th>
<th className="px-4 py-3 text-left font-semibold">Email</th>
<th className="px-4 py-3 text-left font-semibold">Stack</th>
<th className="px-4 py-3 text-left font-semibold">Progress</th>
<th className="px-4 py-3 text-left font-semibold">Status</th>
<th className="px-4 py-3 text-left font-semibold">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 bg-white dark:divide-slate-800 dark:bg-slate-900">
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-10 text-center text-slate-500 dark:text-slate-400">
No learners match your filters.
</td>
</tr>
) : (
filtered.map((l) => (
<tr
key={l.id}
onClick={() => setSelectedId(l.id)}
className="cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200">
{initials(l.name)}
</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{l.name}
</span>
</div>
</td>
<td className="px-4 py-3 text-slate-600 dark:text-slate-400">
{l.email}
</td>
<td className="px-4 py-3 text-slate-600 dark:text-slate-400">
{l.stackName}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-2 w-24 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className="h-full rounded-full bg-primary-500"
style={{ width: `${l.progressPct}%` }}
/>
</div>
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">
{l.progressPct}%
</span>
</div>
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[l.status]}>
{STATUS_LABEL[l.status]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-600 dark:text-slate-400">
{formatDate(l.joinedAt)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Mobile card list */}
<div className="flex flex-col gap-3 md:hidden">
{filtered.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
No learners match your filters.
</div>
) : (
filtered.map((l) => (
<button
key={l.id}
type="button"
onClick={() => setSelectedId(l.id)}
className="flex flex-col gap-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm transition-colors hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800/60"
>
<div className="flex items-center gap-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200">
{initials(l.name)}
</span>
<div className="flex flex-1 flex-col">
<span className="font-medium text-slate-900 dark:text-slate-100">
{l.name}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{l.email}
</span>
</div>
<Badge variant={STATUS_VARIANT[l.status]}>
{STATUS_LABEL[l.status]}
</Badge>
</div>
<div className="flex items-center justify-between gap-2 text-xs text-slate-600 dark:text-slate-400">
<span>{l.stackName}</span>
<span>Joined {formatDate(l.joinedAt)}</span>
</div>
<div className="flex items-center gap-2">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className="h-full rounded-full bg-primary-500"
style={{ width: `${l.progressPct}%` }}
/>
</div>
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">
{l.progressPct}%
</span>
</div>
</button>
))
)}
</div>
<LearnerDetailPanel
learner={selected}
onClose={() => setSelectedId(null)}
/>
</div>
);
}
@@ -0,0 +1,288 @@
'use client';
import { useState } from 'react';
import {
CheckCircle,
FileText,
Flag,
Shield,
XCircle,
Building2,
} from 'lucide-react';
import { Badge } from '@nextcraft/ui';
import {
employerVerificationQueue,
flaggedContentQueue,
jobReviewQueue,
} from '@nextcraft/mock-data';
import type {
EmployerVerificationItem,
FlaggedContentItem,
JobReviewItem,
} from '@nextcraft/mock-data';
type TabId = 'jobs' | 'employers' | 'flagged';
const TABS: Array<{ id: TabId; label: string }> = [
{ id: 'jobs', label: 'Job Posting Review' },
{ id: 'employers', label: 'Employer Verification' },
{ id: 'flagged', label: 'Flagged Content' },
];
const STATUS_VARIANT = { ok: 'success', warn: 'warning', err: 'error' } as const;
export function ModerationTabs() {
const [tab, setTab] = useState<TabId>('jobs');
const counts: Record<TabId, number> = {
jobs: jobReviewQueue.length,
employers: employerVerificationQueue.length,
flagged: flaggedContentQueue.length,
};
return (
<div className="flex flex-col gap-5">
{/* Tab bar */}
<div className="flex flex-wrap gap-1 rounded-lg border border-slate-200 bg-white p-1 dark:border-slate-800 dark:bg-slate-900">
{TABS.map((t) => {
const active = t.id === tab;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={`inline-flex items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors ${
active
? 'bg-primary-600 text-white shadow-sm'
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800'
}`}
aria-pressed={active}
>
{t.label}
<span
className={`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold ${
active
? 'bg-white/20 text-white'
: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200'
}`}
>
{counts[t.id]}
</span>
</button>
);
})}
</div>
{tab === 'jobs' && <JobReviewTab />}
{tab === 'employers' && <EmployerVerificationTab />}
{tab === 'flagged' && <FlaggedContentTab />}
</div>
);
}
// ---------------------------------------------------------------------------
// Tab 1: Job Posting Review
// ---------------------------------------------------------------------------
function JobReviewTab() {
return (
<div className="flex flex-col gap-3">
{jobReviewQueue.map((item) => (
<JobReviewCard key={item.id} item={item} />
))}
</div>
);
}
function JobReviewCard({ item }: { item: JobReviewItem }) {
return (
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
<FileText className="h-5 w-5" />
</span>
<div className="flex flex-col gap-0.5">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{item.title}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
{item.employerName} · {item.stackName}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{item.location} · Submitted {formatDate(item.submittedAt)}
</p>
</div>
</div>
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
Review
</button>
</div>
<div className="flex flex-wrap gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
<ActionButton tone="success" icon={<CheckCircle className="h-3.5 w-3.5" />}>
Approve
</ActionButton>
<ActionButton tone="error" icon={<XCircle className="h-3.5 w-3.5" />}>
Reject
</ActionButton>
<ActionButton tone="warning" icon={<Flag className="h-3.5 w-3.5" />}>
Flag
</ActionButton>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Tab 2: Employer Verification
// ---------------------------------------------------------------------------
function EmployerVerificationTab() {
return (
<div className="flex flex-col gap-3">
{employerVerificationQueue.map((item) => (
<EmployerVerificationCard key={item.id} item={item} />
))}
</div>
);
}
function EmployerVerificationCard({ item }: { item: EmployerVerificationItem }) {
return (
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200">
{item.logoInitials}
</span>
<div className="flex flex-col gap-0.5">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{item.name}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
{item.industry} · {item.location}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Submitted {formatDate(item.submittedAt)}
</p>
</div>
</div>
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
Review Documents
</button>
</div>
<div className="flex flex-wrap gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
<ActionButton tone="success" icon={<CheckCircle className="h-3.5 w-3.5" />}>
Verify
</ActionButton>
<ActionButton tone="info" icon={<Building2 className="h-3.5 w-3.5" />}>
Request More Info
</ActionButton>
<ActionButton tone="error" icon={<XCircle className="h-3.5 w-3.5" />}>
Reject
</ActionButton>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Tab 3: Flagged Content
// ---------------------------------------------------------------------------
function FlaggedContentTab() {
return (
<div className="flex flex-col gap-3">
{flaggedContentQueue.map((item) => (
<FlaggedContentCard key={item.id} item={item} />
))}
</div>
);
}
function FlaggedContentCard({ item }: { item: FlaggedContentItem }) {
return (
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">
<Shield className="h-5 w-5" />
</span>
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{item.title}
</h3>
<Badge variant="warning">{item.contentType}</Badge>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Flagged by {item.flaggedBy} · {item.reason}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{formatDate(item.flaggedAt)}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
<ActionButton tone="neutral" icon={<XCircle className="h-3.5 w-3.5" />}>
Dismiss Flag
</ActionButton>
<ActionButton tone="error" icon={<XCircle className="h-3.5 w-3.5" />}>
Remove Content
</ActionButton>
<ActionButton tone="warning" icon={<Flag className="h-3.5 w-3.5" />}>
Warn User
</ActionButton>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Shared action button
// ---------------------------------------------------------------------------
type ActionTone = 'success' | 'error' | 'warning' | 'info' | 'neutral';
const ACTION_CLASSES: Record<ActionTone, string> = {
success: 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 dark:hover:bg-emerald-900/50',
error: 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-800 dark:bg-rose-900/30 dark:text-rose-300 dark:hover:bg-rose-900/50',
warning: 'border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/50',
info: 'border-primary-300 bg-primary-50 text-primary-700 hover:bg-primary-100 dark:border-primary-800 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/50',
neutral: 'border-slate-300 bg-slate-50 text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700',
};
function ActionButton({
tone,
icon,
children,
}: {
tone: ActionTone;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors ${ACTION_CLASSES[tone]}`}
>
{icon}
{children}
</button>
);
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
+470
View File
@@ -0,0 +1,470 @@
/**
* Nextcraft — Admin Surface Mock Data
*
* Mock data for the admin surface only: platform metrics, activity feed,
* system health, learner roster (admin view), and marketplace moderation
* queues. Pure prototype data — no backend, no business logic.
*/
// ---------------------------------------------------------------------------
// Platform metrics (REQ-022)
// ---------------------------------------------------------------------------
export interface PlatformMetric {
id: string;
label: string;
value: number;
/** Rendered suffix (e.g. "%" or ""). */
suffix: string;
/** Trend vs previous period, in percentage points (signed). */
trendPct: number;
/** Lucide icon name (resolved by the page). */
icon: string;
/** Tailwind color token used for the icon chip. */
tone: 'indigo' | 'emerald' | 'amber' | 'rose' | 'cyan' | 'violet';
}
export const platformMetrics: PlatformMetric[] = [
{
id: 'metric-learners',
label: 'Total Learners',
value: 1247,
suffix: '',
trendPct: 4.2,
icon: 'Users',
tone: 'indigo',
},
{
id: 'metric-employers',
label: 'Total Employers',
value: 89,
suffix: '',
trendPct: 1.8,
icon: 'Building2',
tone: 'emerald',
},
{
id: 'metric-placements',
label: 'Active Placements',
value: 312,
suffix: '',
trendPct: 6.5,
icon: 'Briefcase',
tone: 'amber',
},
{
id: 'metric-completion',
label: 'Completion Rate',
value: 38,
suffix: '%',
trendPct: 2.1,
icon: 'GraduationCap',
tone: 'violet',
},
{
id: 'metric-nps',
label: 'NPS Score',
value: 47,
suffix: '',
trendPct: -1.2,
icon: 'ThumbsUp',
tone: 'cyan',
},
];
// ---------------------------------------------------------------------------
// Activity feed (REQ-022)
// ---------------------------------------------------------------------------
export interface ActivityEvent {
id: string;
message: string;
/** Relative-time label shown to the user (kept static for the prototype). */
relativeTime: string;
/** Lucide icon name. */
icon: string;
/** Tailwind color token used for the icon chip. */
tone: 'indigo' | 'emerald' | 'amber' | 'rose' | 'cyan' | 'violet';
}
export const activityFeed: ActivityEvent[] = [
{
id: 'act-001',
message: 'New learner registered: Sarah Chen joined AI Orchestration Engineer stack',
relativeTime: '5 min ago',
icon: 'UserPlus',
tone: 'indigo',
},
{
id: 'act-002',
message: "Competency mastered: Marcus Lee completed 'Multi-Agent Communication'",
relativeTime: '23 min ago',
icon: 'CheckCircle',
tone: 'emerald',
},
{
id: 'act-003',
message: "New job posted: OpenAI Labs posted 'Senior LLM Engineer'",
relativeTime: '1 hour ago',
icon: 'Briefcase',
tone: 'amber',
},
{
id: 'act-004',
message: 'Placement recorded: Alex Rivera hired at Anthropic Research',
relativeTime: '2 hours ago',
icon: 'Award',
tone: 'violet',
},
{
id: 'act-005',
message: 'Employer verified: Hugging Face completed identity verification',
relativeTime: '3 hours ago',
icon: 'ShieldCheck',
tone: 'emerald',
},
{
id: 'act-006',
message: 'Microcredential issued: 5 learners earned RAG Pipeline Design credential',
relativeTime: '5 hours ago',
icon: 'GraduationCap',
tone: 'cyan',
},
{
id: 'act-007',
message: "Oral defense completed: Jamie Park defended 'Agent Architecture Patterns'",
relativeTime: '8 hours ago',
icon: 'MessageSquare',
tone: 'indigo',
},
{
id: 'act-008',
message: 'New enrollment spike: 23 new learners in Computational Sciences',
relativeTime: '12 hours ago',
icon: 'TrendingUp',
tone: 'rose',
},
{
id: 'act-009',
message: "Competency published: 'Guardrails & Output Validation' added to AI Orchestration stack",
relativeTime: '1 day ago',
icon: 'BookOpen',
tone: 'violet',
},
{
id: 'act-010',
message: 'Employer application submitted: Mistral AI requested marketplace access',
relativeTime: '2 days ago',
icon: 'Building2',
tone: 'amber',
},
];
// ---------------------------------------------------------------------------
// System health (REQ-022)
// ---------------------------------------------------------------------------
export type ServiceStatus = 'operational' | 'degraded' | 'down';
export interface ServiceHealth {
id: string;
name: string;
status: ServiceStatus;
}
export const serviceHealth: ServiceHealth[] = [
{ id: 'svc-web', name: 'Web Server', status: 'operational' },
{ id: 'svc-db', name: 'Database', status: 'operational' },
{ id: 'svc-ai-tutor', name: 'AI Tutor Service', status: 'operational' },
{ id: 'svc-assessment', name: 'Assessment Engine', status: 'degraded' },
{ id: 'svc-jobs', name: 'Job Aggregation', status: 'operational' },
{ id: 'svc-search', name: 'Search Service', status: 'operational' },
];
/** 30-day uptime bars (mock percentages for the simple visual). */
export const uptimeBars: number[] = [
99.98, 99.99, 99.95, 99.97, 100.0, 99.91, 99.88, 99.97, 99.99, 99.96, 99.92,
99.98, 99.99, 99.95, 100.0, 99.97, 99.93, 99.9, 99.96, 99.98, 99.99, 99.94,
99.97, 99.96, 99.92, 99.99, 99.98, 99.95, 99.97, 99.96,
];
export interface SystemStats {
errorRate: string;
avgResponseMs: number;
}
export const systemStats: SystemStats = {
errorRate: '0.02%',
avgResponseMs: 142,
};
// ---------------------------------------------------------------------------
// Learner roster (admin view) (REQ-023)
// ---------------------------------------------------------------------------
export type LearnerStatus = 'active' | 'completed' | 'paused';
export interface AdminLearnerCompetency {
id: string;
name: string;
status: 'mastered' | 'in_progress' | 'available' | 'locked';
}
export interface AdminLearnerCredential {
id: string;
name: string;
issuedAt: string;
score: number;
}
export interface AdminLearner {
id: string;
name: string;
email: string;
stackId: string;
stackName: string;
progressPct: number;
masteredCount: number;
totalCompetencies: number;
status: LearnerStatus;
joinedAt: string;
competencies: AdminLearnerCompetency[];
credentials: AdminLearnerCredential[];
}
const STACKS = [
{ id: 'stack-orchestration', name: 'AI Orchestration Engineer' },
{ id: 'stack-safety', name: 'AI Safety & Governance Lead' },
{ id: 'stack-designer', name: 'Human-AI Product Designer' },
{ id: 'stack-operator', name: 'AI-Augmented Field Operator' },
{ id: 'stack-science', name: 'Computational Sciences Practitioner' },
];
const STACK_TOTALS: Record<string, number> = {
'stack-orchestration': 15,
'stack-safety': 14,
'stack-designer': 13,
'stack-operator': 12,
'stack-science': 16,
};
const COMP_NAME_BANK: Record<string, string[]> = {
'stack-orchestration': [
'Agent Architecture Patterns',
'Multi-Agent Communication',
'Tool Use & Function Calling',
'Prompt Engineering Fundamentals',
'RAG Pipeline Design',
'Vector Databases & Embeddings',
'LLM Evaluation & Metrics',
'Guardrails & Output Validation',
'Agent Memory Systems',
'Workflow Orchestration',
'Model Routing & Cascading',
'Streaming & Incremental Output',
'Observability for Agents',
'Cost Optimization Strategies',
'Production Deployment Patterns',
],
'stack-safety': [
'Alignment Fundamentals',
'Red Teaming Methodologies',
'Model Card Authoring',
'Bias Auditing',
'AI Policy Frameworks',
'Risk Taxonomy & Classification',
'Interpretability Techniques',
'Incident Response for AI',
'Data Provenance & Lineage',
'Jailbreak & Prompt Injection Defense',
'Model Monitoring in Production',
'Privacy-Preserving ML',
'Governance Documentation',
'Stakeholder Communication',
],
'stack-designer': [
'Agentic Interaction Patterns',
'Conversational UX',
'AI Transparency Patterns',
'Human-in-the-Loop Design',
'Prompt UX',
'Failure & Fallback Design',
'Multimodal Interface Design',
'Trust & Calibration',
'Accessibility for AI Interfaces',
'Persona & Tone Systems',
'Evaluation of AI UX',
'Onboarding to Agentic Systems',
'AI Ethics in Product Design',
],
'stack-operator': [
'AI Co-Pilot Operation',
'Robotics Safety Protocols',
'Predictive Maintenance Alerts',
'Computer Vision Inspection',
'Sensor Data Interpretation',
'Digital Twin Fundamentals',
'Augmented Reality Overlays',
'Edge Model Deployment',
'Calibration & Drift Correction',
'Field Data Collection',
'Autonomous System Supervision',
'Safety-Critical Decision Making',
],
'stack-science': [
'Scientific Computing with Python',
'ML for Scientific Discovery',
'Molecular & Materials Simulation',
'Climate Modeling Fundamentals',
'Bioinformatics Pipelines',
'High-Performance Computing',
'Scientific Data Visualization',
'Reproducible Research Practices',
'Statistical Inference',
'Physics-Informed Neural Networks',
'Generative Models for Science',
'Causal Inference Methods',
'Experiment Design',
'Data Assimilation',
'Scientific Writing with AI',
'Open Science & FAIR Data',
],
};
function buildCompetencies(
stackId: string,
mastered: number,
inProgress: number,
): AdminLearnerCompetency[] {
const names = COMP_NAME_BANK[stackId] ?? [];
const out: AdminLearnerCompetency[] = names.map((name, i) => {
let status: AdminLearnerCompetency['status'] = 'locked';
if (i < mastered) status = 'mastered';
else if (i < mastered + inProgress) status = 'in_progress';
else if (i < mastered + inProgress + 3) status = 'available';
return { id: `${stackId}-c${i.toString().padStart(3, '0')}`, name, status };
});
return out;
}
function buildCredentials(
stackId: string,
mastered: number,
): AdminLearnerCredential[] {
const names = COMP_NAME_BANK[stackId] ?? [];
const count = Math.min(mastered, 6);
const out: AdminLearnerCredential[] = [];
for (let i = 0; i < count; i++) {
out.push({
id: `mc-${stackId}-${i}`,
name: names[i] ?? `Competency ${i + 1}`,
issuedAt: `2026-0${(i % 8) + 1}-${((i * 4) % 27 + 1).toString().padStart(2, '0')}T10:00:00Z`,
score: 80 + ((i * 7) % 18),
});
}
return out;
}
interface RosterSeed {
name: string;
email: string;
stackIdx: number;
mastered: number;
inProgress: number;
status: LearnerStatus;
joinedAt: string;
}
const ROSTER_SEEDS: RosterSeed[] = [
{ name: 'Sarah Chen', email: 'sarah.chen@example.com', stackIdx: 0, mastered: 9, inProgress: 2, status: 'active', joinedAt: '2026-05-12T09:00:00Z' },
{ name: 'Marcus Lee', email: 'marcus.lee@example.com', stackIdx: 0, mastered: 14, inProgress: 1, status: 'active', joinedAt: '2026-02-03T09:00:00Z' },
{ name: 'Jamie Park', email: 'jamie.park@example.com', stackIdx: 0, mastered: 15, inProgress: 0, status: 'completed', joinedAt: '2025-12-01T09:00:00Z' },
{ name: 'Alex Rivera', email: 'alex.rivera@example.com', stackIdx: 1, mastered: 10, inProgress: 2, status: 'active', joinedAt: '2026-04-18T09:00:00Z' },
{ name: 'Priya Sharma', email: 'priya.sharma@example.com', stackIdx: 1, mastered: 5, inProgress: 3, status: 'active', joinedAt: '2026-06-22T09:00:00Z' },
{ name: 'Diego Morales', email: 'diego.morales@example.com', stackIdx: 1, mastered: 14, inProgress: 0, status: 'completed', joinedAt: '2025-11-10T09:00:00Z' },
{ name: 'Riley Thompson', email: 'riley.thompson@example.com', stackIdx: 2, mastered: 7, inProgress: 2, status: 'active', joinedAt: '2026-07-01T09:00:00Z' },
{ name: 'Maya Patel', email: 'maya.patel@example.com', stackIdx: 2, mastered: 3, inProgress: 1, status: 'paused', joinedAt: '2026-08-15T09:00:00Z' },
{ name: 'Jordan Kim', email: 'jordan.kim@example.com', stackIdx: 2, mastered: 13, inProgress: 0, status: 'completed', joinedAt: '2026-01-20T09:00:00Z' },
{ name: 'Sam Wilson', email: 'sam.wilson@example.com', stackIdx: 3, mastered: 4, inProgress: 2, status: 'active', joinedAt: '2026-08-02T09:00:00Z' },
{ name: 'Taylor Brooks', email: 'taylor.brooks@example.com', stackIdx: 3, mastered: 12, inProgress: 0, status: 'completed', joinedAt: '2026-01-05T09:00:00Z' },
{ name: 'Casey Nguyen', email: 'casey.nguyen@example.com', stackIdx: 4, mastered: 8, inProgress: 3, status: 'active', joinedAt: '2026-03-30T09:00:00Z' },
{ name: 'Morgan Davis', email: 'morgan.davis@example.com', stackIdx: 4, mastered: 16, inProgress: 0, status: 'completed', joinedAt: '2025-10-14T09:00:00Z' },
{ name: 'Avery Garcia', email: 'avery.garcia@example.com', stackIdx: 4, mastered: 2, inProgress: 1, status: 'paused', joinedAt: '2026-08-28T09:00:00Z' },
{ name: 'Quinn Foster', email: 'quinn.foster@example.com', stackIdx: 0, mastered: 6, inProgress: 3, status: 'active', joinedAt: '2026-07-19T09:00:00Z' },
];
export const adminLearners: AdminLearner[] = ROSTER_SEEDS.map((seed, i) => {
const stack = STACKS[seed.stackIdx];
const total = STACK_TOTALS[stack.id];
const competencies = buildCompetencies(stack.id, seed.mastered, seed.inProgress);
const credentials = buildCredentials(stack.id, seed.mastered);
const progressPct = Math.round((seed.mastered / total) * 100);
return {
id: `adm-lrn-${(i + 1).toString().padStart(3, '0')}`,
name: seed.name,
email: seed.email,
stackId: stack.id,
stackName: stack.name,
progressPct,
masteredCount: seed.mastered,
totalCompetencies: total,
status: seed.status,
joinedAt: seed.joinedAt,
competencies,
credentials,
};
});
// ---------------------------------------------------------------------------
// Marketplace moderation queues (REQ-025)
// ---------------------------------------------------------------------------
export interface JobReviewItem {
id: string;
title: string;
employerName: string;
submittedAt: string;
stackName: string;
location: string;
}
export const jobReviewQueue: JobReviewItem[] = [
{ id: 'rev-001', title: 'Senior LLM Engineer', employerName: 'OpenAI Labs', submittedAt: '2026-09-08T14:00:00Z', stackName: 'AI Orchestration Engineer', location: 'San Francisco, CA (Remote)' },
{ id: 'rev-002', title: 'AI Safety Auditor', employerName: 'Anthropic Research', submittedAt: '2026-09-09T11:30:00Z', stackName: 'AI Safety & Governance Lead', location: 'San Francisco, CA' },
{ id: 'rev-003', title: 'Agentic UX Designer', employerName: 'Hugging Face', submittedAt: '2026-09-09T16:45:00Z', stackName: 'Human-AI Product Designer', location: 'Remote' },
{ id: 'rev-004', title: 'Field Robotics Lead', employerName: 'Boston Dynamics', submittedAt: '2026-09-10T08:15:00Z', stackName: 'AI-Augmented Field Operator', location: 'Waltham, MA' },
{ id: 'rev-005', title: 'Climate ML Researcher', employerName: 'DeepMind', submittedAt: '2026-09-10T10:00:00Z', stackName: 'Computational Sciences Practitioner', location: 'London (Remote)' },
];
export interface EmployerVerificationItem {
id: string;
name: string;
logoInitials: string;
industry: string;
submittedAt: string;
location: string;
}
export const employerVerificationQueue: EmployerVerificationItem[] = [
{ id: 'ver-001', name: 'Mistral AI', logoInitials: 'MA', industry: 'Artificial Intelligence', submittedAt: '2026-09-07T09:00:00Z', location: 'Paris, France' },
{ id: 'ver-002', name: 'Cohere', logoInitials: 'CO', industry: 'Language Models', submittedAt: '2026-09-08T13:20:00Z', location: 'Toronto, Canada' },
{ id: 'ver-003', name: 'Scale AI', logoInitials: 'SA', industry: 'Data & Annotation', submittedAt: '2026-09-09T17:00:00Z', location: 'San Francisco, CA' },
];
export type FlaggedContentType = 'Job Posting' | 'Review' | 'Employer Profile';
export interface FlaggedContentItem {
id: string;
contentType: FlaggedContentType;
title: string;
flaggedBy: string;
reason: string;
flaggedAt: string;
}
export const flaggedContentQueue: FlaggedContentItem[] = [
{ id: 'flag-001', contentType: 'Job Posting', title: 'Junior Prompt Engineer', flaggedBy: 'Automated filter', reason: 'Suspicious salary range', flaggedAt: '2026-09-09T12:00:00Z' },
{ id: 'flag-002', contentType: 'Review', title: 'Anonymous review on OpenAI Labs', flaggedBy: 'User report', reason: 'Inappropriate language', flaggedAt: '2026-09-09T18:30:00Z' },
{ id: 'flag-003', contentType: 'Employer Profile', title: 'Stealth Startup 42', flaggedBy: 'Moderator', reason: 'Unverified contact info', flaggedAt: '2026-09-10T07:45:00Z' },
{ id: 'flag-004', contentType: 'Job Posting', title: 'ML Internship', flaggedBy: 'Automated filter', reason: 'Missing required fields', flaggedAt: '2026-09-10T09:15:00Z' },
];
+26 -1
View File
@@ -10,4 +10,29 @@ export {
learnerSummary,
} from './learner-progress';
export { aiTutorResponses } from './ai-tutor-responses';
export type { TutorResponse } from './ai-tutor-responses';
export type { TutorResponse } from './ai-tutor-responses';
export {
platformMetrics,
activityFeed,
serviceHealth,
uptimeBars,
systemStats,
adminLearners,
jobReviewQueue,
employerVerificationQueue,
flaggedContentQueue,
} from './admin';
export type {
PlatformMetric,
ActivityEvent,
ServiceStatus,
ServiceHealth,
AdminLearner,
AdminLearnerCompetency,
AdminLearnerCredential,
LearnerStatus,
JobReviewItem,
EmployerVerificationItem,
FlaggedContentItem,
FlaggedContentType,
} from './admin';