16 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-pdf-fill-and-field-mapping | 03 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom teressa-copeland-homes/src/lib/db/schema.ts (clients table — used for client selector):
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):
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
'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:
'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>
);
}
Updated page.tsx:
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"
>
← 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} · <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:
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -15
<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>