feat(clients): multi-contact support — co-buyers, auto-seed document signers from client contacts
This commit is contained in:
1
teressa-copeland-homes/drizzle/0011_common_mystique.sql
Normal file
1
teressa-copeland-homes/drizzle/0011_common_mystique.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "clients" ADD COLUMN "contacts" jsonb;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" }}>
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* 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.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.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="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"
|
||||
/>
|
||||
<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" }}>
|
||||
×
|
||||
</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.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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user