Files

440 lines
16 KiB
Markdown
Raw Permalink Normal View History

---
phase: 05-pdf-fill-and-field-mapping
plan: 03
type: execute
wave: 2
depends_on: [05-01]
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/TextFillForm.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx
autonomous: true
requirements: [DOC-05, DOC-06]
must_haves:
truths:
- "Agent sees a text fill form below (or beside) the PDF viewer where they can add key-value pairs (label + value)"
- "Agent can add up to 10 key-value text fill rows and remove individual rows"
- "Agent sees a client selector dropdown pre-populated with the current document's assigned client (or all clients if unassigned)"
- "Agent clicks Prepare and Send and receives feedback (loading state then success or error message)"
- "After Prepare and Send succeeds, the document status badge on the dashboard shows Sent"
- "The prepared PDF file exists on disk at uploads/clients/{clientId}/{docId}_prepared.pdf"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/TextFillForm.tsx"
provides: "Key-value form for agent text field data"
min_lines: 50
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx"
provides: "Combined panel: client selector + text fill form + Prepare and Send button"
min_lines: 60
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx"
provides: "Extended document detail page: fetches clients list, passes to PreparePanel"
key_links:
- from: "PreparePanel.tsx"
to: "POST /api/documents/[id]/prepare"
via: "fetch POST with { textFillData, assignedClientId }"
pattern: "fetch.*prepare.*POST"
- from: "page.tsx"
to: "PreparePanel.tsx"
via: "server component fetches clients list, passes as prop"
pattern: "db.*clients.*PreparePanel"
- from: "POST /api/documents/[id]/prepare response"
to: "document status Sent"
via: "router.refresh() after successful prepare"
pattern: "router\\.refresh"
---
<objective>
Add the text fill form and Prepare and Send workflow to the document detail page. Agent can add labeled text values (property address, client names, dates), select the assigned client, then trigger document preparation. The server fills AcroForm fields (or draws text), burns signature rectangles, writes the prepared PDF, and transitions document status to Sent.
Purpose: Fulfills DOC-05 (text fill) and DOC-06 (assign to client + initiate signing request). Completes the agent-facing preparation workflow before Phase 6 sends the actual email.
Output: TextFillForm, PreparePanel components + extended document page.
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/05-pdf-fill-and-field-mapping/05-RESEARCH.md
@.planning/phases/05-pdf-fill-and-field-mapping/05-01-SUMMARY.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From teressa-copeland-homes/src/lib/db/schema.ts (clients table — used for client selector):
```typescript
export const clients = pgTable("clients", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
email: text("email").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
From teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx (current — MODIFY):
```typescript
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { documents, clients } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import Link from 'next/link';
import { PdfViewerWrapper } from './_components/PdfViewerWrapper';
export default async function DocumentPage({
params,
}: {
params: Promise<{ docId: string }>;
}) {
const session = await auth();
if (!session) redirect('/login');
const { docId } = await params;
const doc = await db.query.documents.findFirst({
where: eq(documents.id, docId),
with: { client: true },
});
if (!doc) redirect('/portal/dashboard');
return (
<div className="max-w-5xl mx-auto px-4 py-6">
{/* header with back link, title */}
<PdfViewerWrapper docId={docId} />
</div>
);
}
```
API contract (from Plan 01):
- POST /api/documents/[id]/prepare
- body: { textFillData?: Record<string, string>; assignedClientId?: string }
- returns: updated document row (with status: 'Sent', sentAt, preparedFilePath)
- 422 if document has no filePath
Project patterns (from STATE.md):
- useActionState imported from 'react' not 'react-dom' (React 19)
- Client sub-components extracted to _components/ (e.g. ClientProfileClient, DashboardFilters)
- 'use client' at file top (cannot inline in server component file)
- Router refresh for post-action UI update: useRouter().refresh() from 'next/navigation'
- StatusBadge already exists in _components — use it for displaying doc status
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create TextFillForm and PreparePanel client components</name>
<files>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/TextFillForm.tsx
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx
</files>
<action>
**TextFillForm.tsx** — a simple key-value pair builder for text fill data:
```typescript
'use client';
import { useState } from 'react';
interface TextRow { label: string; value: string; }
interface TextFillFormProps {
onChange: (data: Record<string, string>) => void;
}
export function TextFillForm({ onChange }: TextFillFormProps) {
const [rows, setRows] = useState<TextRow[]>([{ label: '', value: '' }]);
function updateRow(index: number, field: 'label' | 'value', val: string) {
const next = rows.map((r, i) => i === index ? { ...r, [field]: val } : r);
setRows(next);
onChange(Object.fromEntries(next.filter(r => r.label).map(r => [r.label, r.value])));
}
function addRow() {
if (rows.length >= 10) return;
setRows([...rows, { label: '', value: '' }]);
}
function removeRow(index: number) {
const next = rows.filter((_, i) => i !== index);
setRows(next.length ? next : [{ label: '', value: '' }]);
onChange(Object.fromEntries(next.filter(r => r.label).map(r => [r.label, r.value])));
}
return (
<div className="space-y-2">
<p className="text-xs text-gray-500">
Field label = AcroForm field name in the PDF (e.g. "PropertyAddress"). Leave blank to skip.
</p>
{rows.map((row, i) => (
<div key={i} className="flex gap-2 items-center">
<input
placeholder="Field label"
value={row.label}
onChange={e => updateRow(i, 'label', e.target.value)}
className="flex-1 border rounded px-2 py-1 text-sm"
/>
<input
placeholder="Value"
value={row.value}
onChange={e => updateRow(i, 'value', e.target.value)}
className="flex-1 border rounded px-2 py-1 text-sm"
/>
<button
onClick={() => removeRow(i)}
className="text-red-400 hover:text-red-600 text-sm px-1"
aria-label="Remove row"
>×</button>
</div>
))}
{rows.length < 10 && (
<button
onClick={addRow}
className="text-blue-600 hover:text-blue-800 text-sm"
>
+ Add field
</button>
)}
</div>
);
}
```
**PreparePanel.tsx** — combines client selector, text fill form, and Prepare & Send button:
```typescript
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { TextFillForm } from './TextFillForm';
interface Client { id: string; name: string; email: string; }
interface PreparePanelProps {
docId: string;
clients: Client[];
currentClientId: string;
currentStatus: string;
}
export function PreparePanel({ docId, clients, currentClientId, currentStatus }: PreparePanelProps) {
const router = useRouter();
const [assignedClientId, setAssignedClientId] = useState(currentClientId);
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
// Don't show the panel if already sent/signed
const canPrepare = currentStatus === 'Draft';
async function handlePrepare() {
setLoading(true);
setResult(null);
try {
const res = await fetch(`/api/documents/${docId}/prepare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ textFillData, assignedClientId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
setResult({ ok: false, message: err.error ?? 'Prepare failed' });
} else {
setResult({ ok: true, message: 'Document prepared successfully. Status updated to Sent.' });
router.refresh(); // Update the page to reflect new status
}
} catch (e) {
setResult({ ok: false, message: String(e) });
} finally {
setLoading(false);
}
}
if (!canPrepare) {
return (
<div className="rounded-lg border border-gray-200 p-4 bg-gray-50 text-sm text-gray-500">
Document status is <strong>{currentStatus}</strong> — preparation is only available for Draft documents.
</div>
);
}
return (
<div className="rounded-lg border border-gray-200 p-4 space-y-4">
<h2 className="font-semibold text-gray-900">Prepare Document</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Assign to client</label>
<select
value={assignedClientId}
onChange={e => setAssignedClientId(e.target.value)}
className="w-full border rounded px-2 py-1.5 text-sm"
>
<option value="">— Select client —</option>
{clients.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.email})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Text fill fields</label>
<TextFillForm onChange={setTextFillData} />
</div>
<button
onClick={handlePrepare}
disabled={loading || !assignedClientId}
className="w-full py-2 px-4 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
{loading ? 'Preparing...' : 'Prepare and Send'}
</button>
{result && (
<p className={`text-sm ${result.ok ? 'text-green-600' : 'text-red-600'}`}>
{result.message}
</p>
)}
</div>
);
}
```
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error TS|Error:|compiled successfully|Build)" | head -10</automated>
</verify>
<done>
- TextFillForm.tsx exported from _components with onChange prop
- PreparePanel.tsx exported from _components with docId, clients, currentClientId, currentStatus props
- PreparePanel.tsx calls POST /api/documents/[id]/prepare on button click
- PreparePanel calls router.refresh() on success
- npm run build compiles without TypeScript errors
</done>
</task>
<task type="auto">
<name>Task 2: Extend document detail page to render PreparePanel</name>
<files>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx
</files>
<action>
Modify the DocumentPage server component to:
1. Import and query ALL clients (for the client selector dropdown): `await db.select().from(clients).orderBy(clients.name)`
2. Import PreparePanel and render it below the PdfViewerWrapper
3. Pass the document's current clientId as `currentClientId`, the clients array as `clients`, doc.status as `currentStatus`, and docId
Updated page.tsx:
```typescript
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { documents, clients } from '@/lib/db/schema';
import { eq, asc } from 'drizzle-orm';
import Link from 'next/link';
import { PdfViewerWrapper } from './_components/PdfViewerWrapper';
import { PreparePanel } from './_components/PreparePanel';
export default async function DocumentPage({
params,
}: {
params: Promise<{ docId: string }>;
}) {
const session = await auth();
if (!session) redirect('/login');
const { docId } = await params;
const [doc, allClients] = await Promise.all([
db.query.documents.findFirst({
where: eq(documents.id, docId),
with: { client: true },
}),
db.select().from(clients).orderBy(asc(clients.name)),
]);
if (!doc) redirect('/portal/dashboard');
return (
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="mb-4 flex items-center justify-between">
<div>
<Link
href={`/portal/clients/${doc.clientId}`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-blue-600 border border-blue-200 rounded-md bg-blue-50 hover:bg-blue-100 hover:border-blue-300 transition-colors"
>
&larr; Back to {doc.client?.name ?? 'Client'}
</Link>
<h1 className="text-2xl font-bold mt-1">{doc.name}</h1>
<p className="text-sm text-gray-500">
{doc.client?.name} &middot; <span className="capitalize">{doc.status}</span>
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<PdfViewerWrapper docId={docId} />
</div>
<div className="lg:col-span-1">
<PreparePanel
docId={docId}
clients={allClients}
currentClientId={doc.assignedClientId ?? doc.clientId}
currentStatus={doc.status}
/>
</div>
</div>
</div>
);
}
```
Note: The layout changes to a 2-column grid on large screens — PDF takes 2/3, PreparePanel takes 1/3. This is a standard portal pattern consistent with the existing split-panel design in the marketing site.
After updating, run build to verify:
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -15
```
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error TS|Error:|compiled successfully|Build)" | head -10</automated>
</verify>
<done>
- page.tsx fetches allClients in parallel with doc via Promise.all
- PreparePanel rendered in right column of 2/3 + 1/3 grid
- currentClientId defaults to doc.assignedClientId ?? doc.clientId
- npm run build compiles without TypeScript errors
</done>
</task>
</tasks>
<verification>
After both tasks complete:
1. `npm run build` — clean compile
2. Run `npm run dev`, navigate to any document detail page
3. Right side shows "Prepare Document" panel with:
- Client dropdown pre-selected to the document's current client
- Text fill form with one empty row and "+ Add field" link
- "Prepare and Send" button (disabled if no client selected)
4. Add a row: label "PropertyAddress", value "123 Main St" — click Prepare and Send
5. Success message appears; page refreshes showing status "Sent"
6. Dashboard shows document with status "Sent"
7. `ls uploads/clients/{clientId}/{docId}_prepared.pdf` — prepared file exists on disk
</verification>
<success_criteria>
- Agent can add labeled key-value text fill rows (up to 10, individually removable)
- Agent can select the client from a dropdown
- Clicking Prepare and Send calls POST /api/documents/[id]/prepare and shows loading/result feedback
- On success: document status transitions to Sent, router.refresh() updates the page
- PreparePanel shows read-only message if document status is not Draft
- npm run build is clean
</success_criteria>
<output>
After completion, create `.planning/phases/05-pdf-fill-and-field-mapping/05-03-SUMMARY.md`
</output>