From defbf589ac1581c1c8bca4baf053a1bd00ec9596 Mon Sep 17 00:00:00 2001
From: Berny-ft
Date: Sun, 19 Jul 2026 12:00:56 -0400
Subject: [PATCH 1/7] feat(user-actions): add Manage Children button and modal
to user actions cell
---
.../children/child-detail-dialog.tsx | 370 ++++++++++++++++++
.../children/create-child-dialog.tsx | 349 +++++++++++++++++
.../users/_components/children/dob-field.tsx | 90 +++++
.../children/emergency-contact-dialog.tsx | 158 ++++++++
.../children/manage-children-modal.tsx | 194 +++++++++
.../users/_components/user-actions-cell.tsx | 23 +-
app/(authenticated)/users/children-actions.ts | 227 +++++++++++
app/(authenticated)/users/children-queries.ts | 65 +++
app/(authenticated)/users/children-schema.ts | 32 ++
9 files changed, 1507 insertions(+), 1 deletion(-)
create mode 100644 app/(authenticated)/users/_components/children/child-detail-dialog.tsx
create mode 100644 app/(authenticated)/users/_components/children/create-child-dialog.tsx
create mode 100644 app/(authenticated)/users/_components/children/dob-field.tsx
create mode 100644 app/(authenticated)/users/_components/children/emergency-contact-dialog.tsx
create mode 100644 app/(authenticated)/users/_components/children/manage-children-modal.tsx
create mode 100644 app/(authenticated)/users/children-actions.ts
create mode 100644 app/(authenticated)/users/children-queries.ts
create mode 100644 app/(authenticated)/users/children-schema.ts
diff --git a/app/(authenticated)/users/_components/children/child-detail-dialog.tsx b/app/(authenticated)/users/_components/children/child-detail-dialog.tsx
new file mode 100644
index 0000000..9560fda
--- /dev/null
+++ b/app/(authenticated)/users/_components/children/child-detail-dialog.tsx
@@ -0,0 +1,370 @@
+"use client";
+
+import { useActionState, useCallback, useState } from "react";
+import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+import {
+ updateChildAdmin,
+ type ChildActionState,
+} from "@/app/(authenticated)/users/children-actions";
+import type { ChildView } from "@/app/(authenticated)/users/children-queries";
+
+import { DobField } from "./dob-field";
+import {
+ EmergencyContactDialog,
+ type DraftEmergencyContact,
+} from "./emergency-contact-dialog";
+
+function FieldError({ errors }: { errors?: string[] }) {
+ if (!errors?.length) return null;
+ return {errors[0]}
;
+}
+
+function toDraftContacts(child: ChildView): DraftEmergencyContact[] {
+ return child.emergencyContacts.map((c) => ({
+ id: c.id,
+ full_name: c.fullName,
+ email_address: c.emailAddress,
+ phone_number: c.phoneNumber,
+ relationship: c.relationship,
+ }));
+}
+
+function ChildEditForm({
+ child,
+ parentId,
+ onClose,
+ onSuccess,
+}: {
+ child: ChildView;
+ parentId: string;
+ onClose: () => void;
+ onSuccess?: () => void;
+}) {
+ const [gender, setGender] = useState(child.gender);
+ const [dob, setDob] = useState(child.dob);
+ const [emergencyContacts, setEmergencyContacts] = useState(() =>
+ toDraftContacts(child),
+ );
+ const [ecSectionOpen, setEcSectionOpen] = useState(true);
+ const [addContactOpen, setAddContactOpen] = useState(false);
+
+ const boundFormAction = useCallback(
+ async (prev: ChildActionState, formData: FormData) => {
+ if (emergencyContacts.length === 0) {
+ return {
+ errors: {
+ emergency_contacts: ["Add at least one emergency contact"],
+ },
+ };
+ }
+ formData.set("child_id", child.id);
+ formData.set("parent_id", parentId);
+ formData.set(
+ "emergency_contacts",
+ JSON.stringify(
+ emergencyContacts.map(
+ ({
+ full_name,
+ email_address,
+ phone_number,
+ relationship,
+ }) => ({
+ full_name,
+ email_address,
+ phone_number,
+ relationship,
+ }),
+ ),
+ ),
+ );
+ const result = await updateChildAdmin(prev, formData);
+ if (result?.message && !result.errors) {
+ toast.success(result.message);
+ onClose();
+ onSuccess?.();
+ } else if (result?.errors?._form?.[0]) {
+ toast.error(result.errors._form[0]);
+ }
+ return result;
+ },
+ [child.id, parentId, emergencyContacts, onClose, onSuccess],
+ );
+
+ const [state, formAction, pending] = useActionState<
+ ChildActionState,
+ FormData
+ >(boundFormAction, null);
+
+ function handleAddContact(contact: Omit) {
+ setEmergencyContacts((prev) => [
+ ...prev,
+ { ...contact, id: crypto.randomUUID() },
+ ]);
+ setEcSectionOpen(true);
+ }
+
+ function handleRemoveContact(id: string) {
+ setEmergencyContacts((prev) => prev.filter((c) => c.id !== id));
+ }
+
+ return (
+ <>
+
+
+
+ {child.firstName} {child.lastName}
+
+
+ Update this child's profile and emergency contacts.
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export function ChildDetailDialog({
+ child,
+ parentId,
+ open,
+ onOpenChange,
+ onSuccess,
+}: {
+ child: ChildView | null;
+ parentId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+}) {
+ if (!child) return null;
+
+ return (
+
+ );
+}
diff --git a/app/(authenticated)/users/_components/children/create-child-dialog.tsx b/app/(authenticated)/users/_components/children/create-child-dialog.tsx
new file mode 100644
index 0000000..4c0fb25
--- /dev/null
+++ b/app/(authenticated)/users/_components/children/create-child-dialog.tsx
@@ -0,0 +1,349 @@
+"use client";
+
+import { useActionState, useCallback, useState } from "react";
+import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+import {
+ createChildAdmin,
+ type ChildActionState,
+} from "@/app/(authenticated)/users/children-actions";
+
+import { DobField } from "./dob-field";
+import {
+ EmergencyContactDialog,
+ type DraftEmergencyContact,
+} from "./emergency-contact-dialog";
+
+function FieldError({ errors }: { errors?: string[] }) {
+ if (!errors?.length) return null;
+ return {errors[0]}
;
+}
+
+export function CreateChildDialog({
+ parentId,
+ open,
+ onOpenChange,
+ onSuccess,
+}: {
+ parentId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+}) {
+ const [formKey, setFormKey] = useState(0);
+ const [gender, setGender] = useState("");
+ const [dob, setDob] = useState("");
+ const [emergencyContacts, setEmergencyContacts] = useState<
+ DraftEmergencyContact[]
+ >([]);
+ const [ecSectionOpen, setEcSectionOpen] = useState(true);
+ const [addContactOpen, setAddContactOpen] = useState(false);
+
+ function resetForm() {
+ setGender("");
+ setDob("");
+ setEmergencyContacts([]);
+ setEcSectionOpen(true);
+ setFormKey((k) => k + 1);
+ }
+
+ function handleOpenChange(nextOpen: boolean) {
+ onOpenChange(nextOpen);
+ if (!nextOpen) resetForm();
+ }
+
+ const boundFormAction = useCallback(
+ async (prev: ChildActionState, formData: FormData) => {
+ if (emergencyContacts.length === 0) {
+ return {
+ errors: {
+ emergency_contacts: ["Add at least one emergency contact"],
+ },
+ };
+ }
+ formData.set("parent_id", parentId);
+ formData.set(
+ "emergency_contacts",
+ JSON.stringify(
+ emergencyContacts.map(
+ ({
+ full_name,
+ email_address,
+ phone_number,
+ relationship,
+ }) => ({
+ full_name,
+ email_address,
+ phone_number,
+ relationship,
+ }),
+ ),
+ ),
+ );
+ const result = await createChildAdmin(prev, formData);
+ if (result?.message && !result.errors) {
+ toast.success(result.message);
+ resetForm();
+ onOpenChange(false);
+ onSuccess?.();
+ } else if (result?.errors?._form?.[0]) {
+ toast.error(result.errors._form[0]);
+ }
+ return result;
+ },
+ [emergencyContacts, parentId, onOpenChange, onSuccess],
+ );
+
+ const [state, formAction, pending] = useActionState(boundFormAction, null);
+
+ function handleAddContact(contact: Omit) {
+ setEmergencyContacts((prev) => [
+ ...prev,
+ { ...contact, id: crypto.randomUUID() },
+ ]);
+ setEcSectionOpen(true);
+ }
+
+ function handleRemoveContact(id: string) {
+ setEmergencyContacts((prev) => prev.filter((c) => c.id !== id));
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/app/(authenticated)/users/_components/children/dob-field.tsx b/app/(authenticated)/users/_components/children/dob-field.tsx
new file mode 100644
index 0000000..ecc9ae3
--- /dev/null
+++ b/app/(authenticated)/users/_components/children/dob-field.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useState } from "react";
+import { CalendarIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { ButtonGroup } from "@/components/ui/button-group";
+import { Calendar } from "@/components/ui/calendar";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+export function toISODate(date: Date): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+export function fromISODate(value: string | undefined): Date | undefined {
+ if (!value) return undefined;
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
+ const [y, m, d] = value.split("-").map(Number);
+ if (!y || !m || !d) return undefined;
+ const date = new Date(y, m - 1, d);
+ if (
+ date.getFullYear() !== y ||
+ date.getMonth() !== m - 1 ||
+ date.getDate() !== d
+ ) {
+ return undefined;
+ }
+ return date;
+}
+
+type DobFieldProps = {
+ id?: string;
+ value: string;
+ onChange: (value: string) => void;
+};
+
+export function DobField({ id = "dob", value, onChange }: DobFieldProps) {
+ const [open, setOpen] = useState(false);
+ const selected = fromISODate(value);
+
+ return (
+ <>
+
+ onChange(e.target.value)}
+ onBlur={() => {
+ const parsed = fromISODate(value.trim());
+ if (parsed) onChange(toISODate(parsed));
+ }}
+ />
+
+
+
+
+
+ {
+ if (!date) return;
+ onChange(toISODate(date));
+ setOpen(false);
+ }}
+ disabled={(date) => date > new Date()}
+ />
+
+
+
+
+ >
+ );
+}
diff --git a/app/(authenticated)/users/_components/children/emergency-contact-dialog.tsx b/app/(authenticated)/users/_components/children/emergency-contact-dialog.tsx
new file mode 100644
index 0000000..4859c5e
--- /dev/null
+++ b/app/(authenticated)/users/_components/children/emergency-contact-dialog.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { useState } from "react";
+import { Plus } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+export type DraftEmergencyContact = {
+ id: string;
+ full_name: string;
+ email_address: string;
+ phone_number: string;
+ relationship: string;
+};
+
+type EmergencyContactDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onAdd: (contact: Omit) => void;
+};
+
+function FieldError({ errors }: { errors?: string[] }) {
+ if (!errors?.length) return null;
+ return {errors[0]}
;
+}
+
+export function EmergencyContactDialog({
+ open,
+ onOpenChange,
+ onAdd,
+}: EmergencyContactDialogProps) {
+ const [formKey, setFormKey] = useState(0);
+ const [errors, setErrors] = useState>({});
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (nextOpen) {
+ setFormKey((k) => k + 1);
+ setErrors({});
+ }
+ onOpenChange(nextOpen);
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const data = new FormData(e.currentTarget);
+ const full_name = String(data.get("full_name") ?? "").trim();
+ const email_address = String(data.get("email_address") ?? "").trim();
+ const phone_number = String(data.get("phone_number") ?? "").replace(
+ /\D/g,
+ "",
+ );
+ const relationship = String(data.get("relationship") ?? "").trim();
+
+ const nextErrors: Record = {};
+ if (!full_name) nextErrors.full_name = ["Full name is required"];
+ if (!email_address) nextErrors.email_address = ["Email is required"];
+ else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email_address)) {
+ nextErrors.email_address = ["Invalid email address"];
+ }
+ if (!phone_number) nextErrors.phone_number = ["Phone number is required"];
+ if (!relationship) nextErrors.relationship = ["Relationship is required"];
+
+ if (Object.keys(nextErrors).length > 0) {
+ setErrors(nextErrors);
+ return;
+ }
+
+ onAdd({ full_name, email_address, phone_number, relationship });
+ handleOpenChange(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/app/(authenticated)/users/_components/children/manage-children-modal.tsx b/app/(authenticated)/users/_components/children/manage-children-modal.tsx
new file mode 100644
index 0000000..4f44f0a
--- /dev/null
+++ b/app/(authenticated)/users/_components/children/manage-children-modal.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { Baby, Pencil, Plus } from "lucide-react";
+
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Spinner } from "@/components/ui/spinner";
+import { formatDate } from "@/lib/format";
+import { listChildrenForUserAdmin } from "@/app/(authenticated)/users/children-actions";
+import type { ChildView } from "@/app/(authenticated)/users/children-queries";
+
+import { ChildDetailDialog } from "./child-detail-dialog";
+import { CreateChildDialog } from "./create-child-dialog";
+
+const GENDER_LABELS: Record = {
+ male: "Male",
+ female: "Female",
+ prefer_not_to_say: "Prefer not to say",
+};
+
+export function ManageChildrenModal({
+ parentId,
+ userName,
+ open,
+ onOpenChange,
+}: {
+ parentId: string;
+ userName: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [children, setChildren] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editChild, setEditChild] = useState(null);
+ const [editOpen, setEditOpen] = useState(false);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await listChildrenForUserAdmin(parentId);
+ if ("error" in result) {
+ setError(result.error);
+ setChildren([]);
+ } else {
+ setChildren(result);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [parentId]);
+
+ useEffect(() => {
+ if (open) {
+ void refresh();
+ } else {
+ setCreateOpen(false);
+ setEditOpen(false);
+ setEditChild(null);
+ setError(null);
+ }
+ }, [open, refresh]);
+
+ function handleEdit(child: ChildView) {
+ setEditChild(child);
+ setEditOpen(true);
+ }
+
+ return (
+ <>
+
+
+
+
+ {
+ setEditOpen(next);
+ if (!next) setEditChild(null);
+ }}
+ onSuccess={refresh}
+ />
+ >
+ );
+}
diff --git a/app/(authenticated)/users/_components/user-actions-cell.tsx b/app/(authenticated)/users/_components/user-actions-cell.tsx
index a6af05b..3300e38 100644
--- a/app/(authenticated)/users/_components/user-actions-cell.tsx
+++ b/app/(authenticated)/users/_components/user-actions-cell.tsx
@@ -2,7 +2,7 @@
import { useRef, useState } from "react";
-import { Pencil, Tag, Trash2, ReceiptText } from "lucide-react";
+import { Baby, Pencil, Tag, Trash2, ReceiptText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
@@ -26,6 +26,7 @@ import { deleteUserAdmin } from "@/app/(authenticated)/users/actions";
import { toast } from "sonner";
import { profileRoleLabel, type UserRow } from "../profile-role-label";
import { UserTransactionsModal } from "./components/user-transactions-modals";
+import { ManageChildrenModal } from "./children/manage-children-modal";
interface UserActionsCellProps {
@@ -35,6 +36,7 @@ interface UserActionsCellProps {
export function UserActionsCell({ user, onEdit }: UserActionsCellProps) {
const [txOpen, setTxOpen] = useState(false);
+ const [childrenOpen, setChildrenOpen] = useState(false);
const [open, setOpen] = useState(false);
const [services, setServices] = useState([]);
const [discounts, setDiscounts] = useState([]);
@@ -136,6 +138,19 @@ export function UserActionsCell({ user, onEdit }: UserActionsCellProps) {
Edit user
+
+
+
+
+ Manage children
+
+
);
}
diff --git a/app/(authenticated)/users/children-actions.ts b/app/(authenticated)/users/children-actions.ts
new file mode 100644
index 0000000..524353a
--- /dev/null
+++ b/app/(authenticated)/users/children-actions.ts
@@ -0,0 +1,227 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { and, eq } from "drizzle-orm";
+
+import { requireAdmin } from "@/lib/auth/require-admin";
+import { db } from "@/lib/db";
+import { children, emergencyContacts, profiles } from "@/lib/db/schema";
+import {
+ createChildAdminSchema,
+ updateChildAdminSchema,
+} from "./children-schema";
+import {
+ listChildrenForParent,
+ type ChildView,
+} from "./children-queries";
+
+export type ChildActionState = {
+ errors?: Record;
+ message?: string;
+ data?: { childId: string };
+} | null;
+
+const USERS_PATH = "/users";
+
+function field(formData: FormData, name: string): string | undefined {
+ const v = formData.get(name);
+ return v === null ? undefined : v.toString();
+}
+
+function parseEmergencyContacts(formData: FormData) {
+ const contactsRaw = field(formData, "emergency_contacts");
+ try {
+ return contactsRaw ? JSON.parse(contactsRaw) : [];
+ } catch {
+ return null;
+ }
+}
+
+async function insertEmergencyContacts(
+ tx: Parameters[0]>[0],
+ childId: string,
+ contacts: {
+ full_name: string;
+ email_address: string;
+ phone_number: string;
+ relationship: string;
+ }[],
+) {
+ if (contacts.length === 0) return;
+ await tx.insert(emergencyContacts).values(
+ contacts.map((c) => ({
+ childId,
+ fullName: c.full_name,
+ emailAddress: c.email_address,
+ phoneNumber: c.phone_number,
+ relationship: c.relationship,
+ })),
+ );
+}
+
+export async function listChildrenForUserAdmin(
+ parentId: string,
+): Promise {
+ try {
+ await requireAdmin();
+ } catch {
+ return { error: "Unauthorized" };
+ }
+
+ return listChildrenForParent(parentId);
+}
+
+export async function createChildAdmin(
+ _prev: ChildActionState,
+ formData: FormData,
+): Promise {
+ try {
+ await requireAdmin();
+ } catch {
+ return { errors: { _form: ["Unauthorized"] } };
+ }
+
+ const emergency_contacts = parseEmergencyContacts(formData);
+ if (emergency_contacts === null) {
+ return { errors: { emergency_contacts: ["Invalid emergency contacts"] } };
+ }
+
+ const parsed = createChildAdminSchema.safeParse({
+ parent_id: field(formData, "parent_id"),
+ first_name: field(formData, "first_name"),
+ last_name: field(formData, "last_name"),
+ dob: field(formData, "dob"),
+ gender: field(formData, "gender"),
+ allergies: field(formData, "allergies"),
+ medical_conditions: field(formData, "medical_conditions"),
+ medications: field(formData, "medications"),
+ emergency_contacts,
+ });
+
+ if (!parsed.success) {
+ return { errors: parsed.error.flatten().fieldErrors };
+ }
+
+ const data = parsed.data;
+
+ const parent = await db
+ .select({ id: profiles.id })
+ .from(profiles)
+ .where(eq(profiles.id, data.parent_id))
+ .limit(1);
+
+ if (parent.length === 0) {
+ return { errors: { _form: ["User not found"] } };
+ }
+
+ try {
+ const childId = await db.transaction(async (tx) => {
+ const [child] = await tx
+ .insert(children)
+ .values({
+ parentId: data.parent_id,
+ firstName: data.first_name,
+ lastName: data.last_name,
+ dob: data.dob,
+ gender: data.gender,
+ allergies: data.allergies || null,
+ medicalConditions: data.medical_conditions || null,
+ medications: data.medications || null,
+ })
+ .returning({ id: children.id });
+
+ await insertEmergencyContacts(tx, child.id, data.emergency_contacts);
+ return child.id;
+ });
+
+ revalidatePath(USERS_PATH);
+ return { message: "Child created.", data: { childId } };
+ } catch {
+ return {
+ errors: { _form: ["Failed to create child. Please try again."] },
+ };
+ }
+}
+
+export async function updateChildAdmin(
+ _prev: ChildActionState,
+ formData: FormData,
+): Promise {
+ try {
+ await requireAdmin();
+ } catch {
+ return { errors: { _form: ["Unauthorized"] } };
+ }
+
+ const emergency_contacts = parseEmergencyContacts(formData);
+ if (emergency_contacts === null) {
+ return { errors: { emergency_contacts: ["Invalid emergency contacts"] } };
+ }
+
+ const parsed = updateChildAdminSchema.safeParse({
+ parent_id: field(formData, "parent_id"),
+ child_id: field(formData, "child_id"),
+ first_name: field(formData, "first_name"),
+ last_name: field(formData, "last_name"),
+ dob: field(formData, "dob"),
+ gender: field(formData, "gender"),
+ allergies: field(formData, "allergies"),
+ medical_conditions: field(formData, "medical_conditions"),
+ medications: field(formData, "medications"),
+ emergency_contacts,
+ });
+
+ if (!parsed.success) {
+ return { errors: parsed.error.flatten().fieldErrors };
+ }
+
+ const data = parsed.data;
+
+ const [existing] = await db
+ .select({ id: children.id })
+ .from(children)
+ .where(
+ and(
+ eq(children.id, data.child_id),
+ eq(children.parentId, data.parent_id),
+ ),
+ )
+ .limit(1);
+
+ if (!existing) return { errors: { _form: ["Child not found"] } };
+
+ try {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(children)
+ .set({
+ firstName: data.first_name,
+ lastName: data.last_name,
+ dob: data.dob,
+ gender: data.gender,
+ allergies: data.allergies || null,
+ medicalConditions: data.medical_conditions || null,
+ medications: data.medications || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(children.id, data.child_id));
+
+ await tx
+ .delete(emergencyContacts)
+ .where(eq(emergencyContacts.childId, data.child_id));
+
+ await insertEmergencyContacts(
+ tx,
+ data.child_id,
+ data.emergency_contacts,
+ );
+ });
+
+ revalidatePath(USERS_PATH);
+ return { message: "Child updated.", data: { childId: data.child_id } };
+ } catch {
+ return {
+ errors: { _form: ["Failed to update child. Please try again."] },
+ };
+ }
+}
diff --git a/app/(authenticated)/users/children-queries.ts b/app/(authenticated)/users/children-queries.ts
new file mode 100644
index 0000000..1ef7eab
--- /dev/null
+++ b/app/(authenticated)/users/children-queries.ts
@@ -0,0 +1,65 @@
+import { asc, eq, inArray } from "drizzle-orm";
+
+import { db } from "@/lib/db";
+import { children, emergencyContacts } from "@/lib/db/schema";
+
+export type ChildView = {
+ id: string;
+ firstName: string;
+ lastName: string;
+ dob: string;
+ gender: "male" | "female" | "prefer_not_to_say";
+ allergies: string | null;
+ medicalConditions: string | null;
+ medications: string | null;
+ emergencyContacts: {
+ id: string;
+ fullName: string;
+ emailAddress: string;
+ phoneNumber: string;
+ relationship: string;
+ }[];
+};
+
+export async function listChildrenForParent(
+ parentId: string,
+): Promise {
+ const rows = await db
+ .select()
+ .from(children)
+ .where(eq(children.parentId, parentId))
+ .orderBy(asc(children.firstName), asc(children.lastName));
+
+ if (rows.length === 0) return [];
+
+ const childIds = rows.map((r) => r.id);
+ const contacts = await db
+ .select()
+ .from(emergencyContacts)
+ .where(inArray(emergencyContacts.childId, childIds));
+
+ const contactsByChildId = new Map();
+ for (const c of contacts) {
+ const list = contactsByChildId.get(c.childId) ?? [];
+ list.push(c);
+ contactsByChildId.set(c.childId, list);
+ }
+
+ return rows.map((row) => ({
+ id: row.id,
+ firstName: row.firstName,
+ lastName: row.lastName,
+ dob: row.dob,
+ gender: row.gender,
+ allergies: row.allergies,
+ medicalConditions: row.medicalConditions,
+ medications: row.medications,
+ emergencyContacts: (contactsByChildId.get(row.id) ?? []).map((c) => ({
+ id: c.id,
+ fullName: c.fullName,
+ emailAddress: c.emailAddress,
+ phoneNumber: c.phoneNumber,
+ relationship: c.relationship,
+ })),
+ }));
+}
diff --git a/app/(authenticated)/users/children-schema.ts b/app/(authenticated)/users/children-schema.ts
new file mode 100644
index 0000000..eca3fcb
--- /dev/null
+++ b/app/(authenticated)/users/children-schema.ts
@@ -0,0 +1,32 @@
+import { z } from "zod";
+
+const genderSchema = z.enum(["male", "female", "prefer_not_to_say"]);
+
+const emergencyContactSchema = z.object({
+ full_name: z.string().min(1, "Full name is required"),
+ email_address: z.string().email("Invalid email address"),
+ phone_number: z.string().min(1, "Phone number is required"),
+ relationship: z.string().min(1, "Relationship is required"),
+});
+
+const childFieldsSchema = z.object({
+ first_name: z.string().min(1, "First name is required"),
+ last_name: z.string().min(1, "Last name is required"),
+ dob: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date of birth"),
+ gender: genderSchema,
+ allergies: z.string().optional(),
+ medical_conditions: z.string().optional(),
+ medications: z.string().optional(),
+ emergency_contacts: z
+ .array(emergencyContactSchema)
+ .min(1, "At least one emergency contact is required"),
+});
+
+export const createChildAdminSchema = childFieldsSchema.extend({
+ parent_id: z.string().uuid(),
+});
+
+export const updateChildAdminSchema = childFieldsSchema.extend({
+ child_id: z.string().uuid(),
+ parent_id: z.string().uuid(),
+});
From ce6ef491b27953c893ccca3ce67350fe15daba7b Mon Sep 17 00:00:00 2001
From: Berny-ft
Date: Wed, 29 Jul 2026 17:49:51 -0400
Subject: [PATCH 2/7] feat(children): add parent /children page for
self-service child management
Non-admins get a sidebar Children tab to create, edit, and delete their own
kids; admins keep managing children from the Users row action.
---
.../children/_components/children-client.tsx | 251 ++++++++++++++++++
app/(authenticated)/children/actions.ts | 222 ++++++++++++++++
app/(authenticated)/children/page.tsx | 50 ++++
app/(authenticated)/layout.tsx | 10 +-
.../children/child-detail-dialog.tsx | 22 +-
.../children/create-child-dialog.tsx | 17 +-
app/(authenticated)/users/children-schema.ts | 11 +-
components/app-sidebar.tsx | 37 ++-
8 files changed, 602 insertions(+), 18 deletions(-)
create mode 100644 app/(authenticated)/children/_components/children-client.tsx
create mode 100644 app/(authenticated)/children/actions.ts
create mode 100644 app/(authenticated)/children/page.tsx
diff --git a/app/(authenticated)/children/_components/children-client.tsx b/app/(authenticated)/children/_components/children-client.tsx
new file mode 100644
index 0000000..a9cc093
--- /dev/null
+++ b/app/(authenticated)/children/_components/children-client.tsx
@@ -0,0 +1,251 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { Pencil, Plus, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+import type { ColumnDef } from "@tanstack/react-table";
+
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { UsersDataTable } from "@/app/(authenticated)/users/_components/users-data-table";
+import { CreateChildDialog } from "@/app/(authenticated)/users/_components/children/create-child-dialog";
+import { ChildDetailDialog } from "@/app/(authenticated)/users/_components/children/child-detail-dialog";
+import type { ChildView } from "@/app/(authenticated)/users/children-queries";
+import { formatDate } from "@/lib/format";
+import {
+ createChild,
+ updateChild,
+ deleteChild,
+} from "@/app/(authenticated)/children/actions";
+
+const GENDER_LABELS: Record = {
+ male: "Male",
+ female: "Female",
+ prefer_not_to_say: "Prefer not to say",
+};
+
+export function ChildrenClient({ childList }: { childList: ChildView[] }) {
+ const router = useRouter();
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editChild, setEditChild] = useState(null);
+ const [editOpen, setEditOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleting, startDelete] = useTransition();
+
+ function refresh() {
+ router.refresh();
+ }
+ const columns = useMemo[]>(
+ () => [
+ {
+ id: "profile",
+ header: "Child",
+ meta: {
+ colWidth: "36%",
+ tdClassName: "whitespace-normal align-middle",
+ },
+ cell: ({ row }) => {
+ const c = row.original;
+ const initials =
+ `${c.firstName[0] ?? ""}${c.lastName[0] ?? ""}`.toUpperCase();
+ return (
+
+
+
+ {initials}
+
+
+
+
+ {c.firstName} {c.lastName}
+
+
+ {GENDER_LABELS[c.gender] ??
+ c.gender.replaceAll("_", " ")}
+
+
+
+ );
+ },
+ },
+ {
+ id: "dob",
+ header: "Date of birth",
+ meta: { colWidth: "22%" },
+ cell: ({ row }) => (
+
+ {formatDate(row.original.dob)}
+
+ ),
+ },
+ {
+ id: "emergencyContacts",
+ header: "Emergency contacts",
+ meta: { colWidth: "18%" },
+ cell: ({ row }) => (
+
+ {row.original.emergencyContacts.length}
+
+ ),
+ },
+ {
+ id: "actions",
+ header: () => Actions
,
+ meta: {
+ colWidth: "24%",
+ thClassName: "text-right",
+ tdClassName: "text-right",
+ },
+ cell: ({ row }) => {
+ const child = row.original;
+ return (
+
+
+
+
+
+ Edit child
+
+
+
+
+
+ Delete child
+
+
+ );
+ },
+ },
+ ],
+ [],
+ );
+
+ function handleDelete() {
+ if (!deleteTarget) return;
+ startDelete(async () => {
+ const fd = new FormData();
+ fd.set("child_id", deleteTarget.id);
+ const result = await deleteChild(null, fd);
+ if (result?.errors) {
+ toast.error("Failed to delete child", {
+ description: Object.values(result.errors).flat().join(" "),
+ });
+ } else {
+ toast.success(result?.message ?? "Child deleted.");
+ setDeleteTarget(null);
+ refresh();
+ }
+ });
+ }
+
+ return (
+
+
+
+
{
+ setEditOpen(open);
+ if (!open) setEditChild(null);
+ }}
+ action={updateChild}
+ onSuccess={refresh}
+ />
+
+ {
+ if (!open) setDeleteTarget(null);
+ }}
+ >
+
+
+ Delete child?
+
+ This permanently deletes{" "}
+ {deleteTarget
+ ? `${deleteTarget.firstName} ${deleteTarget.lastName}`
+ : "this child"}
+ ’s profile and emergency contacts. This action cannot
+ be undone.
+
+
+
+
+ Cancel
+
+ {
+ e.preventDefault();
+ handleDelete();
+ }}
+ disabled={deleting}
+ >
+ {deleting ? "Deleting…" : "Delete"}
+
+
+
+
+
+
+
+ {childList.length} child{childList.length === 1 ? "" : "ren"}
+
+
+
+
+ `${n} child${n === 1 ? "" : "ren"}`}
+ />
+
+ );
+}
diff --git a/app/(authenticated)/children/actions.ts b/app/(authenticated)/children/actions.ts
new file mode 100644
index 0000000..04d1410
--- /dev/null
+++ b/app/(authenticated)/children/actions.ts
@@ -0,0 +1,222 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { and, eq } from "drizzle-orm";
+
+import { createClient } from "@/utils/supabase/server";
+import { db } from "@/lib/db";
+import { children, emergencyContacts } from "@/lib/db/schema";
+import {
+ createChildSchema,
+ updateChildSchema,
+} from "@/app/(authenticated)/users/children-schema";
+import type { ChildActionState } from "@/app/(authenticated)/users/children-actions";
+
+const CHILDREN_PATH = "/children";
+
+function field(formData: FormData, name: string): string | undefined {
+ const v = formData.get(name);
+ return v === null ? undefined : v.toString();
+}
+
+function parseEmergencyContacts(formData: FormData) {
+ const contactsRaw = field(formData, "emergency_contacts");
+ try {
+ return contactsRaw ? JSON.parse(contactsRaw) : [];
+ } catch {
+ return null;
+ }
+}
+
+async function insertEmergencyContacts(
+ tx: Parameters[0]>[0],
+ childId: string,
+ contacts: {
+ full_name: string;
+ email_address: string;
+ phone_number: string;
+ relationship: string;
+ }[],
+) {
+ if (contacts.length === 0) return;
+ await tx.insert(emergencyContacts).values(
+ contacts.map((c) => ({
+ childId,
+ fullName: c.full_name,
+ emailAddress: c.email_address,
+ phoneNumber: c.phone_number,
+ relationship: c.relationship,
+ })),
+ );
+}
+
+async function requireUserId(): Promise {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ return user?.id ?? null;
+}
+
+export async function createChild(
+ _prev: ChildActionState,
+ formData: FormData,
+): Promise {
+ const userId = await requireUserId();
+ if (!userId) return { errors: { _form: ["Unauthorized"] } };
+
+ const emergency_contacts = parseEmergencyContacts(formData);
+ if (emergency_contacts === null) {
+ return { errors: { emergency_contacts: ["Invalid emergency contacts"] } };
+ }
+
+ const parsed = createChildSchema.safeParse({
+ first_name: field(formData, "first_name"),
+ last_name: field(formData, "last_name"),
+ dob: field(formData, "dob"),
+ gender: field(formData, "gender"),
+ allergies: field(formData, "allergies"),
+ medical_conditions: field(formData, "medical_conditions"),
+ medications: field(formData, "medications"),
+ emergency_contacts,
+ });
+
+ if (!parsed.success) {
+ return { errors: parsed.error.flatten().fieldErrors };
+ }
+
+ const data = parsed.data;
+
+ try {
+ const childId = await db.transaction(async (tx) => {
+ const [child] = await tx
+ .insert(children)
+ .values({
+ parentId: userId,
+ firstName: data.first_name,
+ lastName: data.last_name,
+ dob: data.dob,
+ gender: data.gender,
+ allergies: data.allergies || null,
+ medicalConditions: data.medical_conditions || null,
+ medications: data.medications || null,
+ })
+ .returning({ id: children.id });
+
+ await insertEmergencyContacts(tx, child.id, data.emergency_contacts);
+ return child.id;
+ });
+
+ revalidatePath(CHILDREN_PATH);
+ return { message: "Child created.", data: { childId } };
+ } catch {
+ return {
+ errors: { _form: ["Failed to create child. Please try again."] },
+ };
+ }
+}
+
+export async function updateChild(
+ _prev: ChildActionState,
+ formData: FormData,
+): Promise {
+ const userId = await requireUserId();
+ if (!userId) return { errors: { _form: ["Unauthorized"] } };
+
+ const emergency_contacts = parseEmergencyContacts(formData);
+ if (emergency_contacts === null) {
+ return { errors: { emergency_contacts: ["Invalid emergency contacts"] } };
+ }
+
+ const parsed = updateChildSchema.safeParse({
+ child_id: field(formData, "child_id"),
+ first_name: field(formData, "first_name"),
+ last_name: field(formData, "last_name"),
+ dob: field(formData, "dob"),
+ gender: field(formData, "gender"),
+ allergies: field(formData, "allergies"),
+ medical_conditions: field(formData, "medical_conditions"),
+ medications: field(formData, "medications"),
+ emergency_contacts,
+ });
+
+ if (!parsed.success) {
+ return { errors: parsed.error.flatten().fieldErrors };
+ }
+
+ const data = parsed.data;
+
+ const [existing] = await db
+ .select({ id: children.id })
+ .from(children)
+ .where(
+ and(eq(children.id, data.child_id), eq(children.parentId, userId)),
+ )
+ .limit(1);
+
+ if (!existing) return { errors: { _form: ["Child not found"] } };
+
+ try {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(children)
+ .set({
+ firstName: data.first_name,
+ lastName: data.last_name,
+ dob: data.dob,
+ gender: data.gender,
+ allergies: data.allergies || null,
+ medicalConditions: data.medical_conditions || null,
+ medications: data.medications || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(children.id, data.child_id));
+
+ await tx
+ .delete(emergencyContacts)
+ .where(eq(emergencyContacts.childId, data.child_id));
+
+ await insertEmergencyContacts(
+ tx,
+ data.child_id,
+ data.emergency_contacts,
+ );
+ });
+
+ revalidatePath(CHILDREN_PATH);
+ return { message: "Child updated.", data: { childId: data.child_id } };
+ } catch {
+ return {
+ errors: { _form: ["Failed to update child. Please try again."] },
+ };
+ }
+}
+
+export async function deleteChild(
+ _prev: ChildActionState,
+ formData: FormData,
+): Promise {
+ const userId = await requireUserId();
+ if (!userId) return { errors: { _form: ["Unauthorized"] } };
+
+ const childId = field(formData, "child_id");
+ if (!childId) return { errors: { _form: ["Child not found"] } };
+
+ const [existing] = await db
+ .select({ id: children.id })
+ .from(children)
+ .where(and(eq(children.id, childId), eq(children.parentId, userId)))
+ .limit(1);
+
+ if (!existing) return { errors: { _form: ["Child not found"] } };
+
+ try {
+ await db.delete(children).where(eq(children.id, childId));
+ revalidatePath(CHILDREN_PATH);
+ return { message: "Child deleted." };
+ } catch {
+ return {
+ errors: { _form: ["Failed to delete child. Please try again."] },
+ };
+ }
+}
diff --git a/app/(authenticated)/children/page.tsx b/app/(authenticated)/children/page.tsx
new file mode 100644
index 0000000..227987d
--- /dev/null
+++ b/app/(authenticated)/children/page.tsx
@@ -0,0 +1,50 @@
+import { Suspense } from "react";
+import { redirect } from "next/navigation";
+
+import { Spinner } from "@/components/ui/spinner";
+import { createClient } from "@/utils/supabase/server";
+import { ROLES } from "@/lib/roles";
+import { listChildrenForParent } from "@/app/(authenticated)/users/children-queries";
+import { ChildrenClient } from "./_components/children-client";
+
+export default function ChildrenPage() {
+ return (
+ }
+ >
+
+
+ );
+}
+
+async function ChildrenContent() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ redirect("/login");
+ }
+
+ const { data: claimsData } = await supabase.auth.getClaims();
+ if (claimsData?.claims?.user_role === ROLES.ADMIN) {
+ redirect("/users");
+ }
+
+ const childList = await listChildrenForParent(user.id);
+
+ return (
+
+
+
+ My children
+
+
+ Add and manage children linked to your account.
+
+
+
+
+ );
+}
diff --git a/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx
index d356f6c..7336336 100644
--- a/app/(authenticated)/layout.tsx
+++ b/app/(authenticated)/layout.tsx
@@ -5,6 +5,7 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import { AppSidebar } from "@/components/app-sidebar";
+import type { Role } from "@/lib/roles";
async function AuthGate({ children }: { children: React.ReactNode }) {
const supabase = await createClient();
@@ -19,6 +20,13 @@ async function AuthGate({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
+async function RoleAwareSidebar() {
+ const supabase = await createClient();
+ const { data: claimsData } = await supabase.auth.getClaims();
+ const role = (claimsData?.claims?.user_role as Role | undefined) ?? null;
+ return ;
+}
+
export default function AuthenticatedLayout({
children,
}: {
@@ -28,7 +36,7 @@ export default function AuthenticatedLayout({
-
+
diff --git a/app/(authenticated)/users/_components/children/child-detail-dialog.tsx b/app/(authenticated)/users/_components/children/child-detail-dialog.tsx
index 9560fda..ab5dff9 100644
--- a/app/(authenticated)/users/_components/children/child-detail-dialog.tsx
+++ b/app/(authenticated)/users/_components/children/child-detail-dialog.tsx
@@ -42,6 +42,11 @@ function FieldError({ errors }: { errors?: string[] }) {
return
{errors[0]}
;
}
+type ChildFormAction = (
+ prev: ChildActionState,
+ formData: FormData,
+) => Promise
;
+
function toDraftContacts(child: ChildView): DraftEmergencyContact[] {
return child.emergencyContacts.map((c) => ({
id: c.id,
@@ -57,11 +62,13 @@ function ChildEditForm({
parentId,
onClose,
onSuccess,
+ action,
}: {
child: ChildView;
- parentId: string;
+ parentId?: string;
onClose: () => void;
onSuccess?: () => void;
+ action: ChildFormAction;
}) {
const [gender, setGender] = useState(child.gender);
const [dob, setDob] = useState(child.dob);
@@ -81,7 +88,9 @@ function ChildEditForm({
};
}
formData.set("child_id", child.id);
- formData.set("parent_id", parentId);
+ if (parentId) {
+ formData.set("parent_id", parentId);
+ }
formData.set(
"emergency_contacts",
JSON.stringify(
@@ -100,7 +109,7 @@ function ChildEditForm({
),
),
);
- const result = await updateChildAdmin(prev, formData);
+ const result = await action(prev, formData);
if (result?.message && !result.errors) {
toast.success(result.message);
onClose();
@@ -110,7 +119,7 @@ function ChildEditForm({
}
return result;
},
- [child.id, parentId, emergencyContacts, onClose, onSuccess],
+ [child.id, parentId, emergencyContacts, onClose, onSuccess, action],
);
const [state, formAction, pending] = useActionState<
@@ -345,12 +354,14 @@ export function ChildDetailDialog({
open,
onOpenChange,
onSuccess,
+ action = updateChildAdmin,
}: {
child: ChildView | null;
- parentId: string;
+ parentId?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
+ action?: ChildFormAction;
}) {
if (!child) return null;
@@ -363,6 +374,7 @@ export function ChildDetailDialog({
parentId={parentId}
onClose={() => onOpenChange(false)}
onSuccess={onSuccess}
+ action={action}
/>
)}
diff --git a/app/(authenticated)/users/_components/children/create-child-dialog.tsx b/app/(authenticated)/users/_components/children/create-child-dialog.tsx
index 4c0fb25..ce83c64 100644
--- a/app/(authenticated)/users/_components/children/create-child-dialog.tsx
+++ b/app/(authenticated)/users/_components/children/create-child-dialog.tsx
@@ -41,16 +41,23 @@ function FieldError({ errors }: { errors?: string[] }) {
return {errors[0]}
;
}
+type ChildFormAction = (
+ prev: ChildActionState,
+ formData: FormData,
+) => Promise;
+
export function CreateChildDialog({
parentId,
open,
onOpenChange,
onSuccess,
+ action = createChildAdmin,
}: {
- parentId: string;
+ parentId?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
+ action?: ChildFormAction;
}) {
const [formKey, setFormKey] = useState(0);
const [gender, setGender] = useState("");
@@ -83,7 +90,9 @@ export function CreateChildDialog({
},
};
}
- formData.set("parent_id", parentId);
+ if (parentId) {
+ formData.set("parent_id", parentId);
+ }
formData.set(
"emergency_contacts",
JSON.stringify(
@@ -102,7 +111,7 @@ export function CreateChildDialog({
),
),
);
- const result = await createChildAdmin(prev, formData);
+ const result = await action(prev, formData);
if (result?.message && !result.errors) {
toast.success(result.message);
resetForm();
@@ -113,7 +122,7 @@ export function CreateChildDialog({
}
return result;
},
- [emergencyContacts, parentId, onOpenChange, onSuccess],
+ [emergencyContacts, parentId, onOpenChange, onSuccess, action],
);
const [state, formAction, pending] = useActionState(boundFormAction, null);
diff --git a/app/(authenticated)/users/children-schema.ts b/app/(authenticated)/users/children-schema.ts
index eca3fcb..31c2ae9 100644
--- a/app/(authenticated)/users/children-schema.ts
+++ b/app/(authenticated)/users/children-schema.ts
@@ -9,7 +9,7 @@ const emergencyContactSchema = z.object({
relationship: z.string().min(1, "Relationship is required"),
});
-const childFieldsSchema = z.object({
+export const createChildSchema = z.object({
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
dob: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date of birth"),
@@ -22,11 +22,14 @@ const childFieldsSchema = z.object({
.min(1, "At least one emergency contact is required"),
});
-export const createChildAdminSchema = childFieldsSchema.extend({
+export const updateChildSchema = createChildSchema.extend({
+ child_id: z.string().uuid(),
+});
+
+export const createChildAdminSchema = createChildSchema.extend({
parent_id: z.string().uuid(),
});
-export const updateChildAdminSchema = childFieldsSchema.extend({
- child_id: z.string().uuid(),
+export const updateChildAdminSchema = updateChildSchema.extend({
parent_id: z.string().uuid(),
});
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
index ac6f6f7..e6849b7 100644
--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useMemo } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import {
@@ -10,6 +11,8 @@ import {
MonitorSmartphone,
Settings,
Form,
+ Baby,
+ type LucideIcon,
} from "lucide-react";
import {
Sidebar,
@@ -25,22 +28,44 @@ import {
} from "@/components/ui/sidebar";
import Image from "next/image";
import { cn } from "@/lib/utils";
+import { ROLES, type Role } from "@/lib/roles";
-const navItems = [
+type NavItem = {
+ title: string;
+ href: string;
+ icon: LucideIcon;
+};
+
+const baseNavItems: NavItem[] = [
{ title: "OVERVIEW", href: "/", icon: LayoutGrid },
{ title: "SERVICES", href: "/services", icon: BookOpen },
{ title: "USERS", href: "/users", icon: Users },
{ title: "FINANCE", href: "/finance", icon: CreditCard },
{ title: "MEMBERSHIPS", href: "/memberships", icon: MonitorSmartphone },
- { title: "FORMS" , href: "/forms", icon: Form}
+ { title: "FORMS", href: "/forms", icon: Form },
];
export function AppSidebar({
className,
+ role,
...props
-}: React.ComponentProps) {
+}: React.ComponentProps & { role?: Role | string | null }) {
const pathname = usePathname();
+ const navItems = useMemo(() => {
+ const items = [...baseNavItems];
+ if (role && role !== ROLES.ADMIN) {
+ const usersIdx = items.findIndex((i) => i.href === "/users");
+ const insertAt = usersIdx >= 0 ? usersIdx + 1 : items.length;
+ items.splice(insertAt, 0, {
+ title: "CHILDREN",
+ href: "/children",
+ icon: Baby,
+ });
+ }
+ return items;
+ }, [role]);
+
return (
{navItems.map((item) => {
- const isActive = pathname === item.href;
+ const isActive =
+ item.href === "/"
+ ? pathname === "/"
+ : pathname === item.href ||
+ pathname.startsWith(`${item.href}/`);
return (
Date: Wed, 29 Jul 2026 17:50:00 -0400
Subject: [PATCH 3/7] fix(sidebar): hide Users nav item for non-admin users
Parents only need Children; Users is an admin surface and should not appear
in the sidebar for normal accounts.
---
components/app-sidebar.tsx | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
index e6849b7..22d565d 100644
--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -53,10 +53,13 @@ export function AppSidebar({
const pathname = usePathname();
const navItems = useMemo(() => {
- const items = [...baseNavItems];
- if (role && role !== ROLES.ADMIN) {
- const usersIdx = items.findIndex((i) => i.href === "/users");
- const insertAt = usersIdx >= 0 ? usersIdx + 1 : items.length;
+ const isAdmin = role === ROLES.ADMIN;
+ const items = baseNavItems.filter(
+ (item) => isAdmin || item.href !== "/users",
+ );
+ if (role && !isAdmin) {
+ const servicesIdx = items.findIndex((i) => i.href === "/services");
+ const insertAt = servicesIdx >= 0 ? servicesIdx + 1 : items.length;
items.splice(insertAt, 0, {
title: "CHILDREN",
href: "/children",
From 67bdf39a662a6d3d552153cdefb69d6a44af77ea Mon Sep 17 00:00:00 2001
From: Berny-ft
Date: Wed, 29 Jul 2026 17:59:16 -0400
Subject: [PATCH 4/7] feat(children): allow editing emergency contacts in place
Reuse the contact dialog for add and edit so parents and admins can update
contact details without deleting and re-adding them.
---
.../children/child-detail-dialog.tsx | 84 +++++++++++++-----
.../children/create-child-dialog.tsx | 88 +++++++++++++------
.../children/emergency-contact-dialog.tsx | 35 ++++++--
3 files changed, 152 insertions(+), 55 deletions(-)
diff --git a/app/(authenticated)/users/_components/children/child-detail-dialog.tsx b/app/(authenticated)/users/_components/children/child-detail-dialog.tsx
index ab5dff9..b66d2cd 100644
--- a/app/(authenticated)/users/_components/children/child-detail-dialog.tsx
+++ b/app/(authenticated)/users/_components/children/child-detail-dialog.tsx
@@ -1,7 +1,7 @@
"use client";
import { useActionState, useCallback, useState } from "react";
-import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
+import { ChevronDown, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -76,7 +76,9 @@ function ChildEditForm({
toDraftContacts(child),
);
const [ecSectionOpen, setEcSectionOpen] = useState(true);
- const [addContactOpen, setAddContactOpen] = useState(false);
+ const [contactDialogOpen, setContactDialogOpen] = useState(false);
+ const [editingContact, setEditingContact] =
+ useState(null);
const boundFormAction = useCallback(
async (prev: ChildActionState, formData: FormData) => {
@@ -127,12 +129,31 @@ function ChildEditForm({
FormData
>(boundFormAction, null);
- function handleAddContact(contact: Omit) {
- setEmergencyContacts((prev) => [
- ...prev,
- { ...contact, id: crypto.randomUUID() },
- ]);
- setEcSectionOpen(true);
+ function openAddContact() {
+ setEditingContact(null);
+ setContactDialogOpen(true);
+ }
+
+ function openEditContact(contact: DraftEmergencyContact) {
+ setEditingContact(contact);
+ setContactDialogOpen(true);
+ }
+
+ function handleSaveContact(contact: Omit) {
+ if (editingContact) {
+ setEmergencyContacts((prev) =>
+ prev.map((c) =>
+ c.id === editingContact.id ? { ...c, ...contact } : c,
+ ),
+ );
+ } else {
+ setEmergencyContacts((prev) => [
+ ...prev,
+ { ...contact, id: crypto.randomUUID() },
+ ]);
+ setEcSectionOpen(true);
+ }
+ setEditingContact(null);
}
function handleRemoveContact(id: string) {
@@ -282,17 +303,30 @@ function ChildEditForm({
{contact.phone_number}
-
+
+
+
+
))}
@@ -306,7 +340,7 @@ function ChildEditForm({
type="button"
variant="outline"
size="sm"
- onClick={() => setAddContactOpen(true)}
+ onClick={openAddContact}
>
Add contact
@@ -340,9 +374,13 @@ function ChildEditForm({
{
+ setContactDialogOpen(open);
+ if (!open) setEditingContact(null);
+ }}
+ contact={editingContact}
+ onSave={handleSaveContact}
/>
>
);
diff --git a/app/(authenticated)/users/_components/children/create-child-dialog.tsx b/app/(authenticated)/users/_components/children/create-child-dialog.tsx
index ce83c64..9ce96d5 100644
--- a/app/(authenticated)/users/_components/children/create-child-dialog.tsx
+++ b/app/(authenticated)/users/_components/children/create-child-dialog.tsx
@@ -1,7 +1,7 @@
"use client";
import { useActionState, useCallback, useState } from "react";
-import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
+import { ChevronDown, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -66,7 +66,9 @@ export function CreateChildDialog({
DraftEmergencyContact[]
>([]);
const [ecSectionOpen, setEcSectionOpen] = useState(true);
- const [addContactOpen, setAddContactOpen] = useState(false);
+ const [contactDialogOpen, setContactDialogOpen] = useState(false);
+ const [editingContact, setEditingContact] =
+ useState(null);
function resetForm() {
setGender("");
@@ -127,12 +129,31 @@ export function CreateChildDialog({
const [state, formAction, pending] = useActionState(boundFormAction, null);
- function handleAddContact(contact: Omit) {
- setEmergencyContacts((prev) => [
- ...prev,
- { ...contact, id: crypto.randomUUID() },
- ]);
- setEcSectionOpen(true);
+ function openAddContact() {
+ setEditingContact(null);
+ setContactDialogOpen(true);
+ }
+
+ function openEditContact(contact: DraftEmergencyContact) {
+ setEditingContact(contact);
+ setContactDialogOpen(true);
+ }
+
+ function handleSaveContact(contact: Omit) {
+ if (editingContact) {
+ setEmergencyContacts((prev) =>
+ prev.map((c) =>
+ c.id === editingContact.id ? { ...c, ...contact } : c,
+ ),
+ );
+ } else {
+ setEmergencyContacts((prev) => [
+ ...prev,
+ { ...contact, id: crypto.randomUUID() },
+ ]);
+ setEcSectionOpen(true);
+ }
+ setEditingContact(null);
}
function handleRemoveContact(id: string) {
@@ -287,19 +308,32 @@ export function CreateChildDialog({
{contact.phone_number}
-
+
+
+
+
))}
@@ -313,7 +347,7 @@ export function CreateChildDialog({
type="button"
variant="outline"
size="sm"
- onClick={() => setAddContactOpen(true)}
+ onClick={openAddContact}
>
Add contact
@@ -349,9 +383,13 @@ export function CreateChildDialog({
{
+ setContactDialogOpen(open);
+ if (!open) setEditingContact(null);
+ }}
+ contact={editingContact}
+ onSave={handleSaveContact}
/>
>
);
diff --git a/app/(authenticated)/users/_components/children/emergency-contact-dialog.tsx b/app/(authenticated)/users/_components/children/emergency-contact-dialog.tsx
index 4859c5e..3cdcc41 100644
--- a/app/(authenticated)/users/_components/children/emergency-contact-dialog.tsx
+++ b/app/(authenticated)/users/_components/children/emergency-contact-dialog.tsx
@@ -26,7 +26,8 @@ export type DraftEmergencyContact = {
type EmergencyContactDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
- onAdd: (contact: Omit) => void;
+ contact?: DraftEmergencyContact | null;
+ onSave: (contact: Omit) => void;
};
function FieldError({ errors }: { errors?: string[] }) {
@@ -37,10 +38,12 @@ function FieldError({ errors }: { errors?: string[] }) {
export function EmergencyContactDialog({
open,
onOpenChange,
- onAdd,
+ contact = null,
+ onSave,
}: EmergencyContactDialogProps) {
const [formKey, setFormKey] = useState(0);
const [errors, setErrors] = useState>({});
+ const isEditing = !!contact;
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
@@ -77,7 +80,7 @@ export function EmergencyContactDialog({
return;
}
- onAdd({ full_name, email_address, phone_number, relationship });
+ onSave({ full_name, email_address, phone_number, relationship });
handleOpenChange(false);
}
@@ -85,7 +88,11 @@ export function EmergencyContactDialog({