feat(clients): multi-contact support — co-buyers, auto-seed document signers from client contacts

This commit is contained in:
Chandler Copeland
2026-04-03 17:37:39 -06:00
parent 4f25a8c124
commit 81ce0b9ab0
7 changed files with 166 additions and 76 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "clients" ADD COLUMN "contacts" jsonb;

View File

@@ -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<string | null>(null);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
const [aiPlacementKey, setAiPlacementKey] = useState(0);
const [signers, setSigners] = useState<DocumentSigner[]>(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<DocumentSigner[]>(defaultSigners);
const [unassignedFieldIds, setUnassignedFieldIds] = useState<Set<string>>(new Set());
const handleFieldsChanged = useCallback(() => {

View File

@@ -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)}
/>
</div>
);

View File

@@ -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<ClientContact[]>(defaultContacts ?? []);
const [contactInput, setContactInput] = useState({ name: "", email: "" });
const [contactError, setContactError] = useState<string | null>(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 (
<div style={{ position: "fixed", inset: 0, zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0,0,0,0.4)" }}>
<div style={{ backgroundColor: "white", borderRadius: "1rem", boxShadow: "0 8px 32px rgba(0,0,0,0.18)", padding: "2rem", width: "100%", maxWidth: "28rem" }}>
<div style={{ position: "fixed", inset: 0, zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0,0,0,0.4)", padding: "1rem" }}>
<div style={{ backgroundColor: "white", borderRadius: "1rem", boxShadow: "0 8px 32px rgba(0,0,0,0.18)", padding: "2rem", width: "100%", maxWidth: "32rem", maxHeight: "90vh", overflowY: "auto" }}>
<h2 style={{ color: "#1B2B4B", fontSize: "1.125rem", fontWeight: 700, marginBottom: "1.25rem" }}>
{mode === "edit" ? "Edit Client" : "Add Client"}
</h2>
<form action={formAction} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* Primary contact */}
<div style={{ padding: "1rem", border: "1px solid #E5E7EB", borderRadius: "0.5rem", backgroundColor: "#F9FAFB" }}>
<p style={{ fontSize: "0.75rem", fontWeight: 600, color: "#6B7280", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "0.75rem" }}>Primary Contact</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div>
<label htmlFor="name" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.375rem" }}>
Name
</label>
<input
id="name"
name="name"
type="text"
defaultValue={defaultName}
required
className="block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent transition"
placeholder="Jane Smith"
/>
<label htmlFor="name" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.25rem" }}>Name</label>
<input id="name" name="name" type="text" defaultValue={defaultName} required className={inputCls} placeholder="Jane Smith" />
</div>
<div>
<label htmlFor="email" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.375rem" }}>
Email
</label>
<input
id="email"
name="email"
type="email"
defaultValue={defaultEmail}
required
className="block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent transition"
placeholder="jane@example.com"
/>
<label htmlFor="email" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.25rem" }}>Email</label>
<input id="email" name="email" type="email" defaultValue={defaultEmail} required className={inputCls} placeholder="jane@example.com" />
</div>
</div>
</div>
{/* Additional people */}
<div>
<label htmlFor="propertyAddress" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.375rem" }}>
Property Address
</label>
<input
id="propertyAddress"
name="propertyAddress"
type="text"
defaultValue={defaultPropertyAddress}
className="block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent transition"
placeholder="123 Main St, Salt Lake City, UT 84101"
/>
<p style={{ fontSize: "0.75rem", fontWeight: 600, color: "#6B7280", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "0.5rem" }}>
Additional People <span style={{ fontWeight: 400, textTransform: "none" }}>(co-buyer, spouse, partner)</span>
</p>
{extraContacts.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem", marginBottom: "0.75rem" }}>
{extraContacts.map(c => (
<div key={c.email} style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 0.75rem", backgroundColor: "#F0F9FF", border: "1px solid #BAE6FD", borderRadius: "0.375rem" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", margin: 0 }}>{c.name}</p>
<p style={{ fontSize: "0.75rem", color: "#6B7280", margin: 0 }}>{c.email}</p>
</div>
<button type="button" onClick={() => setExtraContacts(p => p.filter(x => x.email !== c.email))}
aria-label={`Remove ${c.name}`}
style={{ background: "none", border: "none", cursor: "pointer", color: "#9CA3AF", fontSize: "1.125rem", lineHeight: 1, padding: "0.25rem" }}>
&#xd7;
</button>
</div>
))}
</div>
)}
<div style={{ padding: "0.75rem", border: "1px dashed #D1D5DB", borderRadius: "0.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input type="text" value={contactInput.name}
onChange={e => { setContactInput(p => ({ ...p, name: e.target.value })); setContactError(null); }}
placeholder="Name" className={inputCls} style={{ flex: 1 }} />
<input type="email" value={contactInput.email}
onChange={e => { 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 }} />
<button type="button" onClick={addContact}
style={{ padding: "0.5rem 0.875rem", backgroundColor: "#1B2B4B", color: "white", borderRadius: "0.375rem", border: "none", fontSize: "0.875rem", fontWeight: 500, cursor: "pointer", whiteSpace: "nowrap" }}>
Add
</button>
</div>
{contactError && <p style={{ fontSize: "0.75rem", color: "#DC2626", margin: 0 }}>{contactError}</p>}
</div>
</div>
{/* Property address */}
<div>
<label htmlFor="propertyAddress" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.25rem" }}>Property Address</label>
<input id="propertyAddress" name="propertyAddress" type="text" defaultValue={defaultPropertyAddress} className={inputCls} placeholder="123 Main St, Salt Lake City, UT 84101" />
</div>
{/* Serialize extra contacts as hidden field */}
<input type="hidden" name="contacts" value={JSON.stringify(extraContacts)} />
{state?.error && <p style={{ color: "#DC2626", fontSize: "0.875rem" }}>{state.error}</p>}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.75rem", paddingTop: "0.5rem" }}>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
Cancel
</button>
<button
type="submit"
disabled={pending}
className="px-4 py-2 text-sm font-semibold text-white rounded-lg transition hover:brightness-110 disabled:opacity-50"
style={{ backgroundColor: "#C9A84C" }}
>
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancel</button>
<button type="submit" disabled={pending} className="px-4 py-2 text-sm font-semibold text-white rounded-lg transition hover:brightness-110 disabled:opacity-50" style={{ backgroundColor: "#C9A84C" }}>
{pending ? "Saving..." : "Save"}
</button>
</div>

View File

@@ -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) {
)}
</div>
<ClientModal isOpen={isEditOpen} onClose={() => setIsEditOpen(false)} mode="edit" clientId={client.id} defaultName={client.name} defaultEmail={client.email} defaultPropertyAddress={client.propertyAddress ?? undefined} />
<ClientModal isOpen={isEditOpen} onClose={() => setIsEditOpen(false)} mode="edit" clientId={client.id} defaultName={client.name} defaultEmail={client.email} defaultPropertyAddress={client.propertyAddress ?? undefined} defaultContacts={client.contacts ?? []} />
{isAddDocOpen && (
<AddDocumentModal clientId={client.id} onClose={() => setIsAddDocOpen(false)} />
)}

View File

@@ -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 };
}

View File

@@ -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<ClientContact[]>(),
propertyAddress: text("property_address"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),