"use client" /** * Column definitions for the three Admin console hubs. * * Two things are deliberate here. * * The People columns are built per view rather than once for all four tabs. * Students carry a cohort and faculty carry a department, and showing both * columns on both tabs would mean every row has a blank cell. The identity * columns stay identical across tabs, which is the whole reason these three * record types share one hub. * * Every row leads to its record through a link inside the name cell, not only * through `onRowClick`. A click handler on a table row is invisible to the * keyboard, and P6 does not bend for admin surfaces. */ import * as React from "react" import { Link } from "react-router" import type { ColumnDef } from "@/components/data-table/types" import { NumericCell, PersonIdentityCell, PillCell, RowActionsCell } from "@/components/data-views" import { ListHubStatusBadge } from "@/components/list-hub-status-badge" import { initialsFromDisplayName } from "@/lib/initials-from-name" import { STATUS_BADGE_TONE_CLASS } from "@/lib/list-status-badges" import { ADMIN_PERSON_TYPE_LABEL, ADMIN_SOURCE_ICON, ADMIN_SOURCE_LABEL, ADMIN_STATUS_LABEL, type AdminCourse, type AdminPerson, type AdminPersonType, type AdminPersonnel, type AdminRecordStatus, type AdminSource, } from "@/lib/mock/admin-directory" /* ── Shared bits ──────────────────────────────────────────────────────────── */ const STATUS_TINT: Record = { active: STATUS_BADGE_TONE_CLASS.success, invited: STATUS_BADGE_TONE_CLASS.warning, inactive: STATUS_BADGE_TONE_CLASS.neutral, } const STATUS_ICON: Record = { active: "fa-circle-check", invited: "fa-paper-plane", inactive: "fa-circle-minus", } const TYPE_ICON: Record = { student: "fa-graduation-cap", faculty: "fa-chalkboard-user", staff: "fa-user-tie", } /** Mutations are not wired: these hubs read mock data and must not pretend. */ const NOT_WIRED = () => {} function selectOptions(values: string[]) { return [...new Set(values.filter(Boolean))].toSorted().map(value => ({ value, label: value })) } function statusFilterOptions() { return (Object.keys(ADMIN_STATUS_LABEL) as AdminRecordStatus[]).map(status => ({ value: status, label: ADMIN_STATUS_LABEL[status], })) } function sourceFilterOptions() { return (Object.keys(ADMIN_SOURCE_LABEL) as AdminSource[]).map(source => ({ value: source, label: ADMIN_SOURCE_LABEL[source], })) } function RecordCode({ children }: { children: React.ReactNode }) { return {children} } /** Names are links, so the anchor swallows the click the row would have taken. */ function RecordLink({ to, children }: { to: string; children: React.ReactNode }) { return ( event.stopPropagation()} className="truncate rounded-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" > {children} ) } function StatusCell({ status }: { status: AdminRecordStatus }) { return ( ) } /** US short date. A sync timestamp is data, not an id, so no mono. */ function formatDay(iso: string) { return new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }) } function DateCell({ iso }: { iso: string }) { return ( {formatDay(iso)} ) } function statusColumn(): ColumnDef { return { key: "status", label: "Status", width: 135, minWidth: 118, sortable: true, sortKey: "status", cellKind: "status", filter: { options: statusFilterOptions() }, cell: row => , } } function sourceColumn(): ColumnDef { return { key: "source", label: "Source", width: 150, minWidth: 130, sortable: true, sortKey: "source", cellKind: "pill", filter: { options: sourceFilterOptions() }, cell: row => ( ), } } function lastUpdatedColumn(): ColumnDef { return { key: "lastSyncedOn", label: "Last updated", width: 140, minWidth: 122, sortable: true, sortKey: "lastSyncedOn", cellKind: "date", cell: row => , } } /* ── People ───────────────────────────────────────────────────────────────── */ /** Which tab is asking. `all` is the only one that needs the Type column. */ export type AdminPeopleView = "all" | AdminPersonType export function buildAdminPeopleColumns( rows: AdminPerson[], view: AdminPeopleView, ): ColumnDef[] { const columns: ColumnDef[] = [ { key: "name", label: "Person", width: 265, minWidth: 210, sortable: true, sortKey: "name", defaultPin: "left", cellKind: "person", filter: { type: "text", icon: "fa-font", operators: ["contains", "not_contains"] }, cell: row => ( {name}} /> ), }, { key: "personCode", label: "ID", width: 122, minWidth: 108, sortable: true, sortKey: "personCode", cellKind: "text", cell: row => {row.personCode}, }, ] if (view === "all") { columns.push({ key: "type", label: "Type", width: 130, minWidth: 115, sortable: true, sortKey: "type", cellKind: "pill", filter: { options: (Object.keys(ADMIN_PERSON_TYPE_LABEL) as AdminPersonType[]).map(type => ({ value: type, label: ADMIN_PERSON_TYPE_LABEL[type], })), }, cell: row => ( ), }) } columns.push({ key: "program", label: "Program", width: 160, minWidth: 135, sortable: true, sortKey: "program", cellKind: "pill", filter: { options: selectOptions(rows.map(r => r.program)) }, cell: row => , }) // Students have a cohort and everyone else has a department. On the combined // tab the two collapse into one column so neither group shows a blank cell. if (view === "all") { columns.push({ key: "group", label: "Cohort or department", width: 205, minWidth: 165, sortable: true, sortKey: "group", cellKind: "text", filter: { options: selectOptions(rows.map(r => (r.type === "student" ? r.cohort : r.department))), }, cell: row => ( {row.type === "student" ? row.cohort : row.department} ), }) } else if (view === "student") { columns.push({ key: "cohort", label: "Cohort", width: 170, minWidth: 145, sortable: true, sortKey: "cohort", cellKind: "pill", filter: { options: selectOptions(rows.map(r => r.cohort)) }, cell: row => , }) } else { columns.push({ key: "department", label: "Department", width: 205, minWidth: 165, sortable: true, sortKey: "department", cellKind: "text", filter: { options: selectOptions(rows.map(r => r.department)) }, cell: row => {row.department}, }) } columns.push( statusColumn(), sourceColumn(), lastUpdatedColumn(), { key: "actions", label: "Action", width: 72, minWidth: 72, defaultPin: "right", lockPin: true, cell: row => ( ), }, ) return columns } /* ── Courses ──────────────────────────────────────────────────────────────── */ export function buildAdminCourseColumns(rows: AdminCourse[]): ColumnDef[] { return [ { key: "title", label: "Course", width: 285, minWidth: 220, sortable: true, sortKey: "title", defaultPin: "left", cellKind: "text", filter: { type: "text", icon: "fa-font", operators: ["contains", "not_contains"] }, cell: row => (
{row.title} {row.courseCode}
), }, { key: "program", label: "Program", width: 160, minWidth: 135, sortable: true, sortKey: "program", cellKind: "pill", filter: { options: selectOptions(rows.map(r => r.program)) }, cell: row => , }, { key: "owner", label: "Faculty of record", width: 240, minWidth: 195, sortable: true, sortKey: "owner", cellKind: "person", filter: { options: selectOptions(rows.map(r => r.owner)) }, cell: row => ( ), }, { key: "credits", label: "Credits", width: 96, minWidth: 82, sortable: true, sortKey: "credits", cellKind: "numeric", cell: row => , }, { key: "enrolled", label: "Enrolled", width: 104, minWidth: 88, sortable: true, sortKey: "enrolled", cellKind: "numeric", cell: row => , }, { key: "delivery", label: "Delivery", width: 140, minWidth: 120, sortable: true, sortKey: "delivery", cellKind: "pill", filter: { options: selectOptions(rows.map(r => r.delivery)) }, cell: row => , }, statusColumn(), sourceColumn(), lastUpdatedColumn(), { key: "actions", label: "Action", width: 72, minWidth: 72, defaultPin: "right", lockPin: true, cell: row => ( ), }, ] } /* ── Personnel ────────────────────────────────────────────────────────────── */ export function buildAdminPersonnelColumns( rows: AdminPersonnel[], ): ColumnDef[] { return [ { key: "name", label: "Person", width: 265, minWidth: 210, sortable: true, sortKey: "name", defaultPin: "left", cellKind: "person", filter: { type: "text", icon: "fa-font", operators: ["contains", "not_contains"] }, cell: row => ( {name}} /> ), }, { key: "personCode", label: "ID", width: 118, minWidth: 104, sortable: true, sortKey: "personCode", cellKind: "text", cell: row => {row.personCode}, }, { key: "brand", label: "Brand", width: 205, minWidth: 165, sortable: true, sortKey: "brand", cellKind: "pill", filter: { options: selectOptions(rows.map(r => r.brand)) }, cell: row => , }, { key: "site", label: "Site", width: 185, minWidth: 150, sortable: true, sortKey: "site", cellKind: "text", filter: { options: selectOptions(rows.map(r => r.site)) }, cell: row => {row.site}, }, { key: "role", label: "Role", width: 165, minWidth: 140, sortable: true, sortKey: "role", cellKind: "pill", filter: { options: selectOptions(rows.map(r => r.role)) }, cell: row => , }, { key: "credential", label: "Credential", width: 135, minWidth: 115, sortable: true, sortKey: "credential", cellKind: "text", filter: { options: selectOptions(rows.map(r => r.credential)) }, cell: row => ( {row.credential} ), }, statusColumn(), sourceColumn(), lastUpdatedColumn(), { key: "actions", label: "Action", width: 72, minWidth: 72, defaultPin: "right", lockPin: true, cell: row => ( ), }, ] }