--- phase: 20-apply-template-and-portal-nav plan: 01 type: execute wave: 1 depends_on: [] files_modified: - teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx - teressa-copeland-homes/src/app/api/documents/route.ts autonomous: true requirements: [TMPL-10, TMPL-11, TMPL-12, TMPL-14] must_haves: truths: - "Agent sees a 'My Templates' tab in the Add Document modal alongside the existing Forms Library" - "Agent can pick a saved template and click Add Document to create a document with all template fields pre-loaded at their saved positions" - "Every field copied from a template has a fresh UUID — no template field ID appears in the new document" - "Template signer roles are auto-mapped to client contacts (first role to client email, second role to co-buyer email)" - "Editing a template afterward does not change the document's fields (snapshot independence)" artifacts: - path: "teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx" provides: "Two-tab modal with Forms Library + My Templates" contains: "activeTab" - path: "teressa-copeland-homes/src/app/api/documents/route.ts" provides: "Template apply branch in POST handler" contains: "documentTemplateId" key_links: - from: "AddDocumentModal.tsx" to: "POST /api/documents" via: "fetch with documentTemplateId in JSON body" pattern: "documentTemplateId.*selectedDocTemplate" - from: "POST /api/documents" to: "documentTemplates table" via: "db.query.documentTemplates.findFirst" pattern: "documentTemplates" - from: "POST /api/documents" to: "clients table" via: "db.query.clients.findFirst for role-to-email mapping" pattern: "clients\\.id.*clientId" --- Add "My Templates" tab to AddDocumentModal and extend POST /api/documents to apply a template — copying fields with fresh UUIDs and auto-mapping signer roles to client contacts. Purpose: Lets the agent start a new document from a saved template instead of a blank form, eliminating repetitive field placement on commonly-used PDFs. Output: Modified AddDocumentModal.tsx with two tabs, extended POST /api/documents route with documentTemplateId branch. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/20-apply-template-and-portal-nav/20-CONTEXT.md @.planning/phases/20-apply-template-and-portal-nav/20-RESEARCH.md From src/lib/db/schema.ts: ```typescript export interface SignatureFieldData { id: string; page: number; // 1-indexed x: number; // PDF user space, bottom-left origin, points y: number; width: number; height: number; type?: SignatureFieldType; signerEmail?: string; // In templates: carries role labels like "Buyer", "Seller" hint?: string; // Optional label for client-text fields } export interface ClientContact { name: string; email: string; } export interface DocumentSigner { email: string; color: string; } export const documentTemplates = pgTable("document_templates", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), name: text("name").notNull(), formTemplateId: text("form_template_id").notNull().references(() => formTemplates.id), signatureFields: jsonb("signature_fields").$type(), archivedAt: timestamp("archived_at"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); export const documentTemplatesRelations = relations(documentTemplates, ({ one }) => ({ formTemplate: one(formTemplates, { fields: [documentTemplates.formTemplateId], references: [formTemplates.id] }), })); ``` From src/app/api/templates/route.ts GET response shape: ```typescript // Each row in the JSON array: { id: string; name: string; formTemplateId: string; formName: string | null; fieldCount: number; createdAt: Date; updatedAt: Date; } ``` From src/app/api/documents/route.ts — existing POST handler structure: ```typescript // Content-type branching: // - multipart/form-data → custom PDF upload (reads file from FormData) // - else (JSON) → form-library copy (reads formTemplateId, copies PDF from seeds/) // Both paths: create destDir, copy/write PDF, INSERT into documents table // The JSON branch is what we extend with documentTemplateId support ``` From src/app/portal/_components/AddDocumentModal.tsx — existing state: ```typescript type FormTemplate = { id: string; name: string; filename: string }; // Props: { clientId: string; onClose: () => void } // State: templates (FormTemplate[]), selectedTemplate, customFile, docName, query // Submit: customFile → FormData POST, else → JSON POST with formTemplateId ``` Task 1: Extend POST /api/documents with documentTemplateId branch teressa-copeland-homes/src/app/api/documents/route.ts teressa-copeland-homes/src/app/api/documents/route.ts teressa-copeland-homes/src/lib/db/schema.ts teressa-copeland-homes/src/app/api/templates/route.ts Extend the JSON body parsing in the POST handler to also extract `documentTemplateId: string | undefined` from `body`. After the existing `if (!clientId || !name)` guard, add a new branch BEFORE the existing `formTemplateId` branch. The logic: ``` if (documentTemplateId) { // 1. Fetch document template with its formTemplate relation const docTemplate = await db.query.documentTemplates.findFirst({ where: eq(documentTemplates.id, documentTemplateId), with: { formTemplate: true }, }); if (!docTemplate) return Response.json({ error: 'Document template not found' }, { status: 404 }); // 2. Copy PDF from seeds dir using the form template's filename const srcPath = path.join(SEEDS_DIR, docTemplate.formTemplate.filename); await copyFile(srcPath, destPath); // 3. Copy fields with fresh UUIDs (D-07) — hints preserved verbatim (D-09) const rawFields: SignatureFieldData[] = (docTemplate.signatureFields as SignatureFieldData[] | null) ?? []; const copiedFields: SignatureFieldData[] = rawFields.map(f => ({ ...f, id: crypto.randomUUID(), })); // 4. Role-to-email mapping (D-06) // Collect unique role labels in order of first appearance const seenRoles = new Set(); const uniqueRoles: string[] = []; for (const f of copiedFields) { if (f.signerEmail && !seenRoles.has(f.signerEmail)) { seenRoles.add(f.signerEmail); uniqueRoles.push(f.signerEmail); } } // Fetch client for email + contacts const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); const clientEmails = [ client?.email, ...((client?.contacts as ClientContact[] | null) ?? []).map(c => c.email), ].filter(Boolean) as string[]; const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b']; const mappedSigners: DocumentSigner[] = uniqueRoles.map((role, i) => ({ email: clientEmails[i] ?? role, color: SIGNER_COLORS[i % SIGNER_COLORS.length], })); // 5. Override formTemplateId for the DB insert formTemplateId = docTemplate.formTemplateId; // 6. Insert with fields and signers const [doc] = await db.insert(documents).values({ id: docId, clientId, name, formTemplateId: formTemplateId ?? null, filePath: relPath, status: 'Draft', signatureFields: copiedFields, signers: mappedSigners.length > 0 ? mappedSigners : null, }).returning(); return Response.json(doc, { status: 201 }); } ``` The existing `else` path (formTemplateId from form library) and file upload path remain completely unchanged. Add imports at top: `import { documentTemplates, clients } from '@/lib/db/schema';` and `import type { SignatureFieldData, ClientContact, DocumentSigner } from '@/lib/db/schema';`. The `clients` import is new; `documents` and `formTemplates` are already imported. Also parse `documentTemplateId` from body: add `let documentTemplateId: string | undefined;` alongside existing declarations, and in the JSON else block add `documentTemplateId = body.documentTemplateId;`. IMPORTANT: The template branch has its OWN db.insert + return, so the existing insert at the bottom of the function only runs for the non-template paths. Structure it so the template branch returns early. cd teressa-copeland-homes && npx tsc --noEmit - grep "documentTemplateId" src/app/api/documents/route.ts returns at least 3 matches (declaration, parse, condition) - grep "crypto.randomUUID" src/app/api/documents/route.ts returns at least 2 matches (docId + field copy) - grep "signatureFields: copiedFields" src/app/api/documents/route.ts returns 1 match - grep "SIGNER_COLORS" src/app/api/documents/route.ts returns at least 1 match - grep "clientEmails" src/app/api/documents/route.ts returns at least 1 match - grep "import.*clients" src/app/api/documents/route.ts returns 1 match (clients table import) - npx tsc --noEmit exits 0 POST /api/documents accepts documentTemplateId, copies fields with fresh UUIDs, maps roles to client contacts, inserts document with signatureFields and signers Task 2: Add My Templates tab to AddDocumentModal teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx Add state variables for the template tab: ```typescript type DocumentTemplateRow = { id: string; name: string; formName: string | null; fieldCount: number; updatedAt: string; }; const [activeTab, setActiveTab] = useState<'forms' | 'templates'>('forms'); const [docTemplates, setDocTemplates] = useState([]); const [docTemplatesLoaded, setDocTemplatesLoaded] = useState(false); const [selectedDocTemplate, setSelectedDocTemplate] = useState(null); ``` Add lazy fetch function — fetches templates only on first click of the My Templates tab: ```typescript function handleSwitchToTemplates() { setActiveTab('templates'); if (!docTemplatesLoaded) { fetch('/api/templates') .then(r => r.json()) .then((data: DocumentTemplateRow[]) => { setDocTemplates(data); setDocTemplatesLoaded(true); }) .catch(console.error); } } ``` Add mutual exclusivity to selection handlers: - In `handleSelectTemplate` (existing form-library handler): add `setSelectedDocTemplate(null);` at top - Add new handler for document template selection: ```typescript const handleSelectDocTemplate = (t: DocumentTemplateRow) => { setSelectedDocTemplate(t); setSelectedTemplate(null); setCustomFile(null); setDocName(t.name); }; ``` Extend `handleSubmit`: - Add a NEW branch at the top of the try block, before the existing `if (customFile)`: ```typescript if (selectedDocTemplate) { await fetch('/api/documents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId, name: docName.trim(), documentTemplateId: selectedDocTemplate.id, }), }); } else if (customFile) { // ... existing custom file path unchanged } else { // ... existing form library path unchanged } ``` Update the early-return guard in handleSubmit: ```typescript if (!docName.trim() || (!selectedTemplate && !customFile && !selectedDocTemplate)) return; ``` Update the submit button disabled condition: ```typescript disabled={saving || (!selectedTemplate && !customFile && !selectedDocTemplate) || !docName.trim()} ``` Render two tab buttons between the h2 heading and the search input. Use underline-style tabs matching the project's plain Tailwind approach: ```tsx
``` Conditionally render tab content: - When `activeTab === 'forms'`: show the existing search input + form list + custom file upload section (ALL existing markup unchanged) - When `activeTab === 'templates'`: show the templates list: ```tsx {activeTab === 'templates' && (
{!docTemplatesLoaded ? (

Loading templates...

) : docTemplates.length === 0 ? (

No templates saved yet. Create one from the Templates page.

) : (
    {docTemplates.map(t => (
  • handleSelectDocTemplate(t)} className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 ${ selectedDocTemplate?.id === t.id ? 'bg-blue-50 font-medium' : '' }`} > {t.name} {t.formName ?? 'Unknown form'} · {t.fieldCount} field{t.fieldCount !== 1 ? 's' : ''}
  • ))}
)}
)} ``` The `{activeTab === 'forms' && ( ... )}` wraps around the existing search input, form list `
    `, and custom file upload `
    `. All existing elements are preserved exactly — only wrapped in a conditional. cd teressa-copeland-homes && npx tsc --noEmit - grep "activeTab" src/app/portal/_components/AddDocumentModal.tsx returns at least 4 matches - grep "My Templates" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match - grep "Forms Library" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match - grep "selectedDocTemplate" src/app/portal/_components/AddDocumentModal.tsx returns at least 3 matches - grep "documentTemplateId" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match - grep "/api/templates" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match - grep "No templates saved yet" src/app/portal/_components/AddDocumentModal.tsx returns 1 match (empty state) - npx tsc --noEmit exits 0 AddDocumentModal has two tabs (Forms Library + My Templates), lazy-loads templates on first tab click, sends documentTemplateId to POST /api/documents when a template is selected 1. `cd teressa-copeland-homes && npx tsc --noEmit` — zero type errors 2. `cd teressa-copeland-homes && npm run build` — production build succeeds 3. grep confirms: documentTemplateId in both route.ts and AddDocumentModal.tsx 4. grep confirms: crypto.randomUUID in route.ts field copy 5. grep confirms: SIGNER_COLORS in route.ts 6. grep confirms: activeTab, selectedDocTemplate in AddDocumentModal.tsx - POST /api/documents accepts documentTemplateId and creates a document with copied fields (fresh UUIDs), mapped signers, and correct formTemplateId - AddDocumentModal shows two tabs; My Templates tab fetches from GET /api/templates and displays template rows - Selecting a template and clicking Add Document creates a document via the template branch - No changes to existing Forms Library tab or custom file upload behavior - TypeScript compiles with zero errors After completion, create `.planning/phases/20-apply-template-and-portal-nav/20-01-SUMMARY.md`