Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export default function AuthenticatedLayout({
return (
<TooltipProvider>
<SidebarProvider>
<AppSidebar />
<Suspense fallback={null}>
<AppSidebar />
</Suspense>
<SidebarInset>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
<Suspense fallback={null}>
Expand Down
77 changes: 77 additions & 0 deletions app/(authenticated)/services/[id]/_components/adult-table.tsx
Original file line number Diff line number Diff line change
@@ -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<string, "default" | "secondary" | "destructive" | "outline"> = {
confirmed: "default",
pending: "secondary",
awaiting_payment: "outline",
cancelled: "destructive",
};

export function AdultTable({ registrations }: { registrations: AdultRegistration[] }) {
const columns = useMemo<ColumnDef<AdultRegistration>[]>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we dont need the use demo for the adult table.

() => [
{
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 (
<div className="flex min-w-0 items-center gap-3">
<Avatar>
<AvatarFallback className="bg-muted text-xs font-semibold text-muted-foreground">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate font-semibold text-sm">
{p.firstName} {p.lastName}
</span>
<span className="truncate text-xs text-muted-foreground">{p.email}</span>
</div>
</div>
);
},
},
{
id: "status",
header: "Status",
meta: { colWidth: "20%" },
cell: ({ row }) => (
<Badge variant={STATUS_VARIANT[row.original.status] ?? "secondary"} className="capitalize">
{row.original.status.replace(/_/g, " ")}
</Badge>
),
},
{
id: "registeredAt",
header: "Registered",
meta: { colWidth: "20%" },
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.original.registeredAt.toISOString().slice(0, 10))}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toISOString()converts to UTC so it can be off by one day.

// lib/format.ts
  export function formatDateFromInstant(d: Date): string {
     return formatDate(
        `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
           d.getDate(),
        ).padStart(2, "0")}`,
     );
  }

</span>
),
},
],
[],
);

return (
<UsersDataTable
columns={columns}
data={registrations}
emptyMessage="No registrations yet."
rowLabel={(n) => `${n} registration${n === 1 ? "" : "s"}`}
/>
);
}
121 changes: 121 additions & 0 deletions app/(authenticated)/services/[id]/_components/child-info-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";

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";

const GENDER_LABELS: Record<string, string> = {
male: "Male",
female: "Female",
prefer_not_to_say: "Prefer not to say",
};

function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="text-right">{value}</span>
</div>
);
}

export function ChildInfoModal({
registration,
open,
onOpenChange,
}: {
registration: KidRegistration | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
if (!registration) return null;
const { child, formAnswers } = registration;

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
{child.firstName} {child.lastName}
</DialogTitle>
</DialogHeader>

<div className="flex flex-col gap-4">
<div className="space-y-2">
<Row label="Date of birth" value={formatDate(child.dob)} />
<Row
label="Gender"
value={GENDER_LABELS[child.gender] ?? child.gender}
/>
{child.allergies && (
<Row label="Allergies" value={child.allergies} />
)}
{child.medicalConditions && (
<Row label="Medical conditions" value={child.medicalConditions} />
)}
{child.medications && (
<Row label="Medications" value={child.medications} />
)}
</div>

{child.emergencyContacts.length > 0 && (
<>
<Separator />
<div className="space-y-3">
<p className="text-sm font-medium">Emergency contacts</p>
{child.emergencyContacts.map((ec, i) => (
<div
key={i}
className={i > 0 ? "border-t border-border pt-3" : ""}
>
<p className="mb-1 text-sm font-medium">
{ec.fullName}{" "}
<span className="font-normal text-muted-foreground">
· {ec.relationship}
</span>
</p>
<p className="text-sm text-muted-foreground">
{ec.emailAddress}
</p>
<p className="text-sm text-muted-foreground">
{ec.phoneNumber}
</p>
</div>
))}
</div>
</>
)}

{formAnswers.length > 0 && (
<>
<Separator />
<div className="space-y-3">
<p className="text-sm font-medium">Form answers</p>
{formAnswers.map((qa, i) => (
<div key={i}>
<p className="text-sm text-muted-foreground">{qa.prompt}</p>
<p className="mt-0.5 text-sm">{qa.answer.join(", ")}</p>
</div>
))}
</div>
</>
)}
</div>

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
132 changes: 132 additions & 0 deletions app/(authenticated)/services/[id]/_components/kid-table.tsx
Original file line number Diff line number Diff line change
@@ -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<string, "default" | "secondary" | "destructive" | "outline"> = {
confirmed: "default",
pending: "secondary",
awaiting_payment: "outline",
cancelled: "destructive",
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are duplicated across adult-table.tsx, kid-table.tsx, and child-info-modal.tsx.
same for GENDER_LABELS.


const GENDER_LABELS: Record<string, string> = {
male: "Male",
female: "Female",
prefer_not_to_say: "Prefer not to say",
};

export function KidTable({ registrations }: { registrations: KidRegistration[] }) {
const [selected, setSelected] = useState<KidRegistration | null>(null);
const [modalOpen, setModalOpen] = useState(false);

const columns = useMemo<ColumnDef<KidRegistration>[]>(
() => [
{
id: "child",
header: "Child",
meta: { colWidth: "22%" },
cell: ({ row }) => (
<span className="font-medium text-sm">
{row.original.child.firstName} {row.original.child.lastName}
</span>
),
},
{
id: "dob",
header: "Date of Birth",
meta: { colWidth: "16%" },
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.original.child.dob)}
</span>
),
},
{
id: "gender",
header: "Gender",
meta: { colWidth: "16%" },
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{GENDER_LABELS[row.original.child.gender] ?? row.original.child.gender}
</span>
),
},
{
id: "parent",
header: "Parent",
meta: { colWidth: "20%" },
cell: ({ row }) => (
<span className="text-sm">
{row.original.parent.firstName} {row.original.parent.lastName}
</span>
),
},
{
id: "status",
header: "Status",
meta: { colWidth: "14%" },
cell: ({ row }) => (
<Badge
variant={STATUS_VARIANT[row.original.status] ?? "secondary"}
className="capitalize"
>
{row.original.status.replace(/_/g, " ")}
</Badge>
),
},
{
id: "info",
header: () => <div className="text-right">Info</div>,
meta: { colWidth: "12%", thClassName: "text-right", tdClassName: "text-right" },
cell: ({ row }) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="View child profile"
onClick={() => {
setSelected(row.original);
setModalOpen(true);
}}
>
<Info />
</Button>
</TooltipTrigger>
<TooltipContent>View profile</TooltipContent>
</Tooltip>
),
},
],
[],
);

return (
<>
<UsersDataTable
columns={columns}
data={registrations}
emptyMessage="No registrations yet."
rowLabel={(n) => `${n} registration${n === 1 ? "" : "s"}`}
/>
<ChildInfoModal
registration={selected}
open={modalOpen}
onOpenChange={setModalOpen}
/>
</>
);
}
46 changes: 46 additions & 0 deletions app/(authenticated)/services/[id]/_components/registered-view.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link href="/services">
<ChevronLeft className="mr-1 h-4 w-4" />
Services
</Link>
</Button>
</div>

<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{service.title ?? "Service"}</h1>
<Badge variant="secondary">
{count} registered
</Badge>
</div>

{data.kind === "adult" ? (
<AdultTable registrations={data.registrations} />
) : (
<KidTable registrations={data.registrations} />
)}
</div>
);
}
Loading
Loading