From 09d4acd58ad1baa8661169add5fb19f775dbb456 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:49:09 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/clients/client-form-dialog.tsx | 362 ++++++++++++++++++ src/routeTree.gen.ts | 24 +- src/routes/clients.index.tsx | 132 +++++++ src/routes/index.tsx | 163 +++++++- 4 files changed, 659 insertions(+), 22 deletions(-) create mode 100644 src/components/clients/client-form-dialog.tsx create mode 100644 src/routes/clients.index.tsx diff --git a/src/components/clients/client-form-dialog.tsx b/src/components/clients/client-form-dialog.tsx new file mode 100644 index 0000000..674d437 --- /dev/null +++ b/src/components/clients/client-form-dialog.tsx @@ -0,0 +1,362 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Loader2, Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +const boardMember = z.object({ + name: z.string().trim().min(1, "Required").max(120), + role: z.string().trim().max(60).optional().or(z.literal("")), + email: z.string().trim().email("Invalid email").max(255).optional().or(z.literal("")), + phone: z.string().trim().max(40).optional().or(z.literal("")), +}); + +const schema = z.object({ + client_type: z.enum(["hoa", "individual", "business"]), + name: z.string().trim().min(1, "Required").max(200), + primary_contact_name: z.string().trim().max(120).optional().or(z.literal("")), + primary_contact_email: z.string().trim().email("Invalid email").max(255).optional().or(z.literal("")), + primary_contact_phone: z.string().trim().max(40).optional().or(z.literal("")), + address_line1: z.string().trim().max(200).optional().or(z.literal("")), + city: z.string().trim().max(100).optional().or(z.literal("")), + state: z.string().trim().max(60).optional().or(z.literal("")), + postal_code: z.string().trim().max(20).optional().or(z.literal("")), + management_company: z.string().trim().max(200).optional().or(z.literal("")), + num_units: z.coerce.number().int().min(0).max(100000).optional().or(z.literal("").transform(() => undefined)), + board_members: z.array(boardMember), + notes: z.string().trim().max(5000).optional().or(z.literal("")), +}); + +type FormValues = z.infer; + +export function ClientFormDialog({ + open, + onOpenChange, + onSaved, + client, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + onSaved?: () => void; + client?: any; +}) { + const { user } = useAuth(); + const [submitting, setSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + client_type: "hoa", + name: "", + primary_contact_name: "", + primary_contact_email: "", + primary_contact_phone: "", + address_line1: "", + city: "", + state: "", + postal_code: "", + management_company: "", + num_units: undefined as any, + board_members: [], + notes: "", + }, + }); + + useEffect(() => { + if (open) { + form.reset({ + client_type: client?.client_type ?? "hoa", + name: client?.name ?? "", + primary_contact_name: client?.primary_contact_name ?? "", + primary_contact_email: client?.primary_contact_email ?? "", + primary_contact_phone: client?.primary_contact_phone ?? "", + address_line1: client?.address_line1 ?? "", + city: client?.city ?? "", + state: client?.state ?? "", + postal_code: client?.postal_code ?? "", + management_company: client?.management_company ?? "", + num_units: client?.num_units ?? (undefined as any), + board_members: client?.board_members ?? [], + notes: client?.notes ?? "", + }); + } + }, [open, client, form]); + + const clientType = form.watch("client_type"); + const board = form.watch("board_members"); + + const addBoardMember = () => { + form.setValue("board_members", [...board, { name: "", role: "", email: "", phone: "" }]); + }; + const removeBoardMember = (idx: number) => { + form.setValue("board_members", board.filter((_, i) => i !== idx)); + }; + + const onSubmit = async (values: FormValues) => { + setSubmitting(true); + const payload: any = { + ...values, + num_units: values.num_units || null, + // strip empty strings to nulls + ...Object.fromEntries( + Object.entries(values).map(([k, v]) => [k, v === "" ? null : v]), + ), + }; + payload.board_members = values.board_members; + if (!client) payload.created_by = user?.id; + + const { error } = client + ? await supabase.from("clients").update(payload).eq("id", client.id) + : await supabase.from("clients").insert(payload); + + setSubmitting(false); + if (error) { + toast.error("Could not save client", { description: error.message }); + return; + } + toast.success(client ? "Client updated" : "Client created"); + onOpenChange(false); + onSaved?.(); + }; + + return ( + + + + {client ? "Edit client" : "New client"} + + HOA associations support board members and management companies. Other client types only show + relevant fields. + + + +
+ +
+ ( + + Type + + + + )} + /> + ( + + {clientType === "hoa" ? "Association name" : "Name"} + + + + )} + /> +
+ + {clientType === "hoa" && ( +
+ ( + + Management company + + + + )} + /> + ( + + # of units + + + + + + )} + /> +
+ )} + +
+ ( + + Primary contact + + + + )} + /> + ( + + Email + + + + )} + /> + ( + + Phone + + + + )} + /> +
+ + ( + + Address + + + + )} + /> +
+ ( + City + )} /> + ( + State + )} /> + ( + Zip + )} /> +
+ + {clientType === "hoa" && ( +
+
+ + +
+
+ {board.length === 0 && ( +

No board members yet.

+ )} + {board.map((_, idx) => ( +
+ { + const next = [...board]; + next[idx].name = e.target.value; + form.setValue("board_members", next); + }} + /> + { + const next = [...board]; + next[idx].role = e.target.value; + form.setValue("board_members", next); + }} + /> + { + const next = [...board]; + next[idx].email = e.target.value; + form.setValue("board_members", next); + }} + /> + { + const next = [...board]; + next[idx].phone = e.target.value; + form.setValue("board_members", next); + }} + /> + +
+ ))} +
+
+ )} + + ( + + Notes +