fix(05-04): pre-select document client and add manual email entry
- PreparePanel now receives separate assignedClientId (nullable) and defaultClientId props so it can distinguish an explicitly locked client from just a default - When document already has assignedClientId: show locked read-only display; user cannot change the primary recipient - When document has no assignedClientId: default to document owner in dropdown but allow changing; option to clear and enter email manually - Added textarea for additional/CC email addresses (comma or newline separated) that is always visible for either mode - POST /api/documents/[id]/prepare now accepts and stores emailAddresses array alongside assignedClientId - Added email_addresses jsonb column to documents table via migration 0004_military_maximus.sql Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
teressa-copeland-homes/drizzle/0004_military_maximus.sql
Normal file
1
teressa-copeland-homes/drizzle/0004_military_maximus.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "documents" ADD COLUMN "email_addresses" jsonb;
|
||||
295
teressa-copeland-homes/drizzle/meta/0004_snapshot.json
Normal file
295
teressa-copeland-homes/drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"id": "20190fda-b801-4baf-bcf7-e83d74af86f0",
|
||||
"prevId": "0f0c2e60-e89f-41d9-8a4f-cec2b4dfe938",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.clients": {
|
||||
"name": "clients",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.documents": {
|
||||
"name": "documents",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "document_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Draft'"
|
||||
},
|
||||
"sent_at": {
|
||||
"name": "sent_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"form_template_id": {
|
||||
"name": "form_template_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"signature_fields": {
|
||||
"name": "signature_fields",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"text_fill_data": {
|
||||
"name": "text_fill_data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"assigned_client_id": {
|
||||
"name": "assigned_client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"prepared_file_path": {
|
||||
"name": "prepared_file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email_addresses": {
|
||||
"name": "email_addresses",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"documents_client_id_clients_id_fk": {
|
||||
"name": "documents_client_id_clients_id_fk",
|
||||
"tableFrom": "documents",
|
||||
"tableTo": "clients",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"documents_form_template_id_form_templates_id_fk": {
|
||||
"name": "documents_form_template_id_form_templates_id_fk",
|
||||
"tableFrom": "documents",
|
||||
"tableTo": "form_templates",
|
||||
"columnsFrom": [
|
||||
"form_template_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.form_templates": {
|
||||
"name": "form_templates",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"form_templates_filename_unique": {
|
||||
"name": "form_templates_filename_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"filename"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.document_status": {
|
||||
"name": "document_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"Draft",
|
||||
"Sent",
|
||||
"Viewed",
|
||||
"Signed"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1773985969484,
|
||||
"tag": "0003_cool_natasha_romanoff",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1773987640772,
|
||||
"tag": "0004_military_maximus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export async function POST(
|
||||
const body = await req.json() as {
|
||||
textFillData?: Record<string, string>;
|
||||
assignedClientId?: string;
|
||||
/** Email addresses to send the document to (client email + any CC addresses). */
|
||||
emailAddresses?: string[];
|
||||
};
|
||||
|
||||
const doc = await db.query.documents.findFirst({ where: eq(documents.id, id) });
|
||||
@@ -45,6 +47,7 @@ export async function POST(
|
||||
preparedFilePath: preparedRelPath,
|
||||
textFillData: body.textFillData ?? null,
|
||||
assignedClientId: body.assignedClientId ?? doc.assignedClientId ?? null,
|
||||
emailAddresses: body.emailAddresses ?? null,
|
||||
status: 'Sent',
|
||||
sentAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -8,13 +8,51 @@ interface Client { id: string; name: string; email: string; }
|
||||
interface PreparePanelProps {
|
||||
docId: string;
|
||||
clients: Client[];
|
||||
currentClientId: string;
|
||||
/** The client already explicitly assigned to this document (null if not yet assigned). */
|
||||
assignedClientId: string | null;
|
||||
/** The client the document belongs to — used as the default selection when no explicit assignedClientId. */
|
||||
defaultClientId: string;
|
||||
currentStatus: string;
|
||||
}
|
||||
|
||||
export function PreparePanel({ docId, clients, currentClientId, currentStatus }: PreparePanelProps) {
|
||||
/**
|
||||
* Parse a comma/newline-separated string of email addresses into a trimmed, deduplicated array.
|
||||
* Empty tokens are filtered out.
|
||||
*/
|
||||
function parseEmails(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,]+/)
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Very light email format check — catches obvious mistakes without a full RFC 5322 parse. */
|
||||
function isValidEmail(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
export function PreparePanel({
|
||||
docId,
|
||||
clients,
|
||||
assignedClientId,
|
||||
defaultClientId,
|
||||
currentStatus,
|
||||
}: PreparePanelProps) {
|
||||
const router = useRouter();
|
||||
const [assignedClientId, setAssignedClientId] = useState(currentClientId);
|
||||
|
||||
// If the document already has an explicit assigned client, lock to that client.
|
||||
// Otherwise default to the document owner (defaultClientId) but allow changing.
|
||||
const isLocked = assignedClientId !== null;
|
||||
|
||||
// selectedClientId: id from the dropdown (empty string = "pick manually by email")
|
||||
const [selectedClientId, setSelectedClientId] = useState<string>(
|
||||
assignedClientId ?? defaultClientId,
|
||||
);
|
||||
|
||||
// emailInput: raw text for manual email entry (used when selectedClientId is '' or
|
||||
// as additional CC addresses)
|
||||
const [emailInput, setEmailInput] = useState('');
|
||||
|
||||
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
@@ -22,14 +60,55 @@ export function PreparePanel({ docId, clients, currentClientId, currentStatus }:
|
||||
// Don't show the panel if already sent/signed
|
||||
const canPrepare = currentStatus === 'Draft';
|
||||
|
||||
// The email addresses that will be sent with the prepare request.
|
||||
// If a known client is selected, start with that client's email.
|
||||
// Any manually-entered addresses are appended.
|
||||
function buildEmailAddresses(): string[] {
|
||||
const addresses: string[] = [];
|
||||
|
||||
if (selectedClientId) {
|
||||
const client = clients.find((c) => c.id === selectedClientId);
|
||||
if (client?.email) addresses.push(client.email);
|
||||
}
|
||||
|
||||
const extras = parseEmails(emailInput);
|
||||
for (const e of extras) {
|
||||
if (!addresses.includes(e)) addresses.push(e);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
async function handlePrepare() {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
|
||||
const emailAddresses = buildEmailAddresses();
|
||||
|
||||
// Require at least one email address
|
||||
if (emailAddresses.length === 0) {
|
||||
setResult({ ok: false, message: 'Please select a client or enter at least one email address.' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all addresses
|
||||
const invalid = emailAddresses.filter((e) => !isValidEmail(e));
|
||||
if (invalid.length > 0) {
|
||||
setResult({ ok: false, message: `Invalid email address(es): ${invalid.join(', ')}` });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/prepare`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ textFillData, assignedClientId }),
|
||||
body: JSON.stringify({
|
||||
textFillData,
|
||||
assignedClientId: selectedClientId || null,
|
||||
emailAddresses,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
@@ -57,18 +136,58 @@ export function PreparePanel({ docId, clients, currentClientId, currentStatus }:
|
||||
<div className="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Prepare Document</h2>
|
||||
|
||||
{/* Client / recipient selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Assign to client</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{isLocked ? 'Assigned client (locked)' : 'Assign to client'}
|
||||
</label>
|
||||
|
||||
{isLocked ? (
|
||||
// Document already has an assigned client — show read-only info
|
||||
<div className="w-full border rounded px-2 py-1.5 text-sm bg-gray-50 text-gray-600">
|
||||
{(() => {
|
||||
const c = clients.find((c) => c.id === assignedClientId);
|
||||
return c ? `${c.name} (${c.email})` : assignedClientId;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
// No explicit assignment yet — allow choosing from list or entering email manually
|
||||
<select
|
||||
value={assignedClientId}
|
||||
onChange={e => setAssignedClientId(e.target.value)}
|
||||
value={selectedClientId}
|
||||
onChange={(e) => setSelectedClientId(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— Select client —</option>
|
||||
{clients.map(c => (
|
||||
<option value="">— Enter email manually below —</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name} ({c.email})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional / manual email addresses */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{isLocked
|
||||
? 'Additional email addresses (optional, comma-separated)'
|
||||
: selectedClientId
|
||||
? 'Additional email addresses (optional, comma-separated)'
|
||||
: 'Email address(es) — required if no client selected above'}
|
||||
</label>
|
||||
<textarea
|
||||
value={emailInput}
|
||||
onChange={(e) => setEmailInput(e.target.value)}
|
||||
placeholder={
|
||||
isLocked || selectedClientId
|
||||
? 'e.g. cc@example.com, another@example.com'
|
||||
: 'Enter recipient email, e.g. client@example.com'
|
||||
}
|
||||
rows={2}
|
||||
className="w-full border rounded px-2 py-1.5 text-sm resize-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Separate multiple addresses with commas or new lines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -78,7 +197,7 @@ export function PreparePanel({ docId, clients, currentClientId, currentStatus }:
|
||||
|
||||
<button
|
||||
onClick={handlePrepare}
|
||||
disabled={loading || !assignedClientId}
|
||||
disabled={loading || (!selectedClientId && parseEmails(emailInput).length === 0)}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -52,7 +52,8 @@ export default async function DocumentPage({
|
||||
<PreparePanel
|
||||
docId={docId}
|
||||
clients={allClients}
|
||||
currentClientId={doc.assignedClientId ?? doc.clientId}
|
||||
assignedClientId={doc.assignedClientId ?? null}
|
||||
defaultClientId={doc.clientId}
|
||||
currentStatus={doc.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,8 @@ export const documents = pgTable("documents", {
|
||||
textFillData: jsonb("text_fill_data").$type<Record<string, string>>(),
|
||||
assignedClientId: text("assigned_client_id"),
|
||||
preparedFilePath: text("prepared_file_path"),
|
||||
/** Email addresses collected at prepare time: assigned client email + any CC addresses. */
|
||||
emailAddresses: jsonb("email_addresses").$type<string[]>(),
|
||||
});
|
||||
|
||||
export const documentsRelations = relations(documents, ({ one }) => ({
|
||||
|
||||
Reference in New Issue
Block a user