Files
red/.planning/phases/05-pdf-fill-and-field-mapping/05-02-PLAN.md
2026-03-19 23:44:23 -06:00

395 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 05-pdf-fill-and-field-mapping
plan: 02
type: execute
wave: 2
depends_on: [05-01]
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
autonomous: true
requirements: [DOC-04]
must_haves:
truths:
- "Agent sees a field palette on the document detail page with a draggable Signature Field token"
- "Agent can drag the Signature Field token onto any part of any PDF page and release it to place a field"
- "Placed fields appear as blue-bordered semi-transparent rectangles overlaid on the correct position of the PDF page"
- "Stored fields persist across page reload (coordinates are saved to the server via PUT /api/documents/[id]/fields)"
- "Fields placed at the top of a page have high PDF Y values (near originalHeight); fields placed at the bottom have low PDF Y values — Y-axis flip is correct"
- "Placed fields can be deleted individually via a remove button"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx"
provides: "dnd-kit DndContext with draggable token palette and droppable PDF page overlay"
min_lines: 80
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx"
provides: "Extended to accept and render placed fields as absolute-positioned overlays; exposes pageInfo via onPageLoad callback"
key_links:
- from: "FieldPlacer.tsx onDragEnd"
to: "screenToPdfCoords() formula"
via: "inline coordinate conversion using pageContainerRef.getBoundingClientRect()"
pattern: "renderedH - screenY.*originalHeight"
- from: "FieldPlacer.tsx"
to: "PUT /api/documents/[id]/fields"
via: "fetch PUT on every field add/remove"
pattern: "fetch.*fields.*PUT"
- from: "PdfViewer.tsx Page"
to: "FieldPlacer.tsx pageInfo state"
via: "onLoadSuccess callback sets pageInfo: { originalWidth, originalHeight, width, height, scale }"
pattern: "onLoadSuccess.*originalWidth"
---
<objective>
Extend the document detail page with a drag-and-drop field placer. The agent drags a Signature Field token from a palette onto any PDF page. On drop, screen coordinates are converted to PDF user-space coordinates (Y-axis flip formula), stored in state and persisted via PUT /api/documents/[id]/fields. Placed fields are rendered as blue rectangle overlays on the PDF. Fields load from the server on mount.
Purpose: Fulfills DOC-04 — agent can place signature fields on any page of a PDF and those coordinates survive for downstream PDF preparation and signing.
Output: FieldPlacer.tsx client component + extended PdfViewer.tsx.
</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 (after Plan 01):
```typescript
export interface SignatureFieldData {
id: string;
page: number; // 1-indexed
x: number; // PDF user space, bottom-left origin, points
y: number; // PDF user space, bottom-left origin, points
width: number; // PDF points
height: number; // PDF points
}
```
From teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx (current — MODIFY):
```typescript
'use client';
import { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
export function PdfViewer({ docId }: { docId: string }) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1.0);
// ...renders Document + Page with Prev/Next, Zoom In/Out, Download
}
```
API contract (from Plan 01):
- GET /api/documents/[id]/fields → SignatureFieldData[] (returns [] if none)
- PUT /api/documents/[id]/fields → body: SignatureFieldData[] → returns updated array
Coordinate conversion (CRITICAL — do not re-derive):
```typescript
// Screen (DOM) → PDF user space. Y-axis flip required.
// DOM: Y=0 at top, increases downward
// PDF: Y=0 at bottom, increases upward
function screenToPdfCoords(screenX: number, screenY: number, pageInfo: PageInfo) {
const pdfX = (screenX / pageInfo.width) * pageInfo.originalWidth;
const pdfY = ((pageInfo.height - screenY) / pageInfo.height) * pageInfo.originalHeight;
return { x: pdfX, y: pdfY };
}
// PDF user space → screen (for rendering stored fields)
function pdfToScreenCoords(pdfX: number, pdfY: number, pageInfo: PageInfo) {
const left = (pdfX / pageInfo.originalWidth) * pageInfo.width;
// top is measured from DOM top; pdfY is from PDF bottom — reverse flip
const top = pageInfo.height - (pdfY / pageInfo.originalHeight) * pageInfo.height;
return { left, top };
}
```
Pitfall guard for originalHeight:
```typescript
// Some PDFs have non-standard mediaBox ordering — use Math.max to handle both
originalWidth: Math.max(page.view[0], page.view[2]),
originalHeight: Math.max(page.view[1], page.view[3]),
```
dnd-kit drop position pattern (from research — MEDIUM confidence, verify during impl):
```typescript
// The draggable token is in the palette, not on the canvas.
// Use DragOverlay for visual ghost + compute final position from
// the mouse/touch coordinates at drop time relative to the container rect.
// event.delta gives displacement from drag start position of the activator.
// For an item dragged FROM the palette onto the PDF zone:
// finalX = activatorClientX + event.delta.x - containerRect.left
// finalY = activatorClientY + event.delta.y - containerRect.top
// The activator coordinates come from event.activatorEvent (MouseEvent or TouchEvent).
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Install dnd-kit and create FieldPlacer component</name>
<files>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
</files>
<action>
**Step A — Install dnd-kit:**
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm install @dnd-kit/core @dnd-kit/utilities
```
**Step B — Create FieldPlacer.tsx.**
This is a client component. It:
1. Fetches existing fields from GET /api/documents/[id]/fields on mount
2. Renders a palette with a single draggable "Signature Field" token using `useDraggable`
3. Renders the PDF page container as a `useDroppable` zone
4. On drop, converts screen coordinates to PDF user-space using the Y-flip formula, adds the new field to state, persists via PUT /api/documents/[id]/fields
5. Renders placed fields as absolute-positioned divs over the PDF page (using pdfToScreenCoords)
6. Each placed field has an X button to delete it (removes from state, persists)
Key implementation details:
- Accept props: `{ docId: string; pageInfo: PageInfo | null; currentPage: number; children: React.ReactNode }` where `children` is the `<Document>/<Page>` tree rendered by PdfViewer
- The droppable zone wraps the children (PDF canvas) with `position: relative` so overlays position correctly
- Default field size: 144 × 36 PDF points (2 inches × 0.5 inches at 72 DPI)
- Use `DragOverlay` to show a ghost during drag (better UX than transform-based dragging)
- The page container ref (`useRef<HTMLDivElement>`) is attached to the droppable wrapper div — use `getBoundingClientRect()` at drop time for current rendered dimensions (NOT stale pageInfo.width because zoom may have changed)
- Persist via async fetch (fire-and-forget with error logging — don't block UI)
- Fields state: `useState<SignatureFieldData[]>([])` loaded from server on mount
PageInfo interface (define locally in this file):
```typescript
interface PageInfo {
originalWidth: number;
originalHeight: number;
width: number;
height: number;
scale: number;
}
```
Structure:
```typescript
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import {
DndContext,
useDraggable,
useDroppable,
DragOverlay,
type DragEndEvent,
} from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import type { SignatureFieldData } from '@/lib/db/schema';
// ... DraggableToken sub-component using useDraggable
// ... FieldPlacer main component
```
Coordinate math — use EXACTLY this formula (do not re-derive):
```typescript
function screenToPdfCoords(screenX: number, screenY: number, containerRect: DOMRect, pageInfo: PageInfo) {
// Use containerRect dimensions (current rendered size) not stale pageInfo
const renderedW = containerRect.width;
const renderedH = containerRect.height;
const pdfX = (screenX / renderedW) * pageInfo.originalWidth;
const pdfY = ((renderedH - screenY) / renderedH) * pageInfo.originalHeight;
return { x: pdfX, y: pdfY };
}
function pdfToScreenCoords(pdfX: number, pdfY: number, containerRect: DOMRect, pageInfo: PageInfo) {
const renderedW = containerRect.width;
const renderedH = containerRect.height;
const left = (pdfX / pageInfo.originalWidth) * renderedW;
const top = renderedH - (pdfY / pageInfo.originalHeight) * renderedH;
return { left, top };
}
```
Persist helper (keep outside component to avoid re-creation):
```typescript
async function persistFields(docId: string, fields: SignatureFieldData[]) {
try {
await fetch(`/api/documents/${docId}/fields`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
});
} catch (e) {
console.error('Failed to persist fields:', e);
}
}
```
Field overlay rendering — subtract height because PDF Y is at bottom-left, but DOM top is at top-left:
```typescript
{fields.filter(f => f.page === currentPage).map(field => {
const containerRect = containerRef.current?.getBoundingClientRect();
if (!containerRect || !pageInfo) return null;
const { left, top } = pdfToScreenCoords(field.x, field.y, containerRect, pageInfo);
const widthPx = (field.width / pageInfo.originalWidth) * containerRect.width;
const heightPx = (field.height / pageInfo.originalHeight) * containerRect.height;
return (
<div key={field.id} style={{
position: 'absolute',
left,
top: top - heightPx, // top is the y of the bottom-left corner; shift up by height for correct placement
width: widthPx,
height: heightPx,
border: '2px solid #2563eb',
background: 'rgba(37,99,235,0.1)',
borderRadius: '2px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 4px',
fontSize: '10px',
color: '#2563eb',
pointerEvents: 'all',
}}>
<span>Signature</span>
<button
onClick={() => {
const next = fields.filter(f => f.id !== field.id);
setFields(next);
persistFields(docId, next);
}}
style={{ cursor: 'pointer', background: 'none', border: 'none', color: '#ef4444', fontWeight: 'bold', padding: '0 2px' }}
aria-label="Remove field"
>×</button>
</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>
- FieldPlacer.tsx exists and exports FieldPlacer component
- @dnd-kit/core and @dnd-kit/utilities in package.json
- Component accepts docId, pageInfo, currentPage, children props
- Uses exactly the Y-flip formula from research (no re-derivation)
- npm run build compiles without TypeScript errors
</done>
</task>
<task type="auto">
<name>Task 2: Extend PdfViewer to expose pageInfo and integrate FieldPlacer</name>
<files>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx
</files>
<action>
Rewrite PdfViewer.tsx to:
1. Add `pageInfo` state (`useState<PageInfo | null>(null)`) where PageInfo is `{ originalWidth, originalHeight, width, height, scale }`
2. Update the `<Page>` component's `onLoadSuccess` callback to set pageInfo using the `Math.max` pattern for originalWidth/Height:
```typescript
onLoadSuccess={(page) => {
setPageInfo({
originalWidth: Math.max(page.view[0], page.view[2]),
originalHeight: Math.max(page.view[1], page.view[3]),
width: page.width,
height: page.height,
scale: page.scale,
});
}}
```
3. Import and wrap the `<Document><Page /></Document>` tree inside `<FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber}>`. FieldPlacer renders children (the PDF canvas) plus the droppable zone + field overlays.
4. Add a `docId` prop to PdfViewer: `{ docId: string }` (already exists — no change needed)
5. Keep all existing controls (Prev, Next, Zoom In, Zoom Out, Download) unchanged
Note on PdfViewerWrapper: PdfViewerWrapper.tsx (dynamic import wrapper) does NOT need to change — it already passes `docId` through to PdfViewer.
Note on `page.view`: For react-pdf v10 (installed), the `Page.onLoadSuccess` callback receives a `page` object where `page.view` is `[x1, y1, x2, y2]`. For standard US Letter PDFs this is `[0, 0, 612, 792]`. The `Math.max` pattern handles non-standard mediaBox ordering.
Do NOT use both `width` and `scale` props on `<Page>` — use only `scale`. Using both causes double scaling.
The final JSX structure should be:
```tsx
<div className="flex flex-col items-center gap-4">
{/* controls toolbar */}
<div className="flex items-center gap-3 text-sm">
{/* Prev, page counter, Next, Zoom In, Zoom Out, Download — keep existing */}
</div>
{/* PDF + field overlay */}
<FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber}>
<Document
file={`/api/documents/${docId}/file`}
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
className="shadow-lg"
>
<Page
pageNumber={pageNumber}
scale={scale}
onLoadSuccess={(page) => {
setPageInfo({
originalWidth: Math.max(page.view[0], page.view[2]),
originalHeight: Math.max(page.view[1], page.view[3]),
width: page.width,
height: page.height,
scale: page.scale,
});
}}
/>
</Document>
</FieldPlacer>
</div>
```
After modifying PdfViewer.tsx, run build to confirm no TypeScript errors:
```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>
- PdfViewer.tsx has pageInfo state with Math.max mediaBox pattern on onLoadSuccess
- FieldPlacer is imported and wraps the Document/Page tree
- Only scale prop used on Page (not both width + scale)
- npm run build compiles without TypeScript errors
- PdfViewerWrapper.tsx unchanged
</done>
</task>
</tasks>
<verification>
After both tasks complete:
1. `npm run build` in teressa-copeland-homes — clean compile
2. Run `npm run dev` and navigate to any document detail page
3. A "Signature Field" draggable token appears in a palette area above or beside the PDF
4. Drag the token onto the PDF page — a blue rectangle appears at the drop location
5. Refresh the page — the blue rectangle is still there (persisted to DB)
6. Click the × button on a placed field — it disappears from the overlay and from DB
7. Navigate to page 2 of a multi-page document — fields placed on page 1 don't appear on page 2
</verification>
<success_criteria>
- Agent can drag a Signature Field token onto any PDF page and see a blue rectangle overlay at the correct position
- Coordinates stored in PDF user space (Y-flip applied) — placing a field at visual top of page stores high pdfY value
- Fields persist across page reload (PUT /api/documents/[id]/fields called on every change)
- Fields are page-scoped (field.page === currentPage filter applied)
- npm run build is clean
</success_criteria>
<output>
After completion, create `.planning/phases/05-pdf-fill-and-field-mapping/05-02-SUMMARY.md`
</output>