From c03bff8736d721e8e998415070b3ac45d71b327f Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Mon, 6 Apr 2026 12:46:48 -0600 Subject: [PATCH] =?UTF-8?q?docs(19):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=203=20plans=20in=203=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 9 +- .../19-template-editor-ui/19-01-PLAN.md | 497 ++++++++++++++ .../19-template-editor-ui/19-02-PLAN.md | 620 ++++++++++++++++++ .../19-template-editor-ui/19-03-PLAN.md | 102 +++ 4 files changed, 1227 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/19-template-editor-ui/19-01-PLAN.md create mode 100644 .planning/phases/19-template-editor-ui/19-02-PLAN.md create mode 100644 .planning/phases/19-template-editor-ui/19-03-PLAN.md 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 + + + +After completion, create `.planning/phases/19-template-editor-ui/19-01-SUMMARY.md` + 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" — `` 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] + + + +After completion, create `.planning/phases/19-template-editor-ui/19-02-SUMMARY.md` + 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. + + + +After completion, create `.planning/phases/19-template-editor-ui/19-03-SUMMARY.md` +