From 8fcdc6feaf882cb4bfe3fa53f90537d5197bc56c Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 22:26:23 +0100 Subject: [PATCH 1/9] feat: enhance TenantAccessGate with organization synchronization and error handling --- .../app/t/[tenantSlug]/tenant-access-gate.tsx | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/web/app/t/[tenantSlug]/tenant-access-gate.tsx b/apps/web/app/t/[tenantSlug]/tenant-access-gate.tsx index 64ac8e5..afd6085 100644 --- a/apps/web/app/t/[tenantSlug]/tenant-access-gate.tsx +++ b/apps/web/app/t/[tenantSlug]/tenant-access-gate.tsx @@ -1,8 +1,11 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; -import { useOrganizationsQuery } from "@/lib/api/organizations-query"; +import { + useOrganizationsQuery, + useSetActiveOrganizationMutation, +} from "@/lib/api/organizations-query"; import { useSession } from "@/lib/auth/session"; import { appShellUrl } from "@/lib/urls"; @@ -15,6 +18,17 @@ export function TenantAccessGate({ }) { const { authStatus, user } = useSession(); const { data: orgs, isLoading: orgsLoading } = useOrganizationsQuery(); + const setActiveOrg = useSetActiveOrganizationMutation(); + const [orgSyncFailed, setOrgSyncFailed] = useState(false); + + const organizationId = useMemo( + () => orgs?.find((o) => o.slug === tenantSlug)?.id ?? null, + [orgs, tenantSlug], + ); + + useEffect(() => { + setOrgSyncFailed(false); + }, [tenantSlug]); useEffect(() => { if (authStatus !== "unauthenticated") return; @@ -26,6 +40,34 @@ export function TenantAccessGate({ window.location.href = appShellUrl(`/login${q}`); }, [authStatus]); + useEffect(() => { + if ( + authStatus !== "authenticated" || + !organizationId || + orgSyncFailed || + user?.activeOrganizationId === organizationId + ) { + return; + } + let cancelled = false; + void (async () => { + try { + await setActiveOrg.mutateAsync(organizationId); + } catch { + if (!cancelled) setOrgSyncFailed(true); + } + })(); + return () => { + cancelled = true; + }; + }, [ + authStatus, + organizationId, + user?.activeOrganizationId, + orgSyncFailed, + setActiveOrg, + ]); + if (authStatus === "loading") { return

Loading session…

; } @@ -55,5 +97,25 @@ export function TenantAccessGate({ ); } + if (orgSyncFailed) { + return ( +
+

Could not open workspace

+

We could not switch your active organization to this tenant.

+

+ Manage organizations +

+
+ ); + } + + const needsOrgSync = + Boolean(organizationId) && + user?.activeOrganizationId !== organizationId; + + if (needsOrgSync || setActiveOrg.isPending) { + return

Switching to this organization…

; + } + return children; } From 984f56b697b8966e95363eec3d54a7a8c67ea3a8 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 13:39:47 +0100 Subject: [PATCH 2/9] feat: add organization and student query hooks for API integration --- .../web/lib/api/organization-members-query.ts | 32 ++ apps/web/lib/api/students-query.ts | 303 ++++++++++++++++++ apps/web/lib/api/subjects-query.ts | 51 +++ 3 files changed, 386 insertions(+) create mode 100644 apps/web/lib/api/organization-members-query.ts create mode 100644 apps/web/lib/api/students-query.ts create mode 100644 apps/web/lib/api/subjects-query.ts diff --git a/apps/web/lib/api/organization-members-query.ts b/apps/web/lib/api/organization-members-query.ts new file mode 100644 index 0000000..324995a --- /dev/null +++ b/apps/web/lib/api/organization-members-query.ts @@ -0,0 +1,32 @@ +"use client"; + +import { unwrapStudiqoResponse } from "@studiqo/api-client/errors"; +import { useQuery } from "@tanstack/react-query"; + +import { useSession } from "@/lib/auth/session"; + +export const organizationMembersQueryKey = (organizationId: string) => + ["organizations", organizationId, "members"] as const; + +export function useOrganizationMembersQuery( + organizationId: string | null, + enabled: boolean, +) { + const { apiClient, authStatus, accessToken } = useSession(); + return useQuery({ + queryKey: organizationId + ? organizationMembersQueryKey(organizationId) + : ["organizations", "members", "disabled"], + queryFn: async () => { + const r = await apiClient.GET("/organizations/{organizationId}/members", { + params: { path: { organizationId: organizationId! } }, + }); + return unwrapStudiqoResponse(r); + }, + enabled: + Boolean(organizationId) && + enabled && + authStatus === "authenticated" && + Boolean(accessToken), + }); +} diff --git a/apps/web/lib/api/students-query.ts b/apps/web/lib/api/students-query.ts new file mode 100644 index 0000000..7dffa85 --- /dev/null +++ b/apps/web/lib/api/students-query.ts @@ -0,0 +1,303 @@ +"use client"; + +import type { components } from "@studiqo/api-client/generated"; +import { + unwrapStudiqoResponse, + unwrapStudiqoVoid, +} from "@studiqo/api-client/errors"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useSession } from "@/lib/auth/session"; + +export const studentsListQueryKey = (organizationId: string) => + ["students", organizationId, "list"] as const; + +export const studentDetailQueryKey = (studentId: string) => + ["students", studentId, "detail"] as const; + +export const studentSubjectsQueryKey = (studentId: string) => + ["students", studentId, "subjects"] as const; + +export const studentEmergencyContactsQueryKey = (studentId: string) => + ["students", studentId, "emergency-contacts"] as const; + +type CreateStudentBody = components["schemas"]["CreateStudentRequest"]; +type UpdateStudentBody = components["schemas"]["UpdateStudentRequest"]; +type LinkStudentSubjectBody = components["schemas"]["LinkStudentSubjectRequest"]; +type CreateEmergencyContactBody = + components["schemas"]["CreateEmergencyContactRequest"]; +type UpdateEmergencyContactBody = + components["schemas"]["UpdateEmergencyContactRequest"]; + +function enabledWorkspace( + organizationId: string | null, + authStatus: string, + accessToken: string | null, +): boolean { + return ( + Boolean(organizationId) && + authStatus === "authenticated" && + Boolean(accessToken) + ); +} + +export function useStudentsListQuery(organizationId: string | null) { + const { apiClient, authStatus, accessToken } = useSession(); + return useQuery({ + queryKey: organizationId + ? studentsListQueryKey(organizationId) + : ["students", "list", "disabled"], + queryFn: async () => { + const r = await apiClient.GET("/students"); + return unwrapStudiqoResponse(r); + }, + enabled: enabledWorkspace(organizationId, authStatus, accessToken), + }); +} + +export function useStudentDetailQuery( + organizationId: string | null, + studentId: string | null, +) { + const { apiClient, authStatus, accessToken } = useSession(); + return useQuery({ + queryKey: studentId + ? studentDetailQueryKey(studentId) + : ["students", "detail", "disabled"], + queryFn: async () => { + const r = await apiClient.GET("/students/{studentId}", { + params: { path: { studentId: studentId! } }, + }); + return unwrapStudiqoResponse(r); + }, + enabled: + enabledWorkspace(organizationId, authStatus, accessToken) && + Boolean(studentId), + }); +} + +export function useStudentSubjectsQuery( + organizationId: string | null, + studentId: string | null, +) { + const { apiClient, authStatus, accessToken } = useSession(); + return useQuery({ + queryKey: studentId + ? studentSubjectsQueryKey(studentId) + : ["students", "subjects", "disabled"], + queryFn: async () => { + const r = await apiClient.GET("/students/{studentId}/subjects", { + params: { path: { studentId: studentId! } }, + }); + return unwrapStudiqoResponse(r); + }, + enabled: + enabledWorkspace(organizationId, authStatus, accessToken) && + Boolean(studentId), + }); +} + +export function useStudentEmergencyContactsQuery( + organizationId: string | null, + studentId: string | null, +) { + const { apiClient, authStatus, accessToken } = useSession(); + return useQuery({ + queryKey: studentId + ? studentEmergencyContactsQueryKey(studentId) + : ["students", "emergency-contacts", "disabled"], + queryFn: async () => { + const r = await apiClient.GET( + "/students/{studentId}/emergency-contacts", + { + params: { path: { studentId: studentId! } }, + }, + ); + return unwrapStudiqoResponse(r); + }, + enabled: + enabledWorkspace(organizationId, authStatus, accessToken) && + Boolean(studentId), + }); +} + +export function useCreateStudentMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: CreateStudentBody) => { + const r = await apiClient.POST("/students", { body }); + return unwrapStudiqoResponse(r); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: studentsListQueryKey(organizationId), + }); + }, + }); +} + +export function useUpdateStudentMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + studentId, + body, + }: { + studentId: string; + body: UpdateStudentBody; + }) => { + const r = await apiClient.PUT("/students/{studentId}", { + params: { path: { studentId } }, + body, + }); + return unwrapStudiqoResponse(r); + }, + onSuccess: (_data, { studentId }) => { + void queryClient.invalidateQueries({ + queryKey: studentsListQueryKey(organizationId), + }); + void queryClient.invalidateQueries({ + queryKey: studentDetailQueryKey(studentId), + }); + }, + }); +} + +export function useDeleteStudentMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (studentId: string) => { + const r = await apiClient.DELETE("/students/{studentId}", { + params: { path: { studentId } }, + }); + unwrapStudiqoVoid(r); + }, + onSuccess: (_void, studentId) => { + void queryClient.invalidateQueries({ + queryKey: studentsListQueryKey(organizationId), + }); + void queryClient.removeQueries({ + queryKey: studentDetailQueryKey(studentId), + }); + }, + }); +} + +export function useLinkStudentSubjectMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + studentId, + body, + }: { + studentId: string; + body: LinkStudentSubjectBody; + }) => { + const r = await apiClient.POST("/students/{studentId}/subjects", { + params: { path: { studentId } }, + body, + }); + return unwrapStudiqoResponse(r); + }, + onSuccess: (_data, { studentId }) => { + void queryClient.invalidateQueries({ + queryKey: studentSubjectsQueryKey(studentId), + }); + void queryClient.invalidateQueries({ + queryKey: studentsListQueryKey(organizationId), + }); + }, + }); +} + +export function useCreateEmergencyContactMutation() { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + studentId, + body, + }: { + studentId: string; + body: CreateEmergencyContactBody; + }) => { + const r = await apiClient.POST( + "/students/{studentId}/emergency-contacts", + { + params: { path: { studentId } }, + body, + }, + ); + return unwrapStudiqoResponse(r); + }, + onSuccess: (_data, { studentId }) => { + void queryClient.invalidateQueries({ + queryKey: studentEmergencyContactsQueryKey(studentId), + }); + }, + }); +} + +export function useUpdateEmergencyContactMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + studentId, + contactId, + body, + }: { + studentId: string; + contactId: string; + body: UpdateEmergencyContactBody; + }) => { + const r = await apiClient.PUT( + "/students/{studentId}/emergency-contacts/{contactId}", + { + params: { path: { studentId, contactId } }, + body, + }, + ); + return unwrapStudiqoResponse(r); + }, + onSuccess: (_data, { studentId }) => { + void queryClient.invalidateQueries({ + queryKey: studentEmergencyContactsQueryKey(studentId), + }); + void queryClient.invalidateQueries({ + queryKey: studentsListQueryKey(organizationId), + }); + }, + }); +} + +export function useDeleteEmergencyContactMutation() { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + studentId, + contactId, + }: { + studentId: string; + contactId: string; + }) => { + const r = await apiClient.DELETE( + "/students/{studentId}/emergency-contacts/{contactId}", + { + params: { path: { studentId, contactId } }, + }, + ); + unwrapStudiqoVoid(r); + }, + onSuccess: (_void, { studentId }) => { + void queryClient.invalidateQueries({ + queryKey: studentEmergencyContactsQueryKey(studentId), + }); + }, + }); +} diff --git a/apps/web/lib/api/subjects-query.ts b/apps/web/lib/api/subjects-query.ts new file mode 100644 index 0000000..b54f7a0 --- /dev/null +++ b/apps/web/lib/api/subjects-query.ts @@ -0,0 +1,51 @@ +"use client"; + +import { unwrapStudiqoResponse } from "@studiqo/api-client/errors"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useSession } from "@/lib/auth/session"; + +export const subjectsListQueryKey = (organizationId: string) => + ["subjects", organizationId, "list"] as const; + +function enabledWorkspace( + organizationId: string | null, + authStatus: string, + accessToken: string | null, +): boolean { + return ( + Boolean(organizationId) && + authStatus === "authenticated" && + Boolean(accessToken) + ); +} + +export function useSubjectsListQuery(organizationId: string | null) { + const { apiClient, authStatus, accessToken } = useSession(); + return useQuery({ + queryKey: organizationId + ? subjectsListQueryKey(organizationId) + : ["subjects", "list", "disabled"], + queryFn: async () => { + const r = await apiClient.GET("/subjects"); + return unwrapStudiqoResponse(r); + }, + enabled: enabledWorkspace(organizationId, authStatus, accessToken), + }); +} + +export function useCreateSubjectMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: { name: string }) => { + const r = await apiClient.POST("/subjects", { body }); + return unwrapStudiqoResponse(r); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: subjectsListQueryKey(organizationId), + }); + }, + }); +} From be8f17758e2c400e350fd2775384b16c33b75e60 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 13:40:09 +0100 Subject: [PATCH 3/9] feat: add validation schemas for student and emergency contact forms --- apps/web/lib/validation/student-forms.ts | 102 +++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 apps/web/lib/validation/student-forms.ts diff --git a/apps/web/lib/validation/student-forms.ts b/apps/web/lib/validation/student-forms.ts new file mode 100644 index 0000000..c25651b --- /dev/null +++ b/apps/web/lib/validation/student-forms.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; + +const uuid = z.string().uuid(); + +/** OpenAPI: min 1, max 255 */ +const nameField = z.string().trim().min(1).max(255); + +/** E.164-style per OpenAPI emergency contact schemas */ +const phoneField = z + .string() + .trim() + .regex(/^\+?[1-9]\d{1,14}$/, "Use E.164-style phone (e.g. +441234567890)"); + +const dateOfBirthInput = z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD") + .transform((s) => `${s}T12:00:00.000Z`); + +export const createStudentFormSchema = z.object({ + parentId: uuid, + tutorId: z.union([uuid, z.literal("")]).optional(), + firstName: nameField, + lastName: nameField, + dateOfBirth: dateOfBirthInput, +}); + +export type CreateStudentForm = z.infer; + +export const updateStudentFormSchema = z + .object({ + parentId: uuid.optional(), + tutorId: z.union([uuid, z.literal("")]).optional(), + firstName: nameField.optional(), + lastName: nameField.optional(), + dateOfBirth: z + .string() + .trim() + .optional() + .refine( + (s) => s === undefined || s === "" || /^\d{4}-\d{2}-\d{2}$/.test(s), + "Use YYYY-MM-DD", + ), + }) + .refine( + (data) => + data.parentId !== undefined || + data.tutorId !== undefined || + data.firstName !== undefined || + data.lastName !== undefined || + (data.dateOfBirth !== undefined && data.dateOfBirth !== ""), + { message: "Change at least one field" }, + ); + +export type UpdateStudentForm = z.infer; + +export const linkStudentSubjectFormSchema = z.object({ + subjectId: z + .string() + .min(1, "Select a subject") + .pipe(z.string().uuid()), + currentGrade: z.string().max(32).optional(), + predictedGrade: z.string().max(32).optional(), +}); + +export type LinkStudentSubjectForm = z.infer< + typeof linkStudentSubjectFormSchema +>; + +export const createEmergencyContactFormSchema = z.object({ + name: nameField, + phone: phoneField, + relationship: z.string().trim().min(1).max(63), +}); + +export type CreateEmergencyContactForm = z.infer< + typeof createEmergencyContactFormSchema +>; + +export const updateEmergencyContactFormSchema = z + .object({ + name: nameField.optional(), + phone: phoneField.optional(), + relationship: z.string().trim().min(1).max(63).optional(), + }) + .refine( + (data) => + data.name !== undefined || + data.phone !== undefined || + data.relationship !== undefined, + { message: "Change at least one field" }, + ); + +export type UpdateEmergencyContactForm = z.infer< + typeof updateEmergencyContactFormSchema +>; + +export const createSubjectFormSchema = z.object({ + name: nameField, +}); + +export type CreateSubjectForm = z.infer; From 1f747fc0883526acf1565f8cafbca3ceff285227 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 13:45:12 +0100 Subject: [PATCH 4/9] feat: add student and student detail pages with routing --- .../students/[studentId]/edit/page.tsx | 5 + .../(workspace)/students/[studentId]/page.tsx | 5 + .../tenant-student-detail-page.tsx | 716 ++++++++++++++++++ .../(workspace)/students/new/page.tsx | 5 + .../(workspace)/students/page.tsx | 5 + .../students/tenant-students-page.tsx | 99 +++ 6 files changed, 835 insertions(+) create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/edit/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/tenant-student-detail-page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/students/new/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/students/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/students/tenant-students-page.tsx diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/edit/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/edit/page.tsx new file mode 100644 index 0000000..ecaa8fc --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/edit/page.tsx @@ -0,0 +1,5 @@ +import { TenantStudentEditPage } from "./tenant-student-edit-page"; + +export default function StudentEditRoutePage() { + return ; +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/page.tsx new file mode 100644 index 0000000..11aa359 --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/page.tsx @@ -0,0 +1,5 @@ +import { TenantStudentDetailPage } from "./tenant-student-detail-page"; + +export default function StudentDetailRoutePage() { + return ; +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/tenant-student-detail-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/tenant-student-detail-page.tsx new file mode 100644 index 0000000..78b5c7a --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/tenant-student-detail-page.tsx @@ -0,0 +1,716 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { isStudiqoApiError } from "@studiqo/api-client/errors"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; + +import { + useCreateEmergencyContactMutation, + useDeleteEmergencyContactMutation, + useDeleteStudentMutation, + useLinkStudentSubjectMutation, + useStudentDetailQuery, + useStudentEmergencyContactsQuery, + useStudentSubjectsQuery, + useUpdateEmergencyContactMutation, +} from "@/lib/api/students-query"; +import { + useCreateSubjectMutation, + useSubjectsListQuery, +} from "@/lib/api/subjects-query"; +import { formatIsoDate, formatIsoDateTime } from "@/lib/datetime"; +import { useTenantOrganizationId } from "@/lib/hooks/use-tenant-organization"; +import { useSession } from "@/lib/auth/session"; +import { isOrgAdminOrSuperadmin } from "@/lib/tenant-role"; +import { + createEmergencyContactFormSchema, + createSubjectFormSchema, + linkStudentSubjectFormSchema, + updateEmergencyContactFormSchema, + type CreateEmergencyContactForm, + type CreateSubjectForm, + type LinkStudentSubjectForm, + type UpdateEmergencyContactForm, +} from "@/lib/validation/student-forms"; + +export function TenantStudentDetailPage() { + const params = useParams<{ tenantSlug: string; studentId: string }>(); + const { tenantSlug, studentId } = params; + const { user } = useSession(); + const { organizationId, orgsLoading } = useTenantOrganizationId(tenantSlug); + const canManage = isOrgAdminOrSuperadmin( + user?.role, + user?.isSuperadmin ?? false, + ); + + const studentQ = useStudentDetailQuery(organizationId, studentId); + const subjectsQ = useStudentSubjectsQuery(organizationId, studentId); + const contactsQ = useStudentEmergencyContactsQuery(organizationId, studentId); + const orgSubjectsQ = useSubjectsListQuery(organizationId); + + const linkSubject = useLinkStudentSubjectMutation(organizationId ?? ""); + const createContact = useCreateEmergencyContactMutation(); + const updateContact = useUpdateEmergencyContactMutation(organizationId ?? ""); + const deleteContact = useDeleteEmergencyContactMutation(); + const deleteStudent = useDeleteStudentMutation(organizationId ?? ""); + const createSubject = useCreateSubjectMutation(organizationId ?? ""); + + const enrolledSubjectIds = useMemo( + () => new Set(subjectsQ.data?.map((s) => s.subjectId) ?? []), + [subjectsQ.data], + ); + + const base = `/t/${tenantSlug}/students`; + const listUrl = base; + + if (orgsLoading || !organizationId) { + return ( +
+

