Files
2026-03-19 23:44:23 -06:00

16 KiB
Raw Permalink Blame History

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
05-01
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
true
DOC-05
DOC-06
truths artifacts key_links
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
path provides min_lines
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/TextFillForm.tsx Key-value form for agent text field data 50
path provides min_lines
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx Combined panel: client selector + text fill form + Prepare and Send button 60
path provides
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx Extended document detail page: fetches clients list, passes to PreparePanel
from to via pattern
PreparePanel.tsx POST /api/documents/[id]/prepare fetch POST with { textFillData, assignedClientId } fetch.*prepare.*POST
from to via pattern
page.tsx PreparePanel.tsx server component fetches clients list, passes as prop db.*clients.*PreparePanel
from to via pattern
POST /api/documents/[id]/prepare response document status Sent router.refresh() after successful prepare router.refresh
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.

<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.md

From 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
Task 1: Create TextFillForm and PreparePanel client components teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/TextFillForm.tsx teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx **TextFillForm.tsx** — a simple key-value pair builder for text fill data:
'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>
  );
}
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error TS|Error:|compiled successfully|Build)" | head -10 - 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 Task 2: Extend document detail page to render PreparePanel teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx 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:

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:

cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -15
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error TS|Error:|compiled successfully|Build)" | head -10 - 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 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

<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>
After completion, create `.planning/phases/05-pdf-fill-and-field-mapping/05-03-SUMMARY.md`