diff --git a/.github/workflows/ci-api.yml b/.github/workflows/ci-api.yml index 09bc869..2b8e337 100644 --- a/.github/workflows/ci-api.yml +++ b/.github/workflows/ci-api.yml @@ -6,7 +6,8 @@ on: - "apps/api/**" - "packages/api-client/**" - "package.json" - - "package-lock.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" - ".github/workflows/ci-api.yml" jobs: @@ -37,13 +38,17 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 with: node-version: "22" - cache: npm + cache: pnpm - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: API quality - run: npm run api:ci + run: pnpm run api:ci diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index 1b3e72b..bdb5a90 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -6,7 +6,8 @@ on: - "apps/web/**" - "packages/api-client/**" - "package.json" - - "package-lock.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" - ".github/workflows/ci-web.yml" jobs: @@ -16,13 +17,17 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 with: node-version: "22" - cache: npm + cache: pnpm - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Web quality - run: npm run web:ci + run: pnpm run web:ci 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/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/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]/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/[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/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/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} +
+ ); +} 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; } 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 (