40227-vm/frontend/src/pages/modules/MyClassPage.tsx

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>
);
}