498 lines
20 KiB
Markdown
498 lines
20 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.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
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs. -->
|
|
|
|
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<SignatureFieldData[]>(),
|
|
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<string, string>;
|
|
onFieldSelect?: (fieldId: string | null) => void;
|
|
onFieldValueChange?: (fieldId: string, value: string) => void;
|
|
aiPlacementKey?: number;
|
|
signers?: DocumentSigner[];
|
|
unassignedFieldIds?: Set<string>;
|
|
}
|
|
// 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<string, string>;
|
|
onFieldSelect?: (fieldId: string | null) => void;
|
|
onFieldValueChange?: (fieldId: string, value: string) => void;
|
|
aiPlacementKey?: number; signers?: DocumentSigner[]; unassignedFieldIds?: Set<string>; }
|
|
```
|
|
|
|
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" },
|
|
];
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Add onPersist + fieldsUrl props to FieldPlacer, fileUrl to PdfViewer/PdfViewerWrapper, and Templates to PortalNav</name>
|
|
<files>
|
|
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
|
|
</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<action>
|
|
**FieldPlacer.tsx — 3 changes:**
|
|
|
|
1. Add two optional props to `FieldPlacerProps` interface (after `unassignedFieldIds`):
|
|
```typescript
|
|
onPersist?: (fields: SignatureFieldData[]) => Promise<void> | 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> | 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 `<FieldPlacer>` (lines 95-107):
|
|
```typescript
|
|
<FieldPlacer
|
|
docId={docId}
|
|
...existing props...
|
|
onPersist={onPersist}
|
|
fieldsUrl={fieldsUrl}
|
|
>
|
|
```
|
|
|
|
**PdfViewerWrapper.tsx — 3 changes:**
|
|
|
|
1. Add three optional props to the inline type annotation:
|
|
```typescript
|
|
onPersist?: (fields: import('@/lib/db/schema').SignatureFieldData[]) => Promise<void> | 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 `<PdfViewer>`:
|
|
```typescript
|
|
<PdfViewer
|
|
...existing props...
|
|
onPersist={onPersist}
|
|
fieldsUrl={fieldsUrl}
|
|
fileUrl={fileUrl}
|
|
/>
|
|
```
|
|
|
|
**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" },
|
|
];
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- FieldPlacer.tsx contains `onPersist?: (fields: SignatureFieldData[]) => Promise<void> | 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)
|
|
</acceptance_criteria>
|
|
<done>All three viewer components accept optional template-mode props (onPersist, fieldsUrl, fileUrl) with backwards-compatible defaults. PortalNav shows Templates link. TypeScript compiles clean.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create three template API routes — file, fields, ai-prepare</name>
|
|
<files>
|
|
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
|
|
</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<action>
|
|
**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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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]/`
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- 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
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/19-template-editor-ui/19-01-SUMMARY.md`
|
|
</output>
|