/academy/studentsStudent roster
Every student with a face — the courses they hold, their average progress, when they were last in and where they stand — with the roster in four figures, facets on status and course, and a sheet per student listing their courses and enrolling them on another.
Installs at /academy/students. The page is a server component inside AppShell: studentRows reads every db.students row and resolves each place against db.courses, so a row carries its courses' titles and links and opening a student costs no second read; rosterTotals counts the students and the active ones, who was in within the seven days to REFERENCE_DATE, the mean progress over every place held, and the graduates. The island holds the rows and the figures: it searches names and addresses, filters on status and on course — a student is kept when any course they hold is one picked, and the course facet counts every course a student holds over the rows the other filters leave, as the status facet does — sorts every column and pages the rows, the most recently active first; on a phone the status, the course count and the last visit ride under the name; "last active" is worked out on the server in whole UTC days against REFERENCE_DATE. A row, clicked or opened with Enter, opens an InspectorPanel with the student's courses, each linked to its page with a progress bar, and a course picker that offers every course — one the student holds says so. Enrol calls enrollStudent, which trusts the student and the course it looks up over the form: a place the student already holds, an unknown course and a student who is gone are refused in an alert above the picker; otherwise taking a place counts as being in — it starts now at 0%, the student was last active now, and a paused or graduated student is active again, so the roster's own rules hold — and the course's own enrolment figure counts the new learner. The status line under the picker says so, the row moves in the table while the reader stays on their page, and the four figures are the ones the action read again from the store. Every opening of the panel starts clean. Composes AppShell, PageHeader, StatCardGroup, StatCard, DataTable, DataTableColumnHeader, UserCell, NumberCell, Progress, StatusBadge, InspectorPanel, DescriptionList, SectionHeader, FormRow, NativeSelect, NativeSelectOption, AsyncButton and Callout.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/academy/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { StudentsView } from "./components/students-view"
import { courseOptions, currentUser, lastUpdated, rosterTotals, shellNotifications, studentRows } from "./data"
/**
* The roster. The page is a server component: it reads every student and
* resolves their places against the catalogue, adds the roster up for the
* four figures, and hands both to the island that searches, filters and
* pages the rows, opens a student's courses and enrols them on another —
* and holds the figures the server reads again after each enrolment.
*/
export default function StudentsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.students}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="academy-students-title"
title="Students"
description="Everyone on the roster: the courses they hold, how far through they are, and when they were last in."
meta={lastUpdated()}
/>
<StudentsView titleId="academy-students-title" rows={studentRows()} totals={rosterTotals()} courses={courseOptions()} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/academy-studentsNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/academy/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { StudentsView } from "./components/students-view"
import { courseOptions, currentUser, lastUpdated, rosterTotals, shellNotifications, studentRows } from "./data"
/**
* The roster. The page is a server component: it reads every student and
* resolves their places against the catalogue, adds the roster up for the
* four figures, and hands both to the island that searches, filters and
* pages the rows, opens a student's courses and enrols them on another —
* and holds the figures the server reads again after each enrolment.
*/
export default function StudentsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.students}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="academy-students-title"
title="Students"
description="Everyone on the roster: the courses they hold, how far through they are, and when they were last in."
meta={lastUpdated()}
/>
<StudentsView titleId="academy-students-title" rows={studentRows()} totals={rosterTotals()} courses={courseOptions()} />
</AppShell>
)
}/**
* What /academy/students reads. The roster is `db.students`; each place a
* student holds is resolved against `db.courses` for its title, so a row
* carries everything the table and the inspector print and opening a student
* costs no second read. A student's progress is the mean of their places'; a
* place is "done" at 100. "This week" and "last active" are measured against
* `REFERENCE_DATE`, in whole UTC days.
*
* The client islands import only the types below; `db` never reaches the
* browser. `actions.ts` builds the row it hands back with `toStudentRow`, so a
* row the server changed and a row the page rendered are the same shape.
*/
import { formatDate, getInitials } from "@/lib/format"
import { courseHref } from "@/lib/dashboards/academy/vocabulary"
import { REFERENCE_DATE, db, type Course, type Member, type Student } from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** One place on one course, with what the inspector prints about the course. */
export type StudentPlace = {
courseId: string
title: string
/** Undefined only for a place on a course the catalogue no longer holds. */
category: Course["category"] | undefined
/** The course's own page. */
href: string
progress: number
startedAt: Date
}
/** One student as the table prints them, and everything the inspector shows. */
export type StudentRow = {
id: string
name: string
email: string
avatarUrl: string
places: StudentPlace[]
courseIds: string[]
/** The mean of their places' progress, in whole percent. */
averageProgress: number
lastActiveAt: Date
/** "Today", "Yesterday", "3 days ago" or the day: when they were last in, read against REFERENCE_DATE. */
lastActive: string
status: Student["status"]
}
/** A course the facet and the enrolment form offer. */
export type CourseOption = { id: string; title: string }
const midnight = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
const DAY_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** When a student was last in, in whole UTC days before REFERENCE_DATE. */
export function lastActiveLabel(at: Date): string {
const days = Math.round((midnight(REFERENCE_DATE) - midnight(at)) / DAY_MS)
if (days <= 0) return "Today"
if (days === 1) return "Yesterday"
if (days < 7) return `${days} days ago`
return DAY_LABEL.format(at)
}
/** The row for one student, their places resolved against the catalogue. */
export function toStudentRow(student: Student): StudentRow {
const courses = new Map(db.courses.all().map((course) => [course.id, course]))
const places = student.enrolled.map((place) => {
const course = courses.get(place.courseId)
return {
courseId: place.courseId,
title: course?.title ?? "A retired course",
category: course?.category,
href: courseHref(place.courseId),
progress: place.progress,
startedAt: place.startedAt,
}
})
return {
id: student.id,
name: student.name,
email: student.email,
avatarUrl: student.avatarUrl,
places,
courseIds: places.map((place) => place.courseId),
averageProgress:
places.length === 0 ? 0 : Math.round(places.reduce((sum, place) => sum + place.progress, 0) / places.length),
lastActiveAt: student.lastActiveAt,
lastActive: lastActiveLabel(student.lastActiveAt),
status: student.status,
}
}
/** Every student on the roster; the table sorts, filters and pages them itself. */
export function studentRows(): StudentRow[] {
return db.students.all().map(toStudentRow)
}
/** Every course in the catalogue, in catalogue order, for the facet and the form. */
export function courseOptions(): CourseOption[] {
return db.courses.all().map((course) => ({ id: course.id, title: course.title }))
}
export type RosterTotals = {
students: number
active: number
/** In within the seven days to REFERENCE_DATE. */
thisWeek: number
/** Every place held, and their mean progress in whole percent. */
places: number
progress: number
graduated: number
}
/** The four figures over the roster. */
export function rosterTotals(): RosterTotals {
const rows = db.students.all()
const places = rows.flatMap((student) => student.enrolled)
return {
students: rows.length,
active: rows.filter((student) => student.status === "active").length,
thisWeek: rows.filter((student) => REFERENCE_DATE.getTime() - student.lastActiveAt.getTime() <= 7 * DAY_MS).length,
places: places.length,
progress: places.length === 0 ? 0 : Math.round(places.reduce((sum, place) => sum + place.progress, 0) / places.length),
graduated: rows.filter((student) => student.status === "graduated").length,
}
}
/** The line under the title: when the roster was read. */
export function lastUpdated(): string {
return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, db, type Result } from "@/lib/sample-data"
import { rosterTotals, toStudentRow, type RosterTotals, type StudentRow } from "./data"
/**
* The two things this page changes. Both return `Result`, so a refusal reaches
* the reader with its reason attached.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/**
* Gives a student a place on a course. The student and the course are both
* looked up here and trusted over anything the form sent; a student already
* holding a place on the course is refused.
*
* Taking a place counts as being in: the new place starts now at 0%, the
* student was last active now, and a graduate or a paused student taking a
* new course is studying again, so they are active — the roster's own rules
* (a place starts before the student was last seen; active is in within the
* fortnight) hold after the write as they did before it. The course's own
* enrolment figure counts the new learner, so a course never has more
* students than it has enrolled.
*
* What comes back is the student's row as the page prints it, never the
* stored record, and the roster's figures read again from the store after the
* write, so the page's four numbers follow.
*/
export async function enrollStudent(
studentId: string,
courseId: string
): Promise<Result<{ student: StudentRow; course: string; reactivated: boolean; totals: RosterTotals }>> {
const student = await db.students.get(studentId)
if (!student) {
return { ok: false, error: { code: "not_found", message: `No student with id "${studentId}".` } }
}
const course = await db.courses.get(courseId)
if (!course) {
return { ok: false, error: { code: "not_found", message: `No course with id "${courseId}".` } }
}
if (student.enrolled.some((place) => place.courseId === course.id)) {
return {
ok: false,
error: { code: "already_enrolled", message: `${student.name} is already on ${course.title}.` },
}
}
const reactivated = student.status !== "active"
const updated = await db.students.update(student.id, {
enrolled: [...student.enrolled, { courseId: course.id, progress: 0, startedAt: REFERENCE_DATE }],
lastActiveAt: REFERENCE_DATE,
status: "active",
})
if (!updated.ok) return updated
const counted = await db.courses.update(course.id, { enrolled: course.enrolled + 1 })
if (!counted.ok) return counted
return {
ok: true,
data: { student: toStudentRow(updated.data), course: course.title, reactivated, totals: rosterTotals() },
}
}import { ActivityIcon, GaugeIcon, GraduationCapIcon, UsersIcon } from "lucide-react"
import { formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type RosterTotals } from "../data"
/**
* The roster in four numbers: who is on it, who was in this week, how far
* through their courses they are, and who has finished everything. Drawn by
* the page's island from the figures it holds, so an enrolment moves them.
*/
export function RosterFigures({ totals }: { totals: RosterTotals }) {
const share = totals.students === 0 ? 0 : totals.thisWeek / totals.students
return (
<StatCardGroup data-widget="widget-academy-students-roster-figures" columns={4} role="region" aria-label="The roster in figures">
<StatCard
label="Students"
value={formatNumber(totals.students)}
description={`${formatNumber(totals.active)} active`}
icon={<UsersIcon />}
/>
<StatCard
label="Active this week"
value={formatNumber(totals.thisWeek)}
description={`${formatPercent(share, { maximumFractionDigits: 0 })} of the roster`}
icon={<ActivityIcon />}
/>
<StatCard
label="Average progress"
value={`${totals.progress}%`}
description={`Across ${formatNumber(totals.places)} places`}
icon={<GaugeIcon />}
/>
<StatCard
label="Graduated"
value={formatNumber(totals.graduated)}
description="Finished every course they took"
icon={<GraduationCapIcon />}
/>
</StatCardGroup>
)
}"use client"
import { STUDENT_STATUS_LABELS, STUDENT_STATUS_MAP } from "@/lib/dashboards/academy/vocabulary"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { Progress } from "@/components/ui/progress"
import { StatusBadge } from "@/components/ui/status-badge"
import { NumberCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type StudentRow } from "../data"
/** What the phone's second line under a name says: the status, the courses, when they were last in. */
const phoneLine = (row: StudentRow): string =>
`${STUDENT_STATUS_LABELS[row.status]} · ${row.courseIds.length} course${row.courseIds.length === 1 ? "" : "s"} · ${row.lastActive}`
/**
* The roster's columns. "Courses" prints how many places a student holds and
* is what the course facet filters: a student is kept when any course they
* hold is one of the courses picked, and the facet counts each course a
* student holds — so its counts follow the other filters, the way the status
* facet's do. "Last active" sorts on the instant and prints the words the
* server worked out against REFERENCE_DATE.
*
* On a phone the table keeps the student and their progress; the status, the
* course count and the last visit ride under the name instead of in columns
* three screens to the right.
*/
export const STUDENT_COLUMNS: DataTableColumnDef<StudentRow>[] = [
{
id: "student",
// Name and address in one accessor, so the search box matches either.
accessorFn: (row) => `${row.name} ${row.email}`,
header: ({ column }) => <DataTableColumnHeader column={column} title="Student" />,
cell: ({ row }) => (
<UserCell
size="sm"
name={row.original.name}
src={row.original.avatarUrl}
description={
<>
<span className="md:hidden">{phoneLine(row.original)}</span>
<span className="hidden md:inline">{row.original.email}</span>
</>
}
className="min-w-40 md:min-w-48"
/>
),
meta: { label: "Student" },
},
{
id: "courses",
accessorFn: (row) => row.courseIds.length,
getUniqueValues: (row) => row.courseIds,
header: ({ column }) => <DataTableColumnHeader column={column} title="Courses" />,
cell: ({ row }) => <NumberCell value={row.original.courseIds.length} />,
filterFn: (row, _columnId, chosen: string[]) => chosen.some((id) => row.original.courseIds.includes(id)),
meta: { align: "right", label: "Courses", className: "hidden md:table-cell" },
},
{
accessorKey: "averageProgress",
header: ({ column }) => <DataTableColumnHeader column={column} title="Progress" />,
cell: ({ row }) => (
<span className="flex min-w-28 items-center gap-2 md:min-w-36">
<Progress
value={row.original.averageProgress}
aria-label={`${row.original.name}'s average progress`}
className="flex-1"
/>
<span className="w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{`${row.original.averageProgress}%`}</span>
</span>
),
meta: { label: "Progress" },
},
{
id: "lastActive",
accessorFn: (row) => row.lastActiveAt.getTime(),
header: ({ column }) => <DataTableColumnHeader column={column} title="Last active" />,
cell: ({ row }) => <span className="whitespace-nowrap tabular-nums text-muted-foreground">{row.original.lastActive}</span>,
meta: { align: "right", label: "Last active", className: "hidden md:table-cell" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge status={row.original.status} label={STUDENT_STATUS_LABELS[row.original.status]} map={STUDENT_STATUS_MAP} />
),
meta: { label: "Status", className: "hidden md:table-cell" },
},
]"use client"
import * as React from "react"
import Link from "next/link"
import { UserPlusIcon } from "lucide-react"
import { CATEGORY_LABELS, STUDENT_STATUS_LABELS, STUDENT_STATUS_MAP } from "@/lib/dashboards/academy/vocabulary"
import { formatDate } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { DescriptionList } from "@/components/ui/description-list"
import { FormRow } from "@/components/ui/form-section"
import { InspectorPanel } from "@/components/ui/inspector-panel"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Progress } from "@/components/ui/progress"
import { SectionHeader } from "@/components/ui/section-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { enrollStudent } from "../actions"
import { type CourseOption, type RosterTotals, type StudentRow } from "../data"
const day = (date: Date) => formatDate(date, "medium", { timeZone: "UTC" })
/** The first course in the catalogue the student holds no place on, else the first course. */
const firstFree = (student: StudentRow | null, courses: CourseOption[]) =>
(courses.find((course) => !student?.courseIds.includes(course.id)) ?? courses[0])?.id ?? ""
export type StudentInspectorProps = {
student: StudentRow | null
courses: CourseOption[]
onClose: () => void
/** The row and the roster's figures the server handed back after an enrolment, for the page to hold. */
onEnrolled: (row: StudentRow, totals: RosterTotals) => void
}
/**
* One student, opened over the roster: where they stand, every course they
* hold with how far through it they are, and a place on another course. The
* course list offers every course — one the student already holds says so —
* and the server decides: a refusal is an alert above the form, a success the
* status line under it, and the two never share a slot.
*/
export function StudentInspector({ student, courses, onClose, onEnrolled }: StudentInspectorProps) {
const selectId = React.useId()
const [shown, setShown] = React.useState(student)
const [openedFor, setOpenedFor] = React.useState<StudentRow | null>(null)
const [courseId, setCourseId] = React.useState(() => firstFree(student, courses))
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
// Every opening is a new conversation — another student, or the same one
// again after the panel closed — and the last one's outcome does not belong
// to it. A row the server hands back while the panel is open is the same
// conversation, so its answer stays. The panel outlives the selection by one
// animation, so the last student stays rendered while it slides out.
if (student !== openedFor) {
if (student && student.id !== openedFor?.id) {
setNotice("")
setRefusal("")
setCourseId(firstFree(student, courses))
}
setOpenedFor(student)
if (student) setShown(student)
}
if (!shown) return null
async function enrol() {
if (!shown) return
setNotice("")
setRefusal("")
const result = await enrollStudent(shown.id, courseId)
if (!result.ok) return setRefusal(result.error.message)
onEnrolled(result.data.student, result.data.totals)
setCourseId(firstFree(result.data.student, courses))
setNotice(
`Enrolled ${result.data.student.name} on ${result.data.course}${result.data.reactivated ? " — back to active" : ""}.`
)
}
return (
<InspectorPanel
open={student !== null}
mode="overlay"
width="lg"
onOpenChange={(next) => {
if (!next) onClose()
}}
title={shown.name}
description={shown.email}
footer={
<div role="group" aria-label={`Enrol ${shown.name} on a course`} className="flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<FormRow label="Course" htmlFor={selectId}>
<NativeSelect id={selectId} className="w-full" value={courseId} onChange={(event) => setCourseId(event.target.value)}>
{courses.map((course) => (
<NativeSelectOption key={course.id} value={course.id}>
{shown.courseIds.includes(course.id) ? `${course.title} · already on it` : course.title}
</NativeSelectOption>
))}
</NativeSelect>
</FormRow>
<AsyncButton size="sm" className="self-start" onClick={enrol}>
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
Enrol
</AsyncButton>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</div>
}
>
<div className="flex flex-col gap-5">
<DescriptionList
size="sm"
items={[
{
term: "Status",
description: (
<StatusBadge status={shown.status} label={STUDENT_STATUS_LABELS[shown.status]} map={STUDENT_STATUS_MAP} />
),
},
{ term: "Last active", description: shown.lastActive },
{ term: "Average progress", description: <span className="tabular-nums">{`${shown.averageProgress}%`}</span> },
]}
/>
<div className="flex flex-col gap-3">
<SectionHeader as="h2" size="sm" title="Courses" />
<ul aria-label={`${shown.name}'s courses`} className="flex flex-col">
{shown.places.map((place) => (
<li key={place.courseId} className="flex flex-col gap-2 border-b py-3 first:pt-0 last:border-b-0">
<div className="flex items-baseline justify-between gap-3">
<Link href={place.href} className="truncate rounded-sm text-sm font-medium underline-offset-4 hover:underline focus-ring">
{place.title}
</Link>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{`${place.progress}%`}</span>
</div>
<Progress value={place.progress} aria-label={`${place.title} progress`} />
<p className="text-xs text-muted-foreground">
{[place.category ? CATEGORY_LABELS[place.category] : null, `Started ${day(place.startedAt)}`]
.filter(Boolean)
.join(" · ")}
</p>
</li>
))}
</ul>
</div>
</div>
</InspectorPanel>
)
}"use client"
import * as React from "react"
import type { PaginationState, Updater } from "@tanstack/react-table"
import { STUDENT_STATUS_LABELS } from "@/lib/dashboards/academy/vocabulary"
import { DataTable, type DataTableFacet } from "@/components/ui/data-table"
import { type CourseOption, type RosterTotals, type StudentRow } from "../data"
import { RosterFigures } from "./roster-figures"
import { STUDENT_COLUMNS } from "./student-columns"
import { StudentInspector } from "./student-inspector"
const PAGE_SIZE = 10
const resolve = <T,>(updater: Updater<T>, current: T): T =>
typeof updater === "function" ? (updater as (old: T) => T)(current) : updater
export type StudentsViewProps = {
/** The page's h1 id, which names the table. */
titleId: string
rows: StudentRow[]
totals: RosterTotals
courses: CourseOption[]
}
/**
* The roster and its four figures. The rows and the figures arrive from the
* server page and this island owns them from then on: an enrolment the
* server accepts swaps in the row it hands back and the figures it read
* again from the store, so the course count, the progress, the status and
* the numbers above the table all move without a second read. A row,
* clicked or opened with Enter, shows the student's courses.
*/
export function StudentsView({ titleId, rows: initial, totals: initialTotals, courses }: StudentsViewProps) {
const [rows, setRows] = React.useState(initial)
const [totals, setTotals] = React.useState(initialTotals)
const [openId, setOpenId] = React.useState<string | null>(null)
const [pagination, setPagination] = React.useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE })
const keepPage = React.useRef<number | null>(null)
const open = rows.find((row) => row.id === openId) ?? null
// The status facet's words; the course facet leaves its counts to the table,
// which counts every course a student holds over the rows the other filters
// leave (the column's getUniqueValues), so the two menus count alike.
const facets = React.useMemo<DataTableFacet<StudentRow>[]>(
() => [
{
columnId: "status",
title: "Status",
options: Object.entries(STUDENT_STATUS_LABELS).map(([value, label]) => ({ value, label })),
},
{ columnId: "courses", title: "Course", options: courses.map((course) => ({ value: course.id, label: course.title })) },
],
[courses]
)
// A swapped row is the same student, so the reader stays on the page they
// were reading. The table resets to the first page whenever its rows
// change, and queues that reset as a microtask while it renders them; this
// one is queued after the render, so it runs after the reset and puts the
// page back.
React.useEffect(() => {
const page = keepPage.current
if (page === null) return
keepPage.current = null
queueMicrotask(() => setPagination((current) => ({ ...current, pageIndex: page })))
}, [rows])
function enrolled(next: StudentRow, fresh: RosterTotals) {
keepPage.current = pagination.pageIndex
setRows((current) => current.map((row) => (row.id === next.id ? next : row)))
setTotals(fresh)
}
return (
<div data-slot="students-view" className="flex flex-col gap-4">
<RosterFigures totals={totals} />
<DataTable
aria-labelledby={titleId}
columns={STUDENT_COLUMNS}
data={rows}
getRowId={(row) => row.id}
searchKey="student"
searchPlaceholder="Search students"
facets={facets}
enableRowSelection={false}
initialSorting={[{ id: "lastActive", desc: true }]}
onRowClick={(row) => setOpenId(row.id)}
state={{ pagination }}
onPaginationChange={(updater) => setPagination((current) => resolve(updater, current))}
pageSize={PAGE_SIZE}
pageSizeOptions={[10, 25, 50]}
emptyMessage="No student matches these filters."
/>
<StudentInspector student={open} courses={courses} onClose={() => setOpenId(null)} onEnrolled={enrolled} />
</div>
)
}