+ ← Students +

+

Loading…

+
+ ); + } + + if (studentQ.isLoading) { + return ( +
+

+ ← Students +

+

Loading student…

+
+ ); + } + + if (studentQ.error || !studentQ.data) { + return ( +
+

+ ← Students +

+

Student

+

+ {studentQ.error instanceof Error + ? studentQ.error.message + : "Student not found or access denied."} +

+
+ ); + } + + const student = studentQ.data; + + return ( +
+

+ ← Students +

+ +
+

+ {student.firstName} {student.lastName} +

+ {canManage ? ( + + Edit + deleteStudent.mutateAsync(studentId)} + disabled={deleteStudent.isPending} + /> + + ) : null} +
+ +

+ Born {formatIsoDate(student.dateOfBirth)} +

+ +
+

Subjects

+ {subjectsQ.isLoading ?

Loading subjects…

: null} + {subjectsQ.error ? ( +

+ {subjectsQ.error instanceof Error + ? subjectsQ.error.message + : "Could not load subjects"} +

+ ) : null} + {subjectsQ.data && subjectsQ.data.length === 0 ? ( +

No subjects linked yet.

+ ) : null} + {subjectsQ.data && subjectsQ.data.length > 0 ? ( +
    + {subjectsQ.data.map((row) => ( +
  • + {row.subjectName} + {row.currentGrade != null || row.predictedGrade != null ? ( + + {" "} + — current: {row.currentGrade ?? "—"}, predicted:{" "} + {row.predictedGrade ?? "—"} + + ) : null} +
    + Updated {formatIsoDateTime(row.updatedAt)} +
    +
  • + ))} +
+ ) : null} + + {canManage ? ( + + ) : null} +
+ +
+

