340 lines
16 KiB
TypeScript
340 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
import {
|
|
filterLearners,
|
|
loadAdminLearnerData,
|
|
progressSummary,
|
|
type AdminLearnerData,
|
|
type LearnerEnrollment,
|
|
type LearnerProfile,
|
|
type LearnerRole,
|
|
type LearnerUserStatus,
|
|
} from '../admin-learners'
|
|
|
|
type LoadState = 'loading' | 'ready' | 'error'
|
|
type StatusFilter = 'all' | LearnerUserStatus
|
|
type RoleFilter = 'all' | LearnerRole
|
|
|
|
const STATUS_FILTERS: Array<{ value: StatusFilter; label: string }> = [
|
|
{ value: 'all', label: 'All statuses' },
|
|
{ value: 'active', label: 'Active' },
|
|
{ value: 'pending', label: 'Pending' },
|
|
{ value: 'inactive', label: 'Inactive' },
|
|
{ value: 'unknown', label: 'Unknown' },
|
|
]
|
|
|
|
const ROLE_FILTERS: Array<{ value: RoleFilter; label: string }> = [
|
|
{ value: 'all', label: 'All roles' },
|
|
{ value: 'learner', label: 'Learner' },
|
|
{ value: 'instructor', label: 'Instructor' },
|
|
{ value: 'editor', label: 'Editor' },
|
|
{ value: 'admin', label: 'Admin' },
|
|
{ value: 'unknown', label: 'Unknown' },
|
|
]
|
|
|
|
function roleClassName(role: LearnerRole): string {
|
|
if (role === 'learner') return 'border-zinc-200 bg-zinc-100 text-zinc-700'
|
|
if (role === 'instructor') return 'border-blue-200 bg-blue-50 text-blue-700'
|
|
if (role === 'editor') return 'border-purple-200 bg-purple-50 text-purple-700'
|
|
if (role === 'admin') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
return 'border-zinc-200 bg-zinc-100 text-zinc-500'
|
|
}
|
|
|
|
function statusClassName(status: LearnerUserStatus): string {
|
|
if (status === 'active') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
if (status === 'pending') return 'border-amber-200 bg-amber-50 text-amber-700'
|
|
if (status === 'inactive') return 'border-zinc-200 bg-zinc-100 text-zinc-600'
|
|
return 'border-zinc-200 bg-zinc-100 text-zinc-500'
|
|
}
|
|
|
|
function enrollmentStatusClassName(status: LearnerEnrollment['status']): string {
|
|
if (status === 'completed') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
if (status === 'in_progress') return 'border-blue-200 bg-blue-50 text-blue-700'
|
|
if (status === 'failed') return 'border-rose-200 bg-rose-50 text-rose-700'
|
|
if (status === 'not_started') return 'border-zinc-200 bg-zinc-100 text-zinc-600'
|
|
return 'border-zinc-200 bg-zinc-100 text-zinc-500'
|
|
}
|
|
|
|
function formatDate(value: number | null): string {
|
|
if (value === null) return 'Not available'
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(value)
|
|
}
|
|
|
|
function totalTrackedProgress(enrollments: LearnerEnrollment[]): number | null {
|
|
const tracked = enrollments.filter(enrollment => enrollment.progress !== null)
|
|
if (tracked.length === 0) return null
|
|
const sum = tracked.reduce((acc, enrollment) => acc + (enrollment.progress ?? 0), 0)
|
|
return Math.round(sum / tracked.length)
|
|
}
|
|
|
|
export function AdminLearnersPage() {
|
|
const [state, setState] = useState<LoadState>('loading')
|
|
const [data, setData] = useState<AdminLearnerData | null>(null)
|
|
const [error, setError] = useState('')
|
|
const [query, setQuery] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
|
const [roleFilter, setRoleFilter] = useState<RoleFilter>('all')
|
|
const [selectedLearnerID, setSelectedLearnerID] = useState<string | null>(null)
|
|
|
|
const load = async () => {
|
|
try {
|
|
setState('loading')
|
|
setError('')
|
|
const next = await loadAdminLearnerData()
|
|
setData(next)
|
|
setSelectedLearnerID(prev => prev ?? next.learners[0]?.id ?? null)
|
|
setState('ready')
|
|
} catch (err) {
|
|
setData(null)
|
|
setState('error')
|
|
setError(err instanceof Error ? err.message : 'Failed to load learner management data')
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
}, [])
|
|
|
|
const filteredLearners = useMemo(() => {
|
|
return filterLearners(data?.learners ?? [], query, statusFilter, roleFilter)
|
|
}, [data?.learners, query, roleFilter, statusFilter])
|
|
|
|
const selectedLearner = useMemo(() => {
|
|
const selectedFromFiltered = filteredLearners.find(learner => learner.id === selectedLearnerID)
|
|
if (selectedFromFiltered) return selectedFromFiltered
|
|
return filteredLearners[0] ?? null
|
|
}, [filteredLearners, selectedLearnerID])
|
|
|
|
if (state === 'loading') {
|
|
return (
|
|
<div className="p-6" data-testid="admin-learners-loading">
|
|
<h2 className="text-lg font-semibold text-zinc-900">Learner Management</h2>
|
|
<p className="mt-2 text-sm text-zinc-600">Loading learners and enrollment records...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (state === 'error') {
|
|
return (
|
|
<div className="p-6" data-testid="admin-learners-error">
|
|
<h2 className="text-lg font-semibold text-zinc-900">Learner Management</h2>
|
|
<p className="mt-2 text-sm text-red-600">{error}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => { void load() }}
|
|
className="mt-3 rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-6" data-testid="admin-learners-ready">
|
|
<header>
|
|
<h2 className="text-lg font-semibold text-zinc-900">Learner Management</h2>
|
|
<p className="mt-1 text-sm text-zinc-600">
|
|
Review learner profiles, enrollment records, and role/status context. Detailed session-level activity tracking is shown as not yet tracked when unavailable.
|
|
</p>
|
|
</header>
|
|
|
|
{data?.loadWarnings.length ? (
|
|
<section className="rounded-lg border border-amber-200 bg-amber-50 p-3" data-testid="admin-learners-warnings">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-800">Data warnings</h3>
|
|
<ul className="mt-2 space-y-1 text-xs text-amber-700">
|
|
{data.loadWarnings.map(warning => (
|
|
<li key={warning}>{warning}</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="grid gap-3 sm:grid-cols-3" data-testid="admin-learners-summary-cards">
|
|
<div className="rounded-lg border border-zinc-200 bg-white p-3">
|
|
<p className="text-xs uppercase tracking-wide text-zinc-500">Learners</p>
|
|
<p className="mt-1 text-2xl font-semibold text-zinc-900">{data?.learners.length ?? 0}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-white p-3">
|
|
<p className="text-xs uppercase tracking-wide text-zinc-500">Enrollments</p>
|
|
<p className="mt-1 text-2xl font-semibold text-zinc-900">
|
|
{(data?.learners ?? []).reduce((acc, learner) => acc + learner.enrollments.length, 0)}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-white p-3">
|
|
<p className="text-xs uppercase tracking-wide text-zinc-500">Tracked progress</p>
|
|
<p className="mt-1 text-2xl font-semibold text-zinc-900">
|
|
{(() => {
|
|
const values = (data?.learners ?? [])
|
|
.flatMap(learner => learner.enrollments.map(enrollment => enrollment.progress))
|
|
.filter((value): value is number => value !== null)
|
|
if (values.length === 0) return 'Not yet tracked'
|
|
const avg = Math.round(values.reduce((acc, value) => acc + value, 0) / values.length)
|
|
return `${avg}%`
|
|
})()}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
|
|
<label className="text-xs font-medium text-zinc-600">
|
|
Search learners
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
onChange={event => setQuery(event.target.value)}
|
|
placeholder="Name, email, or role"
|
|
className="mt-1 w-full min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900 outline-none focus:border-zinc-500 xl:w-[320px]"
|
|
data-testid="admin-learners-search"
|
|
/>
|
|
</label>
|
|
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<label className="text-xs font-medium text-zinc-600">
|
|
Status
|
|
<select
|
|
value={statusFilter}
|
|
onChange={event => setStatusFilter(event.target.value as StatusFilter)}
|
|
className="mt-1 rounded-md border border-zinc-300 px-2 py-1.5 text-sm text-zinc-900"
|
|
data-testid="admin-learners-status-filter"
|
|
>
|
|
{STATUS_FILTERS.map(filter => (
|
|
<option key={filter.value} value={filter.value}>{filter.label}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="text-xs font-medium text-zinc-600">
|
|
Role
|
|
<select
|
|
value={roleFilter}
|
|
onChange={event => setRoleFilter(event.target.value as RoleFilter)}
|
|
className="mt-1 rounded-md border border-zinc-300 px-2 py-1.5 text-sm text-zinc-900"
|
|
data-testid="admin-learners-role-filter"
|
|
>
|
|
{ROLE_FILTERS.map(filter => (
|
|
<option key={filter.value} value={filter.value}>{filter.label}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 xl:grid-cols-[1.3fr_1fr]">
|
|
<div className="overflow-x-auto rounded-lg border border-zinc-200 bg-white" data-testid="admin-learners-table">
|
|
<table className="responsive-table min-w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
<th className="px-3 py-2 font-semibold">Learner</th>
|
|
<th className="px-3 py-2 font-semibold">Status</th>
|
|
<th className="px-3 py-2 font-semibold">Roles</th>
|
|
<th className="px-3 py-2 font-semibold">Enrollments</th>
|
|
<th className="px-3 py-2 font-semibold">Last active</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredLearners.map(learner => (
|
|
<tr
|
|
key={learner.id}
|
|
onClick={() => setSelectedLearnerID(learner.id)}
|
|
className={`cursor-pointer border-t border-zinc-100 ${selectedLearner?.id === learner.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`}
|
|
data-testid={`admin-learners-row-${learner.id}`}
|
|
>
|
|
<td data-primary="true" data-label="Learner" className="px-3 py-2">
|
|
<p className="font-medium text-zinc-900">{learner.displayName}</p>
|
|
<p className="text-xs text-zinc-500">{learner.email || 'Email not provided'}</p>
|
|
</td>
|
|
<td data-label="Status" className="px-3 py-2">
|
|
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusClassName(learner.status)}`}>
|
|
{learner.status}
|
|
</span>
|
|
</td>
|
|
<td data-label="Roles" className="px-3 py-2">
|
|
<div className="flex flex-wrap gap-1">
|
|
{learner.roles.map(role => (
|
|
<span key={`${learner.id}-${role}`} className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${roleClassName(role)}`}>
|
|
{role}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</td>
|
|
<td data-label="Enrollments" className="px-3 py-2 text-zinc-700">{learner.enrollments.length}</td>
|
|
<td data-label="Last active" className="px-3 py-2 text-zinc-700">{formatDate(learner.lastActiveAt)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{filteredLearners.length === 0 ? (
|
|
<div className="border-t border-zinc-100 p-6 text-center text-sm text-zinc-600" data-testid="admin-learners-empty">
|
|
No learners matched this search/filter combination.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<aside className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4" data-testid="admin-learner-detail">
|
|
{selectedLearner ? (
|
|
<>
|
|
<div>
|
|
<h3 className="text-base font-semibold text-zinc-900">{selectedLearner.displayName}</h3>
|
|
<p className="text-sm text-zinc-600">{selectedLearner.email || 'Email not provided'}</p>
|
|
</div>
|
|
|
|
<div className="space-y-1 text-sm text-zinc-700">
|
|
<p><span className="font-medium text-zinc-900">Status:</span> {selectedLearner.status}</p>
|
|
<p><span className="font-medium text-zinc-900">Roles:</span> {selectedLearner.roles.join(', ')}</p>
|
|
<p><span className="font-medium text-zinc-900">Created:</span> {formatDate(selectedLearner.createdAt)}</p>
|
|
<p><span className="font-medium text-zinc-900">Last active:</span> {formatDate(selectedLearner.lastActiveAt)}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Enrollment records</h4>
|
|
<div className="mt-2 space-y-2">
|
|
{selectedLearner.enrollments.length === 0 ? (
|
|
<p className="rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">
|
|
No enrollment records available for this learner.
|
|
</p>
|
|
) : selectedLearner.enrollments.map(enrollment => {
|
|
const progress = progressSummary(enrollment)
|
|
return (
|
|
<article key={enrollment.id} className="rounded-md border border-zinc-200 p-2">
|
|
<p className="text-sm font-medium text-zinc-900">{enrollment.courseTitle}</p>
|
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
|
<span className={`inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ${enrollmentStatusClassName(enrollment.status)}`}>
|
|
{enrollment.status}
|
|
</span>
|
|
<span className="inline-flex rounded-full border border-zinc-200 bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600">
|
|
{enrollment.courseType}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-xs text-zinc-600">
|
|
Progress: {progress.label}
|
|
{progress.value === null ? ' (detailed module/session progress not yet tracked)' : ''}
|
|
</p>
|
|
<p className="text-xs text-zinc-600">
|
|
Activity: {enrollment.timeSpentMin === null ? 'Not yet tracked' : `${enrollment.timeSpentMin} minutes logged`}
|
|
</p>
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700">
|
|
Detailed lesson and session activity is not available for this learner yet.
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-zinc-600">Select a learner to view profile, roles, and enrollment details.</p>
|
|
)}
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|