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;
|
signedAt?: Date | null;
|
||||||
clientPropertyAddress?: string | null;
|
clientPropertyAddress?: string | null;
|
||||||
initialSigners: DocumentSigner[];
|
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({
|
export function DocumentPageClient({
|
||||||
docId,
|
docId,
|
||||||
docStatus,
|
docStatus,
|
||||||
@@ -24,12 +28,21 @@ export function DocumentPageClient({
|
|||||||
signedAt,
|
signedAt,
|
||||||
clientPropertyAddress,
|
clientPropertyAddress,
|
||||||
initialSigners,
|
initialSigners,
|
||||||
|
clientContacts = [],
|
||||||
}: DocumentPageClientProps) {
|
}: DocumentPageClientProps) {
|
||||||
const [previewToken, setPreviewToken] = useState<string | null>(null);
|
const [previewToken, setPreviewToken] = useState<string | null>(null);
|
||||||
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
||||||
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
|
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
|
||||||
const [aiPlacementKey, setAiPlacementKey] = useState(0);
|
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 [unassignedFieldIds, setUnassignedFieldIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const handleFieldsChanged = useCallback(() => {
|
const handleFieldsChanged = useCallback(() => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default async function DocumentPage({
|
|||||||
|
|
||||||
const [doc, docClient] = await Promise.all([
|
const [doc, docClient] = await Promise.all([
|
||||||
db.query.documents.findFirst({ where: eq(documents.id, docId) }),
|
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)
|
.from(clients)
|
||||||
.innerJoin(documents, eq(documents.clientId, clients.id))
|
.innerJoin(documents, eq(documents.clientId, clients.id))
|
||||||
.where(eq(documents.id, docId))
|
.where(eq(documents.id, docId))
|
||||||
@@ -61,6 +61,10 @@ export default async function DocumentPage({
|
|||||||
signedAt={doc.signedAt ?? null}
|
signedAt={doc.signedAt ?? null}
|
||||||
clientPropertyAddress={docClient?.propertyAddress ?? null}
|
clientPropertyAddress={docClient?.propertyAddress ?? null}
|
||||||
initialSigners={doc.signers ?? []}
|
initialSigners={doc.signers ?? []}
|
||||||
|
clientContacts={[
|
||||||
|
{ name: docClient?.name ?? '', email: docClient?.email ?? '' },
|
||||||
|
...(docClient?.contacts ?? []),
|
||||||
|
].filter(c => c.name && c.email)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
import { createClient, updateClient } from "@/lib/actions/clients";
|
import { createClient, updateClient } from "@/lib/actions/clients";
|
||||||
|
import type { ClientContact } from "@/lib/db/schema";
|
||||||
|
|
||||||
type ClientModalProps = {
|
type ClientModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -11,81 +12,122 @@ type ClientModalProps = {
|
|||||||
defaultName?: string;
|
defaultName?: string;
|
||||||
defaultEmail?: string;
|
defaultEmail?: string;
|
||||||
defaultPropertyAddress?: 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 boundAction = mode === "edit" && clientId ? updateClient.bind(null, clientId) : createClient;
|
||||||
const [state, formAction, pending] = useActionState(boundAction, null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (state?.success) onClose();
|
setExtraContacts(defaultContacts ?? []);
|
||||||
}, [state, onClose]);
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "fixed", inset: 0, zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0,0,0,0.4)" }}>
|
<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: "28rem" }}>
|
<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" }}>
|
<h2 style={{ color: "#1B2B4B", fontSize: "1.125rem", fontWeight: 700, marginBottom: "1.25rem" }}>
|
||||||
{mode === "edit" ? "Edit Client" : "Add Client"}
|
{mode === "edit" ? "Edit Client" : "Add Client"}
|
||||||
</h2>
|
</h2>
|
||||||
<form action={formAction} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<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>
|
<div>
|
||||||
<label htmlFor="name" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.375rem" }}>
|
<label htmlFor="name" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.25rem" }}>Name</label>
|
||||||
Name
|
<input id="name" name="name" type="text" defaultValue={defaultName} required className={inputCls} placeholder="Jane Smith" />
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.375rem" }}>
|
<label htmlFor="email" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.25rem" }}>Email</label>
|
||||||
Email
|
<input id="email" name="email" type="email" defaultValue={defaultEmail} required className={inputCls} placeholder="jane@example.com" />
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional people */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="propertyAddress" style={{ display: "block", fontSize: "0.875rem", fontWeight: 500, color: "#1B2B4B", marginBottom: "0.375rem" }}>
|
<p style={{ fontSize: "0.75rem", fontWeight: 600, color: "#6B7280", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "0.5rem" }}>
|
||||||
Property Address
|
Additional People <span style={{ fontWeight: 400, textTransform: "none" }}>(co-buyer, spouse, partner…)</span>
|
||||||
</label>
|
</p>
|
||||||
<input
|
|
||||||
id="propertyAddress"
|
{extraContacts.length > 0 && (
|
||||||
name="propertyAddress"
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem", marginBottom: "0.75rem" }}>
|
||||||
type="text"
|
{extraContacts.map(c => (
|
||||||
defaultValue={defaultPropertyAddress}
|
<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" }}>
|
||||||
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"
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
placeholder="123 Main St, Salt Lake City, UT 84101"
|
<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>
|
</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.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>}
|
{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" }}>
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.75rem", paddingTop: "0.5rem" }}>
|
||||||
<button
|
<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>
|
||||||
type="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" }}>
|
||||||
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"}
|
{pending ? "Saving..." : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ConfirmDialog } from "./ConfirmDialog";
|
|||||||
import { DocumentsTable } from "./DocumentsTable";
|
import { DocumentsTable } from "./DocumentsTable";
|
||||||
import { AddDocumentModal } from "./AddDocumentModal";
|
import { AddDocumentModal } from "./AddDocumentModal";
|
||||||
import { deleteClient } from "@/lib/actions/clients";
|
import { deleteClient } from "@/lib/actions/clients";
|
||||||
|
import type { ClientContact } from "@/lib/db/schema";
|
||||||
|
|
||||||
type DocumentRow = {
|
type DocumentRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,7 +21,7 @@ type DocumentRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
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[];
|
docs: DocumentRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ export function ClientProfileClient({ client, docs }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{isAddDocOpen && (
|
||||||
<AddDocumentModal clientId={client.id} onClose={() => setIsAddDocOpen(false)} />
|
<AddDocumentModal clientId={client.id} onClose={() => setIsAddDocOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { clients } from "@/lib/db/schema";
|
import { clients } from "@/lib/db/schema";
|
||||||
|
import type { ClientContact } from "@/lib/db/schema";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -13,14 +14,28 @@ const clientSchema = z.object({
|
|||||||
propertyAddress: z.string().optional(),
|
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(
|
export async function createClient(
|
||||||
_prevState: { error?: string; success?: boolean } | null,
|
_prevState: { error?: string; success?: boolean } | null,
|
||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<{ error?: string; success?: boolean }> {
|
): Promise<{ error?: string; success?: boolean }> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) return { error: "Unauthorized" };
|
||||||
return { error: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = clientSchema.safeParse({
|
const parsed = clientSchema.safeParse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
@@ -28,14 +43,15 @@ export async function createClient(
|
|||||||
propertyAddress: formData.get("propertyAddress"),
|
propertyAddress: formData.get("propertyAddress"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) return { error: parsed.error.issues[0].message };
|
||||||
return { error: parsed.error.issues[0].message };
|
|
||||||
}
|
const contacts = parseContacts(formData.get("contacts"));
|
||||||
|
|
||||||
await db.insert(clients).values({
|
await db.insert(clients).values({
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
propertyAddress: parsed.data.propertyAddress || null,
|
propertyAddress: parsed.data.propertyAddress || null,
|
||||||
|
contacts,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/portal/clients");
|
revalidatePath("/portal/clients");
|
||||||
@@ -48,9 +64,7 @@ export async function updateClient(
|
|||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<{ error?: string; success?: boolean }> {
|
): Promise<{ error?: string; success?: boolean }> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) return { error: "Unauthorized" };
|
||||||
return { error: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = clientSchema.safeParse({
|
const parsed = clientSchema.safeParse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
@@ -58,13 +72,19 @@ export async function updateClient(
|
|||||||
propertyAddress: formData.get("propertyAddress"),
|
propertyAddress: formData.get("propertyAddress"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) return { error: parsed.error.issues[0].message };
|
||||||
return { error: parsed.error.issues[0].message };
|
|
||||||
}
|
const contacts = parseContacts(formData.get("contacts"));
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(clients)
|
.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));
|
.where(eq(clients.id, id));
|
||||||
|
|
||||||
revalidatePath("/portal/clients");
|
revalidatePath("/portal/clients");
|
||||||
@@ -76,12 +96,9 @@ export async function deleteClient(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<{ error?: string; success?: boolean }> {
|
): Promise<{ error?: string; success?: boolean }> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) return { error: "Unauthorized" };
|
||||||
return { error: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(clients).where(eq(clients.id, id));
|
await db.delete(clients).where(eq(clients.id, id));
|
||||||
|
|
||||||
revalidatePath("/portal/clients");
|
revalidatePath("/portal/clients");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,10 +66,22 @@ export const documentStatusEnum = pgEnum("document_status", [
|
|||||||
"Signed",
|
"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", {
|
export const clients = pgTable("clients", {
|
||||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
|
/** Additional contacts on this client record (co-buyers, partners, etc.) */
|
||||||
|
contacts: jsonb("contacts").$type<ClientContact[]>(),
|
||||||
propertyAddress: text("property_address"),
|
propertyAddress: text("property_address"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
|||||||
Reference in New Issue
Block a user