ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
@@ -0,0 +1,388 @@
import { useEffect, useState, useMemo } from 'react';
import {
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
} from '@/components/ui/dialog';
import {
Pagination, PaginationContent, PaginationItem,
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from '@/components/ui/pagination';
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
const PAGE_SIZE = 10;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
if (!status) return <Badge variant="outline" className="text-muted-foreground text-xs">Not started</Badge>;
return status === 'completed'
? <Badge variant="outline" className="text-emerald-600 border-emerald-400 text-xs">Completed</Badge>
: <Badge><RefreshCcw />In Progress</Badge>;
}
function ProgressBar({ value, total, className = '' }) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-emerald-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{value} / {total}
</span>
</div>
);
}
function UserAvatar({ name, email, avatarUrl }) {
const initials = name
? name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
: (email?.[0] ?? '?').toUpperCase();
return (
<Avatar className="size-9 shrink-0">
<AvatarImage src={avatarUrl ?? undefined} alt={name ?? email} />
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
{initials}
</AvatarFallback>
</Avatar>
);
}
// ─── Dialog: full breakdown for one user ─────────────────────────────────────
function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress();
const breakdown = entry ? detailCache[entry.user_id] : null;
useEffect(() => {
if (open && entry && !breakdown) {
fetchUserReadingProgress(courseId, entry.user_id);
}
}, [open, entry]);
const lastSeen = entry?.last_accessed_at
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg flex flex-col max-h-[80vh]">
<DialogHeader>
<div className="flex items-center gap-3">
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />}
<div className="min-w-0">
<DialogTitle className="truncate">
{entry?.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
</DialogTitle>
<DialogDescription className="truncate">{entry?.user.email}</DialogDescription>
</div>
</div>
</DialogHeader>
{/* ── Meta strip ── */}
{entry && (
<div className="flex items-center gap-3 flex-wrap">
<StatusBadge status={entry.course_status} />
<span className="text-xs">Last seen {lastSeen}</span>
<span className="text-xs text-muted-foreground ml-auto">
{entry.lessons_completed} / {entry.lessons_total} lessons
</span>
</div>
)}
{/* ── Progress bar ── */}
{entry && (
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
)}
<Separator />
{/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */}
<ScrollArea className="flex-1 min-h-0 pr-2">
{detailLoading && !breakdown ? (
<div className="space-y-3 py-1">
{[...Array(4)].map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-36 ml-5" />
<Skeleton className="h-3 w-40 ml-5" />
</div>
))}
</div>
) : breakdown?.length ? (
<div className="space-y-4 py-1">
{breakdown.map((unit, ui) => (
<div key={unit.unit_id} className="space-y-2">
{/* Unit header */}
<div className="flex items-center gap-2">
{unit.status === 'completed'
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: unit.status === 'in_progress'
? <Circle className="size-4 text-amber-400 shrink-0" />
: <Circle className="size-4 text-muted-foreground/30 shrink-0" />
}
<span className="text-sm font-semibold truncate flex-1">
Unit {ui + 1}: {unit.title}
</span>
{unit.status && <StatusBadge status={unit.status} />}
</div>
{/* Lesson rows */}
<div className="ml-6 space-y-1.5 border-l pl-3">
{unit.lessons.map((lesson) => (
<div key={lesson.lesson_id} className="flex items-center gap-2">
{lesson.status === 'completed'
? <CheckCircle2 className="size-3 text-emerald-500 shrink-0" />
: lesson.status === 'in_progress'
? <Circle className="size-3 text-amber-400 shrink-0" />
: <Circle className="size-3 text-muted-foreground/25 shrink-0" />
}
<span className={`text-xs truncate flex-1 ${lesson.status ? 'text-foreground' : 'text-muted-foreground'}`}>
{lesson.title}
</span>
{lesson.status === 'completed' && lesson.completed_at && (
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{new Date(lesson.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
)}
</div>
))}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">No lesson data available.</p>
)}
</ScrollArea>
<DialogFooter showCloseButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── User summary card ────────────────────────────────────────────────────────
function UserCard({ entry, onOpen }) {
const lastSeen = entry.last_accessed_at
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<button
type="button"
onClick={() => onOpen(entry)}
className="w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate flex-1">
{entry.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
</span>
<StatusBadge status={entry.course_status} />
</div>
<p className="text-xs text-muted-foreground truncate">{entry.user.email}</p>
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
<p className="text-xs">Last seen {lastSeen}</p>
</div>
</button>
);
}
// ─── Pagination controls ──────────────────────────────────────────────────────
function PaginationControls({ page, totalPages, onPage }) {
if (totalPages <= 1) return null;
const pages = [];
for (let i = 1; i <= totalPages; i++) pages.push(i);
// Show at most 5 page numbers around current
const getVisible = () => {
if (totalPages <= 5) return pages;
if (page <= 3) return [1, 2, 3, 4, null, totalPages];
if (page >= totalPages - 2) return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [1, null, page - 1, page, page + 1, null, totalPages];
};
return (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); if (page > 1) onPage(page - 1); }}
className={page === 1 ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{getVisible().map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPage(p); }}
>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPage(page + 1); }}
className={page === totalPages ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function CourseReadingProgressList({ courseId }) {
const { progressList, listLoading, fetchCourseReadingProgress } = useAdminCourseReadingProgress();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [dialogEntry, setDialogEntry] = useState(null);
useEffect(() => {
fetchCourseReadingProgress(courseId);
}, [courseId]);
// Reset to page 1 when search changes
useEffect(() => { setPage(1); }, [search]);
// ── Filter ────────────────────────────────────────────────────────────────
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return progressList;
return progressList.filter((e) =>
e.user.full_name?.toLowerCase().includes(q) ||
e.user.email?.toLowerCase().includes(q)
);
}, [progressList, search]);
// ── Paginate ──────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const completedCount = progressList.filter((e) => e.course_status === 'completed').length;
const inProgressCount = progressList.length - completedCount;
// ── Loading skeleton ──────────────────────────────────────────────────────
if (listLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-9 w-full rounded-md" />
{[...Array(4)].map((_, i) => (
<div key={i} className="border rounded-lg p-4 flex items-center gap-3">
<Skeleton className="size-9 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-3 w-56" />
<Skeleton className="h-1.5 w-full rounded-full" />
</div>
<Skeleton className="h-5 w-20 rounded-full shrink-0" />
</div>
))}
</div>
);
}
// ── Empty state ───────────────────────────────────────────────────────────
if (!progressList.length) {
return (
<div className="flex flex-col items-center justify-center py-10 text-center gap-2">
<BookOpen className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No students have started reading this course yet.</p>
</div>
);
}
return (
<>
{/* ── Summary strip ── */}
<div className="flex items-center gap-4 flex-wrap text-sm">
<span className="flex items-center gap-1.5">
<Users className="size-3.5" />
{progressList.length} enrolled
</span>
<span className="flex items-center gap-1.5">
<CheckCircle2 className="size-3.5 text-emerald-500" />
{completedCount} completed
</span>
<span className="flex items-center gap-1.5">
<Circle className="size-3.5 text-amber-400" />
{inProgressCount} in progress
</span>
</div>
{/* ── Search ── */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
maxLength={50}
className="pl-9 pr-16"
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${search.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
{search.length}/50
</span>
</div>
{/* ── List ── */}
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No results for "{search}".</p>
) : (
<div className="space-y-2">
{paginated.map((entry) => (
<UserCard
key={entry.user_id}
entry={entry}
onOpen={setDialogEntry}
/>
))}
</div>
)}
{/* ── Pagination ── */}
<PaginationControls page={page} totalPages={totalPages} onPage={setPage} />
{/* ── Detail Dialog ── */}
<UserDetailDialog
open={!!dialogEntry}
onOpenChange={(v) => { if (!v) setDialogEntry(null); }}
entry={dialogEntry}
courseId={courseId}
/>
</>
);
}