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>['rows'][number]>; export default function MyClassPage() { const { user } = useAuth(); const permissions = usePermissions(); const queryClient = useQueryClient(); const classId = user?.classId ?? null; const [studentForm, setStudentForm] = useState(() => emptyStudentForm()); const [editingStudentId, setEditingStudentId] = useState(null); const [isStudentFormOpen, setIsStudentFormOpen] = useState(false); const [studentFormSaving, setStudentFormSaving] = useState(false); const [studentFormStatus, setStudentFormStatus] = useState(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(); 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(); 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) => { setStudentForm((current) => ({ ...current, ...patch })); setStudentFormStatus(null); }; const updateGuardianForm = (guardianKey: string, patch: Partial) => { 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 (
You are not assigned to a class yet.
); } if (classQuery.isLoading || membersQuery.isLoading || rolesQuery.isLoading) { return ; } 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 (
Students ({students.length}) {canCreateStudents && ( )}
{studentFormStatus && (
{studentFormStatus.text}
)} {isStudentFormOpen && (

{editingStudentId ? 'Edit student' : 'Add student'}

updateStudentForm({ avatar })} table="users" field="avatar" label="Avatar" shape="square" previewSize="lg" />
updateStudentForm({ namePrefix: event.target.value })} > {USER_NAME_PREFIX_OPTIONS.map((option) => ( ))}
updateStudentForm({ firstName: event.target.value })} />
updateStudentForm({ lastName: event.target.value })} />
updateStudentForm({ email: event.target.value })} required />
updateStudentForm({ phoneNumber: event.target.value })} />

Guardians

{studentForm.guardians.map((guardian, index) => (

Guardian {index + 1}

{guardian.id ? ( Linked guardian ) : ( )}
updateGuardianForm(guardian.key, { avatar })} table="users" field="avatar" label="Photo" shape="square" previewSize="lg" />
updateGuardianForm(guardian.key, { namePrefix: event.target.value })} > {USER_NAME_PREFIX_OPTIONS.map((option) => ( ))}
updateGuardianForm(guardian.key, { firstName: event.target.value })} />
updateGuardianForm(guardian.key, { lastName: event.target.value })} />
updateGuardianForm(guardian.key, { email: event.target.value })} />
updateGuardianForm(guardian.key, { phoneNumber: event.target.value })} />
))}
)} {students.length === 0 ? (

No students in this class yet.

) : (
    {students.map((s) => { const guardians = guardiansByStudent.get(s.id) ?? []; return (
  • {personName(s)}

    {guardians.length > 0 ? `Guardians: ${guardians.join(', ')}` : 'No guardian linked'}

    {canUpdateStudents && ( )}
  • ); })}
)}
Staff {staff.length === 0 ? (

No staff assigned.

) : (
    {staff.map((m: AdminUserRow) => (
  • {personName(m)} {m.app_role?.name === 'teacher' ? 'Teacher' : 'Support'}
  • ))}
)}
); }