diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 8ad9326..2b4bef4 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -413,9 +413,16 @@ Plans:
3. Signer assignment in the template editor accepts role labels ("Buyer", "Seller", "Agent") rather than email addresses — role strings are stored in the `signerEmail` slot and the editor does not reject them as invalid emails
4. Agent can set a text hint on any text field — the hint is stored in the template and is visible as a placeholder label in the editor
5. Agent can click Save and the current field layout (with role labels and text hints) is persisted to `document_templates.signatureFields` — a subsequent page reload shows the saved state
-**Plans**: TBD
+**Plans**: 3 plans
+
+Plans:
+- [ ] 19-01-PLAN.md — FieldPlacer/PdfViewer prop abstraction (onPersist, fieldsUrl, fileUrl), three template API routes (file, fields, ai-prepare), PortalNav Templates link
+- [ ] 19-02-PLAN.md — Templates list page with create modal, template editor page (TemplatePageClient + TemplatePanel with roles, AI, save)
+- [ ] 19-03-PLAN.md — Full Phase 19 human verification checkpoint (9-step E2E browser test)
**UI hint**: yes
+
+
### Phase 20: Apply Template and Portal Nav
**Goal**: Agent can start any new client document from a saved template — all fields are pre-loaded with fresh IDs, roles map to real signer emails, text hints appear as quick-fill suggestions — and "Templates" is a top-level portal destination
**Depends on**: Phase 19
diff --git a/.planning/phases/19-template-editor-ui/19-01-PLAN.md b/.planning/phases/19-template-editor-ui/19-01-PLAN.md
new file mode 100644
index 0000000..ebad3ee
--- /dev/null
+++ b/.planning/phases/19-template-editor-ui/19-01-PLAN.md
@@ -0,0 +1,497 @@
+---
+phase: 19-template-editor-ui
+plan: "01"
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
+ - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx
+ - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx
+ - teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx
+ - teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts
+ - teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts
+ - teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts
+autonomous: true
+requirements: [TMPL-05, TMPL-06, TMPL-07, TMPL-09]
+
+must_haves:
+ truths:
+ - "FieldPlacer accepts an onPersist callback that replaces internal persistFields when provided"
+ - "FieldPlacer accepts a fieldsUrl prop that overrides the default /api/documents/{docId}/fields endpoint"
+ - "PdfViewer and PdfViewerWrapper pass through onPersist and fieldsUrl to FieldPlacer plus accept a fileUrl prop for PDF source"
+ - "GET /api/templates/[id]/file streams the form PDF from seeds/forms/"
+ - "GET /api/templates/[id]/fields returns the template signatureFields array"
+ - "POST /api/templates/[id]/ai-prepare extracts blanks and classifies fields with AI then writes to DB"
+ - "Templates link appears in portal nav between Clients and Profile"
+ artifacts:
+ - path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx"
+ provides: "onPersist + fieldsUrl props on FieldPlacerProps interface"
+ contains: "onPersist"
+ - path: "teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts"
+ provides: "PDF file streaming for templates"
+ exports: ["GET"]
+ - path: "teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts"
+ provides: "Template fields JSON endpoint"
+ exports: ["GET"]
+ - path: "teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts"
+ provides: "AI auto-place for templates"
+ exports: ["POST"]
+ key_links:
+ - from: "FieldPlacer.tsx"
+ to: "onPersist callback"
+ via: "conditional call at 4 persistFields sites"
+ pattern: "onPersist.*next.*persistFields"
+ - from: "FieldPlacer.tsx"
+ to: "fieldsUrl || /api/documents/${docId}/fields"
+ via: "useEffect loadFields"
+ pattern: "fieldsUrl.*docId"
+ - from: "PdfViewer.tsx"
+ to: "fileUrl || /api/documents/${docId}/file"
+ via: "Document file prop and download href"
+ pattern: "fileUrl.*docId"
+---
+
+
+Add optional `onPersist`, `fieldsUrl`, and `fileUrl` props to the FieldPlacer/PdfViewer/PdfViewerWrapper chain (non-breaking), create three template API routes (file, fields, ai-prepare), and add "Templates" to portal nav.
+
+Purpose: Establish the infrastructure that Plan 02's template editor UI will consume. Existing document workflows are unaffected because all new props are optional with backwards-compatible defaults.
+
+Output: Modified FieldPlacer/PdfViewer/PdfViewerWrapper with template-mode props; three new API routes under /api/templates/[id]/; PortalNav with Templates link.
+
+
+
+@$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/19-template-editor-ui/19-CONTEXT.md
+@.planning/phases/19-template-editor-ui/19-RESEARCH.md
+@.planning/phases/18-template-schema-and-crud-api/18-02-SUMMARY.md
+
+
+
+
+From src/lib/db/schema.ts:
+```typescript
+export interface SignatureFieldData {
+ id: string;
+ page: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ type?: SignatureFieldType;
+ signerEmail?: string;
+ hint?: string; // Optional label shown to signer for client-text / client-checkbox fields
+}
+
+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(),
+});
+```
+
+From FieldPlacer.tsx (current):
+```typescript
+interface FieldPlacerProps {
+ docId: string;
+ pageInfo: PageInfo | null;
+ currentPage: number;
+ children: React.ReactNode;
+ readOnly?: boolean;
+ onFieldsChanged?: () => void;
+ selectedFieldId?: string | null;
+ textFillData?: Record;
+ onFieldSelect?: (fieldId: string | null) => void;
+ onFieldValueChange?: (fieldId: string, value: string) => void;
+ aiPlacementKey?: number;
+ signers?: DocumentSigner[];
+ unassignedFieldIds?: Set;
+}
+// persistFields called at lines 324, 508, 575, 745
+```
+
+From PdfViewerWrapper.tsx (current props):
+```typescript
+{ docId: string; docStatus?: string; onFieldsChanged?: () => void;
+ selectedFieldId?: string | null; textFillData?: Record;
+ onFieldSelect?: (fieldId: string | null) => void;
+ onFieldValueChange?: (fieldId: string, value: string) => void;
+ aiPlacementKey?: number; signers?: DocumentSigner[]; unassignedFieldIds?: Set; }
+```
+
+From PdfViewer.tsx (same props as PdfViewerWrapper, uses docId at lines 85 and 110):
+- Line 85: `href={/api/documents/${docId}/file}` (download link)
+- Line 110: `file={/api/documents/${docId}/file}` (react-pdf Document source)
+
+From /api/documents/[id]/ai-prepare/route.ts (pattern to copy):
+```typescript
+export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const session = await auth();
+ if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
+ if (!process.env.OPENAI_API_KEY) return Response.json({ error: '...' }, { status: 503 });
+ // ... extractBlanks + classifyFieldsWithAI ...
+}
+```
+
+From PortalNav.tsx (current):
+```typescript
+const navLinks = [
+ { href: "/portal/dashboard", label: "Dashboard" },
+ { href: "/portal/clients", label: "Clients" },
+ { href: "/portal/profile", label: "Profile" },
+];
+```
+
+
+
+
+
+
+ Task 1: Add onPersist + fieldsUrl props to FieldPlacer, fileUrl to PdfViewer/PdfViewerWrapper, and Templates to PortalNav
+
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx,
+ teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx
+
+
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx,
+ teressa-copeland-homes/src/app/portal/_components/PortalNav.tsx
+
+
+**FieldPlacer.tsx — 3 changes:**
+
+1. Add two optional props to `FieldPlacerProps` interface (after `unassignedFieldIds`):
+```typescript
+ onPersist?: (fields: SignatureFieldData[]) => Promise | void;
+ fieldsUrl?: string;
+```
+
+2. Destructure both new props in the function signature with defaults:
+```typescript
+export function FieldPlacer({ ..., onPersist, fieldsUrl }: FieldPlacerProps) {
+```
+
+3. Update the `useEffect` at line ~222 that fetches fields — replace the hardcoded URL:
+```typescript
+const res = await fetch(fieldsUrl ?? `/api/documents/${docId}/fields`);
+```
+Add `fieldsUrl` to the useEffect dependency array: `[docId, aiPlacementKey, fieldsUrl]`
+
+4. Update all 4 `persistFields` call sites to conditionally call `onPersist` instead:
+
+Line ~324 (handleDragEnd, after `setFields(next)`):
+```typescript
+if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
+```
+
+Line ~508 (handleZonePointerUp move branch, after `setFields(next)`):
+```typescript
+if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
+```
+
+Line ~575 (handleZonePointerUp resize branch, after `setFields(next)`):
+```typescript
+if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
+```
+
+Line ~745 (delete button onClick, after `setFields(next)`):
+```typescript
+if (onPersist) { onPersist(next); } else { persistFields(docId, next); }
+```
+
+5. Add `onPersist` to the dependency arrays of the useCallback hooks that contain the 4 call sites:
+- `handleDragEnd` (line ~327): add `onPersist` to `[fields, pageInfo, currentPage, docId, readOnly, onFieldsChanged, activeSignerEmail]`
+- `handleZonePointerUp` (line ~578): add `onPersist` to `[docId, onFieldsChanged]`
+- The delete button's onClick is inline in `renderFields` — `onPersist` is already in scope (function component body), no useCallback change needed.
+
+Per D-01 and D-02 from CONTEXT.md. Backwards compatible: when `onPersist` is undefined, all existing callers get the original `persistFields(docId, next)` behavior.
+
+**PdfViewer.tsx — 3 changes:**
+
+1. Add three optional props to the inline type annotation of the function:
+```typescript
+ onPersist?: (fields: import('@/lib/db/schema').SignatureFieldData[]) => Promise | void;
+ fieldsUrl?: string;
+ fileUrl?: string;
+```
+(Import `SignatureFieldData` type from `@/lib/db/schema` at the top if not already imported.)
+
+2. Destructure `onPersist`, `fieldsUrl`, `fileUrl` in the function params.
+
+3. Replace the 2 hardcoded file URL usages:
+- Line 85 download link: `href={fileUrl ?? \`/api/documents/${docId}/file\`}`
+- Line 110 Document file prop: `file={fileUrl ?? \`/api/documents/${docId}/file\`}`
+
+4. Pass `onPersist` and `fieldsUrl` through to `` (lines 95-107):
+```typescript
+
+```
+
+**PdfViewerWrapper.tsx — 3 changes:**
+
+1. Add three optional props to the inline type annotation:
+```typescript
+ onPersist?: (fields: import('@/lib/db/schema').SignatureFieldData[]) => Promise | void;
+ fieldsUrl?: string;
+ fileUrl?: string;
+```
+(Import `SignatureFieldData` from `@/lib/db/schema` at the top.)
+
+2. Destructure `onPersist`, `fieldsUrl`, `fileUrl` in the function params.
+
+3. Pass all three through to ``:
+```typescript
+
+```
+
+**PortalNav.tsx — 1 change (per D-17):**
+
+Insert Templates link between Clients and Profile in the `navLinks` array:
+```typescript
+const navLinks = [
+ { href: "/portal/dashboard", label: "Dashboard" },
+ { href: "/portal/clients", label: "Clients" },
+ { href: "/portal/templates", label: "Templates" },
+ { href: "/portal/profile", label: "Profile" },
+];
+```
+
+
+ cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - FieldPlacer.tsx contains `onPersist?: (fields: SignatureFieldData[]) => Promise | void`
+ - FieldPlacer.tsx contains `fieldsUrl?: string`
+ - FieldPlacer.tsx contains `fieldsUrl ??` in the loadFields useEffect fetch URL
+ - FieldPlacer.tsx contains `if (onPersist)` at all 4 persistFields call sites (lines ~324, ~508, ~575, ~745)
+ - PdfViewer.tsx contains `fileUrl?: string`
+ - PdfViewer.tsx contains `fileUrl ??` before `/api/documents/${docId}/file` in both the download href and the Document file prop
+ - PdfViewer.tsx passes `onPersist={onPersist}` and `fieldsUrl={fieldsUrl}` to FieldPlacer
+ - PdfViewerWrapper.tsx passes `onPersist`, `fieldsUrl`, `fileUrl` through to PdfViewer
+ - PortalNav.tsx contains `{ href: "/portal/templates", label: "Templates" }`
+ - `npx tsc --noEmit` exits 0 (no type errors)
+
+ All three viewer components accept optional template-mode props (onPersist, fieldsUrl, fileUrl) with backwards-compatible defaults. PortalNav shows Templates link. TypeScript compiles clean.
+
+
+
+ Task 2: Create three template API routes — file, fields, ai-prepare
+
+ teressa-copeland-homes/src/app/api/templates/[id]/file/route.ts,
+ teressa-copeland-homes/src/app/api/templates/[id]/fields/route.ts,
+ teressa-copeland-homes/src/app/api/templates/[id]/ai-prepare/route.ts
+
+
+ teressa-copeland-homes/src/app/api/templates/[id]/route.ts,
+ teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts,
+ teressa-copeland-homes/src/lib/db/schema.ts,
+ teressa-copeland-homes/src/lib/ai/extract-text.ts,
+ teressa-copeland-homes/src/lib/ai/field-placement.ts
+
+
+**Create `src/app/api/templates/[id]/file/route.ts`** — GET handler that streams the template's source PDF from `seeds/forms/`:
+
+```typescript
+import { auth } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { documentTemplates } from '@/lib/db/schema';
+import { and, eq, isNull } from 'drizzle-orm';
+import path from 'node:path';
+import { readFile } from 'node:fs/promises';
+
+const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms');
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const session = await auth();
+ if (!session) return new Response('Unauthorized', { status: 401 });
+
+ const { id } = await params;
+ const template = await db.query.documentTemplates.findFirst({
+ where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
+ with: { formTemplate: true },
+ });
+ if (!template?.formTemplate) return Response.json({ error: 'Not found' }, { status: 404 });
+
+ const filePath = path.join(SEEDS_FORMS_DIR, template.formTemplate.filename);
+ // Path traversal guard
+ if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 });
+
+ try {
+ const file = await readFile(filePath);
+ return new Response(file, {
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': `inline; filename="${template.formTemplate.filename}"`,
+ },
+ });
+ } catch {
+ return Response.json({ error: 'Form PDF not found on disk' }, { status: 404 });
+ }
+}
+```
+
+**Create `src/app/api/templates/[id]/fields/route.ts`** — GET handler returning `signatureFields ?? []`:
+
+```typescript
+import { auth } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { documentTemplates } from '@/lib/db/schema';
+import { and, eq, isNull } from 'drizzle-orm';
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const session = await auth();
+ if (!session) return new Response('Unauthorized', { status: 401 });
+
+ const { id } = await params;
+ const template = await db.query.documentTemplates.findFirst({
+ where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
+ });
+ if (!template) return Response.json({ error: 'Not found' }, { status: 404 });
+
+ return Response.json(template.signatureFields ?? []);
+}
+```
+
+**Create `src/app/api/templates/[id]/ai-prepare/route.ts`** — POST handler (per D-14, D-15):
+
+Copy pattern from `/api/documents/[id]/ai-prepare/route.ts` with these differences:
+- Load from `documentTemplates` (with `formTemplate` relation) instead of `documents`
+- PDF path is `path.join(SEEDS_FORMS_DIR, template.formTemplate.filename)` instead of uploads dir
+- No Draft status check (templates have no status)
+- Pass `null` as client context to `classifyFieldsWithAI` (per D-15 — no client pre-fill)
+- Write result to `documentTemplates.signatureFields` via `db.update(documentTemplates).set(...)` with explicit `updatedAt: new Date()`
+- Return `{ fields }` only (no `textFillData` — templates don't use it)
+
+```typescript
+import { auth } from '@/lib/auth';
+import { db } from '@/lib/db';
+import { documentTemplates } from '@/lib/db/schema';
+import { and, eq, isNull } from 'drizzle-orm';
+import path from 'node:path';
+import { extractBlanks } from '@/lib/ai/extract-text';
+import { classifyFieldsWithAI } from '@/lib/ai/field-placement';
+
+const SEEDS_FORMS_DIR = path.join(process.cwd(), 'seeds', 'forms');
+
+export async function POST(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const session = await auth();
+ if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
+
+ if (!process.env.OPENAI_API_KEY) {
+ return Response.json(
+ { error: 'OPENAI_API_KEY not configured. Add it to .env.local.' },
+ { status: 503 }
+ );
+ }
+
+ const { id } = await params;
+ const template = await db.query.documentTemplates.findFirst({
+ where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
+ with: { formTemplate: true },
+ });
+ if (!template?.formTemplate) return Response.json({ error: 'Not found' }, { status: 404 });
+
+ const filePath = path.join(SEEDS_FORMS_DIR, template.formTemplate.filename);
+ if (!filePath.startsWith(SEEDS_FORMS_DIR)) return new Response('Forbidden', { status: 403 });
+
+ try {
+ const blanks = await extractBlanks(filePath);
+ const { fields } = await classifyFieldsWithAI(blanks, null); // null = no client context per D-15
+
+ const [updated] = await db
+ .update(documentTemplates)
+ .set({ signatureFields: fields, updatedAt: new Date() })
+ .where(eq(documentTemplates.id, id))
+ .returning();
+
+ return Response.json({ fields: updated.signatureFields ?? [] });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return Response.json({ error: message }, { status: 500 });
+ }
+}
+```
+
+All three routes follow the same auth guard + soft-delete filter (`isNull(archivedAt)`) pattern established in Phase 18.
+
+
+ cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - File exists: `src/app/api/templates/[id]/file/route.ts`
+ - File exists: `src/app/api/templates/[id]/fields/route.ts`
+ - File exists: `src/app/api/templates/[id]/ai-prepare/route.ts`
+ - file/route.ts contains `export async function GET`
+ - file/route.ts contains `SEEDS_FORMS_DIR` and `readFile(filePath)`
+ - file/route.ts contains `!filePath.startsWith(SEEDS_FORMS_DIR)` path traversal guard
+ - fields/route.ts contains `export async function GET`
+ - fields/route.ts contains `template.signatureFields ?? []`
+ - ai-prepare/route.ts contains `export async function POST`
+ - ai-prepare/route.ts contains `classifyFieldsWithAI(blanks, null)` (null client context)
+ - ai-prepare/route.ts contains `updatedAt: new Date()` in the update set
+ - ai-prepare/route.ts contains `isNull(documentTemplates.archivedAt)` in the where clause
+ - `npx tsc --noEmit` exits 0
+
+ Three template API routes created and type-checking. GET /api/templates/[id]/file streams the form PDF. GET /api/templates/[id]/fields returns signatureFields. POST /api/templates/[id]/ai-prepare runs AI field placement with null client context and writes to DB.
+
+
+
+
+
+1. `cd teressa-copeland-homes && npx tsc --noEmit` exits 0 — no type errors introduced
+2. `npm run build` succeeds — no build-time errors
+3. Grep confirms: `onPersist` appears in FieldPlacer, PdfViewer, PdfViewerWrapper
+4. Grep confirms: `fieldsUrl` appears in FieldPlacer, PdfViewer, PdfViewerWrapper
+5. Grep confirms: `fileUrl` appears in PdfViewer, PdfViewerWrapper
+6. Grep confirms: `"/portal/templates"` appears in PortalNav
+7. Three new route files exist under `src/app/api/templates/[id]/`
+
+
+
+- FieldPlacer/PdfViewer/PdfViewerWrapper accept template-mode props without breaking existing document workflows
+- All three template API routes respond to their HTTP methods with proper auth guards and soft-delete filters
+- PortalNav shows "Templates" link in the correct position (between Clients and Profile)
+- TypeScript compiles clean across the entire project
+
+
+
diff --git a/.planning/phases/19-template-editor-ui/19-02-PLAN.md b/.planning/phases/19-template-editor-ui/19-02-PLAN.md
new file mode 100644
index 0000000..ab6dc2f
--- /dev/null
+++ b/.planning/phases/19-template-editor-ui/19-02-PLAN.md
@@ -0,0 +1,620 @@
+---
+phase: 19-template-editor-ui
+plan: "02"
+type: execute
+wave: 2
+depends_on: ["19-01"]
+files_modified:
+ - teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx
+ - teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/page.tsx
+ - teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx
+ - teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx
+autonomous: true
+requirements: [TMPL-05, TMPL-06, TMPL-07, TMPL-08, TMPL-09]
+
+must_haves:
+ truths:
+ - "Agent can see a list of all active templates at /portal/templates"
+ - "Agent can create a new template by selecting a form from the library"
+ - "Agent can open a template at /portal/templates/[id] and see the PDF with fields"
+ - "Agent can drag-drop fields onto the template PDF via FieldPlacer"
+ - "Agent can add/remove/rename signer role labels (Buyer, Seller, custom)"
+ - "Agent can click AI Auto-place to populate fields on the template"
+ - "Agent can type text hints on client-text fields that are saved as field.hint"
+ - "Agent can save the template and fields persist across page refresh"
+ artifacts:
+ - path: "teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx"
+ provides: "Templates list page with create modal"
+ contains: "TemplatesPage"
+ - path: "teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/page.tsx"
+ provides: "Template editor server component"
+ contains: "TemplateEditorPage"
+ - path: "teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx"
+ provides: "Template editor state owner"
+ contains: "TemplatePageClient"
+ - path: "teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx"
+ provides: "Right panel with roles, AI button, save button"
+ contains: "TemplatePanel"
+ key_links:
+ - from: "TemplatePageClient.tsx"
+ to: "/api/templates/[id]"
+ via: "handlePersist callback passed as onPersist to PdfViewerWrapper"
+ pattern: "PATCH.*templates.*signatureFields"
+ - from: "TemplatePageClient.tsx"
+ to: "PdfViewerWrapper"
+ via: "fileUrl + fieldsUrl + onPersist props"
+ pattern: "fileUrl.*fieldsUrl.*onPersist"
+ - from: "TemplatePanel.tsx"
+ to: "/api/templates/[id]/ai-prepare"
+ via: "AI Auto-place button POST call"
+ pattern: "ai-prepare.*POST"
+ - from: "TemplatePanel.tsx"
+ to: "/api/templates/[id]"
+ via: "Save button PATCH call"
+ pattern: "PATCH.*signatureFields"
+---
+
+
+Build the template editor UI — list page at `/portal/templates`, editor page at `/portal/templates/[id]` with TemplatePageClient state owner and TemplatePanel right panel — enabling the agent to place fields, assign signer roles, set text hints, use AI auto-place, and save templates.
+
+Purpose: This is the core user-facing deliverable of Phase 19. The agent gains the ability to visually build reusable field templates on any PDF form.
+
+Output: Four new files (list page, editor server component, TemplatePageClient, TemplatePanel) implementing all TMPL-05 through TMPL-09 requirements.
+
+
+
+@$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/19-template-editor-ui/19-CONTEXT.md
+@.planning/phases/19-template-editor-ui/19-RESEARCH.md
+@.planning/phases/19-template-editor-ui/19-UI-SPEC.md
+@.planning/phases/19-template-editor-ui/19-01-SUMMARY.md
+
+
+
+
+From PdfViewerWrapper.tsx (after Plan 01):
+```typescript
+export function PdfViewerWrapper({
+ docId, docStatus, onFieldsChanged, selectedFieldId, textFillData,
+ onFieldSelect, onFieldValueChange, aiPlacementKey, signers, unassignedFieldIds,
+ onPersist, fieldsUrl, fileUrl, // NEW from Plan 01
+}: {
+ docId: string;
+ docStatus?: string;
+ onFieldsChanged?: () => void;
+ selectedFieldId?: string | null;
+ textFillData?: Record;
+ onFieldSelect?: (fieldId: string | null) => void;
+ onFieldValueChange?: (fieldId: string, value: string) => void;
+ aiPlacementKey?: number;
+ signers?: DocumentSigner[];
+ unassignedFieldIds?: Set;
+ onPersist?: (fields: SignatureFieldData[]) => Promise | void;
+ fieldsUrl?: string;
+ fileUrl?: string;
+})
+```
+
+From schema.ts:
+```typescript
+export interface SignatureFieldData {
+ id: string; page: number; x: number; y: number;
+ width: number; height: number;
+ type?: SignatureFieldType; signerEmail?: string; hint?: string;
+}
+export interface DocumentSigner { email: string; color: string; }
+export const documentTemplates = pgTable("document_templates", {
+ id: text("id"), name: text("name").notNull(),
+ formTemplateId: text("form_template_id").notNull(),
+ signatureFields: jsonb("signature_fields").$type(),
+ archivedAt: timestamp("archived_at"),
+ createdAt: timestamp("created_at"), updatedAt: timestamp("updated_at"),
+});
+```
+
+From DocumentPageClient.tsx (pattern to mirror):
+```typescript
+const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
+const [aiPlacementKey, setAiPlacementKey] = useState(0);
+const [selectedFieldId, setSelectedFieldId] = useState(null);
+const [textFillData, setTextFillData] = useState>({});
+```
+
+From clients/page.tsx (list page pattern):
+```typescript
+export default async function ClientsPage() {
+ const clientRows = await db.select({ ... }).from(clients)...;
+ return ;
+}
+```
+
+API routes from Plan 01:
+- GET /api/templates/[id]/file — streams PDF
+- GET /api/templates/[id]/fields — returns signatureFields[]
+- POST /api/templates/[id]/ai-prepare — AI auto-place, returns { fields }
+
+API routes from Phase 18:
+- GET /api/templates — list active templates (name, formName, fieldCount, updatedAt)
+- POST /api/templates — create template { name, formTemplateId }
+- PATCH /api/templates/[id] — update { name?, signatureFields? }
+- DELETE /api/templates/[id] — soft-delete
+
+Forms API for form picker:
+- GET /api/forms (or direct DB query for formTemplates)
+
+
+
+
+
+
+ Task 1: Create templates list page with create-template modal
+
+ teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx
+
+
+ teressa-copeland-homes/src/app/portal/(protected)/clients/page.tsx,
+ teressa-copeland-homes/src/app/portal/_components/ClientsPageClient.tsx,
+ teressa-copeland-homes/src/app/api/templates/route.ts,
+ teressa-copeland-homes/src/lib/db/schema.ts,
+ .planning/phases/19-template-editor-ui/19-UI-SPEC.md
+
+
+Create `src/app/portal/(protected)/templates/page.tsx` as a **server component** (per D-18) that:
+
+1. Imports `db` from `@/lib/db` and `documentTemplates`, `formTemplates` from `@/lib/db/schema`.
+2. Queries all active templates with a LEFT JOIN to formTemplates:
+```typescript
+const templates = await db
+ .select({
+ id: documentTemplates.id,
+ name: documentTemplates.name,
+ formName: formTemplates.name,
+ signatureFields: documentTemplates.signatureFields,
+ updatedAt: documentTemplates.updatedAt,
+ })
+ .from(documentTemplates)
+ .leftJoin(formTemplates, eq(documentTemplates.formTemplateId, formTemplates.id))
+ .where(isNull(documentTemplates.archivedAt))
+ .orderBy(desc(documentTemplates.updatedAt));
+```
+3. Also queries all form templates for the form picker in the create modal:
+```typescript
+const forms = await db.select({ id: formTemplates.id, name: formTemplates.name }).from(formTemplates).orderBy(formTemplates.name);
+```
+4. Renders a `TemplatesListClient` (defined as a `'use client'` component inline or in the same file using a named export — follow the pattern from ClientsPageClient if it's a separate file, OR keep it inline for simplicity). The server component passes `templates` and `forms` as props.
+
+**TemplatesListClient** (client component, can be defined in the same file or a sibling):
+
+Layout per UI-SPEC:
+- Page heading: "Templates" (24px/700, navy `#1B2B4B`)
+- Subtitle: `{templates.length} template{templates.length !== 1 ? 's' : ''}` (14px/400, gray-500 `#6B7280`)
+- Top-right: "+ New Template" button (gold `#C9A84C` fill, white text, 36px height)
+- If no templates: empty state card with "No templates yet" heading, "Create a template to reuse field placements across documents." body, and "+ Create your first template" CTA
+- Template list: flex column, gap 8px. Each row is a clickable div (cursor pointer) that navigates to `/portal/templates/${template.id}` using `useRouter().push(...)`:
+ - Template name (14px/600, navy)
+ - Form name (14px/400, gray-500)
+ - Field count: `${(template.signatureFields ?? []).length} field${count !== 1 ? 's' : ''}` (12px/400, gray-500)
+ - Last updated: formatted date (12px/400, gray-500) — use `new Date(template.updatedAt).toLocaleDateString()`
+ - Row hover: `background: #F0EDE8`
+ - Row has border-bottom `1px solid #E5E7EB`
+
+**Create Template Modal:**
+- State: `const [showModal, setShowModal] = useState(false);`
+- Modal overlay: fixed inset-0, bg black/50, z-50, flex center
+- Modal card: white rounded-lg, padding 24px, max-width 400px
+- Fields:
+ - "Template name" text input (required)
+ - "Select form" — `
+
+ cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - File exists: `src/app/portal/(protected)/templates/page.tsx`
+ - File contains `export default async function` (server component)
+ - File contains `documentTemplates` and `formTemplates` imports from schema
+ - File contains `isNull(documentTemplates.archivedAt)` in the where clause
+ - File contains `'use client'` for the client component (either inline or separate)
+ - File contains `"/portal/templates/"` in the row click handler (navigation)
+ - File contains `POST` and `/api/templates` in the create handler
+ - File contains `"+ New Template"` button text
+ - File contains `"No templates yet"` empty state text
+ - File contains `#C9A84C` (gold accent color)
+ - File contains `#1B2B4B` (navy color)
+ - `npx tsc --noEmit` exits 0
+
+ Templates list page renders active templates with form name, field count, and last-updated. Create modal allows picking a form and naming a template. Navigation to editor works on row click and after creation.
+
+
+
+ Task 2: Create template editor page — server component, TemplatePageClient, and TemplatePanel
+
+ teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/page.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx
+
+
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx,
+ teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx,
+ teressa-copeland-homes/src/lib/db/schema.ts,
+ .planning/phases/19-template-editor-ui/19-CONTEXT.md,
+ .planning/phases/19-template-editor-ui/19-RESEARCH.md,
+ .planning/phases/19-template-editor-ui/19-UI-SPEC.md
+
+
+**Create `src/app/portal/(protected)/templates/[id]/page.tsx`** — server component (per D-09):
+
+```typescript
+import { db } from '@/lib/db';
+import { documentTemplates } from '@/lib/db/schema';
+import { and, eq, isNull } from 'drizzle-orm';
+import { notFound } from 'next/navigation';
+import { TemplatePageClient } from './_components/TemplatePageClient';
+
+export default async function TemplateEditorPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const template = await db.query.documentTemplates.findFirst({
+ where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
+ with: { formTemplate: true },
+ });
+ if (!template || !template.formTemplate) notFound();
+
+ return (
+
+ );
+}
+```
+
+**Create `src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx`** — client state owner mirroring DocumentPageClient:
+
+```typescript
+'use client';
+```
+
+Props interface:
+```typescript
+interface TemplatePageClientProps {
+ templateId: string;
+ templateName: string;
+ formName: string;
+ initialFields: SignatureFieldData[];
+}
+```
+
+Import `SignatureFieldData`, `DocumentSigner` from `@/lib/db/schema`. Import `PdfViewerWrapper` from the documents `_components` directory (reuse the existing component — DO NOT duplicate). Use a relative path or `@/` alias: `import { PdfViewerWrapper } from '@/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper';`
+
+State:
+```typescript
+const ROLE_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
+
+function deriveRolesFromFields(fields: SignatureFieldData[]): DocumentSigner[] {
+ const seen = new Map();
+ fields.forEach(f => {
+ if (f.signerEmail && !seen.has(f.signerEmail)) {
+ seen.set(f.signerEmail, ROLE_COLORS[seen.size % ROLE_COLORS.length]);
+ }
+ });
+ if (seen.size === 0) return [
+ { email: 'Buyer', color: ROLE_COLORS[0] },
+ { email: 'Seller', color: ROLE_COLORS[1] },
+ ];
+ return Array.from(seen.entries()).map(([email, color]) => ({ email, color }));
+}
+
+const [signers, setSigners] = useState(() => deriveRolesFromFields(initialFields));
+const [selectedFieldId, setSelectedFieldId] = useState(null);
+const [textFillData, setTextFillData] = useState>(() => {
+ // Initialize textFillData from existing field.hint values so hints display in inline inputs
+ const data: Record = {};
+ initialFields.forEach(f => { if (f.hint) data[f.id] = f.hint; });
+ return data;
+});
+const [aiPlacementKey, setAiPlacementKey] = useState(0);
+const [name, setName] = useState(templateName);
+```
+
+Callbacks:
+
+`handlePersist` — the `onPersist` callback passed to PdfViewerWrapper (per D-01). Before saving, merge `textFillData` values into `field.hint` for client-text fields (per Research Pitfall 6):
+```typescript
+const handlePersist = useCallback(async (rawFields: SignatureFieldData[]) => {
+ const fieldsWithHints = rawFields.map(f =>
+ f.type === 'client-text' && textFillData[f.id]
+ ? { ...f, hint: textFillData[f.id] }
+ : f
+ );
+ await fetch(`/api/templates/${templateId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ signatureFields: fieldsWithHints }),
+ });
+}, [templateId, textFillData]);
+```
+
+Note: the `type` check uses `'client-text'` — however the actual schema type value for text fields is just `'text'`. Read the FieldPlacer palette token IDs to confirm the exact string. The existing codebase uses `type: 'text'` in SignatureFieldType. So the check should be:
+```typescript
+f.type === 'text' && textFillData[f.id]
+```
+
+`handleFieldValueChange`:
+```typescript
+const handleFieldValueChange = useCallback((fieldId: string, value: string) => {
+ setTextFillData(prev => ({ ...prev, [fieldId]: value }));
+}, []);
+```
+
+`handleFieldsChanged`:
+```typescript
+const handleFieldsChanged = useCallback(() => {
+ // No preview token to reset in template mode — no-op
+}, []);
+```
+
+`handleAiAutoPlace` — passed down to TemplatePanel:
+```typescript
+const handleAiAutoPlace = useCallback(async () => {
+ const res = await fetch(`/api/templates/${templateId}/ai-prepare`, { method: 'POST' });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: 'AI placement failed' }));
+ throw new Error(err.error || 'AI placement failed');
+ }
+ setAiPlacementKey(k => k + 1);
+}, [templateId]);
+```
+
+`handleSave` — explicit save button action:
+```typescript
+const handleSave = useCallback(async () => {
+ // FieldPlacer calls onPersist on every field change (drag/drop/delete/resize).
+ // The Save button additionally saves the name and ensures final hint merge.
+ // Since we can't read FieldPlacer's internal fields state from here,
+ // the save button calls PATCH with just the name.
+ // Fields are already persisted via onPersist on every change.
+ await fetch(`/api/templates/${templateId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name }),
+ });
+}, [templateId, name]);
+```
+
+`handleRenameRole` — rename a role label across all fields:
+```typescript
+const handleRenameRole = useCallback(async (oldLabel: string, newLabel: string) => {
+ setSigners(prev => prev.map(s => s.email === oldLabel ? { ...s, email: newLabel } : s));
+ // Also need to update any fields that have signerEmail === oldLabel
+ // This requires re-persisting fields — but we don't hold field state here.
+ // The renaming happens in the signers array; FieldPlacer uses signers for display.
+ // The actual field.signerEmail values are updated next time the agent interacts with a field.
+ // For a complete rename, we fetch current fields, update signerEmail, and PATCH:
+ const res = await fetch(`/api/templates/${templateId}/fields`);
+ if (res.ok) {
+ const fields: SignatureFieldData[] = await res.json();
+ const updated = fields.map(f => f.signerEmail === oldLabel ? { ...f, signerEmail: newLabel } : f);
+ await fetch(`/api/templates/${templateId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ signatureFields: updated }),
+ });
+ setAiPlacementKey(k => k + 1); // reload FieldPlacer to reflect renamed signerEmail
+ }
+}, [templateId]);
+```
+
+`handleRemoveRole`:
+```typescript
+const handleRemoveRole = useCallback(async (label: string) => {
+ setSigners(prev => prev.filter(s => s.email !== label));
+ // Unassign fields with this role
+ const res = await fetch(`/api/templates/${templateId}/fields`);
+ if (res.ok) {
+ const fields: SignatureFieldData[] = await res.json();
+ const updated = fields.map(f => f.signerEmail === label ? { ...f, signerEmail: undefined } : f);
+ await fetch(`/api/templates/${templateId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ signatureFields: updated }),
+ });
+ setAiPlacementKey(k => k + 1);
+ }
+}, [templateId]);
+```
+
+`handleAddRole`:
+```typescript
+const handleAddRole = useCallback((label: string) => {
+ if (!label.trim() || signers.some(s => s.email === label.trim())) return;
+ setSigners(prev => [...prev, { email: label.trim(), color: ROLE_COLORS[prev.length % ROLE_COLORS.length] }]);
+}, [signers]);
+```
+
+Layout (per D-12, UI-SPEC):
+```tsx
+
+
+ Edit Template: {name}
+
+
+
+
+
+
+
+
+```
+
+**Create `src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx`** — right panel (per D-13, UI-SPEC):
+
+```typescript
+'use client';
+```
+
+Props:
+```typescript
+interface TemplatePanelProps {
+ templateId: string;
+ name: string;
+ onNameChange: (name: string) => void;
+ signers: DocumentSigner[];
+ onAddRole: (label: string) => void;
+ onRenameRole: (oldLabel: string, newLabel: string) => void;
+ onRemoveRole: (label: string) => void;
+ onAiAutoPlace: () => Promise;
+ onSave: () => Promise;
+}
+```
+
+Layout — 280px fixed width, flex-shrink 0, sticky:
+
+1. **Template name** — `` with value `name`, onChange calls `onNameChange`, onBlur calls PATCH to save name immediately (per Research Pitfall 3):
+```typescript
+const handleNameBlur = async () => {
+ await fetch(`/api/templates/${templateId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name }),
+ });
+};
+```
+Style: 14px, border-bottom 1px solid #E5E7EB when at rest, border-bottom 1px solid #C9A84C when focused. Full width.
+
+2. **Signers / Roles section** — heading "Signers / Roles" (12px uppercase, `#6B7280`, letterSpacing 0.08em). List of role pills:
+- Each pill: flex row, gap 8px, padding 8px, items-center
+ - Color dot: 8px width/height, borderRadius 50%, background `signer.color`
+ - Role label: 14px/400. If editing, show inline `` same font. Click label to enter edit mode. Enter/blur commits rename. Escape cancels.
+ - Remove button: `x` character, 12px, color `#DC2626` on hover, cursor pointer. On click, count fields with `signerEmail === signer.email` (pass field count from parent, OR call `/api/templates/[id]/fields` to check). If count > 0, show ConfirmDialog (import from `@/app/portal/_components/ConfirmDialog`). Dialog text per UI-SPEC: title "Remove role?", body "Removing '{role}' will unassign {N} field(s). This cannot be undone.", confirm "Remove Role" (red), cancel "Cancel".
+
+3. **Add role** — text input + "Add" button. Preset suggestion chips below: "Buyer", "Co-Buyer", "Seller", "Co-Seller" — clicking a chip inserts that value. Only show chips that are not already in the signers list. Input placeholder: `"Role label (e.g. Buyer)"`.
+
+4. **AI Auto-place button** — full width, background `#1B2B4B`, color white, height 36px, border-radius 6px. Text: "AI Auto-place Fields". Loading state: "Placing..." with a simple CSS spinner (border animation). Error state: inline red error text below button. On click calls `onAiAutoPlace()`.
+```typescript
+const [aiLoading, setAiLoading] = useState(false);
+const [aiError, setAiError] = useState(null);
+const handleAi = async () => {
+ setAiLoading(true); setAiError(null);
+ try { await onAiAutoPlace(); }
+ catch (e) { setAiError(e instanceof Error ? e.message : 'AI placement failed. Check that the form PDF is accessible and try again.'); }
+ finally { setAiLoading(false); }
+};
+```
+
+5. **Save button** — full width, background `#C9A84C`, color white, height 36px, border-radius 6px. Text: "Save Template". Loading: "Saving..." at 0.7 opacity. Success: inline "Saved" in green `#059669` below, fades after 3s via setTimeout. Error: inline red text.
+```typescript
+const [saving, setSaving] = useState(false);
+const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
+const handleSaveClick = async () => {
+ setSaving(true); setSaveStatus('idle');
+ try { await onSave(); setSaveStatus('saved'); setTimeout(() => setSaveStatus('idle'), 3000); }
+ catch { setSaveStatus('error'); }
+ finally { setSaving(false); }
+};
+```
+
+Style the entire panel: width 280px, flexShrink 0, background `#F9FAFB`, borderRadius 8px, padding 16px, display flex, flexDirection column, gap 24px. Position sticky, top 96px (64px nav + 32px padding).
+
+Import `ConfirmDialog` from `@/app/portal/_components/ConfirmDialog` for role removal confirmation. Use the existing ConfirmDialog API — read the component first to understand its props (likely: `open`, `title`, `message`, `confirmLabel`, `onConfirm`, `onCancel`).
+
+
+ cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
+
+
+ - File exists: `src/app/portal/(protected)/templates/page.tsx`
+ - File exists: `src/app/portal/(protected)/templates/[id]/page.tsx`
+ - File exists: `src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx`
+ - File exists: `src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx`
+ - templates/page.tsx contains `export default async function` (server component)
+ - templates/page.tsx contains `isNull(documentTemplates.archivedAt)` in the query
+ - templates/page.tsx contains `"+ New Template"` button text
+ - templates/page.tsx contains `POST` and `/api/templates` for template creation
+ - templates/[id]/page.tsx contains `notFound()` for missing/archived templates
+ - templates/[id]/page.tsx contains `with: { formTemplate: true }` in the query
+ - TemplatePageClient.tsx contains `onPersist={handlePersist}` in the PdfViewerWrapper usage
+ - TemplatePageClient.tsx contains `fieldsUrl={\`/api/templates/${templateId}/fields\`}`
+ - TemplatePageClient.tsx contains `fileUrl={\`/api/templates/${templateId}/file\`}`
+ - TemplatePageClient.tsx contains `deriveRolesFromFields` function
+ - TemplatePageClient.tsx contains `f.hint` or `hint:` for the hint merge in handlePersist
+ - TemplatePanel.tsx contains `"Signers / Roles"` section heading
+ - TemplatePanel.tsx contains `"AI Auto-place Fields"` button text
+ - TemplatePanel.tsx contains `"Save Template"` button text
+ - TemplatePanel.tsx contains `#C9A84C` (gold) and `#1B2B4B` (navy) colors
+ - TemplatePanel.tsx contains `"Placing..."` or `"Placing…"` loading text
+ - TemplatePanel.tsx contains `"Saved"` success text
+ - TemplatePanel.tsx contains `ConfirmDialog` import for role removal
+ - `npx tsc --noEmit` exits 0
+
+ Complete template editor UI is functional: list page shows templates with create modal, editor page renders PDF with FieldPlacer in template mode (onPersist, fieldsUrl, fileUrl), TemplatePanel provides role management, AI auto-place, and save. All TMPL-05 through TMPL-09 requirements are addressed.
+
+
+
+
+
+1. `cd teressa-copeland-homes && npx tsc --noEmit` exits 0
+2. `npm run build` succeeds
+3. Navigate to `/portal/templates` — list page renders (may be empty)
+4. Click "+ New Template" — modal opens with form picker
+5. Create a template — redirects to `/portal/templates/[id]`
+6. Template editor page shows PDF on left, TemplatePanel on right
+7. FieldPlacer drag-drop works (fields appear on PDF)
+8. "Signers / Roles" section shows Buyer and Seller by default
+9. "AI Auto-place Fields" button is clickable (requires OPENAI_API_KEY for actual placement)
+10. "Save Template" button persists fields and name
+
+
+
+- All four new files exist and compile cleanly
+- Templates list page is accessible at /portal/templates
+- Template editor is accessible at /portal/templates/[id]
+- FieldPlacer operates in template mode (onPersist saves to /api/templates/[id], fields load from /api/templates/[id]/fields, PDF loads from /api/templates/[id]/file)
+- Role labels (not emails) are used in the template editor
+- Text hints on client-text fields are merged into field.hint before persisting
+- AI Auto-place triggers POST /api/templates/[id]/ai-prepare
+- Save persists via PATCH /api/templates/[id]
+
+
+
diff --git a/.planning/phases/19-template-editor-ui/19-03-PLAN.md b/.planning/phases/19-template-editor-ui/19-03-PLAN.md
new file mode 100644
index 0000000..c6da54b
--- /dev/null
+++ b/.planning/phases/19-template-editor-ui/19-03-PLAN.md
@@ -0,0 +1,102 @@
+---
+phase: 19-template-editor-ui
+plan: "03"
+type: execute
+wave: 3
+depends_on: ["19-02"]
+files_modified: []
+autonomous: false
+requirements: [TMPL-05, TMPL-06, TMPL-07, TMPL-08, TMPL-09]
+
+must_haves:
+ truths:
+ - "Agent can open a template editor and drag fields onto the PDF"
+ - "Agent can use AI auto-place to populate fields"
+ - "Agent can assign signer roles instead of emails"
+ - "Agent can set text hints on text fields"
+ - "Agent can save and fields persist across refresh"
+ artifacts: []
+ key_links: []
+---
+
+
+Human verification of all Phase 19 TMPL-05 through TMPL-09 requirements via live browser testing.
+
+Purpose: Confirm the template editor UI works end-to-end before marking Phase 19 complete.
+
+Output: Human sign-off on all 5 requirements.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+
+
+
+@.planning/phases/19-template-editor-ui/19-CONTEXT.md
+@.planning/phases/19-template-editor-ui/19-01-SUMMARY.md
+@.planning/phases/19-template-editor-ui/19-02-SUMMARY.md
+
+
+
+
+
+ Task 1: Full Phase 19 human verification — template editor E2E
+
+ Human verifies all TMPL-05 through TMPL-09 requirements in a single 9-step browser test.
+
+ What was built:
+ - Templates list page at /portal/templates with create-template modal
+ - Template editor page at /portal/templates/[id] with FieldPlacer + TemplatePanel
+ - AI auto-place for templates via POST /api/templates/[id]/ai-prepare
+ - Signer role label system (Buyer/Seller instead of emails)
+ - Text hint support on client-text fields
+ - Save via PATCH /api/templates/[id]
+ - Three supporting API routes (file, fields, ai-prepare)
+ - "Templates" link in portal nav
+
+
+ Ensure the dev server is running: `cd teressa-copeland-homes && npm run dev`
+
+ **Step 1 — Nav link (TMPL-05 prereq):**
+ Visit http://localhost:3000/portal/dashboard. Confirm "Templates" appears in the top nav between "Clients" and "Profile". Click it.
+
+ **Step 2 — Templates list page:**
+ Confirm the list page loads at /portal/templates. If empty, verify the empty state message "No templates yet" is shown.
+
+ **Step 3 — Create a template:**
+ Click "+ New Template". In the modal, enter a name (e.g. "Test Listing Agreement") and select a form from the dropdown. Click "Create Template". Verify redirect to the editor page.
+
+ **Step 4 — TMPL-05: Drag-drop fields:**
+ On the editor page, verify the PDF is displayed on the left and TemplatePanel is on the right. Drag a "Signature" token from the palette onto the PDF. Verify it appears at the drop location. Drag a "Text" token. Verify it also appears.
+
+ **Step 5 — TMPL-07: Signer role labels:**
+ Verify "Signers / Roles" section shows "Buyer" and "Seller" by default. Place a field while "Buyer" is the active role. Verify the field appears with the Buyer color. Add a custom role (e.g. "Lender") using the Add Role input. Verify no email validation error occurs.
+
+ **Step 6 — TMPL-06: AI Auto-place:**
+ Click "AI Auto-place Fields". Wait for the spinner. Verify fields appear on the PDF after completion. (Requires OPENAI_API_KEY in .env.local — skip if not configured and note in feedback.)
+
+ **Step 7 — TMPL-08: Text hints:**
+ Click on a placed text field. Type a hint value (e.g. "Enter property address"). Click "Save Template". Refresh the page (Ctrl+R). Verify the hint value is still displayed in the text field.
+
+ **Step 8 — TMPL-09: Save persists:**
+ After placing several fields and saving, refresh the page. Verify all field positions, types, and role assignments are preserved. Verify the template name is preserved.
+
+ **Step 9 — Templates list page verification:**
+ Navigate back to /portal/templates. Verify the template you created appears in the list with the correct name, form name, field count, and a recent "last updated" date.
+
+ Human confirms all 9 verification steps pass. All TMPL-05 through TMPL-09 requirements satisfied. Phase 19 is complete.
+
+
+
+
+
+All 5 requirements (TMPL-05 through TMPL-09) verified by human in a single 9-step live browser test.
+
+
+
+Human confirms all 9 verification steps pass. Phase 19 is complete.
+
+
+