Emergency contacts

+ {contactsQ.isLoading ?

Loading contacts…

: null} + {contactsQ.error ? ( +

+ {contactsQ.error instanceof Error + ? contactsQ.error.message + : "Could not load contacts"} +

+ ) : null} + {contactsQ.data && contactsQ.data.length === 0 ? ( +

No emergency contacts on file.

+ ) : null} + {contactsQ.data?.map((c) => ( + + ))} + + {canManage ? ( + + ) : null} +
+
+ ); +} + +function DeleteStudentButton({ + listUrl, + onDelete, + disabled, +}: { + listUrl: string; + onDelete: () => Promise; + disabled: boolean; +}) { + const router = useRouter(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [err, setErr] = useState(null); + + if (!confirmOpen) { + return ( + + ); + } + + return ( + + Delete this student? + {err ? {err} : null} + + + + + + ); +} + +function LinkSubjectBlock({ + studentId, + orgSubjects, + enrolledIds, + linkSubject, + createSubject, + subjectsLoading, +}: { + studentId: string; + orgSubjects: { id: string; name: string }[]; + enrolledIds: Set; + linkSubject: ReturnType; + createSubject: ReturnType; + subjectsLoading: boolean; +}) { + const [formError, setFormError] = useState(null); + const [showNewSubject, setShowNewSubject] = useState(false); + const form = useForm({ + resolver: zodResolver(linkStudentSubjectFormSchema), + defaultValues: { + subjectId: "", + currentGrade: "", + predictedGrade: "", + }, + }); + + const available = orgSubjects.filter((s) => !enrolledIds.has(s.id)); + + async function onLink(values: LinkStudentSubjectForm) { + setFormError(null); + try { + await linkSubject.mutateAsync({ + studentId, + body: { + subjectId: values.subjectId, + ...(values.currentGrade ? { currentGrade: values.currentGrade } : {}), + ...(values.predictedGrade + ? { predictedGrade: values.predictedGrade } + : {}), + }, + }); + form.reset({ + subjectId: "", + currentGrade: "", + predictedGrade: "", + }); + } catch (e) { + if (isStudiqoApiError(e)) { + setFormError( + e.status === 409 + ? "That subject is already linked." + : e.message, + ); + } else setFormError("Could not link subject"); + } + } + + return ( +
+

Link a subject

+ {subjectsLoading ?

Loading subject list…

: null} +
+ + + + {formError ? ( +

{formError}

+ ) : null} + +
+ + {available.length === 0 && !subjectsLoading ? ( +

+ All subjects are linked, or the list is empty. +

+ ) : null} + + + {showNewSubject ? ( + setShowNewSubject(false)} + /> + ) : null} +
+ ); +} + +function CreateSubjectInline({ + createSubject, + onCreated, +}: { + createSubject: ReturnType; + onCreated: () => void; +}) { + const [err, setErr] = useState(null); + const form = useForm({ + resolver: zodResolver(createSubjectFormSchema), + defaultValues: { name: "" }, + }); + + async function onSubmit(values: CreateSubjectForm) { + setErr(null); + try { + await createSubject.mutateAsync({ name: values.name }); + form.reset({ name: "" }); + onCreated(); + } catch (e) { + if (isStudiqoApiError(e)) setErr(e.message); + else setErr("Could not create subject"); + } + } + + return ( +
+ + {err ? {err} : null} + +
+ ); +} + +function EmergencyContactRow({ + contact, + studentId, + canManage, + updateContact, + deleteContact, +}: { + contact: { + id: string; + name: string; + phone: string; + relationship: string; + }; + studentId: string; + canManage: boolean; + updateContact: ReturnType; + deleteContact: ReturnType; +}) { + const [editing, setEditing] = useState(false); + + return ( +
+ {!editing ? ( + <> +
+ {contact.name} — {contact.relationship} +
+
{contact.phone}
+ {canManage ? ( +
+ + + deleteContact.mutateAsync({ + studentId, + contactId: contact.id, + }) + } + disabled={deleteContact.isPending} + /> +
+ ) : null} + + ) : ( + setEditing(false)} + updateContact={updateContact} + /> + )} +
+ ); +} + +function DeleteContactButton({ + onDelete, + disabled, +}: { + onDelete: () => Promise; + disabled: boolean; +}) { + const [open, setOpen] = useState(false); + const [err, setErr] = useState(null); + if (!open) { + return ( + + ); + } + return ( + + {err ? {err} : null} + + + + + + ); +} + +function EditEmergencyContactForm({ + contact, + studentId, + onCancel, + updateContact, +}: { + contact: { id: string; name: string; phone: string; relationship: string }; + studentId: string; + onCancel: () => void; + updateContact: ReturnType; +}) { + const [err, setErr] = useState(null); + const form = useForm({ + resolver: zodResolver(updateEmergencyContactFormSchema), + defaultValues: { + name: contact.name, + phone: contact.phone, + relationship: contact.relationship, + }, + }); + + async function onSubmit(values: UpdateEmergencyContactForm) { + setErr(null); + const dirty = form.formState.dirtyFields; + const body: Record = {}; + if (dirty.name && values.name) body.name = values.name; + if (dirty.phone && values.phone) body.phone = values.phone; + if (dirty.relationship && values.relationship) { + body.relationship = values.relationship; + } + if (Object.keys(body).length === 0) { + setErr("Change at least one field"); + return; + } + try { + await updateContact.mutateAsync({ + studentId, + contactId: contact.id, + body, + }); + onCancel(); + } catch (e) { + if (isStudiqoApiError(e)) setErr(e.message); + else setErr("Could not update"); + } + } + + return ( +
+ + + + {err ? {err} : null} + + + + +
+ ); +} + +function AddEmergencyContactForm({ + studentId, + createContact, + contactCount, +}: { + studentId: string; + createContact: ReturnType; + contactCount: number; +}) { + const [err, setErr] = useState(null); + const form = useForm({ + resolver: zodResolver(createEmergencyContactFormSchema), + defaultValues: { name: "", phone: "", relationship: "" }, + }); + + if (contactCount >= 2) { + return ( +

+ Maximum of two emergency contacts reached. +

+ ); + } + + async function onSubmit(values: CreateEmergencyContactForm) { + setErr(null); + try { + await createContact.mutateAsync({ studentId, body: values }); + form.reset({ name: "", phone: "", relationship: "" }); + } catch (e) { + if (isStudiqoApiError(e)) { + setErr( + e.status === 409 + ? "Maximum emergency contacts reached." + : e.message, + ); + } else setErr("Could not add contact"); + } + } + + return ( +
+

Add emergency contact

+ + + + {err ? {err} : null} + +
+ ); +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/students/new/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/students/new/page.tsx new file mode 100644 index 0000000..a9049e1 --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/students/new/page.tsx @@ -0,0 +1,5 @@ +import { TenantStudentNewPage } from "./tenant-student-new-page"; + +export default function StudentNewRoutePage() { + return ; +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/students/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/students/page.tsx new file mode 100644 index 0000000..1deceaf --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/students/page.tsx @@ -0,0 +1,5 @@ +import { TenantStudentsPage } from "./tenant-students-page"; + +export default function StudentsRoutePage() { + return ; +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/students/tenant-students-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/students/tenant-students-page.tsx new file mode 100644 index 0000000..7fc0226 --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/students/tenant-students-page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; + +import { useStudentsListQuery } from "@/lib/api/students-query"; +import { formatIsoDate } from "@/lib/datetime"; +import { useTenantOrganizationId } from "@/lib/hooks/use-tenant-organization"; +import { useSession } from "@/lib/auth/session"; +import { isOrgAdminOrSuperadmin } from "@/lib/tenant-role"; + +export function TenantStudentsPage() { + const params = useParams<{ tenantSlug: string }>(); + const tenantSlug = params.tenantSlug; + const { user } = useSession(); + const { organizationId, orgsLoading } = useTenantOrganizationId(tenantSlug); + const { data: students, isLoading, error } = useStudentsListQuery( + organizationId, + ); + + const canManage = isOrgAdminOrSuperadmin( + user?.role, + user?.isSuperadmin ?? false, + ); + const base = `/t/${tenantSlug}/students`; + + if (orgsLoading || !organizationId) { + return ( +
+

Students

+

Loading…

+
+ ); + } + + return ( +
+
+

Students

+ {canManage ? ( + + New student + + ) : null} +
+ + {isLoading ?

Loading students…

: null} + {error ? ( +

+ {error instanceof Error ? error.message : "Could not load students"} +

+ ) : null} + + {!isLoading && !error && students?.length === 0 ? ( +

+ No students yet. + {canManage ? ( + <> + {" "} + Create the first student. + + ) : null} +

+ ) : null} + + {!isLoading && students && students.length > 0 ? ( +
    + {students.map((s) => ( +
  • + + {s.firstName} {s.lastName} + +
    + Born {formatIsoDate(s.dateOfBirth)} +
    +
  • + ))} +
+ ) : null} +
+ ); +} From eaa96292f10026a92b224fbf611059667c3f2db6 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 13:46:12 +0100 Subject: [PATCH 5/9] feat: add email field to OrganizationMembership and update related API responses --- apps/api/docs/openapi/openapi.yaml | 6 +++++- .../modules/organizations/organizations.mapper.ts | 3 ++- .../organizations/organizations.repository.ts | 13 +++++++++++-- .../modules/organizations/organizations.service.ts | 5 ++++- .../modules/organizations/organizations.types.ts | 2 ++ packages/api-client/src/generated.ts | 5 +++++ 6 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/api/docs/openapi/openapi.yaml b/apps/api/docs/openapi/openapi.yaml index 5830d24..15f4f1d 100644 --- a/apps/api/docs/openapi/openapi.yaml +++ b/apps/api/docs/openapi/openapi.yaml @@ -1679,7 +1679,7 @@ components: OrganizationMembership: type: object - required: [organizationId, userId, role, createdAt] + required: [organizationId, userId, role, createdAt, email] properties: organizationId: type: string @@ -1692,6 +1692,10 @@ components: createdAt: type: string format: date-time + email: + type: string + format: email + description: Account email for the member (display label; no separate name field on users). additionalProperties: false AddOrganizationMemberRequest: diff --git a/apps/api/src/modules/organizations/organizations.mapper.ts b/apps/api/src/modules/organizations/organizations.mapper.ts index 40c9dc4..ad2d8f8 100644 --- a/apps/api/src/modules/organizations/organizations.mapper.ts +++ b/apps/api/src/modules/organizations/organizations.mapper.ts @@ -19,13 +19,14 @@ export function toOrganizationResponse(row: Organization): OrganizationResponse } export function toOrganizationMembershipResponse( - row: OrganizationMembership, + row: OrganizationMembership & { email: string }, ): OrganizationMembershipResponse { return { organizationId: row.organizationId, userId: row.userId, role: row.role, createdAt: row.createdAt, + email: row.email, }; } diff --git a/apps/api/src/modules/organizations/organizations.repository.ts b/apps/api/src/modules/organizations/organizations.repository.ts index 68d6b94..5e38905 100644 --- a/apps/api/src/modules/organizations/organizations.repository.ts +++ b/apps/api/src/modules/organizations/organizations.repository.ts @@ -7,6 +7,7 @@ import { type OrganizationMembership, organizationMemberships, organizations, + users, } from "../../db/schema.js"; export const organizationsRepository = { @@ -63,10 +64,18 @@ export const organizationsRepository = { listMembershipsForOrganization: async ( organizationId: string, - ): Promise => { + ): Promise> => { return db - .select() + .select({ + organizationId: organizationMemberships.organizationId, + userId: organizationMemberships.userId, + role: organizationMemberships.role, + createdAt: organizationMemberships.createdAt, + updatedAt: organizationMemberships.updatedAt, + email: users.email, + }) .from(organizationMemberships) + .innerJoin(users, eq(organizationMemberships.userId, users.id)) .where(eq(organizationMemberships.organizationId, organizationId)) .orderBy( asc(organizationMemberships.createdAt), diff --git a/apps/api/src/modules/organizations/organizations.service.ts b/apps/api/src/modules/organizations/organizations.service.ts index 042d0ac..08119b8 100644 --- a/apps/api/src/modules/organizations/organizations.service.ts +++ b/apps/api/src/modules/organizations/organizations.service.ts @@ -152,7 +152,10 @@ export const organizationsService = { userId: body.userId, role: body.role, }); - return toOrganizationMembershipResponse(membership); + return toOrganizationMembershipResponse({ + ...membership, + email: user.email, + }); }, listOrganizationMembers: async ( diff --git a/apps/api/src/modules/organizations/organizations.types.ts b/apps/api/src/modules/organizations/organizations.types.ts index f4e7b86..b0ee7ac 100644 --- a/apps/api/src/modules/organizations/organizations.types.ts +++ b/apps/api/src/modules/organizations/organizations.types.ts @@ -17,6 +17,8 @@ export type OrganizationMembershipResponse = { userId: string; role: OrganizationMembershipRole; createdAt: Date; + /** User's account email (used as display label; users have no separate legal name field). */ + email: string; }; export type AddOrganizationMemberRequest = { diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts index a66afb9..9880a74 100644 --- a/packages/api-client/src/generated.ts +++ b/packages/api-client/src/generated.ts @@ -627,6 +627,11 @@ export interface components { role: components["schemas"]["OrganizationMembershipRole"]; /** Format: date-time */ createdAt: string; + /** + * Format: email + * @description Account email for the member (display label; no separate name field on users). + */ + email: string; }; AddOrganizationMemberRequest: { /** Format: uuid */ From f4d9d71c47fb1fd976fc8ff3b1d39da6dcedabb2 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 13:46:42 +0100 Subject: [PATCH 6/9] feat: add student management pages and navigation updates --- .../app/t/[tenantSlug]/(workspace)/page.tsx | 6 +- .../edit/tenant-student-edit-page.tsx | 267 ++++++++++++++++++ .../students/new/tenant-student-new-page.tsx | 201 +++++++++++++ apps/web/components/tenant-nav.tsx | 11 +- apps/web/lib/datetime.ts | 5 + apps/web/lib/format-org-member.ts | 16 ++ apps/web/lib/hooks/use-tenant-organization.ts | 14 + apps/web/lib/tenant-role.ts | 10 + apps/web/tsconfig.tsbuildinfo | 1 + pnpm-lock.yaml | 9 + 10 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/edit/tenant-student-edit-page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/students/new/tenant-student-new-page.tsx create mode 100644 apps/web/lib/format-org-member.ts create mode 100644 apps/web/lib/hooks/use-tenant-organization.ts create mode 100644 apps/web/lib/tenant-role.ts create mode 100644 apps/web/tsconfig.tsbuildinfo create mode 100644 pnpm-lock.yaml diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/page.tsx index 6d564fa..a688dce 100644 --- a/apps/web/app/t/[tenantSlug]/(workspace)/page.tsx +++ b/apps/web/app/t/[tenantSlug]/(workspace)/page.tsx @@ -1,3 +1,5 @@ +import Link from "next/link"; + type PageProps = { params: Promise<{ tenantSlug: string }> }; export default async function TenantHomePage({ params }: PageProps) { @@ -6,7 +8,9 @@ export default async function TenantHomePage({ params }: PageProps) {

Workspace

- You are in {tenantSlug}. Student and lesson tools arrive in Phase 2 and 3. + You are in {tenantSlug}. Manage students from{" "} + Students. Lesson tools + arrive in Phase 3.

); diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/edit/tenant-student-edit-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/edit/tenant-student-edit-page.tsx new file mode 100644 index 0000000..f004d83 --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/students/[studentId]/edit/tenant-student-edit-page.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import type { components } from "@studiqo/api-client/generated"; +import { isStudiqoApiError } from "@studiqo/api-client/errors"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; + +import { useOrganizationMembersQuery } from "@/lib/api/organization-members-query"; +import { + useStudentDetailQuery, + useUpdateStudentMutation, +} from "@/lib/api/students-query"; +import { formatIsoDate } from "@/lib/datetime"; +import { formatOrgMemberOptionLabel } from "@/lib/format-org-member"; +import { useTenantOrganizationId } from "@/lib/hooks/use-tenant-organization"; +import { useSession } from "@/lib/auth/session"; +import { isOrgAdminOrSuperadmin } from "@/lib/tenant-role"; +import { + updateStudentFormSchema, + type UpdateStudentForm, +} from "@/lib/validation/student-forms"; + +function dateInputFromIso(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +export function TenantStudentEditPage() { + const params = useParams<{ tenantSlug: string; studentId: string }>(); + const { tenantSlug, studentId } = params; + const router = useRouter(); + const { user } = useSession(); + const { organizationId, orgsLoading } = useTenantOrganizationId(tenantSlug); + const canManage = isOrgAdminOrSuperadmin( + user?.role, + user?.isSuperadmin ?? false, + ); + const { data: student, isLoading: studentLoading } = useStudentDetailQuery( + organizationId, + studentId, + ); + const { data: members, isLoading: membersLoading } = + useOrganizationMembersQuery(organizationId, canManage); + const updateStudent = useUpdateStudentMutation(organizationId ?? ""); + const [formError, setFormError] = useState(null); + + const form = useForm({ + resolver: zodResolver(updateStudentFormSchema), + defaultValues: {}, + }); + + useEffect(() => { + if (!student) return; + form.reset({ + parentId: student.parentId, + tutorId: student.tutorId ?? "", + firstName: student.firstName, + lastName: student.lastName, + dateOfBirth: dateInputFromIso(student.dateOfBirth), + }); + }, [student, form]); + + const parents = + members?.filter((m) => m.role === "parent") ?? []; + const tutors = + members?.filter((m) => m.role === "tutor") ?? []; + + const base = `/t/${tenantSlug}/students`; + const detailUrl = `${base}/${studentId}`; + + if (!canManage) { + return ( +
+

Edit student

+

Only organization admins can edit students.

+

+ Back +

+
+ ); + } + + if (orgsLoading || !organizationId) { + return ( +
+

Edit student

+

Loading…

+
+ ); + } + + async function onSubmit(values: UpdateStudentForm) { + setFormError(null); + const dirty = form.formState.dirtyFields; + const body: components["schemas"]["UpdateStudentRequest"] = {}; + if (dirty.parentId && values.parentId !== undefined) { + body.parentId = values.parentId; + } + if (dirty.firstName && values.firstName !== undefined) { + body.firstName = values.firstName; + } + if (dirty.lastName && values.lastName !== undefined) { + body.lastName = values.lastName; + } + if ( + dirty.dateOfBirth && + values.dateOfBirth !== undefined && + values.dateOfBirth !== "" + ) { + body.dateOfBirth = `${values.dateOfBirth}T12:00:00.000Z`; + } + if (dirty.tutorId && values.tutorId !== undefined) { + if (values.tutorId === "") { + setFormError("Removing a tutor is not supported by the API yet."); + return; + } + body.tutorId = values.tutorId; + } + if (Object.keys(body).length === 0) { + setFormError("Change at least one field"); + return; + } + try { + await updateStudent.mutateAsync({ studentId, body }); + router.push(detailUrl); + } catch (e) { + if (isStudiqoApiError(e)) setFormError(e.message); + else setFormError("Could not update student"); + } + } + + if (studentLoading) { + return ( +
+

+ ← Students +

+

Edit student

+

Loading…

+
+ ); + } + + if (!student) { + return ( +
+

+ ← Students +

+

Edit student

+

Student not found.

+
+ ); + } + + return ( +
+

+ ← {student.firstName} {student.lastName} +

+

Edit student

+

+ Born {formatIsoDate(student.dateOfBirth)} +

+ + {membersLoading ?

Loading members…

: null} + +
+ + + + + + + + + + + {form.formState.errors.root ? ( + + {form.formState.errors.root.message} + + ) : null} + + {formError ? ( +

{formError}

+ ) : null} + + +
+
+ ); +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/students/new/tenant-student-new-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/students/new/tenant-student-new-page.tsx new file mode 100644 index 0000000..498e3df --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/students/new/tenant-student-new-page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { isStudiqoApiError } from "@studiqo/api-client/errors"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { useCreateStudentMutation } from "@/lib/api/students-query"; +import { useOrganizationMembersQuery } from "@/lib/api/organization-members-query"; +import { useTenantOrganizationId } from "@/lib/hooks/use-tenant-organization"; +import { useSession } from "@/lib/auth/session"; +import { isOrgAdminOrSuperadmin } from "@/lib/tenant-role"; +import { formatOrgMemberOptionLabel } from "@/lib/format-org-member"; +import { + createStudentFormSchema, + type CreateStudentForm, +} from "@/lib/validation/student-forms"; + +export function TenantStudentNewPage() { + const params = useParams<{ tenantSlug: string }>(); + const tenantSlug = params.tenantSlug; + const router = useRouter(); + const { user } = useSession(); + const { organizationId, orgsLoading } = useTenantOrganizationId(tenantSlug); + const canManage = isOrgAdminOrSuperadmin( + user?.role, + user?.isSuperadmin ?? false, + ); + const { data: members, isLoading: membersLoading } = + useOrganizationMembersQuery(organizationId, canManage); + const createStudent = useCreateStudentMutation(organizationId ?? ""); + const [formError, setFormError] = useState(null); + + const form = useForm({ + resolver: zodResolver(createStudentFormSchema), + defaultValues: { + parentId: "", + tutorId: "", + firstName: "", + lastName: "", + dateOfBirth: "", + }, + }); + + const parents = + members?.filter((m) => m.role === "parent") ?? []; + const tutors = + members?.filter((m) => m.role === "tutor") ?? []; + + const base = `/t/${tenantSlug}/students`; + + if (!canManage) { + return ( +
+

New student

+

+ Only organization admins can create students. +

+

+ Back to students +

+
+ ); + } + + if (orgsLoading || !organizationId) { + return ( +
+

New student

+

Loading…

+
+ ); + } + + async function onSubmit(values: CreateStudentForm) { + setFormError(null); + try { + const body = { + parentId: values.parentId, + firstName: values.firstName, + lastName: values.lastName, + dateOfBirth: values.dateOfBirth, + ...(values.tutorId && values.tutorId !== "" + ? { tutorId: values.tutorId } + : {}), + }; + const student = await createStudent.mutateAsync(body); + router.push(`${base}/${student.id}`); + } catch (e) { + if (isStudiqoApiError(e)) setFormError(e.message); + else setFormError("Could not create student"); + } + } + + return ( +
+

+ ← Students +

+

New student

+ + {membersLoading ?

Loading members…

: null} + +
+ + + + + + + + + + + {formError ? ( +

{formError}

+ ) : null} + + +
+
+ ); +} diff --git a/apps/web/components/tenant-nav.tsx b/apps/web/components/tenant-nav.tsx index 80b7c9d..44737a2 100644 --- a/apps/web/components/tenant-nav.tsx +++ b/apps/web/components/tenant-nav.tsx @@ -16,14 +16,17 @@ export function TenantNav({ isSuperadmin: boolean; }) { const base = `/t/${tenantSlug}`; - const showStaffLinks = - role === "org_admin" || role === "tutor" || isSuperadmin; + const showStudentsLink = + role === "org_admin" || + role === "tutor" || + role === "parent" || + isSuperadmin; return (