Files

429 lines
17 KiB
Markdown
Raw Permalink Normal View History

---
phase: 04-pdf-ingest
plan: 03
type: execute
wave: 3
depends_on:
- 04-01
- 04-02
files_modified:
- teressa-copeland-homes/src/app/portal/clients/[id]/page.tsx
- teressa-copeland-homes/src/app/portal/clients/[id]/_components/AddDocumentModal.tsx
- teressa-copeland-homes/src/app/portal/documents/[docId]/page.tsx
- teressa-copeland-homes/src/app/portal/documents/[docId]/_components/PdfViewer.tsx
- teressa-copeland-homes/package.json
- teressa-copeland-homes/next.config.ts
autonomous: true
requirements:
- DOC-01
- DOC-03
must_haves:
truths:
- "Client profile page has an 'Add Document' button that opens a modal"
- "Modal shows searchable list of forms from the library; agent can filter by typing"
- "Modal has a 'Browse files' option for custom PDF upload (file picker)"
- "After adding a document, the modal closes and the new document appears in the client's documents list without page reload"
- "Clicking a document name navigates to the document detail page"
- "Document detail page renders the PDF in the browser using react-pdf with page nav and zoom controls"
- "Document detail page shows document name, client name, and a back link to the client profile"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/clients/[id]/_components/AddDocumentModal.tsx"
provides: "Client component — searchable forms library modal with file picker fallback"
exports: ["AddDocumentModal"]
- path: "teressa-copeland-homes/src/app/portal/documents/[docId]/_components/PdfViewer.tsx"
provides: "Client component — react-pdf canvas renderer with page nav and zoom"
exports: ["PdfViewer"]
- path: "teressa-copeland-homes/src/app/portal/documents/[docId]/page.tsx"
provides: "Document detail page — server component wrapping PdfViewer"
key_links:
- from: "AddDocumentModal.tsx"
to: "/api/forms-library"
via: "fetch on modal open"
pattern: "fetch.*forms-library"
- from: "AddDocumentModal.tsx"
to: "/api/documents"
via: "fetch POST on form submit"
pattern: "fetch.*api/documents"
- from: "PdfViewer.tsx"
to: "/api/documents/{docId}/file"
via: "react-pdf Document file prop"
pattern: "api/documents.*file"
---
<objective>
Install react-pdf, build the "Add Document" modal on the client profile page, and build the PDF viewer document detail page. This delivers the visible, agent-facing portion of Phase 4.
Purpose: Agent can browse the forms library, add a document to a client, and view it rendered in the browser.
Output: AddDocumentModal component, PdfViewer component, document detail page route.
</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/phases/04-pdf-ingest/04-CONTEXT.md
@.planning/phases/04-pdf-ingest/04-RESEARCH.md
@.planning/phases/04-pdf-ingest/04-01-SUMMARY.md
@.planning/phases/04-pdf-ingest/04-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Install react-pdf and configure Next.js for ESM compatibility</name>
<files>
teressa-copeland-homes/package.json
teressa-copeland-homes/next.config.ts
</files>
<action>
Run:
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm install react-pdf
```
react-pdf v9+ pulls in pdfjs-dist as a peer dep automatically.
Open `next.config.ts` and add `transpilePackages` if not already present:
```typescript
const nextConfig = {
// ... existing config ...
transpilePackages: ['react-pdf', 'pdfjs-dist'],
};
```
This is necessary because react-pdf ships as ESM and Next.js webpack needs to transpile it.
Verify the dev server still starts:
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -20
```
If build errors mention react-pdf or pdfjs-dist, confirm transpilePackages is applied. If errors persist around a specific import, check the react-pdf v9 release notes for Next.js App Router setup — the worker config in PdfViewer (Task 2) must use the `new URL(...)` pattern, not a CDN URL.
</action>
<verify>
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && node -e "require('react-pdf')" 2>&1 | head -5
```
Expected: No error (or ESM warning that resolves at build time — acceptable).
</verify>
<done>react-pdf installed. next.config.ts has transpilePackages. Build does not error on react-pdf imports.</done>
</task>
<task type="auto">
<name>Task 2: Add Document modal + PDF viewer page</name>
<files>
teressa-copeland-homes/src/app/portal/clients/[id]/_components/AddDocumentModal.tsx
teressa-copeland-homes/src/app/portal/clients/[id]/page.tsx
teressa-copeland-homes/src/app/portal/documents/[docId]/page.tsx
teressa-copeland-homes/src/app/portal/documents/[docId]/_components/PdfViewer.tsx
</files>
<action>
**A. Create AddDocumentModal.tsx**
`src/app/portal/clients/[id]/_components/AddDocumentModal.tsx`:
```typescript
'use client';
import { useState, useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
type FormTemplate = { id: string; name: string; filename: string };
export function AddDocumentModal({ clientId, onClose }: { clientId: string; onClose: () => void }) {
const [templates, setTemplates] = useState<FormTemplate[]>([]);
const [query, setQuery] = useState('');
const [docName, setDocName] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<FormTemplate | null>(null);
const [customFile, setCustomFile] = useState<File | null>(null);
const [isPending, startTransition] = useTransition();
const [saving, setSaving] = useState(false);
const router = useRouter();
useEffect(() => {
fetch('/api/forms-library')
.then(r => r.json())
.then(setTemplates)
.catch(console.error);
}, []);
const filtered = templates.filter(t =>
t.name.toLowerCase().includes(query.toLowerCase())
);
const handleSelectTemplate = (t: FormTemplate) => {
setSelectedTemplate(t);
setCustomFile(null);
setDocName(t.name);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setCustomFile(file);
setSelectedTemplate(null);
if (file) setDocName(file.name.replace(/\.pdf$/i, ''));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!docName.trim() || (!selectedTemplate && !customFile)) return;
setSaving(true);
try {
if (customFile) {
const fd = new FormData();
fd.append('clientId', clientId);
fd.append('name', docName.trim());
fd.append('file', customFile);
await fetch('/api/documents', { method: 'POST', body: fd });
} else {
await fetch('/api/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clientId, name: docName.trim(), formTemplateId: selectedTemplate!.id }),
});
}
startTransition(() => router.refresh());
onClose();
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
<h2 className="text-xl font-semibold mb-4">Add Document</h2>
<input
type="text"
placeholder="Search forms..."
value={query}
onChange={e => setQuery(e.target.value)}
className="w-full border rounded px-3 py-2 mb-3 text-sm"
/>
<ul className="border rounded max-h-48 overflow-y-auto mb-4">
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-gray-500">No forms found</li>
)}
{filtered.map(t => (
<li
key={t.id}
onClick={() => handleSelectTemplate(t)}
className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 ${selectedTemplate?.id === t.id ? 'bg-blue-50 font-medium' : ''}`}
>
{t.name}
</li>
))}
</ul>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Or upload a custom PDF</label>
<input type="file" accept="application/pdf" onChange={handleFileChange} className="text-sm" />
</div>
<form onSubmit={handleSubmit}>
<label className="block text-sm font-medium mb-1">Document name</label>
<input
type="text"
value={docName}
onChange={e => setDocName(e.target.value)}
required
className="w-full border rounded px-3 py-2 mb-4 text-sm"
placeholder="e.g. 123 Main St Purchase Agreement"
/>
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm border rounded hover:bg-gray-50">
Cancel
</button>
<button
type="submit"
disabled={saving || (!selectedTemplate && !customFile) || !docName.trim()}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Add Document'}
</button>
</div>
</form>
</div>
</div>
);
}
```
**B. Wire "Add Document" button into the existing client profile page**
Read `src/app/portal/clients/[id]/page.tsx` first to understand the current structure (Phase 3 built this). Then:
- Add a state-controlled "Add Document" button near the top of the documents section (or the page header)
- The button triggers a `showModal` state that renders `<AddDocumentModal clientId={client.id} onClose={() => setShowModal(false)} />`
- Since the profile page is likely a server component with a `ClientProfileClient` sub-component (per Phase 3 pattern), add the modal trigger and state inside the existing client component — do NOT convert the server page to a client component. Add the button and modal inside `_components/ClientProfileClient.tsx` or the equivalent client sub-component.
**C. Create PdfViewer.tsx**
`src/app/portal/documents/[docId]/_components/PdfViewer.tsx`:
```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';
// Worker setup — must use import.meta.url for local/Docker environments (no CDN)
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);
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-3 text-sm">
<button
onClick={() => setPageNumber(p => Math.max(1, p - 1))}
disabled={pageNumber <= 1}
className="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100"
>
Prev
</button>
<span>{pageNumber} / {numPages || '?'}</span>
<button
onClick={() => setPageNumber(p => Math.min(numPages, p + 1))}
disabled={pageNumber >= numPages}
className="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100"
>
Next
</button>
<button
onClick={() => setScale(s => Math.min(3, s + 0.2))}
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Zoom In
</button>
<button
onClick={() => setScale(s => Math.max(0.4, s - 0.2))}
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Zoom Out
</button>
<a
href={`/api/documents/${docId}/file`}
download
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Download
</a>
</div>
<Document
file={`/api/documents/${docId}/file`}
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
className="shadow-lg"
>
<Page pageNumber={pageNumber} scale={scale} />
</Document>
</div>
);
}
```
**D. Create document detail page**
`src/app/portal/documents/[docId]/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 } from 'drizzle-orm';
import Link from 'next/link';
import { PdfViewer } from './_components/PdfViewer';
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">
<div className="mb-4 flex items-center justify-between">
<div>
<Link
href={`/portal/clients/${doc.clientId}`}
className="text-sm text-blue-600 hover:underline"
>
← 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}</p>
</div>
</div>
<PdfViewer docId={docId} />
</div>
);
}
```
NOTE: The `with: { client: true }` relation requires that the Drizzle relations are defined in schema.ts. If Phase 3 already defined client/documents relations, use those. If not, add the minimal relation:
```typescript
// In schema.ts, add after table definitions:
import { relations } from 'drizzle-orm';
export const documentsRelations = relations(documents, ({ one }) => ({
client: one(clients, { fields: [documents.clientId], references: [clients.id] }),
}));
```
If relations already exist in schema.ts (check the existing file), extend them rather than overwriting.
Also ensure that clicking a document name in the client profile's documents table navigates to `/portal/documents/{doc.id}` — read the existing DocumentsTable component from Phase 3 and add the link if not present.
</action>
<verify>
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "error|Error|compiled" | head -20
```
Expected: "compiled successfully" or "Compiled" with no errors referencing react-pdf, PdfViewer, or AddDocumentModal.
</verify>
<done>
- Client profile page has "Add Document" button that opens the modal.
- Modal shows searchable template list and file picker fallback.
- Submitting the modal creates a document and refreshes the documents list.
- Document name in client profile links to `/portal/documents/{id}`.
- Document detail page renders PdfViewer with page nav, zoom, and download controls.
- Build passes with no errors.
</done>
</task>
</tasks>
<verification>
- `npm run build` completes without errors
- AddDocumentModal imports from react-pdf DO NOT include CDN worker URLs
- Both CSS layers imported in PdfViewer: AnnotationLayer.css and TextLayer.css
- Document detail page uses `await params` (Next.js 15 pattern)
- PdfViewer uses `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)` for worker — not a CDN URL
</verification>
<success_criteria>
Agent can open the forms library modal, add a document from the library or via file picker, see it appear in the documents list, click it to navigate to the detail page, and see the PDF rendered in the browser with page navigation and zoom.
</success_criteria>
<output>
After completion, create `.planning/phases/04-pdf-ingest/04-03-SUMMARY.md`
</output>