Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
65 changes: 65 additions & 0 deletions app/(authenticated)/services/[id]/_components/adult-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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 { formatDateFromInstant } from "@/lib/format";
import type { AdultRegistration } from "../queries";
import { STATUS_VARIANT } from "./constants";

const columns: ColumnDef<AdultRegistration>[] = [
{
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">
{formatDateFromInstant(row.original.registeredAt)}
</span>
),
},
];

export function AdultTable({ registrations }: { registrations: AdultRegistration[] }) {
return (
<UsersDataTable
columns={columns}
data={registrations}
emptyMessage="No registrations yet."
rowLabel={(n) => `${n} registration${n === 1 ? "" : "s"}`}
/>
);
}
116 changes: 116 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,116 @@
"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";
import { GENDER_LABELS } from "./constants";

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>
);
}
12 changes: 12 additions & 0 deletions app/(authenticated)/services/[id]/_components/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const STATUS_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
confirmed: "default",
pending: "secondary",
awaiting_payment: "outline",
cancelled: "destructive",
};

export const GENDER_LABELS: Record<string, string> = {
male: "Male",
female: "Female",
prefer_not_to_say: "Prefer not to say",
};
120 changes: 120 additions & 0 deletions app/(authenticated)/services/[id]/_components/kid-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"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";
import { STATUS_VARIANT, GENDER_LABELS } from "./constants";

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