From 3c28267f499c0590e57bf86fe612fe09e059860e Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:04:38 -0700 Subject: [PATCH 1/9] feat(services): add service registration views and queries for adults and kids --- .../services/[id]/_components/adult-table.tsx | 77 +++++++ .../[id]/_components/child-info-modal.tsx | 123 +++++++++++ .../services/[id]/_components/kid-table.tsx | 132 ++++++++++++ .../[id]/_components/registered-view.tsx | 46 ++++ app/(authenticated)/services/[id]/page.tsx | 29 +++ app/(authenticated)/services/[id]/queries.ts | 202 ++++++++++++++++++ app/(authenticated)/services/queries.ts | 4 + .../services/services-data-table.tsx | 13 +- 8 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 app/(authenticated)/services/[id]/_components/adult-table.tsx create mode 100644 app/(authenticated)/services/[id]/_components/child-info-modal.tsx create mode 100644 app/(authenticated)/services/[id]/_components/kid-table.tsx create mode 100644 app/(authenticated)/services/[id]/_components/registered-view.tsx create mode 100644 app/(authenticated)/services/[id]/page.tsx create mode 100644 app/(authenticated)/services/[id]/queries.ts diff --git a/app/(authenticated)/services/[id]/_components/adult-table.tsx b/app/(authenticated)/services/[id]/_components/adult-table.tsx new file mode 100644 index 0000000..eadacbf --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/adult-table.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { UsersDataTable } from "@/app/(authenticated)/users/_components/users-data-table"; +import { formatDate } from "@/lib/format"; +import type { AdultRegistration } from "../queries"; + +const STATUS_VARIANT: Record = { + confirmed: "default", + pending: "secondary", + awaiting_payment: "outline", + cancelled: "destructive", +}; + +export function AdultTable({ registrations }: { registrations: AdultRegistration[] }) { + const columns = useMemo[]>( + () => [ + { + id: "profile", + header: "User", + meta: { colWidth: "40%", tdClassName: "whitespace-normal align-middle" }, + cell: ({ row }) => { + const p = row.original.profile; + const initials = `${p.firstName[0] ?? ""}${p.lastName[0] ?? ""}`.toUpperCase(); + return ( +
+ + + {initials} + + +
+ + {p.firstName} {p.lastName} + + {p.email} +
+
+ ); + }, + }, + { + id: "status", + header: "Status", + meta: { colWidth: "20%" }, + cell: ({ row }) => ( + + {row.original.status.replace(/_/g, " ")} + + ), + }, + { + id: "registeredAt", + header: "Registered", + meta: { colWidth: "20%" }, + cell: ({ row }) => ( + + {formatDate(row.original.registeredAt.toISOString().slice(0, 10))} + + ), + }, + ], + [], + ); + + return ( + `${n} registration${n === 1 ? "" : "s"}`} + /> + ); +} diff --git a/app/(authenticated)/services/[id]/_components/child-info-modal.tsx b/app/(authenticated)/services/[id]/_components/child-info-modal.tsx new file mode 100644 index 0000000..924efee --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/child-info-modal.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { formatDate } from "@/lib/format"; +import type { KidRegistration } from "../queries"; + +const GENDER_LABELS: Record = { + male: "Male", + female: "Female", + prefer_not_to_say: "Prefer not to say", +}; + +export function ChildInfoModal({ + registration, + open, + onOpenChange, +}: { + registration: KidRegistration | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + if (!registration) return null; + const { child, formAnswers } = registration; + + return ( + + + + + {child.firstName} {child.lastName} + + + +
+ {/* Profile */} +
+

+ Profile +

+
+
Date of birth
+
{formatDate(child.dob)}
+
Gender
+
{GENDER_LABELS[child.gender] ?? child.gender}
+ {child.allergies && ( + <> +
Allergies
+
{child.allergies}
+ + )} + {child.medicalConditions && ( + <> +
Medical conditions
+
{child.medicalConditions}
+ + )} + {child.medications && ( + <> +
Medications
+
{child.medications}
+ + )} +
+
+ + {/* Emergency contacts */} + {child.emergencyContacts.length > 0 && ( + <> + +
+

+ Emergency Contacts +

+
    + {child.emergencyContacts.map((ec, i) => ( +
  • + Name + {ec.fullName} + Relationship + {ec.relationship} + Email + {ec.emailAddress} + Phone + {ec.phoneNumber} +
  • + ))} +
+
+ + )} + + {/* Form answers */} + {formAnswers.length > 0 && ( + <> + +
+

+ Form Answers +

+
    + {formAnswers.map((qa, i) => ( +
  • + {qa.prompt} + + {qa.answer.join(", ")} + +
  • + ))} +
+
+ + )} +
+
+
+ ); +} diff --git a/app/(authenticated)/services/[id]/_components/kid-table.tsx b/app/(authenticated)/services/[id]/_components/kid-table.tsx new file mode 100644 index 0000000..e5609f5 --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/kid-table.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Info } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { UsersDataTable } from "@/app/(authenticated)/users/_components/users-data-table"; +import { formatDate } from "@/lib/format"; +import type { KidRegistration } from "../queries"; +import { ChildInfoModal } from "./child-info-modal"; + +const STATUS_VARIANT: Record = { + confirmed: "default", + pending: "secondary", + awaiting_payment: "outline", + cancelled: "destructive", +}; + +const GENDER_LABELS: Record = { + male: "Male", + female: "Female", + prefer_not_to_say: "Prefer not to say", +}; + +export function KidTable({ registrations }: { registrations: KidRegistration[] }) { + const [selected, setSelected] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const columns = useMemo[]>( + () => [ + { + id: "child", + header: "Child", + meta: { colWidth: "22%" }, + cell: ({ row }) => ( + + {row.original.child.firstName} {row.original.child.lastName} + + ), + }, + { + id: "dob", + header: "Date of Birth", + meta: { colWidth: "16%" }, + cell: ({ row }) => ( + + {formatDate(row.original.child.dob)} + + ), + }, + { + id: "gender", + header: "Gender", + meta: { colWidth: "16%" }, + cell: ({ row }) => ( + + {GENDER_LABELS[row.original.child.gender] ?? row.original.child.gender} + + ), + }, + { + id: "parent", + header: "Parent", + meta: { colWidth: "20%" }, + cell: ({ row }) => ( + + {row.original.parent.firstName} {row.original.parent.lastName} + + ), + }, + { + id: "status", + header: "Status", + meta: { colWidth: "14%" }, + cell: ({ row }) => ( + + {row.original.status.replace(/_/g, " ")} + + ), + }, + { + id: "info", + header: () =>
Info
, + meta: { colWidth: "12%", thClassName: "text-right", tdClassName: "text-right" }, + cell: ({ row }) => ( + + + + + View profile + + ), + }, + ], + [], + ); + + return ( + <> + `${n} registration${n === 1 ? "" : "s"}`} + /> + + + ); +} diff --git a/app/(authenticated)/services/[id]/_components/registered-view.tsx b/app/(authenticated)/services/[id]/_components/registered-view.tsx new file mode 100644 index 0000000..1f63a96 --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/registered-view.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import type { ServiceView } from "@/app/(authenticated)/services/queries"; +import type { ServiceRegistrations } from "../queries"; +import { AdultTable } from "./adult-table"; +import { KidTable } from "./kid-table"; + +export function RegisteredView({ + service, + data, +}: { + service: ServiceView; + data: ServiceRegistrations; +}) { + const count = data.registrations.length; + + return ( +
+
+ +
+ +
+

{service.title ?? "Service"}

+ + {count} registered + +
+ + {data.kind === "adult" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/app/(authenticated)/services/[id]/page.tsx b/app/(authenticated)/services/[id]/page.tsx new file mode 100644 index 0000000..bb0b0f5 --- /dev/null +++ b/app/(authenticated)/services/[id]/page.tsx @@ -0,0 +1,29 @@ +import { redirect } from "next/navigation"; +import { requireAdmin } from "@/lib/auth/require-admin"; +import { getService } from "@/app/(authenticated)/services/queries"; +import { getServiceRegistrations } from "./queries"; +import { RegisteredView } from "./_components/registered-view"; + +export default async function ServiceRegisteredPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + try { + await requireAdmin(); + } catch { + redirect("/"); + } + + const { id } = await params; + const service = await getService(id); + if (!service) redirect("/services"); + + const registrations = await getServiceRegistrations(id); + + return ( +
+ +
+ ); +} diff --git a/app/(authenticated)/services/[id]/queries.ts b/app/(authenticated)/services/[id]/queries.ts new file mode 100644 index 0000000..c50428f --- /dev/null +++ b/app/(authenticated)/services/[id]/queries.ts @@ -0,0 +1,202 @@ +import { cacheTag } from "next/cache"; +import { eq, inArray } from "drizzle-orm"; +import { pgSchema, uuid, text } from "drizzle-orm/pg-core"; + +import { db } from "@/lib/db"; +import { + children, + emergencyContacts, + formQuestionAnswers, + formQuestions, + profiles, + serviceBookings, + services, +} from "@/lib/db/schema"; + +const auth = pgSchema("auth"); +const authUsers = auth.table("users", { + id: uuid("id").primaryKey(), + email: text("email"), +}); + +const SERVICES_TAG = "services"; + +export type AdultRegistration = { + bookingId: string; + status: string; + registeredAt: Date; + profile: { id: string; firstName: string; lastName: string; email: string }; +}; + +export type KidRegistration = { + bookingId: string; + status: string; + registeredAt: Date; + child: { + id: string; + firstName: string; + lastName: string; + dob: string; + gender: string; + allergies: string | null; + medicalConditions: string | null; + medications: string | null; + emergencyContacts: { + fullName: string; + emailAddress: string; + phoneNumber: string; + relationship: string; + }[]; + }; + parent: { firstName: string; lastName: string; email: string }; + formAnswers: { prompt: string; answer: string[] }[]; +}; + +export type ServiceRegistrations = + | { kind: "adult"; registrations: AdultRegistration[] } + | { kind: "kid"; registrations: KidRegistration[] }; + +export async function getServiceRegistrations( + serviceId: string, +): Promise { + "use cache"; + cacheTag(SERVICES_TAG); + + const [service] = await db + .select({ isForChildren: services.isForChildren, formId: services.formId }) + .from(services) + .where(eq(services.id, serviceId)) + .limit(1); + + if (!service) return { kind: "adult", registrations: [] }; + + if (!service.isForChildren) { + const rows = await db + .select({ + bookingId: serviceBookings.id, + status: serviceBookings.status, + registeredAt: serviceBookings.createdAt, + profileId: profiles.id, + firstName: profiles.firstName, + lastName: profiles.lastName, + email: authUsers.email, + }) + .from(serviceBookings) + .innerJoin(profiles, eq(profiles.id, serviceBookings.userId)) + .innerJoin(authUsers, eq(authUsers.id, serviceBookings.userId)) + .where(eq(serviceBookings.serviceId, serviceId)); + + return { + kind: "adult", + registrations: rows.map((r) => ({ + bookingId: r.bookingId, + status: r.status, + registeredAt: r.registeredAt, + profile: { + id: r.profileId, + firstName: r.firstName, + lastName: r.lastName, + email: r.email ?? "", + }, + })), + }; + } + + // Kid service: bookings link to a child via childId + const bookingRows = await db + .select({ + bookingId: serviceBookings.id, + status: serviceBookings.status, + registeredAt: serviceBookings.createdAt, + childId: children.id, + childFirstName: children.firstName, + childLastName: children.lastName, + childDob: children.dob, + childGender: children.gender, + childAllergies: children.allergies, + childMedicalConditions: children.medicalConditions, + childMedications: children.medications, + parentFirstName: profiles.firstName, + parentLastName: profiles.lastName, + parentEmail: authUsers.email, + }) + .from(serviceBookings) + .innerJoin(children, eq(children.id, serviceBookings.childId)) + .innerJoin(profiles, eq(profiles.id, children.parentId)) + .innerJoin(authUsers, eq(authUsers.id, children.parentId)) + .where(eq(serviceBookings.serviceId, serviceId)); + + if (bookingRows.length === 0) return { kind: "kid", registrations: [] }; + + const childIds = [...new Set(bookingRows.map((r) => r.childId))]; + + const contactRows = await db + .select({ + childId: emergencyContacts.childId, + fullName: emergencyContacts.fullName, + emailAddress: emergencyContacts.emailAddress, + phoneNumber: emergencyContacts.phoneNumber, + relationship: emergencyContacts.relationship, + }) + .from(emergencyContacts) + .where(inArray(emergencyContacts.childId, childIds)); + + const contactsByChild = new Map< + string, + { fullName: string; emailAddress: string; phoneNumber: string; relationship: string }[] + >(); + for (const c of contactRows) { + const list = contactsByChild.get(c.childId) ?? []; + list.push(c); + contactsByChild.set(c.childId, list); + } + + const answersByChild = new Map(); + if (service.formId) { + const answerRows = await db + .select({ + childId: formQuestionAnswers.childId, + prompt: formQuestions.prompt, + answer: formQuestionAnswers.answer, + }) + .from(formQuestionAnswers) + .innerJoin( + formQuestions, + eq(formQuestions.id, formQuestionAnswers.formQuestionId), + ) + .where(eq(formQuestions.formId, service.formId)); + + for (const a of answerRows) { + if (!childIds.includes(a.childId)) continue; + const list = answersByChild.get(a.childId) ?? []; + list.push({ prompt: a.prompt, answer: a.answer }); + answersByChild.set(a.childId, list); + } + } + + return { + kind: "kid", + registrations: bookingRows.map((r) => ({ + bookingId: r.bookingId, + status: r.status, + registeredAt: r.registeredAt, + child: { + id: r.childId, + firstName: r.childFirstName, + lastName: r.childLastName, + dob: r.childDob, + gender: r.childGender, + allergies: r.childAllergies, + medicalConditions: r.childMedicalConditions, + medications: r.childMedications, + emergencyContacts: contactsByChild.get(r.childId) ?? [], + }, + parent: { + firstName: r.parentFirstName, + lastName: r.parentLastName, + email: r.parentEmail ?? "", + }, + formAnswers: answersByChild.get(r.childId) ?? [], + })), + }; +} diff --git a/app/(authenticated)/services/queries.ts b/app/(authenticated)/services/queries.ts index db7b6d1..c67c565 100644 --- a/app/(authenticated)/services/queries.ts +++ b/app/(authenticated)/services/queries.ts @@ -17,6 +17,8 @@ export type ServiceType = "private_lessons" | "programs"; export type ServiceView = { id: string; type: ServiceType; + isForChildren: boolean; + formId: string | null; scheduledAt: ProgramSchedule | null; durationMinutes: number; status: ServiceStatus; @@ -48,6 +50,8 @@ async function buildServiceView( return { id: row.id, type: row.type, + isForChildren: row.isForChildren, + formId: row.formId, scheduledAt: rowToSchedule(row), durationMinutes: row.durationMinutes, status: row.status, diff --git a/app/(authenticated)/services/services-data-table.tsx b/app/(authenticated)/services/services-data-table.tsx index 268ce43..1c479e8 100644 --- a/app/(authenticated)/services/services-data-table.tsx +++ b/app/(authenticated)/services/services-data-table.tsx @@ -1,8 +1,9 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Archive, ArchiveRestore, Ban, Pencil, Power } from "lucide-react"; +import { Archive, ArchiveRestore, Ban, Pencil, Power, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -84,6 +85,16 @@ export function ServicesDataTable({ const s = row.original; return (
+ + + + + View registered + {(s.status === "active" || s.status === "disabled") && ( From 313afdb9880568d1e2596d5a8d6093bffa4cc372 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:23:24 -0700 Subject: [PATCH 2/9] feat(layout): wrap AppSidebar in Suspense for improved loading experience feat(service): add Suspense wrapper with spinner for ServiceRegisteredPage --- app/(authenticated)/layout.tsx | 4 +++- app/(authenticated)/services/[id]/page.tsx | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx index 9af0930..d356f6c 100644 --- a/app/(authenticated)/layout.tsx +++ b/app/(authenticated)/layout.tsx @@ -27,7 +27,9 @@ export default function AuthenticatedLayout({ return ( - + + +
diff --git a/app/(authenticated)/services/[id]/page.tsx b/app/(authenticated)/services/[id]/page.tsx index bb0b0f5..5f9ded7 100644 --- a/app/(authenticated)/services/[id]/page.tsx +++ b/app/(authenticated)/services/[id]/page.tsx @@ -1,14 +1,27 @@ +import { Suspense } from "react"; +import { connection } from "next/server"; import { redirect } from "next/navigation"; +import { Spinner } from "@/components/ui/spinner"; import { requireAdmin } from "@/lib/auth/require-admin"; import { getService } from "@/app/(authenticated)/services/queries"; import { getServiceRegistrations } from "./queries"; import { RegisteredView } from "./_components/registered-view"; -export default async function ServiceRegisteredPage({ +export default function ServiceRegisteredPage({ params, }: { params: Promise<{ id: string }>; }) { + return ( + }> + + + ); +} + +async function PageContent({ params }: { params: Promise<{ id: string }> }) { + await connection(); + try { await requireAdmin(); } catch { From 7728d25a1ff45543d73ff91fdeb6033dfb4e8455 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:45:22 -0700 Subject: [PATCH 3/9] feat(services): integrate forms into ServicesPage, ServiceDialog, and ServicesTable components --- app/(authenticated)/services/page.tsx | 6 +- .../services/service-dialog.tsx | 55 ++++++++++++++++++- .../services/services-table.tsx | 6 +- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/app/(authenticated)/services/page.tsx b/app/(authenticated)/services/page.tsx index be7d832..2559d7e 100644 --- a/app/(authenticated)/services/page.tsx +++ b/app/(authenticated)/services/page.tsx @@ -1,16 +1,18 @@ import { listCoaches, listServices } from "./queries"; +import { listForms } from "@/app/(authenticated)/forms/queries"; import { ServicesTable } from "./services-table"; export default async function ServicesPage() { - const [services, coaches] = await Promise.all([ + const [services, coaches, forms] = await Promise.all([ listServices(), listCoaches(), + listForms(), ]); return (

Services

- +
); } diff --git a/app/(authenticated)/services/service-dialog.tsx b/app/(authenticated)/services/service-dialog.tsx index ddb2ed6..86a34c4 100644 --- a/app/(authenticated)/services/service-dialog.tsx +++ b/app/(authenticated)/services/service-dialog.tsx @@ -48,7 +48,9 @@ import type { ServiceView, } from "@/app/(authenticated)/services/queries"; -type Props = { coaches: CoachOption[] } & ( +type FormOption = { id: string; name: string }; + +type Props = { coaches: CoachOption[]; forms: FormOption[] } & ( | { mode: "add" } | { mode: "edit"; @@ -243,12 +245,16 @@ function ProgramScheduleFields({ export function ServiceDialog(props: Props) { const isEdit = props.mode === "edit"; const service = isEdit ? props.service : null; - const { coaches } = props; + const { coaches, forms } = props; const [type, setType] = React.useState<"programs" | "private_lessons">( service?.type ?? "programs", ); const [coachId, setCoachId] = React.useState(service?.coachId ?? ""); + const [isForChildren, setIsForChildren] = React.useState( + service?.isForChildren ?? false, + ); + const [formId, setFormId] = React.useState(service?.formId ?? ""); const [title, setTitle] = React.useState(service?.title ?? ""); const [description, setDescription] = React.useState( service?.description ?? "", @@ -268,6 +274,8 @@ export function ServiceDialog(props: Props) { if (service) { setType(service.type); setCoachId(service.coachId ?? ""); + setIsForChildren(service.isForChildren ?? false); + setFormId(service.formId ?? ""); setTitle(service.title ?? ""); setDescription(service.description ?? ""); setDurationMinutes(String(service.durationMinutes ?? 60)); @@ -287,6 +295,8 @@ export function ServiceDialog(props: Props) { closeRef.current?.click(); setType("programs"); setCoachId(""); + setIsForChildren(false); + setFormId(""); setTitle(""); setDescription(""); setDurationMinutes("60"); @@ -429,6 +439,47 @@ export function ServiceDialog(props: Props) {
+
+ +
+ setIsForChildren(e.target.checked)} + className="size-4 rounded border-input accent-primary" + /> + +
+
+ + {isForChildren && ( +
+ + + +
+ )} + {type === "programs" && ( ("active"); const [editing, setEditing] = React.useState(null); @@ -42,7 +45,7 @@ export function ServicesTable({ ))} - +
@@ -52,6 +55,7 @@ export function ServicesTable({ { From 8e2685010217e9984fc28499aae30ed905e4e208 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:51:10 -0700 Subject: [PATCH 4/9] feat(services): enhance ChildInfoModal with structured info rows and improved layout --- .../[id]/_components/child-info-modal.tsx | 134 +++++++++--------- 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/app/(authenticated)/services/[id]/_components/child-info-modal.tsx b/app/(authenticated)/services/[id]/_components/child-info-modal.tsx index 924efee..c71b462 100644 --- a/app/(authenticated)/services/[id]/_components/child-info-modal.tsx +++ b/app/(authenticated)/services/[id]/_components/child-info-modal.tsx @@ -3,9 +3,11 @@ import { Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { formatDate } from "@/lib/format"; import type { KidRegistration } from "../queries"; @@ -16,6 +18,15 @@ const GENDER_LABELS: Record = { prefer_not_to_say: "Prefer not to say", }; +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + export function ChildInfoModal({ registration, open, @@ -30,93 +41,80 @@ export function ChildInfoModal({ return ( - + {child.firstName} {child.lastName} -
- {/* Profile */} -
-

- Profile -

-
-
Date of birth
-
{formatDate(child.dob)}
-
Gender
-
{GENDER_LABELS[child.gender] ?? child.gender}
- {child.allergies && ( - <> -
Allergies
-
{child.allergies}
- - )} - {child.medicalConditions && ( - <> -
Medical conditions
-
{child.medicalConditions}
- - )} - {child.medications && ( - <> -
Medications
-
{child.medications}
- - )} -
-
+
+
+ + + {child.allergies && ( + + )} + {child.medicalConditions && ( + + )} + {child.medications && ( + + )} +
- {/* Emergency contacts */} {child.emergencyContacts.length > 0 && ( <> -
-

- Emergency Contacts -

-
    - {child.emergencyContacts.map((ec, i) => ( -
  • - Name - {ec.fullName} - Relationship - {ec.relationship} - Email - {ec.emailAddress} - Phone - {ec.phoneNumber} -
  • - ))} -
-
+
+

Emergency contacts

+ {child.emergencyContacts.map((ec, i) => ( +
0 ? "border-t border-border pt-3" : ""} + > +

+ {ec.fullName}{" "} + + ยท {ec.relationship} + +

+

+ {ec.emailAddress} +

+

+ {ec.phoneNumber} +

+
+ ))} +
)} - {/* Form answers */} {formAnswers.length > 0 && ( <> -
-

- Form Answers -

-
    - {formAnswers.map((qa, i) => ( -
  • - {qa.prompt} - - {qa.answer.join(", ")} - -
  • - ))} -
-
+
+

Form answers

+ {formAnswers.map((qa, i) => ( +
+

{qa.prompt}

+

{qa.answer.join(", ")}

+
+ ))} +
)}
+ + + +
); From aba76e67a2a2ffc312f3a270931bb1c216967ced Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:13:55 -0700 Subject: [PATCH 5/9] fix(services): center Suspense spinner in full-screen layout --- app/(authenticated)/services/[id]/page.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/(authenticated)/services/[id]/page.tsx b/app/(authenticated)/services/[id]/page.tsx index 5f9ded7..cd46a13 100644 --- a/app/(authenticated)/services/[id]/page.tsx +++ b/app/(authenticated)/services/[id]/page.tsx @@ -13,7 +13,13 @@ export default function ServiceRegisteredPage({ params: Promise<{ id: string }>; }) { return ( - }> + + + + } + > ); From 8b34cdb685ad050a74c67457719328789b1e2c7c Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:14:54 -0700 Subject: [PATCH 6/9] fix(services): replace native checkbox with shadcn Checkbox component --- .../services/service-dialog.tsx | 7 ++-- components/ui/checkbox.tsx | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 components/ui/checkbox.tsx diff --git a/app/(authenticated)/services/service-dialog.tsx b/app/(authenticated)/services/service-dialog.tsx index 86a34c4..a884ffa 100644 --- a/app/(authenticated)/services/service-dialog.tsx +++ b/app/(authenticated)/services/service-dialog.tsx @@ -7,6 +7,7 @@ import { CalendarIcon, DollarSign, Plus, X } from "lucide-react"; import { type DateRange } from "react-day-picker"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"; import { Calendar } from "@/components/ui/calendar"; import { @@ -446,12 +447,10 @@ export function ServiceDialog(props: Props) { value={String(isForChildren)} />
- setIsForChildren(e.target.checked)} - className="size-4 rounded border-input accent-primary" + onCheckedChange={(checked) => setIsForChildren(checked === true)} />
diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 0000000..6d1d6be --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } From 04b5b033e565c66642c056c211f68738249b3fc4 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:15:00 -0700 Subject: [PATCH 7/9] refactor(services): extract STATUS_VARIANT and GENDER_LABELS into shared constants --- .../services/[id]/_components/child-info-modal.tsx | 7 +------ .../services/[id]/_components/constants.ts | 12 ++++++++++++ .../services/[id]/_components/kid-table.tsx | 14 +------------- 3 files changed, 14 insertions(+), 19 deletions(-) create mode 100644 app/(authenticated)/services/[id]/_components/constants.ts diff --git a/app/(authenticated)/services/[id]/_components/child-info-modal.tsx b/app/(authenticated)/services/[id]/_components/child-info-modal.tsx index c71b462..97b590e 100644 --- a/app/(authenticated)/services/[id]/_components/child-info-modal.tsx +++ b/app/(authenticated)/services/[id]/_components/child-info-modal.tsx @@ -11,12 +11,7 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { formatDate } from "@/lib/format"; import type { KidRegistration } from "../queries"; - -const GENDER_LABELS: Record = { - male: "Male", - female: "Female", - prefer_not_to_say: "Prefer not to say", -}; +import { GENDER_LABELS } from "./constants"; function Row({ label, value }: { label: string; value: string }) { return ( diff --git a/app/(authenticated)/services/[id]/_components/constants.ts b/app/(authenticated)/services/[id]/_components/constants.ts new file mode 100644 index 0000000..763349c --- /dev/null +++ b/app/(authenticated)/services/[id]/_components/constants.ts @@ -0,0 +1,12 @@ +export const STATUS_VARIANT: Record = { + confirmed: "default", + pending: "secondary", + awaiting_payment: "outline", + cancelled: "destructive", +}; + +export const GENDER_LABELS: Record = { + male: "Male", + female: "Female", + prefer_not_to_say: "Prefer not to say", +}; diff --git a/app/(authenticated)/services/[id]/_components/kid-table.tsx b/app/(authenticated)/services/[id]/_components/kid-table.tsx index e5609f5..3f3c364 100644 --- a/app/(authenticated)/services/[id]/_components/kid-table.tsx +++ b/app/(authenticated)/services/[id]/_components/kid-table.tsx @@ -14,19 +14,7 @@ import { UsersDataTable } from "@/app/(authenticated)/users/_components/users-da import { formatDate } from "@/lib/format"; import type { KidRegistration } from "../queries"; import { ChildInfoModal } from "./child-info-modal"; - -const STATUS_VARIANT: Record = { - confirmed: "default", - pending: "secondary", - awaiting_payment: "outline", - cancelled: "destructive", -}; - -const GENDER_LABELS: Record = { - male: "Male", - female: "Female", - prefer_not_to_say: "Prefer not to say", -}; +import { STATUS_VARIANT, GENDER_LABELS } from "./constants"; export function KidTable({ registrations }: { registrations: KidRegistration[] }) { const [selected, setSelected] = useState(null); From 2f3c245e87a0e446e0f2ac7fc73a069916ea5b5b Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:15:04 -0700 Subject: [PATCH 8/9] fix(format): add formatDateFromInstant to avoid UTC off-by-one --- lib/format.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/format.ts b/lib/format.ts index d18c7ba..6cd5596 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -7,3 +7,11 @@ export function formatDate(value: string): string { const [y, m, d] = value.split("-"); return `${MONTHS_SHORT[Number(m) - 1]} ${Number(d)}, ${y}`; } + +export function formatDateFromInstant(d: Date): string { + return formatDate( + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`, + ); +} From cdd0edb0a0a5ca0b732e814a9bef7a81276709b4 Mon Sep 17 00:00:00 2001 From: Jia Xuan Li <77560326+Jxl-s@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:15:16 -0700 Subject: [PATCH 9/9] =?UTF-8?q?refactor(services):=20simplify=20AdultTable?= =?UTF-8?q?=20=E2=80=94=20use=20shared=20constants,=20fix=20date,=20drop?= =?UTF-8?q?=20useMemo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/[id]/_components/adult-table.tsx | 108 ++++++++---------- 1 file changed, 48 insertions(+), 60 deletions(-) diff --git a/app/(authenticated)/services/[id]/_components/adult-table.tsx b/app/(authenticated)/services/[id]/_components/adult-table.tsx index eadacbf..e9731fc 100644 --- a/app/(authenticated)/services/[id]/_components/adult-table.tsx +++ b/app/(authenticated)/services/[id]/_components/adult-table.tsx @@ -1,71 +1,59 @@ -"use client"; - -import { useMemo } from "react"; import { ColumnDef } from "@tanstack/react-table"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { UsersDataTable } from "@/app/(authenticated)/users/_components/users-data-table"; -import { formatDate } from "@/lib/format"; +import { formatDateFromInstant } from "@/lib/format"; import type { AdultRegistration } from "../queries"; +import { STATUS_VARIANT } from "./constants"; -const STATUS_VARIANT: Record = { - confirmed: "default", - pending: "secondary", - awaiting_payment: "outline", - cancelled: "destructive", -}; +const columns: ColumnDef[] = [ + { + id: "profile", + header: "User", + meta: { colWidth: "40%", tdClassName: "whitespace-normal align-middle" }, + cell: ({ row }) => { + const p = row.original.profile; + const initials = `${p.firstName[0] ?? ""}${p.lastName[0] ?? ""}`.toUpperCase(); + return ( +
+ + + {initials} + + +
+ + {p.firstName} {p.lastName} + + {p.email} +
+
+ ); + }, + }, + { + id: "status", + header: "Status", + meta: { colWidth: "20%" }, + cell: ({ row }) => ( + + {row.original.status.replace(/_/g, " ")} + + ), + }, + { + id: "registeredAt", + header: "Registered", + meta: { colWidth: "20%" }, + cell: ({ row }) => ( + + {formatDateFromInstant(row.original.registeredAt)} + + ), + }, +]; export function AdultTable({ registrations }: { registrations: AdultRegistration[] }) { - const columns = useMemo[]>( - () => [ - { - id: "profile", - header: "User", - meta: { colWidth: "40%", tdClassName: "whitespace-normal align-middle" }, - cell: ({ row }) => { - const p = row.original.profile; - const initials = `${p.firstName[0] ?? ""}${p.lastName[0] ?? ""}`.toUpperCase(); - return ( -
- - - {initials} - - -
- - {p.firstName} {p.lastName} - - {p.email} -
-
- ); - }, - }, - { - id: "status", - header: "Status", - meta: { colWidth: "20%" }, - cell: ({ row }) => ( - - {row.original.status.replace(/_/g, " ")} - - ), - }, - { - id: "registeredAt", - header: "Registered", - meta: { colWidth: "20%" }, - cell: ({ row }) => ( - - {formatDate(row.original.registeredAt.toISOString().slice(0, 10))} - - ), - }, - ], - [], - ); - return (