learn-platform-deploy/Frontend/src/app/pages/AdminLearnersPage.tsx

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