diff --git a/__tests__/children-actions.test.ts b/__tests__/children-actions.test.ts new file mode 100644 index 0000000..31bda2d --- /dev/null +++ b/__tests__/children-actions.test.ts @@ -0,0 +1,213 @@ +/** + * @jest-environment node + */ +import { + createChild, + updateChild, + deleteChild, +} from "@/app/(authenticated)/children/actions"; +import { revalidatePath } from "next/cache"; + +const PARENT_ID = "11111111-1111-1111-1111-111111111111"; +const CHILD_ID = "22222222-2222-2222-2222-222222222222"; +const OTHER_CHILD = "33333333-3333-3333-3333-333333333333"; + +const insertReturning = jest.fn(); +const insertValues = jest.fn(() => ({ returning: insertReturning })); +const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock; + +const deleteWhere = jest.fn().mockResolvedValue(undefined); +const deleteFn = jest.fn(() => ({ where: deleteWhere })) as jest.Mock; + +const updateWhere = jest.fn().mockResolvedValue(undefined); +const updateSet = jest.fn(() => ({ where: updateWhere })); +const update = jest.fn(() => ({ set: updateSet })) as jest.Mock; + +const selectLimit = jest.fn(); +const selectWhere = jest.fn(() => ({ limit: selectLimit })); +const selectFrom = jest.fn(() => ({ where: selectWhere })); +const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock; + +const transaction = jest.fn(async (cb: (tx: unknown) => unknown) => + cb({ insert, update, delete: deleteFn }), +); + +jest.mock("@/lib/db", () => ({ + db: { + transaction: (...args: unknown[]) => transaction(...args), + select: (...args: unknown[]) => select(...args), + delete: (...args: unknown[]) => deleteFn(...args), + }, +})); + +const getUser = jest.fn(); +const getClaims = jest.fn(); +jest.mock("@/utils/supabase/server", () => ({ + createClient: async () => ({ auth: { getUser, getClaims } }), +})); + +jest.mock("next/cache", () => ({ + revalidatePath: jest.fn(), +})); + +const validContact = { + full_name: "Jane Doe", + email_address: "jane@example.com", + phone_number: "4165551234", + relationship: "Mother", +}; + +function fd(obj: Record): FormData { + const f = new FormData(); + for (const [k, v] of Object.entries(obj)) f.append(k, v); + return f; +} + +function childFields(extra: Record = {}) { + return fd({ + first_name: "Kid", + last_name: "Name", + dob: "2018-05-01", + gender: "male", + emergency_contacts: JSON.stringify([validContact]), + ...extra, + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + getUser.mockResolvedValue({ data: { user: { id: PARENT_ID } } }); + getClaims.mockResolvedValue({ data: { claims: { user_role: "user" } } }); + insertReturning.mockResolvedValue([{ id: CHILD_ID }]); + selectLimit.mockResolvedValue([{ id: CHILD_ID }]); +}); + +describe("createChild", () => { + it("returns Unauthorized when not signed in", async () => { + getUser.mockResolvedValue({ data: { user: null } }); + + const result = await createChild(null, childFields()); + + expect(result).toEqual({ errors: { _form: ["Unauthorized"] } }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("returns Unauthorized for coordinators", async () => { + getClaims.mockResolvedValue({ + data: { claims: { user_role: "coordinator" } }, + }); + + const result = await createChild(null, childFields()); + + expect(result).toEqual({ errors: { _form: ["Unauthorized"] } }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("rejects a future date of birth", async () => { + const result = await createChild( + null, + childFields({ dob: "2099-01-01" }), + ); + + expect(result?.errors?.dob).toBeDefined(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("rejects a short phone number", async () => { + const result = await createChild( + null, + childFields({ + emergency_contacts: JSON.stringify([ + { ...validContact, phone_number: "123" }, + ]), + }), + ); + + expect(result?.errors?.emergency_contacts).toBeDefined(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("requires at least one emergency contact", async () => { + const result = await createChild( + null, + childFields({ emergency_contacts: "[]" }), + ); + + expect(result?.errors?.emergency_contacts).toBeDefined(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("creates a child for the signed-in parent", async () => { + const result = await createChild(null, childFields()); + + expect(result).toEqual({ + message: "Child created.", + data: { childId: CHILD_ID }, + }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + parentId: PARENT_ID, + firstName: "Kid", + lastName: "Name", + }), + ); + expect(revalidatePath).toHaveBeenCalledWith("/children"); + expect(revalidatePath).toHaveBeenCalledWith("/users"); + }); +}); + +describe("updateChild", () => { + it("blocks updates for children the parent does not own", async () => { + selectLimit.mockResolvedValue([]); + + const result = await updateChild( + null, + childFields({ child_id: OTHER_CHILD }), + ); + + expect(result).toEqual({ errors: { _form: ["Child not found"] } }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("updates an owned child", async () => { + const result = await updateChild( + null, + childFields({ child_id: CHILD_ID, first_name: "Updated" }), + ); + + expect(result).toEqual({ + message: "Child updated.", + data: { childId: CHILD_ID }, + }); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ firstName: "Updated" }), + ); + }); +}); + +describe("deleteChild", () => { + it("rejects a non-uuid child_id", async () => { + const result = await deleteChild(null, fd({ child_id: "not-a-uuid" })); + + expect(result?.errors?.child_id).toBeDefined(); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it("blocks deleting a child the parent does not own", async () => { + selectLimit.mockResolvedValue([]); + + const result = await deleteChild(null, fd({ child_id: OTHER_CHILD })); + + expect(result).toEqual({ errors: { _form: ["Child not found"] } }); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it("deletes an owned child", async () => { + const result = await deleteChild(null, fd({ child_id: CHILD_ID })); + + expect(result).toEqual({ message: "Child deleted." }); + expect(deleteFn).toHaveBeenCalled(); + expect(revalidatePath).toHaveBeenCalledWith("/children"); + expect(revalidatePath).toHaveBeenCalledWith("/users"); + }); +}); diff --git a/__tests__/children-admin-actions.test.ts b/__tests__/children-admin-actions.test.ts new file mode 100644 index 0000000..9fc5dcc --- /dev/null +++ b/__tests__/children-admin-actions.test.ts @@ -0,0 +1,158 @@ +/** + * @jest-environment node + */ +import { + createChildAdmin, + updateChildAdmin, + listChildrenForUserAdmin, +} from "@/app/(authenticated)/users/children-actions"; +import { revalidatePath } from "next/cache"; + +const PARENT_ID = "11111111-1111-1111-1111-111111111111"; +const CHILD_ID = "22222222-2222-2222-2222-222222222222"; + +const insertReturning = jest.fn(); +const insertValues = jest.fn(() => ({ returning: insertReturning })); +const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock; + +const deleteWhere = jest.fn().mockResolvedValue(undefined); +const deleteFn = jest.fn(() => ({ where: deleteWhere })) as jest.Mock; + +const updateWhere = jest.fn().mockResolvedValue(undefined); +const updateSet = jest.fn(() => ({ where: updateWhere })); +const update = jest.fn(() => ({ set: updateSet })) as jest.Mock; + +const selectLimit = jest.fn(); +const selectWhere = jest.fn(() => ({ limit: selectLimit })); +const selectFrom = jest.fn(() => ({ where: selectWhere })); +const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock; + +const transaction = jest.fn(async (cb: (tx: unknown) => unknown) => + cb({ insert, update, delete: deleteFn }), +); + +jest.mock("@/lib/db", () => ({ + db: { + transaction: (...args: unknown[]) => transaction(...args), + select: (...args: unknown[]) => select(...args), + }, +})); + +jest.mock("@/app/(authenticated)/users/children-queries", () => ({ + listChildrenForParent: jest.fn().mockResolvedValue([]), +})); + +const requireAdmin = jest.fn(); +jest.mock("@/lib/auth/require-admin", () => ({ + requireAdmin: (...args: unknown[]) => requireAdmin(...args), +})); + +jest.mock("next/cache", () => ({ + revalidatePath: jest.fn(), +})); + +const validContact = { + full_name: "Jane Doe", + email_address: "jane@example.com", + phone_number: "4165551234", + relationship: "Mother", +}; + +function fd(obj: Record): FormData { + const f = new FormData(); + for (const [k, v] of Object.entries(obj)) f.append(k, v); + return f; +} + +function childFields(extra: Record = {}) { + return fd({ + parent_id: PARENT_ID, + first_name: "Kid", + last_name: "Name", + dob: "2018-05-01", + gender: "female", + emergency_contacts: JSON.stringify([validContact]), + ...extra, + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + requireAdmin.mockResolvedValue(undefined); + insertReturning.mockResolvedValue([{ id: CHILD_ID }]); + // first select = parent exists; second (update) = child exists + selectLimit.mockResolvedValue([{ id: PARENT_ID }]); +}); + +describe("listChildrenForUserAdmin", () => { + it("returns Unauthorized when the caller is not an admin", async () => { + requireAdmin.mockRejectedValue(new Error("Forbidden")); + + const result = await listChildrenForUserAdmin(PARENT_ID); + + expect(result).toEqual({ error: "Unauthorized" }); + }); +}); + +describe("createChildAdmin", () => { + it("returns Unauthorized when the caller is not an admin", async () => { + requireAdmin.mockRejectedValue(new Error("Forbidden")); + + const result = await createChildAdmin(null, childFields()); + + expect(result).toEqual({ errors: { _form: ["Unauthorized"] } }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("rejects when the parent profile does not exist", async () => { + selectLimit.mockResolvedValue([]); + + const result = await createChildAdmin(null, childFields()); + + expect(result).toEqual({ errors: { _form: ["User not found"] } }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("creates a child for the given parent", async () => { + const result = await createChildAdmin(null, childFields()); + + expect(result).toEqual({ + message: "Child created.", + data: { childId: CHILD_ID }, + }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ parentId: PARENT_ID, firstName: "Kid" }), + ); + expect(revalidatePath).toHaveBeenCalledWith("/users"); + expect(revalidatePath).toHaveBeenCalledWith("/children"); + }); +}); + +describe("updateChildAdmin", () => { + it("rejects clearing required fields via schema", async () => { + const result = await updateChildAdmin( + null, + childFields({ child_id: CHILD_ID, first_name: "" }), + ); + + expect(result?.errors?.first_name).toBeDefined(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("updates when the child belongs to the parent", async () => { + selectLimit.mockResolvedValue([{ id: CHILD_ID }]); + + const result = await updateChildAdmin( + null, + childFields({ child_id: CHILD_ID, first_name: "Pat" }), + ); + + expect(result).toEqual({ + message: "Child updated.", + data: { childId: CHILD_ID }, + }); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ firstName: "Pat" }), + ); + }); +}); 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..46f2dd5 --- /dev/null +++ b/app/(authenticated)/children/actions.ts @@ -0,0 +1,202 @@ +"use server"; + +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 { ROLES } from "@/lib/roles"; +import { + createChildSchema, + deleteChildSchema, + updateChildSchema, + type ChildActionState, +} from "@/app/(authenticated)/users/children-schema"; +import { + field, + insertEmergencyContacts, + parseEmergencyContacts, + revalidateChildrenPaths, +} from "@/app/(authenticated)/users/children-shared"; + +async function requireParentUserId(): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return null; + + const { data: claimsData } = await supabase.auth.getClaims(); + if (claimsData?.claims?.user_role !== ROLES.USER) return null; + + return user.id; +} + +export async function createChild( + _prev: ChildActionState, + formData: FormData, +): Promise { + const userId = await requireParentUserId(); + 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; + }); + + revalidateChildrenPaths(); + 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 requireParentUserId(); + 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, + ); + }); + + revalidateChildrenPaths(); + 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 requireParentUserId(); + if (!userId) return { errors: { _form: ["Unauthorized"] } }; + + const parsed = deleteChildSchema.safeParse({ + child_id: field(formData, "child_id"), + }); + if (!parsed.success) { + return { errors: parsed.error.flatten().fieldErrors }; + } + + const { child_id: childId } = parsed.data; + + 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)); + revalidateChildrenPaths(); + 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..de82c32 --- /dev/null +++ b/app/(authenticated)/children/page.tsx @@ -0,0 +1,54 @@ +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(); + const role = claimsData?.claims?.user_role; + if (role === ROLES.ADMIN) { + redirect("/users"); + } + if (role !== ROLES.USER) { + redirect("/"); + } + + 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 new file mode 100644 index 0000000..821e160 --- /dev/null +++ b/app/(authenticated)/users/_components/children/child-detail-dialog.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { useActionState, useCallback, useState } from "react"; +import { ChevronDown, Loader2, Pencil, 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, +} from "@/app/(authenticated)/users/children-actions"; +import type { ChildActionState } from "@/app/(authenticated)/users/children-schema"; +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]}

; +} + +type ChildFormAction = ( + prev: ChildActionState, + formData: FormData, +) => Promise; + +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, + action, +}: { + child: ChildView; + parentId?: string; + onClose: () => void; + onSuccess?: () => void; + action: ChildFormAction; +}) { + const [gender, setGender] = useState(child.gender); + const [dob, setDob] = useState(child.dob); + const [emergencyContacts, setEmergencyContacts] = useState(() => + toDraftContacts(child), + ); + const [ecSectionOpen, setEcSectionOpen] = useState(true); + const [contactDialogOpen, setContactDialogOpen] = useState(false); + const [editingContact, setEditingContact] = + useState(null); + + 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); + if (parentId) { + 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 action(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, action], + ); + + const [state, formAction, pending] = useActionState< + ChildActionState, + FormData + >(boundFormAction, null); + + 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) { + setEmergencyContacts((prev) => prev.filter((c) => c.id !== id)); + } + + return ( + <> + + + + {child.firstName} {child.lastName} + + + Update this child's profile and emergency contacts. + + + +
+
+ {state?.errors?._form?.map((msg) => ( +

+ {msg} +

+ ))} + +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + + +
+
+ +
+
+ +