From 80366caf76ece0349ebeff203592cca3189210dd Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 20:47:49 +0100 Subject: [PATCH 1/6] feat: add lessons query hooks and mutations for lesson management --- apps/web/lib/api/lessons-query.ts | 195 ++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 apps/web/lib/api/lessons-query.ts diff --git a/apps/web/lib/api/lessons-query.ts b/apps/web/lib/api/lessons-query.ts new file mode 100644 index 0000000..7f74c96 --- /dev/null +++ b/apps/web/lib/api/lessons-query.ts @@ -0,0 +1,195 @@ +"use client"; + +import type { components } from "@studiqo/api-client/generated"; +import { unwrapStudiqoResponse } from "@studiqo/api-client/errors"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; + +import { useSession } from "@/lib/auth/session"; + +export type LessonsListFilters = { + from: string; + to: string; + studentId?: string; + tutorId?: string; +}; + +export const lessonsListQueryKey = ( + organizationId: string, + filters: LessonsListFilters, +) => + [ + "lessons", + organizationId, + "list", + filters.from, + filters.to, + filters.studentId ?? "", + filters.tutorId ?? "", + ] as const; + +export const lessonDetailQueryKey = (lessonId: string) => + ["lessons", lessonId, "detail"] as const; + +type CreateLessonBody = components["schemas"]["CreateLessonRequest"]; +type UpdateLessonBody = components["schemas"]["UpdateLessonRequest"]; + +function enabledWorkspace( + organizationId: string | null, + authStatus: string, + accessToken: string | null, +): boolean { + return ( + Boolean(organizationId) && + authStatus === "authenticated" && + Boolean(accessToken) + ); +} + +export function invalidateLessonQueriesForOrg( + queryClient: QueryClient, + organizationId: string, +): void { + void queryClient.invalidateQueries({ + predicate: (q) => + Array.isArray(q.queryKey) && + q.queryKey[0] === "lessons" && + q.queryKey[1] === organizationId, + }); +} + +export function useLessonsListQuery( + organizationId: string | null, + filters: LessonsListFilters | null, + rangeValid: boolean, +) { + const { apiClient, authStatus, accessToken } = useSession(); + return useQuery({ + queryKey: + organizationId && filters && rangeValid + ? lessonsListQueryKey(organizationId, filters) + : ["lessons", "list", "disabled"], + queryFn: async () => { + const r = await apiClient.GET("/lessons", { + params: { + query: { + from: filters!.from, + to: filters!.to, + ...(filters!.studentId ? { studentId: filters!.studentId } : {}), + ...(filters!.tutorId ? { tutorId: filters!.tutorId } : {}), + }, + }, + }); + return unwrapStudiqoResponse(r); + }, + enabled: + enabledWorkspace(organizationId, authStatus, accessToken) && + Boolean(filters) && + rangeValid, + }); +} + +export function useLessonDetailQuery( + organizationId: string | null, + lessonId: string | null, +) { + const { apiClient, authStatus, accessToken } = useSession(); + return useQuery({ + queryKey: lessonId + ? lessonDetailQueryKey(lessonId) + : ["lessons", "detail", "disabled"], + queryFn: async () => { + const r = await apiClient.GET("/lessons/{lessonId}", { + params: { path: { lessonId: lessonId! } }, + }); + return unwrapStudiqoResponse(r); + }, + enabled: + enabledWorkspace(organizationId, authStatus, accessToken) && + Boolean(lessonId), + }); +} + +export function useCreateLessonMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: CreateLessonBody) => { + const r = await apiClient.POST("/lessons", { body }); + return unwrapStudiqoResponse(r); + }, + onSuccess: () => { + invalidateLessonQueriesForOrg(queryClient, organizationId); + }, + }); +} + +export function useUpdateLessonMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + lessonId, + body, + }: { + lessonId: string; + body: UpdateLessonBody; + }) => { + const r = await apiClient.PUT("/lessons/{lessonId}", { + params: { path: { lessonId } }, + body, + }); + return unwrapStudiqoResponse(r); + }, + onSuccess: (_data, { lessonId }) => { + invalidateLessonQueriesForOrg(queryClient, organizationId); + void queryClient.invalidateQueries({ + queryKey: lessonDetailQueryKey(lessonId), + }); + }, + }); +} + +export function useCompleteLessonMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (lessonId: string) => { + const r = await apiClient.POST("/lessons/{lessonId}/complete", { + params: { path: { lessonId } }, + body: {}, + }); + return unwrapStudiqoResponse(r); + }, + onSuccess: (_data, lessonId) => { + invalidateLessonQueriesForOrg(queryClient, organizationId); + void queryClient.invalidateQueries({ + queryKey: lessonDetailQueryKey(lessonId), + }); + }, + }); +} + +export function useCancelLessonMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (lessonId: string) => { + const r = await apiClient.POST("/lessons/{lessonId}/cancel", { + params: { path: { lessonId } }, + body: {}, + }); + return unwrapStudiqoResponse(r); + }, + onSuccess: (_data, lessonId) => { + invalidateLessonQueriesForOrg(queryClient, organizationId); + void queryClient.invalidateQueries({ + queryKey: lessonDetailQueryKey(lessonId), + }); + }, + }); +} From 93885fbc2299868b5b32ec411386e778decf0ff4 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 20:49:38 +0100 Subject: [PATCH 2/6] feat: add date-time utilities and lesson form validation schemas --- apps/web/lib/datetime.ts | 34 +++++++++++ apps/web/lib/validation/lesson-forms.ts | 79 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 apps/web/lib/validation/lesson-forms.ts diff --git a/apps/web/lib/datetime.ts b/apps/web/lib/datetime.ts index df0aa2e..c49a904 100644 --- a/apps/web/lib/datetime.ts +++ b/apps/web/lib/datetime.ts @@ -37,3 +37,37 @@ export function formatIsoDateTime( timeZone, }).format(d); } + +/** Monday 00:00:00.000 in the user's local timezone. */ +export function startOfIsoWeekLocal(ref = new Date()): Date { + const d = new Date(ref); + const day = d.getDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + d.setDate(d.getDate() + mondayOffset); + d.setHours(0, 0, 0, 0); + return d; +} + +/** Sunday 23:59:59.999 local, for the week that contains `ref`. */ +export function endOfIsoWeekLocal(ref = new Date()): Date { + const start = startOfIsoWeekLocal(ref); + const end = new Date(start); + end.setDate(start.getDate() + 6); + end.setHours(23, 59, 59, 999); + return end; +} + +/** Value for `` in local time. */ +export function toDatetimeLocalValue(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Parse `datetime-local` string (no timezone) as local instant → ISO UTC for the API. */ +export function parseDatetimeLocalToIso(local: string): string { + const d = new Date(local); + if (Number.isNaN(d.getTime())) { + throw new RangeError(`Invalid datetime-local: ${local}`); + } + return d.toISOString(); +} diff --git a/apps/web/lib/validation/lesson-forms.ts b/apps/web/lib/validation/lesson-forms.ts new file mode 100644 index 0000000..fc8ca5a --- /dev/null +++ b/apps/web/lib/validation/lesson-forms.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; + +const uuid = z.string().uuid(); + +export const lessonListRangeSchema = z + .object({ + fromIso: z.string().min(1), + toIso: z.string().min(1), + }) + .refine( + (d) => { + const a = new Date(d.fromIso).getTime(); + const b = new Date(d.toIso).getTime(); + return !Number.isNaN(a) && !Number.isNaN(b) && b > a; + }, + { message: "End must be after start", path: ["toIso"] }, + ); + +export type LessonListRange = z.infer; + +export const createLessonFormSchema = z + .object({ + studentId: uuid, + subjectId: uuid, + startsAtLocal: z.string().min(1, "Start is required"), + endsAtLocal: z.string().min(1, "End is required"), + }) + .refine( + (d) => { + const s = new Date(d.startsAtLocal).getTime(); + const e = new Date(d.endsAtLocal).getTime(); + return !Number.isNaN(s) && !Number.isNaN(e) && e > s; + }, + { message: "End must be after start", path: ["endsAtLocal"] }, + ); + +export type CreateLessonForm = z.infer; + +export const updateLessonFormSchema = z + .object({ + tutorId: z.union([uuid, z.literal("")]).optional(), + subjectId: z.union([uuid, z.literal("")]).optional(), + startsAtLocal: z.string().optional(), + endsAtLocal: z.string().optional(), + notes: z.string().optional(), + }) + .refine( + (data) => { + const hasTutor = data.tutorId !== undefined && data.tutorId !== ""; + const hasSubject = data.subjectId !== undefined && data.subjectId !== ""; + const hasStart = + data.startsAtLocal !== undefined && data.startsAtLocal.trim() !== ""; + const hasEnd = + data.endsAtLocal !== undefined && data.endsAtLocal.trim() !== ""; + const hasNotes = data.notes !== undefined; + return hasTutor || hasSubject || hasStart || hasEnd || hasNotes; + }, + { message: "Change at least one field" }, + ) + .refine( + (data) => { + const hasStart = + data.startsAtLocal !== undefined && data.startsAtLocal.trim() !== ""; + const hasEnd = + data.endsAtLocal !== undefined && data.endsAtLocal.trim() !== ""; + if (hasStart !== hasEnd) { + return false; + } + if (!hasStart || !hasEnd) { + return true; + } + const s = new Date(data.startsAtLocal!).getTime(); + const e = new Date(data.endsAtLocal!).getTime(); + return !Number.isNaN(s) && !Number.isNaN(e) && e > s; + }, + { message: "End must be after start", path: ["endsAtLocal"] }, + ); + +export type UpdateLessonForm = z.infer; From ee9eccdc4594dc2c1f2a2d9d43eefed6f57bb14d Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 20:50:54 +0100 Subject: [PATCH 3/6] feat: add lessons page and tenant lessons component --- .../[tenantSlug]/(workspace)/lessons/page.tsx | 5 + .../lessons/tenant-lessons-page.tsx | 288 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/lessons/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/lessons/tenant-lessons-page.tsx diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/lessons/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/page.tsx new file mode 100644 index 0000000..5af5dd0 --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/page.tsx @@ -0,0 +1,5 @@ +import { TenantLessonsPage } from "./tenant-lessons-page"; + +export default function LessonsRoutePage() { + return ; +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/lessons/tenant-lessons-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/tenant-lessons-page.tsx new file mode 100644 index 0000000..d71d17c --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/tenant-lessons-page.tsx @@ -0,0 +1,288 @@ +"use client"; + +import type { components } from "@studiqo/api-client/generated"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useMemo, useState } from "react"; + +import { useLessonsListQuery } from "@/lib/api/lessons-query"; +import { useOrganizationMembersQuery } from "@/lib/api/organization-members-query"; +import { useStudentsListQuery } from "@/lib/api/students-query"; +import { useSubjectsListQuery } from "@/lib/api/subjects-query"; +import { + endOfIsoWeekLocal, + formatIsoDateTime, + startOfIsoWeekLocal, +} 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 { lessonListRangeSchema } from "@/lib/validation/lesson-forms"; + +type Student = components["schemas"]["Student"]; +type Subject = components["schemas"]["Subject"]; +type OrgMember = components["schemas"]["OrganizationMembership"]; + +function addDays(d: Date, n: number): Date { + const x = new Date(d); + x.setDate(x.getDate() + n); + return x; +} + +export function TenantLessonsPage() { + const params = useParams<{ tenantSlug: string }>(); + const tenantSlug = params.tenantSlug; + const { user } = useSession(); + const { organizationId, orgsLoading } = useTenantOrganizationId(tenantSlug); + const [weekAnchor, setWeekAnchor] = useState(() => new Date()); + const [studentFilter, setStudentFilter] = useState(""); + const [tutorFilter, setTutorFilter] = useState(""); + + const isAdmin = isOrgAdminOrSuperadmin( + user?.role, + user?.isSuperadmin ?? false, + ); + const canUseTutorFilter = isAdmin; + + const fromDate = useMemo(() => startOfIsoWeekLocal(weekAnchor), [weekAnchor]); + const toDate = useMemo(() => endOfIsoWeekLocal(weekAnchor), [weekAnchor]); + const fromIso = fromDate.toISOString(); + const toIso = toDate.toISOString(); + + const rangeParsed = lessonListRangeSchema.safeParse({ fromIso, toIso }); + const rangeValid = rangeParsed.success; + + const listFilters = + rangeValid && organizationId + ? { + from: fromIso, + to: toIso, + ...(studentFilter ? { studentId: studentFilter } : {}), + ...(canUseTutorFilter && tutorFilter ? { tutorId: tutorFilter } : {}), + } + : null; + + const studentsQ = useStudentsListQuery(organizationId); + const subjectsQ = useSubjectsListQuery(organizationId); + const membersQ = useOrganizationMembersQuery(organizationId, canUseTutorFilter); + const lessonsQ = useLessonsListQuery(organizationId, listFilters, rangeValid); + + const studentById = useMemo(() => { + const m = new Map(); + for (const s of studentsQ.data ?? []) { + m.set(s.id, s); + } + return m; + }, [studentsQ.data]); + + const subjectById = useMemo(() => { + const m = new Map(); + for (const s of subjectsQ.data ?? []) { + m.set(s.id, s); + } + return m; + }, [subjectsQ.data]); + + const tutorByUserId = useMemo(() => { + const m = new Map(); + for (const mem of membersQ.data ?? []) { + if (mem.role === "tutor") { + m.set(mem.userId, mem); + } + } + return m; + }, [membersQ.data]); + + const sortedLessons = useMemo(() => { + const list = lessonsQ.data ?? []; + return [...list].sort( + (a, b) => + new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(), + ); + }, [lessonsQ.data]); + + const base = `/t/${tenantSlug}/lessons`; + const tutors = membersQ.data?.filter((m) => m.role === "tutor") ?? []; + + if (orgsLoading || !organizationId) { + return ( +
+

Lessons

+

Loading…

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

Lessons

+ {isAdmin ? ( + + New lesson + + ) : null} +
+ +
+ Week + + + + + {formatIsoDateTime(fromIso)} – {formatIsoDateTime(toIso)} + +
+ + {!rangeValid ? ( +

+ {rangeParsed.success ? null : rangeParsed.error.issues[0]?.message} +

+ ) : null} + +
+ + {canUseTutorFilter ? ( + + ) : null} +
+ + {studentsQ.isLoading || subjectsQ.isLoading || membersQ.isLoading ? ( +

Loading filters…

+ ) : null} + + {lessonsQ.isLoading ?

Loading lessons…

: null} + {lessonsQ.error ? ( +

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

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

+ No lessons in this range. + {isAdmin ? ( + <> + {" "} + Schedule one. + + ) : null} +

+ ) : null} + + {!lessonsQ.isLoading && !lessonsQ.error && sortedLessons.length > 0 ? ( +
    + {sortedLessons.map((lesson) => { + const stu = studentById.get(lesson.studentId); + const sub = subjectById.get(lesson.subjectId); + const tut = tutorByUserId.get(lesson.tutorId); + const studentLabel = stu + ? `${stu.firstName} ${stu.lastName}` + : lesson.studentId; + const subjectLabel = sub?.name ?? lesson.subjectId; + const tutorLabel = tut?.email ?? lesson.tutorId; + return ( +
  • + + {formatIsoDateTime(lesson.startsAt)} →{" "} + {formatIsoDateTime(lesson.endsAt)} + +
    + {studentLabel} · {subjectLabel} · {tutorLabel} +
    +
    + Status: {lesson.status} +
    +
  • + ); + })} +
+ ) : null} +
+ ); +} From 0fb33b82a4dbc6f7499935ec12a741a8a3e4c095 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 21:28:13 +0100 Subject: [PATCH 4/6] feat: add lesson detail page component for tenant workspace --- .../(workspace)/lessons/[lessonId]/page.tsx | 5 + .../[lessonId]/tenant-lesson-detail-page.tsx | 228 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/tenant-lesson-detail-page.tsx diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/page.tsx new file mode 100644 index 0000000..8c2007b --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/page.tsx @@ -0,0 +1,5 @@ +import { TenantLessonDetailPage } from "./tenant-lesson-detail-page"; + +export default function LessonDetailRoutePage() { + return ; +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/tenant-lesson-detail-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/tenant-lesson-detail-page.tsx new file mode 100644 index 0000000..8a53604 --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/tenant-lesson-detail-page.tsx @@ -0,0 +1,228 @@ +"use client"; + +import type { components } from "@studiqo/api-client/generated"; +import { isStudiqoApiError } from "@studiqo/api-client/errors"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useMemo, useState } from "react"; + +import { + useCancelLessonMutation, + useCompleteLessonMutation, + useLessonDetailQuery, +} from "@/lib/api/lessons-query"; +import { useOrganizationMembersQuery } from "@/lib/api/organization-members-query"; +import { useStudentsListQuery } from "@/lib/api/students-query"; +import { useSubjectsListQuery } from "@/lib/api/subjects-query"; +import { formatIsoDateTime } from "@/lib/datetime"; +import { useTenantOrganizationId } from "@/lib/hooks/use-tenant-organization"; +import { useSession } from "@/lib/auth/session"; +import { isOrgAdminOrSuperadmin } from "@/lib/tenant-role"; + +type Student = components["schemas"]["Student"]; +type Subject = components["schemas"]["Subject"]; +type OrgMember = components["schemas"]["OrganizationMembership"]; + +export function TenantLessonDetailPage() { + const params = useParams<{ tenantSlug: string; lessonId: string }>(); + const { tenantSlug, lessonId } = params; + const { user } = useSession(); + const { organizationId, orgsLoading } = useTenantOrganizationId(tenantSlug); + const [actionError, setActionError] = useState(null); + + const isAdmin = isOrgAdminOrSuperadmin( + user?.role, + user?.isSuperadmin ?? false, + ); + const isTutor = user?.role === "tutor"; + const canTryLifecycle = isAdmin || isTutor; + + const lessonQ = useLessonDetailQuery(organizationId, lessonId); + const studentsQ = useStudentsListQuery(organizationId); + const subjectsQ = useSubjectsListQuery(organizationId); + const membersQ = useOrganizationMembersQuery(organizationId, true); + + const completeMut = useCompleteLessonMutation(organizationId ?? ""); + const cancelMut = useCancelLessonMutation(organizationId ?? ""); + + const studentById = useMemo(() => { + const m = new Map(); + for (const s of studentsQ.data ?? []) { + m.set(s.id, s); + } + return m; + }, [studentsQ.data]); + + const subjectById = useMemo(() => { + const m = new Map(); + for (const s of subjectsQ.data ?? []) { + m.set(s.id, s); + } + return m; + }, [subjectsQ.data]); + + const memberByUserId = useMemo(() => { + const m = new Map(); + for (const mem of membersQ.data ?? []) { + m.set(mem.userId, mem); + } + return m; + }, [membersQ.data]); + + const base = `/t/${tenantSlug}/lessons`; + const listUrl = base; + + if (orgsLoading || !organizationId) { + return ( +
+

+ ← Lessons +

+

Loading…

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

+ ← Lessons +

+

Loading lesson…

+
+ ); + } + + if (lessonQ.error) { + return ( +
+

+ ← Lessons +

+

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

+
+ ); + } + + const lesson = lessonQ.data; + if (!lesson) { + return ( +
+

+ ← Lessons +

+

Lesson not found.

+
+ ); + } + + const stu = studentById.get(lesson.studentId); + const sub = subjectById.get(lesson.subjectId); + const tut = memberByUserId.get(lesson.tutorId); + const studentLabel = stu + ? `${stu.firstName} ${stu.lastName}` + : lesson.studentId; + const subjectLabel = sub?.name ?? lesson.subjectId; + const tutorLabel = tut?.email ?? lesson.tutorId; + + const terminal = lesson.status === "completed" || lesson.status === "cancelled"; + const canCompleteOrCancel = canTryLifecycle && !terminal; + const mutating = completeMut.isPending || cancelMut.isPending; + const lessonIdStable = lesson.id; + + async function onComplete() { + setActionError(null); + try { + await completeMut.mutateAsync(lessonIdStable); + } catch (e) { + if (isStudiqoApiError(e)) setActionError(e.message); + else setActionError("Could not complete lesson"); + } + } + + async function onCancel() { + setActionError(null); + try { + await cancelMut.mutateAsync(lessonIdStable); + } catch (e) { + if (isStudiqoApiError(e)) setActionError(e.message); + else setActionError("Could not cancel lesson"); + } + } + + const canEdit = isAdmin && lesson.status === "scheduled"; + + return ( +
+

+ ← Lessons +

+

Lesson

+

+ {formatIsoDateTime(lesson.startsAt)} + {" → "} + {formatIsoDateTime(lesson.endsAt)} +

+

+ Status: {lesson.status} +

+
    +
  • Student: {studentLabel}
  • +
  • Subject: {subjectLabel}
  • +
  • Tutor: {tutorLabel}
  • +
  • + Notes:{" "} + {lesson.notes === null || lesson.notes === "" + ? "—" + : lesson.notes} +
  • +
+ + {canTryLifecycle ? ( +
+ + +
+ ) : null} + + {terminal && canTryLifecycle ? ( +

+ This lesson is finished; complete and cancel are not available. +

+ ) : null} + + {actionError ? ( +

{actionError}

+ ) : null} + + {isAdmin ? ( +

+ {canEdit ? ( + Edit lesson + ) : ( + + Only scheduled lessons can be edited. + + )} +

+ ) : null} +
+ ); +} From 1c64cf1ffc9e4a29805eda9d56c52c8da58e38b4 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sun, 5 Apr 2026 21:32:46 +0100 Subject: [PATCH 5/6] feat: add lesson creation and editing pages for tenant workspace --- .../lessons/[lessonId]/edit/page.tsx | 5 + .../edit/tenant-lesson-edit-page.tsx | 291 ++++++++++++++++++ .../(workspace)/lessons/new/page.tsx | 5 + .../lessons/new/tenant-lesson-new-page.tsx | 225 ++++++++++++++ 4 files changed, 526 insertions(+) create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/edit/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/edit/tenant-lesson-edit-page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/lessons/new/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/lessons/new/tenant-lesson-new-page.tsx diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/edit/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/edit/page.tsx new file mode 100644 index 0000000..384d23a --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/edit/page.tsx @@ -0,0 +1,5 @@ +import { TenantLessonEditPage } from "./tenant-lesson-edit-page"; + +export default function LessonEditRoutePage() { + return ; +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/edit/tenant-lesson-edit-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/edit/tenant-lesson-edit-page.tsx new file mode 100644 index 0000000..1de8f0d --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/lessons/[lessonId]/edit/tenant-lesson-edit-page.tsx @@ -0,0 +1,291 @@ +"use client"; + +import type { components } from "@studiqo/api-client/generated"; +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 { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; + +import { useLessonDetailQuery, useUpdateLessonMutation } from "@/lib/api/lessons-query"; +import { useOrganizationMembersQuery } from "@/lib/api/organization-members-query"; +import { useSubjectsListQuery } from "@/lib/api/subjects-query"; +import { + parseDatetimeLocalToIso, + parseIsoDateTime, + toDatetimeLocalValue, +} 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 { + updateLessonFormSchema, + type UpdateLessonForm, +} from "@/lib/validation/lesson-forms"; + +type Lesson = components["schemas"]["Lesson"]; +type UpdateLessonBody = components["schemas"]["UpdateLessonRequest"]; + +function buildUpdateBody(lesson: Lesson, values: UpdateLessonForm): UpdateLessonBody { + const body: UpdateLessonBody = {}; + if ( + values.tutorId && + values.tutorId !== "" && + values.tutorId !== lesson.tutorId + ) { + body.tutorId = values.tutorId; + } + if ( + values.subjectId && + values.subjectId !== "" && + values.subjectId !== lesson.subjectId + ) { + body.subjectId = values.subjectId; + } + if (values.startsAtLocal?.trim()) { + const iso = parseDatetimeLocalToIso(values.startsAtLocal.trim()); + if (iso !== lesson.startsAt) { + body.startsAt = iso; + } + } + if (values.endsAtLocal?.trim()) { + const iso = parseDatetimeLocalToIso(values.endsAtLocal.trim()); + if (iso !== lesson.endsAt) { + body.endsAt = iso; + } + } + if (values.notes !== undefined) { + const next = values.notes === "" ? null : values.notes; + const prev = lesson.notes; + if (next !== prev) { + body.notes = next; + } + } + return body; +} + +export function TenantLessonEditPage() { + const params = useParams<{ tenantSlug: string; lessonId: string }>(); + const { tenantSlug, lessonId } = params; + const router = useRouter(); + const { user } = useSession(); + const { organizationId, orgsLoading } = useTenantOrganizationId(tenantSlug); + const canManage = isOrgAdminOrSuperadmin( + user?.role, + user?.isSuperadmin ?? false, + ); + const lessonQ = useLessonDetailQuery(organizationId, lessonId); + const subjectsQ = useSubjectsListQuery(organizationId); + const membersQ = useOrganizationMembersQuery(organizationId, canManage); + const updateLesson = useUpdateLessonMutation(organizationId ?? ""); + const [formError, setFormError] = useState(null); + + const form = useForm({ + resolver: zodResolver(updateLessonFormSchema), + defaultValues: { + tutorId: "", + subjectId: "", + startsAtLocal: "", + endsAtLocal: "", + notes: "", + }, + }); + + const lesson = lessonQ.data; + useEffect(() => { + if (lesson && lesson.status === "scheduled") { + form.reset({ + tutorId: lesson.tutorId, + subjectId: lesson.subjectId, + startsAtLocal: toDatetimeLocalValue(parseIsoDateTime(lesson.startsAt)), + endsAtLocal: toDatetimeLocalValue(parseIsoDateTime(lesson.endsAt)), + notes: lesson.notes ?? "", + }); + } + }, [lesson, form]); + + const base = `/t/${tenantSlug}/lessons`; + + if (!canManage) { + return ( +
+

Edit lesson

+

+ Only organization admins can edit lessons. +

+

+ Back to lesson +

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

+ ← Lesson +

+

Loading…

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

+ ← Lesson +

+

Loading lesson…

+
+ ); + } + + if (lessonQ.error || !lesson) { + return ( +
+

+ ← Lessons +

+

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

+
+ ); + } + + if (lesson.status !== "scheduled") { + return ( +
+

+ ← Lesson +

+

Edit lesson

+

+ Only scheduled lessons can be edited. This lesson is{" "} + {lesson.status}. +

+
+ ); + } + + const editingLesson = lesson; + const tutors = membersQ.data?.filter((m) => m.role === "tutor") ?? []; + + async function onSubmit(values: UpdateLessonForm) { + setFormError(null); + const body = buildUpdateBody(editingLesson, values); + if (Object.keys(body).length === 0) { + setFormError("Change at least one field."); + return; + } + try { + await updateLesson.mutateAsync({ lessonId: editingLesson.id, body }); + router.push(`${base}/${editingLesson.id}`); + } catch (e) { + if (isStudiqoApiError(e)) setFormError(e.message); + else setFormError("Could not update lesson"); + } + } + + const loadingRefs = subjectsQ.isLoading || membersQ.isLoading; + + return ( +
+

+ ← Lesson +

+

Edit lesson

+ + {loadingRefs ?

Loading…

: null} + +
+ + + + + + + + +