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)
229 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
} |