From 81ce0b9ab041691cc0a041d72e950f2d7db8f0dc Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 3 Apr 2026 17:37:39 -0600 Subject: [PATCH] =?UTF-8?q?feat(clients):=20multi-contact=20support=20?= =?UTF-8?q?=E2=80=94=20co-buyers,=20auto-seed=20document=20signers=20from?= =?UTF-8?q?=20client=20contacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drizzle/0011_common_mystique.sql | 1 + .../_components/DocumentPageClient.tsx | 15 +- .../(protected)/documents/[docId]/page.tsx | 6 +- .../app/portal/_components/ClientModal.tsx | 152 +++++++++++------- .../_components/ClientProfileClient.tsx | 5 +- .../src/lib/actions/clients.ts | 51 ++++-- teressa-copeland-homes/src/lib/db/schema.ts | 12 ++ 7 files changed, 166 insertions(+), 76 deletions(-) create mode 100644 teressa-copeland-homes/drizzle/0011_common_mystique.sql diff --git a/teressa-copeland-homes/drizzle/0011_common_mystique.sql b/teressa-copeland-homes/drizzle/0011_common_mystique.sql new file mode 100644 index 0000000..4cc5000 --- /dev/null +++ b/teressa-copeland-homes/drizzle/0011_common_mystique.sql @@ -0,0 +1 @@ +ALTER TABLE "clients" ADD COLUMN "contacts" jsonb; \ No newline at end of file diff --git a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx index 3899589..b1c44cd 100644 --- a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx @@ -13,8 +13,12 @@ interface DocumentPageClientProps { signedAt?: Date | null; clientPropertyAddress?: string | null; initialSigners: DocumentSigner[]; + /** All people on the client record (primary + co-buyers). Used to auto-seed signers. */ + clientContacts?: { name: string; email: string }[]; } +const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b']; + export function DocumentPageClient({ docId, docStatus, @@ -24,12 +28,21 @@ export function DocumentPageClient({ signedAt, clientPropertyAddress, initialSigners, + clientContacts = [], }: DocumentPageClientProps) { const [previewToken, setPreviewToken] = useState(null); const [selectedFieldId, setSelectedFieldId] = useState(null); const [textFillData, setTextFillData] = useState>({}); const [aiPlacementKey, setAiPlacementKey] = useState(0); - const [signers, setSigners] = useState(initialSigners); + + // Auto-seed signers from client contacts when no signers have been configured yet + const defaultSigners: DocumentSigner[] = initialSigners.length > 0 + ? initialSigners + : clientContacts + .filter(c => c.email) + .map((c, i) => ({ email: c.email, color: SIGNER_COLORS[i % SIGNER_COLORS.length] })); + + const [signers, setSigners] = useState(defaultSigners); const [unassignedFieldIds, setUnassignedFieldIds] = useState>(new Set()); const handleFieldsChanged = useCallback(() => { diff --git a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx index 6569440..364df1b 100644 --- a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx @@ -19,7 +19,7 @@ export default async function DocumentPage({ const [doc, docClient] = await Promise.all([ db.query.documents.findFirst({ where: eq(documents.id, docId) }), - db.select({ email: clients.email, name: clients.name, propertyAddress: clients.propertyAddress }) + db.select({ email: clients.email, name: clients.name, propertyAddress: clients.propertyAddress, contacts: clients.contacts }) .from(clients) .innerJoin(documents, eq(documents.clientId, clients.id)) .where(eq(documents.id, docId)) @@ -61,6 +61,10 @@ export default async function DocumentPage({ signedAt={doc.signedAt ?? null} clientPropertyAddress={docClient?.propertyAddress ?? null} initialSigners={doc.signers ?? []} + clientContacts={[ + { name: docClient?.name ?? '', email: docClient?.email ?? '' }, + ...(docClient?.contacts ?? []), + ].filter(c => c.name && c.email)} /> ); diff --git a/teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx b/teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx index 6db2a87..137b3d6 100644 --- a/teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx +++ b/teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx @@ -1,7 +1,8 @@ "use client"; -import { useActionState, useEffect } from "react"; +import { useActionState, useEffect, useState } from "react"; import { createClient, updateClient } from "@/lib/actions/clients"; +import type { ClientContact } from "@/lib/db/schema"; type ClientModalProps = { isOpen: boolean; @@ -11,81 +12,122 @@ type ClientModalProps = { defaultName?: string; defaultEmail?: string; defaultPropertyAddress?: string; + defaultContacts?: ClientContact[]; }; -export function ClientModal({ isOpen, onClose, mode = "create", clientId, defaultName, defaultEmail, defaultPropertyAddress }: ClientModalProps) { +const inputCls = "block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent transition"; + +export function ClientModal({ + isOpen, onClose, mode = "create", clientId, + defaultName, defaultEmail, defaultPropertyAddress, defaultContacts, +}: ClientModalProps) { const boundAction = mode === "edit" && clientId ? updateClient.bind(null, clientId) : createClient; const [state, formAction, pending] = useActionState(boundAction, null); + const [extraContacts, setExtraContacts] = useState(defaultContacts ?? []); + const [contactInput, setContactInput] = useState({ name: "", email: "" }); + const [contactError, setContactError] = useState(null); + + useEffect(() => { if (state?.success) onClose(); }, [state, onClose]); + useEffect(() => { - if (state?.success) onClose(); - }, [state, onClose]); + setExtraContacts(defaultContacts ?? []); + setContactInput({ name: "", email: "" }); + setContactError(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clientId, isOpen]); + + function addContact() { + const name = contactInput.name.trim(); + const email = contactInput.email.trim().toLowerCase(); + if (!name) { setContactError("Name is required"); return; } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setContactError("Valid email required"); return; } + if (extraContacts.some(c => c.email === email)) { setContactError("That email is already added"); return; } + setExtraContacts(prev => [...prev, { name, email }]); + setContactInput({ name: "", email: "" }); + setContactError(null); + } if (!isOpen) return null; return ( -
-
+
+

