Files
nextcraft/apps/web/components/admin/learner-table.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

229 lines
9.0 KiB
TypeScript

'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>
);
}