617 lines
28 KiB
Markdown
617 lines
28 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</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/19-template-editor-ui/19-UI-SPEC.md
|
|
@.planning/phases/19-template-editor-ui/19-01-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- Types and contracts from Plan 01 output + existing codebase. -->
|
|
|
|
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<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:
|
|
```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<SignatureFieldData[]>(),
|
|
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<string | null>(null);
|
|
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
|
|
```
|
|
|
|
From clients/page.tsx (list page pattern):
|
|
```typescript
|
|
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)
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create templates list page with create-template modal</name>
|
|
<files>
|
|
teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx
|
|
</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<action>
|
|
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" — `<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.
|
|
</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/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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create template editor page — server component, TemplatePageClient, and TemplatePanel</name>
|
|
<files>
|
|
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
|
|
</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<action>
|
|
**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 (
|
|
<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:
|
|
|
|
```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<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:
|
|
```typescript
|
|
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`:
|
|
```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
|
|
<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):
|
|
|
|
```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<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):
|
|
```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 `<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".
|
|
|
|
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<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); }
|
|
};
|
|
```
|
|
|
|
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`).
|
|
</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/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.type === 'text'` (NOT `'client-text'`) in the hint-merge branch of handlePersist
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/19-template-editor-ui/19-02-SUMMARY.md`
|
|
</output>
|