Files

28 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
19-template-editor-ui 02 execute 2
19-01
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
true
TMPL-05
TMPL-06
TMPL-07
TMPL-08
TMPL-09
truths artifacts key_links
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
path provides contains
teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx Templates list page with create modal TemplatesPage
path provides contains
teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/page.tsx Template editor server component TemplateEditorPage
path provides contains
teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx Template editor state owner TemplatePageClient
path provides contains
teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx Right panel with roles, AI button, save button TemplatePanel
from to via pattern
TemplatePageClient.tsx /api/templates/[id] handlePersist callback passed as onPersist to PdfViewerWrapper PATCH.*templates.*signatureFields
from to via pattern
TemplatePageClient.tsx PdfViewerWrapper fileUrl + fieldsUrl + onPersist props fileUrl.*fieldsUrl.*onPersist
from to via pattern
TemplatePanel.tsx /api/templates/[id]/ai-prepare AI Auto-place button POST call ai-prepare.*POST
from to via pattern
TemplatePanel.tsx /api/templates/[id] Save button PATCH call 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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/19-template-editor-ui/19-UI-SPEC.md @.planning/phases/19-template-editor-ui/19-01-SUMMARY.md

From PdfViewerWrapper.tsx (after Plan 01):

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<string, string>;
  onFieldSelect?: (fieldId: string | null) => void;
  onFieldValueChange?: (fieldId: string, value: string) => void;
  aiPlacementKey?: number;
  signers?: DocumentSigner[];
  unassignedFieldIds?: Set<string>;
  onPersist?: (fields: SignatureFieldData[]) => Promise<void> | void;
  fieldsUrl?: string;
  fileUrl?: string;
})

From schema.ts:

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<SignatureFieldData[]>(),
  archivedAt: timestamp("archived_at"),
  createdAt: timestamp("created_at"), updatedAt: timestamp("updated_at"),
});

From DocumentPageClient.tsx (pattern to mirror):

const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
const [aiPlacementKey, setAiPlacementKey] = useState(0);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const [textFillData, setTextFillData] = useState<Record<string, string>>({});

From clients/page.tsx (list page pattern):

export default async function ClientsPage() {
  const clientRows = await db.select({ ... }).from(clients)...;
  return <ClientsPageClient clients={clientRows} />;
}

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:
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));
  1. Also queries all form templates for the form picker in the create modal:
const forms = await db.select({ id: formTemplates.id, name: formTemplates.name }).from(formTemplates).orderBy(formTemplates.name);
  1. 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" — <select> dropdown populated from forms prop (required). Each option: <option value={form.id}>{form.name}</option>
  • CTA: "Create Template" button (gold fill). On click:
    • POST /api/templates with { name, formTemplateId }
    • On success (201): router.push(\/portal/templates/${response.id}`)` to navigate to the new editor
    • On error: show inline error message in red
  • Cancel button or click overlay to close

Styling: inline styles + Tailwind utilities matching existing portal patterns. No shadcn. Background #FAF9F7 for page, #F9FAFB for modal card. Use var(--font-sans) for all text. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30 <acceptance_criteria> - 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 </acceptance_criteria> 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):
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 (
    <TemplatePageClient
      templateId={template.id}
      templateName={template.name}
      formName={template.formTemplate.name}
      initialFields={template.signatureFields ?? []}
    />
  );
}

Create src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx — client state owner mirroring DocumentPageClient:

'use client';

Props interface:

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:

const ROLE_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];

function deriveRolesFromFields(fields: SignatureFieldData[]): DocumentSigner[] {
  const seen = new Map<string, string>();
  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<DocumentSigner[]>(() => deriveRolesFromFields(initialFields));
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const [textFillData, setTextFillData] = useState<Record<string, string>>(() => {
  // Initialize textFillData from existing field.hint values so hints display in inline inputs
  const data: Record<string, string> = {};
  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 text fields (per Research Pitfall 6). IMPORTANT: The schema type value is 'text' (from SignatureFieldType), NOT 'client-text'. Use f.type === 'text' exactly:

const handlePersist = useCallback(async (rawFields: SignatureFieldData[]) => {
  const fieldsWithHints = rawFields.map(f =>
    f.type === '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]);

handleFieldValueChange:

const handleFieldValueChange = useCallback((fieldId: string, value: string) => {
  setTextFillData(prev => ({ ...prev, [fieldId]: value }));
}, []);

handleFieldsChanged:

const handleFieldsChanged = useCallback(() => {
  // No preview token to reset in template mode — no-op
}, []);

handleAiAutoPlace — passed down to TemplatePanel:

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:

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:

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:

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:

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):

<div style={{ maxWidth: 1200, margin: '0 auto', padding: 32 }}>
  <h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: '#1B2B4B', marginBottom: 24 }}>
    Edit Template: {name}
  </h1>
  <div style={{ display: 'flex', gap: 24 }}>
    <div style={{ flex: 1, minWidth: 0 }}>
      <PdfViewerWrapper
        docId={templateId}
        docStatus="Draft"
        onFieldsChanged={handleFieldsChanged}
        selectedFieldId={selectedFieldId}
        textFillData={textFillData}
        onFieldSelect={setSelectedFieldId}
        onFieldValueChange={handleFieldValueChange}
        aiPlacementKey={aiPlacementKey}
        signers={signers}
        onPersist={handlePersist}
        fieldsUrl={`/api/templates/${templateId}/fields`}
        fileUrl={`/api/templates/${templateId}/file`}
      />
    </div>
    <TemplatePanel
      templateId={templateId}
      name={name}
      onNameChange={setName}
      signers={signers}
      onAddRole={handleAddRole}
      onRenameRole={handleRenameRole}
      onRemoveRole={handleRemoveRole}
      onAiAutoPlace={handleAiAutoPlace}
      onSave={handleSave}
    />
  </div>
</div>

Create src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx — right panel (per D-13, UI-SPEC):

'use client';

Props:

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<void>;
  onSave: () => Promise<void>;
}

Layout — 280px fixed width, flex-shrink 0, sticky:

  1. Template name<input> with value name, onChange calls onNameChange, onBlur calls PATCH to save name immediately (per Research Pitfall 3):
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.

  1. 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 <input> 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".
  1. 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)".

  2. 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().

const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(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); }
};
  1. 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.
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 <acceptance_criteria> - 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 containsfileUrl={`/api/templates/${templateId}/file`}- TemplatePageClient.tsx containsderiveRolesFromFieldsfunction - TemplatePageClient.tsx containsf.type === 'text'(NOT'client-text') in the hint-merge branch of handlePersist - TemplatePageClient.tsx contains f.hintorhint: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 containsConfirmDialogimport for role removal -npx tsc --noEmit` exits 0 </acceptance_criteria> 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

<success_criteria>

  • 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 text fields (f.type === 'text') are merged into field.hint before persisting
  • AI Auto-place triggers POST /api/templates/[id]/ai-prepare
  • Save persists via PATCH /api/templates/[id] </success_criteria>
After completion, create `.planning/phases/19-template-editor-ui/19-02-SUMMARY.md`