{mode === "edit" ? "Edit Client" : "Add Client"}

-
- - + + {/* Primary contact */} +
+

Primary Contact

+
+
+ + +
+
+ + +
+
+ + {/* Additional people */}
- - +

+ Additional People (co-buyer, spouse, partner…) +

+ + {extraContacts.length > 0 && ( +
+ {extraContacts.map(c => ( +
+
+

{c.name}

+

{c.email}

+
+ +
+ ))} +
+ )} + +
+
+ { setContactInput(p => ({ ...p, name: e.target.value })); setContactError(null); }} + placeholder="Name" className={inputCls} style={{ flex: 1 }} /> + { setContactInput(p => ({ ...p, email: e.target.value })); setContactError(null); }} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addContact(); } }} + placeholder="Email" className={inputCls} style={{ flex: 1 }} /> + +
+ {contactError &&

{contactError}

} +
+ + {/* Property address */}
- - + +
+ + {/* Serialize extra contacts as hidden field */} + + {state?.error &&

{state.error}

}
- - +
diff --git a/teressa-copeland-homes/src/app/portal/_components/ClientProfileClient.tsx b/teressa-copeland-homes/src/app/portal/_components/ClientProfileClient.tsx index 0774ab1..8c69ace 100644 --- a/teressa-copeland-homes/src/app/portal/_components/ClientProfileClient.tsx +++ b/teressa-copeland-homes/src/app/portal/_components/ClientProfileClient.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from "./ConfirmDialog"; import { DocumentsTable } from "./DocumentsTable"; import { AddDocumentModal } from "./AddDocumentModal"; import { deleteClient } from "@/lib/actions/clients"; +import type { ClientContact } from "@/lib/db/schema"; type DocumentRow = { id: string; @@ -20,7 +21,7 @@ type DocumentRow = { }; type Props = { - client: { id: string; name: string; email: string; propertyAddress?: string | null }; + client: { id: string; name: string; email: string; propertyAddress?: string | null; contacts?: ClientContact[] | null }; docs: DocumentRow[]; }; @@ -92,7 +93,7 @@ export function ClientProfileClient({ client, docs }: Props) { )}
- setIsEditOpen(false)} mode="edit" clientId={client.id} defaultName={client.name} defaultEmail={client.email} defaultPropertyAddress={client.propertyAddress ?? undefined} /> + setIsEditOpen(false)} mode="edit" clientId={client.id} defaultName={client.name} defaultEmail={client.email} defaultPropertyAddress={client.propertyAddress ?? undefined} defaultContacts={client.contacts ?? []} /> {isAddDocOpen && ( setIsAddDocOpen(false)} /> )} diff --git a/teressa-copeland-homes/src/lib/actions/clients.ts b/teressa-copeland-homes/src/lib/actions/clients.ts index 464b1f8..a436df4 100644 --- a/teressa-copeland-homes/src/lib/actions/clients.ts +++ b/teressa-copeland-homes/src/lib/actions/clients.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db } from "@/lib/db"; import { clients } from "@/lib/db/schema"; +import type { ClientContact } from "@/lib/db/schema"; import { auth } from "@/lib/auth"; import { revalidatePath } from "next/cache"; import { eq } from "drizzle-orm"; @@ -13,14 +14,28 @@ const clientSchema = z.object({ propertyAddress: z.string().optional(), }); +/** Parse the hidden `contacts` JSON field from the form — returns null on invalid/empty */ +function parseContacts(raw: FormDataEntryValue | null): ClientContact[] | null { + if (!raw || typeof raw !== 'string' || !raw.trim()) return null; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + const valid = parsed.filter((c): c is ClientContact => + typeof c?.name === 'string' && typeof c?.email === 'string' && + c.name.trim() !== '' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) + ); + return valid.length > 0 ? valid : null; + } catch { + return null; + } +} + export async function createClient( _prevState: { error?: string; success?: boolean } | null, formData: FormData ): Promise<{ error?: string; success?: boolean }> { const session = await auth(); - if (!session) { - return { error: "Unauthorized" }; - } + if (!session) return { error: "Unauthorized" }; const parsed = clientSchema.safeParse({ name: formData.get("name"), @@ -28,14 +43,15 @@ export async function createClient( propertyAddress: formData.get("propertyAddress"), }); - if (!parsed.success) { - return { error: parsed.error.issues[0].message }; - } + if (!parsed.success) return { error: parsed.error.issues[0].message }; + + const contacts = parseContacts(formData.get("contacts")); await db.insert(clients).values({ name: parsed.data.name, email: parsed.data.email, propertyAddress: parsed.data.propertyAddress || null, + contacts, }); revalidatePath("/portal/clients"); @@ -48,9 +64,7 @@ export async function updateClient( formData: FormData ): Promise<{ error?: string; success?: boolean }> { const session = await auth(); - if (!session) { - return { error: "Unauthorized" }; - } + if (!session) return { error: "Unauthorized" }; const parsed = clientSchema.safeParse({ name: formData.get("name"), @@ -58,13 +72,19 @@ export async function updateClient( propertyAddress: formData.get("propertyAddress"), }); - if (!parsed.success) { - return { error: parsed.error.issues[0].message }; - } + if (!parsed.success) return { error: parsed.error.issues[0].message }; + + const contacts = parseContacts(formData.get("contacts")); await db .update(clients) - .set({ name: parsed.data.name, email: parsed.data.email, propertyAddress: parsed.data.propertyAddress || null, updatedAt: new Date() }) + .set({ + name: parsed.data.name, + email: parsed.data.email, + propertyAddress: parsed.data.propertyAddress || null, + contacts, + updatedAt: new Date(), + }) .where(eq(clients.id, id)); revalidatePath("/portal/clients"); @@ -76,12 +96,9 @@ export async function deleteClient( id: string ): Promise<{ error?: string; success?: boolean }> { const session = await auth(); - if (!session) { - return { error: "Unauthorized" }; - } + if (!session) return { error: "Unauthorized" }; await db.delete(clients).where(eq(clients.id, id)); - revalidatePath("/portal/clients"); return { success: true }; } diff --git a/teressa-copeland-homes/src/lib/db/schema.ts b/teressa-copeland-homes/src/lib/db/schema.ts index 59ca130..a64e54f 100644 --- a/teressa-copeland-homes/src/lib/db/schema.ts +++ b/teressa-copeland-homes/src/lib/db/schema.ts @@ -66,10 +66,22 @@ export const documentStatusEnum = pgEnum("document_status", [ "Signed", ]); +/** + * A single person on a client record (primary or co-buyer). + * Primary contact uses the top-level clients.name / clients.email. + * Additional contacts (co-buyers, co-sellers) live in clients.contacts[]. + */ +export interface ClientContact { + name: string; + email: string; +} + export const clients = pgTable("clients", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), name: text("name").notNull(), email: text("email").notNull(), + /** Additional contacts on this client record (co-buyers, partners, etc.) */ + contacts: jsonb("contacts").$type(), propertyAddress: text("property_address"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),