640 lines
25 KiB
TypeScript
640 lines
25 KiB
TypeScript
import type { FormEvent } from 'react';
|
|
import { useMemo, useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { ChevronDown, GraduationCap, Loader2, Pencil, Plus, UserPlus, Users, X } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { ImageUpload } from '@/components/common/ImageUpload';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { ModuleHeader } from '@/components/ui/module-header';
|
|
import { NativeSelect } from '@/components/ui/native-select';
|
|
import { PageSkeleton } from '@/components/ui/page-skeleton';
|
|
import { UserAvatar } from '@/components/common/UserAvatar';
|
|
import { useAuth } from '@/contexts/useAuth';
|
|
import { usePermissions } from '@/hooks/usePermissions';
|
|
import {
|
|
createUser,
|
|
getClass,
|
|
linkGuardianStudent,
|
|
listGuardianStudents,
|
|
listRoles,
|
|
listUsers,
|
|
updateUser,
|
|
type AdminUserRow,
|
|
} from '@/business/my-class/api';
|
|
import {
|
|
buildMyClassStudentSaveData,
|
|
buildMyClassGuardianSaveData,
|
|
canManageMyClassStudents,
|
|
hasMyClassGuardianFormValues,
|
|
type MyClassGuardianFormValues,
|
|
type MyClassStudentFormValues,
|
|
} from '@/business/my-class/selectors';
|
|
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
|
|
import { getErrorMessage } from '@/shared/errors/errorMessages';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
function personName(row: { firstName?: string | null; lastName?: string | null; email?: string }): string {
|
|
return [row.firstName, row.lastName].filter(Boolean).join(' ').trim() || row.email || '—';
|
|
}
|
|
|
|
interface StatusMessage {
|
|
readonly type: 'success' | 'error';
|
|
readonly text: string;
|
|
}
|
|
|
|
const emptyStudentForm = (): MyClassStudentFormValues => ({
|
|
namePrefix: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
email: '',
|
|
phoneNumber: '',
|
|
avatar: null,
|
|
guardians: [emptyGuardianForm()],
|
|
});
|
|
|
|
function emptyGuardianForm(): MyClassGuardianFormValues {
|
|
return {
|
|
key: `guardian-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
id: null,
|
|
namePrefix: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
email: '',
|
|
phoneNumber: '',
|
|
avatar: null,
|
|
};
|
|
}
|
|
|
|
function studentFormFromRow(
|
|
row: AdminUserRow,
|
|
guardians: readonly GuardianStudentLink[] = [],
|
|
): MyClassStudentFormValues {
|
|
const guardianForms = guardians
|
|
.map((link): MyClassGuardianFormValues | null => {
|
|
const guardian = link.guardian;
|
|
if (!guardian?.id) return null;
|
|
return {
|
|
key: link.id,
|
|
id: guardian.id,
|
|
namePrefix: guardian.name_prefix ?? '',
|
|
firstName: guardian.firstName ?? '',
|
|
lastName: guardian.lastName ?? '',
|
|
email: guardian.email ?? '',
|
|
phoneNumber: guardian.phoneNumber ?? '',
|
|
avatar: guardian.avatar?.[0]?.privateUrl ?? null,
|
|
};
|
|
})
|
|
.filter((guardian): guardian is MyClassGuardianFormValues => Boolean(guardian));
|
|
|
|
return {
|
|
namePrefix: row.name_prefix ?? '',
|
|
firstName: row.firstName ?? '',
|
|
lastName: row.lastName ?? '',
|
|
email: row.email,
|
|
phoneNumber: row.phoneNumber ?? '',
|
|
avatar: row.avatar?.[0]?.privateUrl ?? null,
|
|
guardians: guardianForms.length > 0 ? guardianForms : [emptyGuardianForm()],
|
|
};
|
|
}
|
|
|
|
type GuardianStudentLink = NonNullable<Awaited<ReturnType<typeof listGuardianStudents>>['rows'][number]>;
|
|
|
|
export default function MyClassPage() {
|
|
const { user } = useAuth();
|
|
const permissions = usePermissions();
|
|
const queryClient = useQueryClient();
|
|
const classId = user?.classId ?? null;
|
|
const [studentForm, setStudentForm] = useState<MyClassStudentFormValues>(() => emptyStudentForm());
|
|
const [editingStudentId, setEditingStudentId] = useState<string | null>(null);
|
|
const [isStudentFormOpen, setIsStudentFormOpen] = useState(false);
|
|
const [studentFormSaving, setStudentFormSaving] = useState(false);
|
|
const [studentFormStatus, setStudentFormStatus] = useState<StatusMessage | null>(null);
|
|
|
|
const classQuery = useQuery({
|
|
queryKey: ['my-class', classId],
|
|
queryFn: () => getClass(classId as string),
|
|
enabled: Boolean(classId),
|
|
});
|
|
const membersQuery = useQuery({
|
|
queryKey: ['my-class-members', classId],
|
|
queryFn: () => listUsers({ classId: classId as string }),
|
|
enabled: Boolean(classId),
|
|
});
|
|
const guardiansQuery = useQuery({
|
|
queryKey: ['my-class-guardians'],
|
|
queryFn: () => listGuardianStudents(),
|
|
enabled: Boolean(classId),
|
|
});
|
|
const rolesQuery = useQuery({
|
|
queryKey: ['roles'],
|
|
queryFn: listRoles,
|
|
enabled: Boolean(classId) && (permissions.has('CREATE_USERS') || permissions.has('UPDATE_USERS')),
|
|
});
|
|
|
|
const members = useMemo(
|
|
() => membersQuery.data?.rows ?? [],
|
|
[membersQuery.data],
|
|
);
|
|
const students = useMemo(
|
|
() => members.filter((m) => m.app_role?.name === 'student'),
|
|
[members],
|
|
);
|
|
const staff = useMemo(
|
|
() => members.filter((m) => m.app_role?.name === 'teacher' || m.app_role?.name === 'support_staff'),
|
|
[members],
|
|
);
|
|
const studentRoleId = useMemo(
|
|
() => rolesQuery.data?.rows.find((role) => role.name === 'student')?.id ?? null,
|
|
[rolesQuery.data],
|
|
);
|
|
const guardianRoleId = useMemo(
|
|
() => rolesQuery.data?.rows.find((role) => role.name === 'guardian')?.id ?? null,
|
|
[rolesQuery.data],
|
|
);
|
|
const canCreateStudents = canManageMyClassStudents({
|
|
hasUserPermission: permissions.has('CREATE_USERS'),
|
|
classId,
|
|
studentRoleId,
|
|
});
|
|
const canUpdateStudents = canManageMyClassStudents({
|
|
hasUserPermission: permissions.has('UPDATE_USERS'),
|
|
classId,
|
|
studentRoleId,
|
|
});
|
|
|
|
// studentId → guardian display names.
|
|
const guardiansByStudent = useMemo(() => {
|
|
const map = new Map<string, string[]>();
|
|
for (const row of guardiansQuery.data?.rows ?? []) {
|
|
const name = row.guardian ? personName(row.guardian) : null;
|
|
if (!name) continue;
|
|
const list = map.get(row.studentId) ?? [];
|
|
list.push(name);
|
|
map.set(row.studentId, list);
|
|
}
|
|
return map;
|
|
}, [guardiansQuery.data]);
|
|
const guardianLinksByStudent = useMemo(() => {
|
|
const map = new Map<string, GuardianStudentLink[]>();
|
|
for (const row of guardiansQuery.data?.rows ?? []) {
|
|
const current = map.get(row.studentId) ?? [];
|
|
map.set(row.studentId, [...current, row]);
|
|
}
|
|
return map;
|
|
}, [guardiansQuery.data]);
|
|
|
|
const updateStudentForm = (patch: Partial<MyClassStudentFormValues>) => {
|
|
setStudentForm((current) => ({ ...current, ...patch }));
|
|
setStudentFormStatus(null);
|
|
};
|
|
const updateGuardianForm = (guardianKey: string, patch: Partial<MyClassGuardianFormValues>) => {
|
|
setStudentForm((current) => ({
|
|
...current,
|
|
guardians: current.guardians.map((guardian) => (
|
|
guardian.key === guardianKey ? { ...guardian, ...patch } : guardian
|
|
)),
|
|
}));
|
|
setStudentFormStatus(null);
|
|
};
|
|
const addGuardianForm = () => {
|
|
setStudentForm((current) => ({
|
|
...current,
|
|
guardians: [...current.guardians, emptyGuardianForm()],
|
|
}));
|
|
setStudentFormStatus(null);
|
|
};
|
|
const removeGuardianForm = (guardianKey: string) => {
|
|
setStudentForm((current) => ({
|
|
...current,
|
|
guardians: current.guardians.length === 1
|
|
? [emptyGuardianForm()]
|
|
: current.guardians.filter((guardian) => guardian.key !== guardianKey),
|
|
}));
|
|
setStudentFormStatus(null);
|
|
};
|
|
|
|
const resetStudentForm = () => {
|
|
setStudentForm(emptyStudentForm());
|
|
setEditingStudentId(null);
|
|
setIsStudentFormOpen(false);
|
|
};
|
|
|
|
const startCreateStudent = () => {
|
|
setStudentFormStatus(null);
|
|
if (isStudentFormOpen && !editingStudentId) {
|
|
resetStudentForm();
|
|
return;
|
|
}
|
|
setStudentForm(emptyStudentForm());
|
|
setEditingStudentId(null);
|
|
setIsStudentFormOpen(true);
|
|
};
|
|
|
|
const startEditStudent = (student: AdminUserRow) => {
|
|
const guardians = guardianLinksByStudent.get(student.id) ?? [];
|
|
setStudentForm(studentFormFromRow(student, guardians));
|
|
setEditingStudentId(student.id);
|
|
setStudentFormStatus(null);
|
|
setIsStudentFormOpen(true);
|
|
};
|
|
|
|
const handleStudentSubmit = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
setStudentFormStatus(null);
|
|
|
|
if (!classId || !studentRoleId || !guardianRoleId) {
|
|
setStudentFormStatus({ type: 'error', text: 'Student access is not available for this class.' });
|
|
return;
|
|
}
|
|
const guardiansToSave = studentForm.guardians.filter(hasMyClassGuardianFormValues);
|
|
if (guardiansToSave.some((guardian) => guardian.email.trim() === '')) {
|
|
setStudentFormStatus({ type: 'error', text: 'Guardian email is required for each entered guardian.' });
|
|
return;
|
|
}
|
|
|
|
const payload = buildMyClassStudentSaveData(studentForm, classId, studentRoleId);
|
|
|
|
setStudentFormSaving(true);
|
|
try {
|
|
let studentId = editingStudentId;
|
|
if (editingStudentId) {
|
|
await updateUser(editingStudentId, payload);
|
|
} else {
|
|
const created = await createUser(payload);
|
|
studentId = created.id;
|
|
}
|
|
|
|
if (studentId) {
|
|
for (const guardian of guardiansToSave) {
|
|
const guardianPayload = buildMyClassGuardianSaveData(guardian, guardianRoleId);
|
|
const guardianId = guardian.id
|
|
? guardian.id
|
|
: (await createUser(guardianPayload)).id;
|
|
if (guardian.id) {
|
|
await updateUser(guardian.id, guardianPayload);
|
|
}
|
|
if (guardianId) {
|
|
await linkGuardianStudent(guardianId, studentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['my-class-members', classId] }),
|
|
queryClient.invalidateQueries({ queryKey: ['my-class-guardians'] }),
|
|
]);
|
|
setStudentFormStatus({
|
|
type: 'success',
|
|
text: editingStudentId ? 'Student updated.' : 'Student created.',
|
|
});
|
|
resetStudentForm();
|
|
} catch (error) {
|
|
setStudentFormStatus({ type: 'error', text: getErrorMessage(error, 'Could not save student') });
|
|
} finally {
|
|
setStudentFormSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!classId) {
|
|
return (
|
|
<div className="p-4 md:p-6">
|
|
<div className="rounded-lg border border-slate-700/50 bg-slate-800/40 px-4 py-3 text-sm text-slate-300">
|
|
You are not assigned to a class yet.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (classQuery.isLoading || membersQuery.isLoading || rolesQuery.isLoading) {
|
|
return <PageSkeleton />;
|
|
}
|
|
|
|
const formControlClassName =
|
|
'border-slate-600 bg-slate-950/80 text-slate-100 placeholder:text-slate-500 focus-visible:ring-lime-400 focus-visible:ring-offset-slate-950';
|
|
|
|
return (
|
|
<div className="space-y-6 p-4 md:p-6">
|
|
<ModuleHeader
|
|
title={classQuery.data?.name ?? 'My Class'}
|
|
description="Your class roster and linked guardians."
|
|
icon={GraduationCap}
|
|
iconClassName="bg-gradient-to-br from-lime-500 to-lime-700"
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Users size={16} />
|
|
Students ({students.length})
|
|
</CardTitle>
|
|
{canCreateStudents && (
|
|
<Button type="button" size="sm" onClick={startCreateStudent}>
|
|
<UserPlus className="mr-2 h-4 w-4" />
|
|
{isStudentFormOpen && !editingStudentId ? 'Hide form' : 'Add student'}
|
|
<ChevronDown
|
|
className={cn(
|
|
'ml-2 h-4 w-4 transition-transform',
|
|
isStudentFormOpen && !editingStudentId && 'rotate-180',
|
|
)}
|
|
/>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{studentFormStatus && (
|
|
<div
|
|
className={`rounded-lg border px-3 py-2 text-sm ${
|
|
studentFormStatus.type === 'success'
|
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
|
|
: 'border-red-500/30 bg-red-500/10 text-red-200'
|
|
}`}
|
|
>
|
|
{studentFormStatus.text}
|
|
</div>
|
|
)}
|
|
|
|
{isStudentFormOpen && (
|
|
<form
|
|
className="space-y-4 rounded-lg border border-slate-600/80 bg-slate-950/45 p-4"
|
|
onSubmit={handleStudentSubmit}
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold text-slate-100">
|
|
{editingStudentId ? 'Edit student' : 'Add student'}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={resetStudentForm}
|
|
className="text-slate-300"
|
|
>
|
|
<X className="mr-2 h-4 w-4" />
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-[auto_1fr]">
|
|
<ImageUpload
|
|
value={studentForm.avatar}
|
|
onChange={(avatar) => updateStudentForm({ avatar })}
|
|
table="users"
|
|
field="avatar"
|
|
label="Avatar"
|
|
shape="square"
|
|
previewSize="lg"
|
|
/>
|
|
<div className="grid gap-4 sm:grid-cols-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="student-prefix" className="text-slate-100">Title</Label>
|
|
<NativeSelect
|
|
id="student-prefix"
|
|
value={studentForm.namePrefix}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateStudentForm({ namePrefix: event.target.value })}
|
|
>
|
|
<option value="">None</option>
|
|
{USER_NAME_PREFIX_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>{option.label}</option>
|
|
))}
|
|
</NativeSelect>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="student-first-name" className="text-slate-100">First name</Label>
|
|
<Input
|
|
id="student-first-name"
|
|
value={studentForm.firstName}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateStudentForm({ firstName: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="student-last-name" className="text-slate-100">Last name</Label>
|
|
<Input
|
|
id="student-last-name"
|
|
value={studentForm.lastName}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateStudentForm({ lastName: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="student-email" className="text-slate-100">Email</Label>
|
|
<Input
|
|
id="student-email"
|
|
type="email"
|
|
value={studentForm.email}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateStudentForm({ email: event.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor="student-phone" className="text-slate-100">Phone number</Label>
|
|
<Input
|
|
id="student-phone"
|
|
type="tel"
|
|
value={studentForm.phoneNumber}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateStudentForm({ phoneNumber: event.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 rounded-lg border border-slate-700/70 bg-slate-950/35 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-sm font-semibold text-slate-100">Guardians</p>
|
|
<Button type="button" variant="outline" size="sm" onClick={addGuardianForm}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add guardian
|
|
</Button>
|
|
</div>
|
|
{studentForm.guardians.map((guardian, index) => (
|
|
<div
|
|
key={guardian.key}
|
|
className="space-y-4 rounded-lg border border-slate-700/70 bg-slate-950/45 p-4"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm font-semibold text-slate-200">Guardian {index + 1}</p>
|
|
{guardian.id ? (
|
|
<span className="text-xs text-slate-400">Linked guardian</span>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeGuardianForm(guardian.key)}
|
|
className="text-slate-300"
|
|
>
|
|
<X className="mr-2 h-4 w-4" />
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-[auto_1fr]">
|
|
<ImageUpload
|
|
value={guardian.avatar}
|
|
onChange={(avatar) => updateGuardianForm(guardian.key, { avatar })}
|
|
table="users"
|
|
field="avatar"
|
|
label="Photo"
|
|
shape="square"
|
|
previewSize="lg"
|
|
/>
|
|
<div className="grid gap-4 sm:grid-cols-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={`guardian-prefix-${guardian.key}`} className="text-slate-100">
|
|
Title
|
|
</Label>
|
|
<NativeSelect
|
|
id={`guardian-prefix-${guardian.key}`}
|
|
value={guardian.namePrefix}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateGuardianForm(guardian.key, { namePrefix: event.target.value })}
|
|
>
|
|
<option value="">None</option>
|
|
{USER_NAME_PREFIX_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>{option.label}</option>
|
|
))}
|
|
</NativeSelect>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={`guardian-first-name-${guardian.key}`} className="text-slate-100">
|
|
First name
|
|
</Label>
|
|
<Input
|
|
id={`guardian-first-name-${guardian.key}`}
|
|
value={guardian.firstName}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateGuardianForm(guardian.key, { firstName: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={`guardian-last-name-${guardian.key}`} className="text-slate-100">
|
|
Last name
|
|
</Label>
|
|
<Input
|
|
id={`guardian-last-name-${guardian.key}`}
|
|
value={guardian.lastName}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateGuardianForm(guardian.key, { lastName: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={`guardian-email-${guardian.key}`} className="text-slate-100">
|
|
Email
|
|
</Label>
|
|
<Input
|
|
id={`guardian-email-${guardian.key}`}
|
|
type="email"
|
|
value={guardian.email}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateGuardianForm(guardian.key, { email: event.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor={`guardian-phone-${guardian.key}`} className="text-slate-100">
|
|
Phone number
|
|
</Label>
|
|
<Input
|
|
id={`guardian-phone-${guardian.key}`}
|
|
type="tel"
|
|
value={guardian.phoneNumber}
|
|
className={formControlClassName}
|
|
onChange={(event) => updateGuardianForm(guardian.key, { phoneNumber: event.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="submit"
|
|
disabled={studentFormSaving || (editingStudentId ? !canUpdateStudents : !canCreateStudents)}
|
|
>
|
|
{studentFormSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{editingStudentId ? 'Save changes' : 'Create student'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{students.length === 0 ? (
|
|
<p className="text-sm text-slate-400">No students in this class yet.</p>
|
|
) : (
|
|
<ul className="divide-y divide-slate-700/50">
|
|
{students.map((s) => {
|
|
const guardians = guardiansByStudent.get(s.id) ?? [];
|
|
return (
|
|
<li key={s.id} className="flex items-center justify-between gap-3 py-3">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<UserAvatar
|
|
name={personName(s)}
|
|
avatarUrl={s.avatar?.[0]?.privateUrl ?? null}
|
|
className="h-9 w-9 text-[11px]"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium text-slate-200">{personName(s)}</p>
|
|
<p className="truncate text-xs text-slate-500">
|
|
{guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{canUpdateStudents && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => startEditStudent(s)}
|
|
className="shrink-0"
|
|
aria-label={`Edit ${personName(s)}`}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Staff</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{staff.length === 0 ? (
|
|
<p className="text-sm text-slate-400">No staff assigned.</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{staff.map((m: AdminUserRow) => (
|
|
<li key={m.id} className="text-sm text-slate-200">
|
|
{personName(m)}
|
|
<span className="ml-2 text-xs text-slate-500">
|
|
{m.app_role?.name === 'teacher' ? 'Teacher' : 'Support'}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|