docs(16): create phase plan — 4 plans in 3 waves

This commit is contained in:
Chandler Copeland
2026-04-03 16:16:07 -06:00
parent 3ab49004cf
commit 0ca698925f
5 changed files with 1058 additions and 5 deletions

View File

@@ -350,14 +350,19 @@ Plans:
2. Each field placed in FieldPlacer has a signer assignment dropdown; selecting a signer changes the field's color to that signer's assigned color
3. If the agent clicks Send with any client-facing field (signature, initials, date, text) having no signer assigned, the send is blocked and a clear error message identifies the unassigned fields
4. The dashboard document detail view shows a per-signer completion row for each signer (who has signed, who hasn't, with timestamps)
**Plans**: 3 plans
**Plans**: 4 plans
Plans:
- [x] 15-01-PLAN.md — Token utility extensions (signerEmail param, signer-download JWT), sendSignerCompletionEmail mailer, public signer download route
- [x] 15-02-PLAN.md — Send route rewrite: multi-signer token loop with Promise.all dispatch, legacy single-signer fallback, APP_BASE_URL rename
- [ ] 15-03-PLAN.md — Sign handler rewrite: GET signer-filtered fields, POST signer-scoped operations + accumulate PDF + atomic completion + notifications
- [ ] 16-01-PLAN.md — DocumentPageClient signers state threading, server page initialSigners prop, PdfViewerWrapper prop pass-through
- [ ] 16-02-PLAN.md — PreparePanel signer list UI (add/remove by email, colored dots, auto-assigned colors) + send-block validation
- [ ] 16-03-PLAN.md — FieldPlacer active signer selector dropdown + per-signer field coloring + validation overlay
- [ ] 16-04-PLAN.md — Dashboard N/M signed badge (signingTokens count query, DocumentsTable badge rendering)
**UI hint**: yes
### Phase 17: Docker Deployment
**Goal**: The application runs reliably in a Docker container on the production server — secrets are injected at runtime, email delivers correctly, uploaded files survive container restarts, and a health check confirms database connectivity
**Depends on**: Phase 16
@@ -400,5 +405,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 13. AI Field Placement and Pre-fill | v1.1 | 3/4 | In Progress | - |
| 14. Multi-Signer Schema | v1.2 | 1/1 | Complete | 2026-04-03 |
| 15. Multi-Signer Backend | v1.2 | 3/3 | Complete | 2026-04-03 |
| 16. Multi-Signer UI | v1.2 | 0/TBD | Not started | - |
| 16. Multi-Signer UI | v1.2 | 0/4 | Not started | - |
| 17. Docker Deployment | v1.2 | 0/TBD | Not started | - |

View File

@@ -0,0 +1,266 @@
---
phase: 16-multi-signer-ui
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx
autonomous: true
requirements: [MSIGN-01, MSIGN-02, MSIGN-03]
must_haves:
truths:
- "DocumentPageClient holds signers state and threads it to both PreparePanel and PdfViewerWrapper/FieldPlacer"
- "DocumentPageClient holds unassignedFieldIds state for send-block validation highlighting"
- "Server page reads documents.signers from DB and passes to DocumentPageClient as initialSigners prop"
- "PdfViewerWrapper passes signers and unassignedFieldIds through to FieldPlacer"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx"
provides: "signers state, setSigners, unassignedFieldIds state, setUnassignedFieldIds — threaded to PreparePanel and PdfViewerWrapper"
exports: ["DocumentPageClient"]
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx"
provides: "Server-side documents.signers fetch, passed as initialSigners to DocumentPageClient"
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx"
provides: "Passes signers and unassignedFieldIds props through to PdfViewer/FieldPlacer"
key_links:
- from: "page.tsx"
to: "DocumentPageClient"
via: "initialSigners prop from db.query.documents"
pattern: "initialSigners=.*doc\\.signers"
- from: "DocumentPageClient"
to: "PdfViewerWrapper"
via: "signers and unassignedFieldIds props"
pattern: "signers=.*unassignedFieldIds="
- from: "DocumentPageClient"
to: "PreparePanel"
via: "signers, onSignersChange, unassignedFieldIds, onUnassignedFieldIdsChange props"
pattern: "signers=.*onSignersChange="
---
<objective>
Thread multi-signer state through the component tree so PreparePanel and FieldPlacer can consume signers data in Wave 2.
Purpose: Foundation wiring — DocumentPageClient is the state owner for signers (from Phase 14 schema) and unassignedFieldIds (for send-block validation). Without this, Wave 2 plans cannot receive or modify signer state.
Output: Updated DocumentPageClient with signers + unassignedFieldIds state; server page fetching documents.signers; PdfViewerWrapper passing new props through.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-multi-signer-ui/16-CONTEXT.md
@.planning/phases/16-multi-signer-ui/16-UI-SPEC.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From src/lib/db/schema.ts:
```typescript
export interface DocumentSigner {
email: string;
color: string;
}
export interface SignatureFieldData {
id: string;
page: number;
x: number;
y: number;
width: number;
height: number;
type?: SignatureFieldType;
signerEmail?: string;
}
export function isClientVisibleField(field: SignatureFieldData): boolean;
export function getFieldType(field: SignatureFieldData): SignatureFieldType;
export function getSignerEmail(field: SignatureFieldData, fallbackEmail: string): string;
```
From DocumentPageClient.tsx (current props):
```typescript
interface DocumentPageClientProps {
docId: string;
docStatus: string;
defaultEmail: string;
clientName: string;
agentDownloadUrl?: string | null;
signedAt?: Date | null;
clientPropertyAddress?: string | null;
}
```
From PdfViewerWrapper.tsx (current props):
```typescript
{
docId: string;
docStatus?: string;
onFieldsChanged?: () => void;
selectedFieldId?: string | null;
textFillData?: Record<string, string>;
onFieldSelect?: (fieldId: string | null) => void;
onFieldValueChange?: (fieldId: string, value: string) => void;
aiPlacementKey?: number;
}
```
From FieldPlacer.tsx (current props — FieldPlacerProps interface at line 155):
```typescript
interface FieldPlacerProps {
docId: string;
pageInfo: PageInfo | null;
currentPage: number;
children: React.ReactNode;
readOnly?: boolean;
onFieldsChanged?: () => void;
selectedFieldId?: string | null;
textFillData?: Record<string, string>;
onFieldSelect?: (fieldId: string | null) => void;
onFieldValueChange?: (fieldId: string, value: string) => void;
aiPlacementKey?: number;
}
```
From PreparePanel.tsx (current props):
```typescript
interface PreparePanelProps {
docId: string;
defaultEmail: string;
clientName: string;
currentStatus: string;
agentDownloadUrl?: string | null;
signedAt?: Date | null;
clientPropertyAddress?: string | null;
previewToken: string | null;
onPreviewTokenChange: (token: string | null) => void;
textFillData: Record<string, string>;
selectedFieldId: string | null;
onQuickFill: (fieldId: string, value: string) => void;
onAiAutoPlace: () => Promise<void>;
}
```
From server page.tsx:
```typescript
// doc fetched via db.query.documents.findFirst — has all document columns including signers
const doc = await db.query.documents.findFirst({ where: eq(documents.id, docId) });
// doc.signers is DocumentSigner[] | null (JSONB column from Phase 14)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add signers prop to server page and thread state through DocumentPageClient</name>
<read_first>
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx
- teressa-copeland-homes/src/lib/db/schema.ts (for DocumentSigner interface)
- .planning/phases/16-multi-signer-ui/16-CONTEXT.md
- .planning/phases/16-multi-signer-ui/16-UI-SPEC.md
</read_first>
<files>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx,
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx
</files>
<action>
**server page.tsx:**
- `doc` is already fetched via `db.query.documents.findFirst` which returns all columns including `signers`. Pass `doc.signers` to DocumentPageClient as `initialSigners`:
```
initialSigners={doc.signers ?? []}
```
**DocumentPageClient.tsx:**
- Import `DocumentSigner` from `@/lib/db/schema`
- Add `initialSigners: DocumentSigner[]` to `DocumentPageClientProps`
- Add state: `const [signers, setSigners] = useState<DocumentSigner[]>(initialSigners);`
- Add state: `const [unassignedFieldIds, setUnassignedFieldIds] = useState<Set<string>>(new Set());`
- Pass to PreparePanel: `signers={signers}`, `onSignersChange={setSigners}`, `unassignedFieldIds={unassignedFieldIds}`, `onUnassignedFieldIdsChange={setUnassignedFieldIds}`
- Pass to PdfViewerWrapper: `signers={signers}`, `unassignedFieldIds={unassignedFieldIds}`
- No other changes to existing props or callbacks
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- `grep -n "initialSigners" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx` shows prop being passed
- `grep -n "DocumentSigner" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx` shows import
- `grep -n "signers={signers}" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx` shows props threaded to both PreparePanel and PdfViewerWrapper
- `grep -n "unassignedFieldIds" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/DocumentPageClient.tsx` shows state and prop threading
- `npx tsc --noEmit` passes
</acceptance_criteria>
<done>DocumentPageClient has signers + unassignedFieldIds state initialized from server; props threaded to PreparePanel and PdfViewerWrapper; TypeScript compiles cleanly</done>
</task>
<task type="auto">
<name>Task 2: Thread signers and unassignedFieldIds through PdfViewerWrapper to FieldPlacer</name>
<read_first>
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx (to understand its prop interface — it wraps FieldPlacer)
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx (lines 155-167 for FieldPlacerProps)
- teressa-copeland-homes/src/lib/db/schema.ts (for DocumentSigner type)
- .planning/phases/16-multi-signer-ui/16-UI-SPEC.md
</read_first>
<files>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx,
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx,
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
</files>
<action>
**PdfViewerWrapper.tsx:**
- Import `DocumentSigner` from `@/lib/db/schema`
- Add optional props: `signers?: DocumentSigner[]`, `unassignedFieldIds?: Set<string>`
- Pass both through to `<PdfViewer signers={signers} unassignedFieldIds={unassignedFieldIds} ... />`
**PdfViewer.tsx:**
- Add same optional props to PdfViewer's prop interface: `signers?: DocumentSigner[]`, `unassignedFieldIds?: Set<string>`
- Pass both through to `<FieldPlacer signers={signers} unassignedFieldIds={unassignedFieldIds} ... />`
**FieldPlacer.tsx:**
- Import `DocumentSigner` from `@/lib/db/schema`
- Add to FieldPlacerProps: `signers?: DocumentSigner[]`, `unassignedFieldIds?: Set<string>`
- Destructure in component function signature (default `signers = []`, `unassignedFieldIds = new Set()`)
- Do NOT implement rendering changes yet (that is Plan 03) — just accept the props
All three files: add the two new optional props and pass them through. No rendering or behavioral changes.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- `grep -n "signers" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx` shows prop accepted and passed through
- `grep -n "signers" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows signers in FieldPlacerProps
- `grep -n "unassignedFieldIds" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows unassignedFieldIds in FieldPlacerProps
- `npx tsc --noEmit` passes
</acceptance_criteria>
<done>signers and unassignedFieldIds props flow from DocumentPageClient through PdfViewerWrapper and PdfViewer into FieldPlacer; TypeScript compiles cleanly</done>
</task>
</tasks>
<verification>
- `npx tsc --noEmit` passes with no errors
- `grep -rn "signers" teressa-copeland-homes/src/app/portal/(protected)/documents/` confirms signers threaded through page.tsx -> DocumentPageClient -> PdfViewerWrapper -> PdfViewer -> FieldPlacer AND DocumentPageClient -> PreparePanel
- `grep -rn "unassignedFieldIds" teressa-copeland-homes/src/app/portal/(protected)/documents/` confirms unassignedFieldIds threaded through the same paths
</verification>
<success_criteria>
- DocumentPageClient has `signers` and `unassignedFieldIds` state
- Server page passes `initialSigners` from `doc.signers`
- Both PreparePanel and FieldPlacer (via PdfViewerWrapper chain) receive signers and unassignedFieldIds as props
- TypeScript compiles with no errors
</success_criteria>
<output>
After completion, create `.planning/phases/16-multi-signer-ui/16-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,246 @@
---
phase: 16-multi-signer-ui
plan: 02
type: execute
wave: 2
depends_on: ["16-01"]
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx
autonomous: true
requirements: [MSIGN-01, MSIGN-04]
must_haves:
truths:
- "Agent can type an email and click Add Signer to add a signer with auto-assigned color"
- "Agent sees a colored dot, email, and remove button for each signer in the list"
- "Agent can remove a signer by clicking the X button"
- "Send is blocked with inline error when client-visible fields have no signerEmail and signers exist"
- "Send is blocked with inline error when signers list is empty but client-visible fields exist"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx"
provides: "Signer list UI section, send-block validation logic"
contains: "Add Signer"
key_links:
- from: "PreparePanel signer add"
to: "onSignersChange callback"
via: "setSigners call with new signer appended"
pattern: "onSignersChange.*email.*color"
- from: "PreparePanel send validation"
to: "onUnassignedFieldIdsChange callback"
via: "Set of field IDs with no signerEmail"
pattern: "unassignedFieldIds|onUnassignedFieldIdsChange"
---
<objective>
Add the signer list UI to PreparePanel and the send-block validation logic per D-02, D-03, D-04, D-09, D-10.
Purpose: MSIGN-01 (agent adds signers by email) and MSIGN-04 (send blocked when fields unassigned). These are the two core PreparePanel features for multi-signer.
Output: PreparePanel with signer email input, signer list with colored dots and remove buttons, send-block validation with error messages.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/16-multi-signer-ui/16-CONTEXT.md
@.planning/phases/16-multi-signer-ui/16-UI-SPEC.md
@.planning/phases/16-multi-signer-ui/16-01-SUMMARY.md
<interfaces>
<!-- Contracts from Plan 01 — signers state threaded from DocumentPageClient -->
From DocumentPageClient (after Plan 01):
```typescript
// Props passed to PreparePanel (new additions from Plan 01):
signers: DocumentSigner[] // current signer list
onSignersChange: (signers: DocumentSigner[]) => void // update signer list
unassignedFieldIds: Set<string> // field IDs with no signerEmail (for send-block)
onUnassignedFieldIdsChange: (ids: Set<string>) => void // set unassigned IDs
```
From schema.ts:
```typescript
export interface DocumentSigner {
email: string;
color: string;
}
export function isClientVisibleField(field: SignatureFieldData): boolean;
```
Signer Color Palette (D-01, locked):
```typescript
const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
// Auto-assigned: signers[0] = indigo, signers[1] = rose, etc. Cycle if >4.
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add signer list UI section to PreparePanel</name>
<read_first>
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx
- teressa-copeland-homes/src/lib/db/schema.ts (DocumentSigner, isClientVisibleField)
- .planning/phases/16-multi-signer-ui/16-CONTEXT.md (D-01, D-02, D-03, D-04)
- .planning/phases/16-multi-signer-ui/16-UI-SPEC.md (Component Inventory sections 1 and 4, Copywriting Contract, Interaction Contract)
</read_first>
<files>teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx</files>
<action>
**New props on PreparePanelProps (from Plan 01 wiring):**
```typescript
signers: DocumentSigner[];
onSignersChange: (signers: DocumentSigner[]) => void;
unassignedFieldIds: Set<string>;
onUnassignedFieldIdsChange: (ids: Set<string>) => void;
```
Import `DocumentSigner` and `isClientVisibleField` from `@/lib/db/schema`.
**Signer color palette constant** (top of file, per D-01):
```typescript
const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
```
**Local state for signer input:**
```typescript
const [signerInput, setSignerInput] = useState('');
const [signerInputError, setSignerInputError] = useState<string | null>(null);
```
**Add signer handler:**
```typescript
function handleAddSigner() {
const email = signerInput.trim().toLowerCase();
if (!email || !isValidEmail(email)) {
setSignerInputError('invalid');
return;
}
if (signers.some(s => s.email === email)) {
setSignerInputError('duplicate');
return;
}
const color = SIGNER_COLORS[signers.length % SIGNER_COLORS.length];
onSignersChange([...signers, { email, color }]);
setSignerInput('');
setSignerInputError(null);
}
```
**Remove signer handler:**
```typescript
function handleRemoveSigner(email: string) {
onSignersChange(signers.filter(s => s.email !== email));
// Clear validation state since signer count changed
onUnassignedFieldIdsChange(new Set());
}
```
**Signer list UI** — insert between "Text field fill" section `</div>` and the AI Auto-place button. Per UI-SPEC section 1:
Section label: `<label className="block text-sm font-medium text-gray-700 mb-1">Signers</label>`
Input row (flex row, gap-2):
- Email input: `className="flex-1 border border-gray-300 rounded-md px-2 py-1.5 text-sm"` with `placeholder="signer@example.com"`. Border changes to `border-red-400` when `signerInputError` is truthy. `onKeyDown` handler: Enter triggers `handleAddSigner()`.
- "Add Signer" button: `className="bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm font-medium hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"`. Disabled when `signerInput.trim() === ''`.
Helper text: `<p className="text-xs text-gray-400 mt-1">Each signer receives their own signing link.</p>`
Duplicate error: When `signerInputError === 'duplicate'`, show `<p className="text-sm text-red-600 mt-1">That email is already in the signer list.</p>`
Signer list (below input): `<div className="space-y-1.5 mt-2">` containing signer rows.
Each signer row: `<div className="flex items-center gap-2 bg-white border border-gray-200 rounded-md py-1 px-2">`
- Colored dot: `<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: signer.color }} />`
- Email: `<span className="flex-1 text-sm text-gray-700 truncate">{signer.email}</span>`
- Remove button: `<button type="button" onClick={() => handleRemoveSigner(signer.email)} className="w-8 h-8 flex items-center justify-center text-gray-400 hover:text-red-600 cursor-pointer" style={{ background: 'none', border: 'none', fontSize: '16px', lineHeight: 1 }} aria-label={`Remove signer ${signer.email}`}>x</button>` (use actual multiplication sign character)
Empty state (when signers.length === 0): Show `<p className="text-sm text-gray-400 italic">No signers added yet.</p>` instead of signer list.
**Send-block validation in handlePrepare:**
Before the existing `prepareRes` fetch call, add validation:
1. Fetch current fields: `const fieldsRes = await fetch(\`/api/documents/${docId}/fields\`); const allFields = await fieldsRes.json();`
2. Filter client-visible: `const clientFields = allFields.filter(isClientVisibleField);`
3. Check no signers: If `signers.length === 0 && clientFields.length > 0`:
- `setResult({ ok: false, message: 'Add at least one signer before sending.' });`
- `setLoading(false); return;`
4. Check unassigned: `const unassigned = clientFields.filter((f: SignatureFieldData) => !f.signerEmail);`
If `unassigned.length > 0 && signers.length > 0`:
- `onUnassignedFieldIdsChange(new Set(unassigned.map((f: SignatureFieldData) => f.id)));`
- `setResult({ ok: false, message: \`${unassigned.length} field(s) need a signer assigned before sending.\` });`
- `setLoading(false); return;`
5. Clear unassigned on success: `onUnassignedFieldIdsChange(new Set());` before proceeding to prepare call.
**Save signers to DB** — per Claude's Discretion (simpler approach): save on Prepare and Send click.
In handlePrepare, right before the prepare fetch, PATCH the document to save signers:
```typescript
await fetch(`/api/documents/${docId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signers }),
});
```
Note: This requires the existing document update API to accept a `signers` field. Check if `/api/documents/[id]` PATCH route exists. If not, add the signers to the prepare route body instead — pass `signers` alongside `textFillData` and `emailAddresses` in the prepare POST body. The prepare route already writes to the document record.
Actually, the simpler approach per CONTEXT.md: pass `signers` in the existing prepare POST body. Modify the prepare POST `body: JSON.stringify({ textFillData, emailAddresses, signers })`. The prepare route handler will need to save signers to the document record — but that is a backend concern. For this UI-only phase, just send the data. The send route at `/api/documents/[id]/send` already reads `documents.signers` from DB (Phase 15 wiring). So we need to persist signers before send.
Simplest path: Save signers via the prepare route. Add `signers` to the JSON body of the prepare POST. Then in the prepare route handler, add one line to update `documents.signers`. This is a minimal backend touch that is required for the UI to function.
In the prepare route (`/api/documents/[id]/prepare/route.ts`), after parsing the body, add:
```typescript
if (body.signers) {
await db.update(documents).set({ signers: body.signers }).where(eq(documents.id, docId));
}
```
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- `grep -n "Add Signer" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` returns the button
- `grep -n 'aria-label.*Remove signer' teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` confirms accessibility attribute
- `grep -n "SIGNER_COLORS" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` shows palette constant with exact hex values `#6366f1`, `#f43f5e`, `#10b981`, `#f59e0b`
- `grep -n "w-2 h-2 rounded-full" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` shows 8px colored dot
- `grep -n "gap-2.*bg-white.*border.*rounded-md.*py-1 px-2" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` shows signer row with exact Tailwind classes
- `grep -n "w-8 h-8" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` shows 32px touch target on remove button
- `grep -n "need a signer assigned before sending" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` shows send-block error copy
- `grep -n "Add at least one signer before sending" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` shows no-signers error copy
- `grep -n "signer@example.com" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` shows placeholder text
- `grep -n "Each signer receives their own signing link" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` shows helper text
- `npx tsc --noEmit` passes
</acceptance_criteria>
<done>
- Agent can type email, click "Add Signer", see colored dot + email + remove button per D-02/D-03
- Signer colors auto-assigned in order: indigo, rose, emerald, amber per D-01
- Remove button has `aria-label="Remove signer {email}"` per accessibility requirement
- Send blocked with "{N} field(s) need a signer assigned before sending." when unassigned fields exist per D-09
- Send blocked with "Add at least one signer before sending." when no signers per D-04
- Signers saved to document on Prepare and Send click
- TypeScript compiles cleanly
</done>
</task>
</tasks>
<verification>
- `npx tsc --noEmit` passes
- PreparePanel renders signer section between text fill and AI Auto-place button
- All copy strings match UI-SPEC Copywriting Contract exactly
- All Tailwind classes match UI-SPEC spacing/color values
</verification>
<success_criteria>
- Agent can add/remove signers with auto-assigned colors
- Send-block validation prevents sending documents with unassigned client-visible fields
- All UI-SPEC copy, spacing, and color values are implemented exactly
- TypeScript compiles with no errors
</success_criteria>
<output>
After completion, create `.planning/phases/16-multi-signer-ui/16-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,280 @@
---
phase: 16-multi-signer-ui
plan: 03
type: execute
wave: 2
depends_on: ["16-01"]
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
autonomous: true
requirements: [MSIGN-02, MSIGN-03]
must_haves:
truths:
- "Active signer dropdown appears above palette when signers.length > 0"
- "Dragged fields auto-get signerEmail of the active signer"
- "Field boxes with signerEmail render in signer color instead of type color"
- "Fields with no signerEmail render in existing type color (unchanged)"
- "Fields in unassignedFieldIds set show red validation outline"
- "Active signer defaults to first signer on load"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx"
provides: "Active signer selector, per-signer field coloring, validation overlay"
contains: "Active signer:"
key_links:
- from: "FieldPlacer handleDragEnd"
to: "newField.signerEmail"
via: "activeSigner.email assignment on field creation"
pattern: "signerEmail.*activeSigner"
- from: "FieldPlacer renderFields"
to: "signer color lookup"
via: "signers.find matching field.signerEmail for color"
pattern: "signers.*find.*signerEmail.*color"
---
<objective>
Add the active signer selector dropdown and per-signer field coloring to FieldPlacer per D-05, D-06, D-07, D-08, D-09/D-10.
Purpose: MSIGN-02 (tag fields to signers) and MSIGN-03 (color-coded fields by signer). When a signer is active, every field dropped gets that signer's email. Field boxes render in the signer's color when assigned.
Output: FieldPlacer with active signer dropdown, signer-aware field creation, per-signer color rendering, and red validation overlay for unassigned fields.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/16-multi-signer-ui/16-CONTEXT.md
@.planning/phases/16-multi-signer-ui/16-UI-SPEC.md
@.planning/phases/16-multi-signer-ui/16-01-SUMMARY.md
<interfaces>
<!-- From Plan 01: signers and unassignedFieldIds are now props on FieldPlacer -->
From FieldPlacer (after Plan 01):
```typescript
interface FieldPlacerProps {
// ... existing props ...
signers?: DocumentSigner[]; // from DocumentPageClient via PdfViewerWrapper
unassignedFieldIds?: Set<string>; // from DocumentPageClient for send-block validation
}
```
From schema.ts:
```typescript
export interface DocumentSigner {
email: string;
color: string;
}
export interface SignatureFieldData {
id: string; page: number; x: number; y: number;
width: number; height: number;
type?: SignatureFieldType;
signerEmail?: string;
}
```
FieldPlacer existing color system (lines 70-78):
```typescript
const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [
{ id: 'client-signature', label: 'Signature', color: '#2563eb' },
{ id: 'initials', label: 'Initials', color: '#7c3aed' },
{ id: 'checkbox', label: 'Checkbox', color: '#059669' },
{ id: 'date', label: 'Date', color: '#d97706' },
{ id: 'text', label: 'Text', color: '#64748b' },
{ id: 'agent-signature', label: 'Agent Signature', color: '#dc2626' },
{ id: 'agent-initials', label: 'Agent Initials', color: '#ea580c' },
];
```
Color Override Rule (D-06, D-07):
- field.signerEmail set AND matching signer in signers[] => use signer.color
- field.signerEmail absent => use PALETTE_TOKENS type color (unchanged)
Validation overlay (D-09/D-10, UI-SPEC section 3):
- unassignedFieldIds contains field.id => border: 2px solid #ef4444, bg: #ef444414
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add active signer selector and signer-aware field coloring</name>
<read_first>
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx (FULL FILE — 822 lines)
- teressa-copeland-homes/src/lib/db/schema.ts (DocumentSigner, SignatureFieldData)
- .planning/phases/16-multi-signer-ui/16-CONTEXT.md (D-05, D-06, D-07, D-08, D-09, D-10)
- .planning/phases/16-multi-signer-ui/16-UI-SPEC.md (Component Inventory sections 2 and 3)
</read_first>
<files>teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx</files>
<action>
**1. Active signer state (inside FieldPlacer function body):**
```typescript
const [activeSignerEmail, setActiveSignerEmail] = useState<string | null>(null);
```
Default to first signer on load and when signers change (per D-08):
```typescript
useEffect(() => {
if (signers && signers.length > 0) {
setActiveSignerEmail(prev => {
// Keep current selection if still valid
if (prev && signers.some(s => s.email === prev)) return prev;
return signers[0].email;
});
} else {
setActiveSignerEmail(null);
}
}, [signers]);
```
**2. Active signer selector UI** — insert ABOVE the palette div (line ~756), ONLY when `signers && signers.length > 0`:
```tsx
{!readOnly && signers && signers.length > 0 && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px',
padding: '8px 12px',
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: '6px',
}}>
<span style={{ fontSize: '12px', color: '#6b7280', fontWeight: 500, whiteSpace: 'nowrap' }}>
Active signer:
</span>
<select
value={activeSignerEmail ?? ''}
onChange={(e) => setActiveSignerEmail(e.target.value)}
style={{
flex: 1,
height: '32px',
border: '1px solid #D1D5DB',
borderRadius: '0.375rem',
fontSize: '0.875rem',
padding: '0 8px',
background: 'white',
}}
>
{signers.map(s => (
<option key={s.email} value={s.email}>
{s.email}
</option>
))}
</select>
{/* Color indicator dot next to the dropdown */}
{(() => {
const activeSigner = signers.find(s => s.email === activeSignerEmail);
return activeSigner ? (
<span style={{
width: '8px', height: '8px', borderRadius: '50%',
backgroundColor: activeSigner.color, flexShrink: 0,
}} />
) : null;
})()}
</div>
)}
```
**3. handleDragEnd modification** — at line ~294 where `newField` is created, add `signerEmail`:
Change the `newField` construction to include:
```typescript
const newField: SignatureFieldData = {
id: crypto.randomUUID(),
page: currentPage,
x: pdfX,
y: pdfY,
width: fieldW,
height: fieldH,
type: droppedType,
...(activeSignerEmail ? { signerEmail: activeSignerEmail } : {}),
};
```
Add `activeSignerEmail` to the useCallback dependency array of handleDragEnd.
**4. renderFields color override** — in the renderFields function (around line 581-584), replace the existing color lookup:
```typescript
// Per-type color and label
const fieldType = getFieldType(field);
const tokenMeta = PALETTE_TOKENS.find((t) => t.id === fieldType);
// Color override: signer color when signerEmail is set (D-06), else type color (D-07)
let fieldColor = tokenMeta?.color ?? '#2563eb';
if (field.signerEmail && signers) {
const matchedSigner = signers.find(s => s.email === field.signerEmail);
if (matchedSigner) {
fieldColor = matchedSigner.color;
}
}
// Validation overlay: unassigned field highlight (D-09/D-10)
const isUnassigned = unassignedFieldIds?.has(field.id) ?? false;
const fieldLabel = tokenMeta?.label ?? 'Signature';
```
**5. Field box style override for validation** — in the field box's `style` object (around line 627-653):
- Change `border` line to: `border: isUnassigned ? '2px solid #ef4444' : \`2px solid ${fieldColor}\``,
- Change `background` line to: `background: isUnassigned ? '#ef444414' : (readOnly ? \`${fieldColor}0d\` : \`${fieldColor}1a\`)`,
**6. DragOverlay ghost color** — in the DragOverlay section (around line 796-798), the ghost should also use signer color if active signer is set. Update:
```typescript
{isDraggingToken ? (() => {
const tokenMeta = PALETTE_TOKENS.find((t) => t.id === isDraggingToken);
const label = tokenMeta?.label ?? 'Field';
// Ghost uses active signer color if one is selected, else type color
let ghostColor = tokenMeta?.color ?? '#2563eb';
if (activeSignerEmail && signers) {
const activeSigner = signers.find(s => s.email === activeSignerEmail);
if (activeSigner) ghostColor = activeSigner.color;
}
const isCheckbox = isDraggingToken === 'checkbox';
// ... rest uses ghostColor instead of color
```
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- `grep -n "Active signer:" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows dropdown label
- `grep -n "activeSignerEmail" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows state variable
- `grep -n "signerEmail.*activeSignerEmail" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows field creation sets signerEmail
- `grep -n "signers.*find.*signerEmail" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows signer color lookup in renderFields
- `grep -n "#ef4444" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows validation overlay border color
- `grep -n "#ef444414" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows validation overlay background
- `grep -n "height.*32px" teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` shows dropdown is 32px tall
- `npx tsc --noEmit` passes
</acceptance_criteria>
<done>
- Active signer dropdown appears above palette when signers exist, hidden otherwise per D-05
- Dropdown defaults to first signer per D-08
- Dragged fields get signerEmail = active signer's email per D-06
- Fields with signerEmail render in signer color; fields without use type color per D-06/D-07
- Fields in unassignedFieldIds set show red #ef4444 outline and #ef444414 background per D-09/D-10
- TypeScript compiles cleanly
</done>
</task>
</tasks>
<verification>
- `npx tsc --noEmit` passes
- Active signer selector renders above palette with correct styling
- Field color follows signer color when assigned, type color when not
- Validation overlay visible on unassigned fields
</verification>
<success_criteria>
- Agent can select active signer from dropdown before placing fields
- Fields placed with active signer are tagged and colored accordingly
- Unassigned fields show red validation highlight when send-block triggers
- Existing behavior unchanged when no signers configured
- TypeScript compiles with no errors
</success_criteria>
<output>
After completion, create `.planning/phases/16-multi-signer-ui/16-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,256 @@
---
phase: 16-multi-signer-ui
plan: 04
type: execute
wave: 3
depends_on: ["16-02", "16-03"]
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx
- teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx
autonomous: true
requirements: [MSIGN-09]
must_haves:
truths:
- "Multi-signer Sent documents show N/M signed badge in Status column"
- "Single-signer documents show no N/M badge (unchanged)"
- "Fully signed documents show existing Signed badge only (no N/M)"
- "N/M is computed from signingTokens with usedAt IS NOT NULL"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx"
provides: "Server-side query joining signingTokens for per-signer completion count"
contains: "signingTokens"
- path: "teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx"
provides: "N/M signed badge rendering in Status column"
contains: "signed"
key_links:
- from: "dashboard/page.tsx query"
to: "DocumentsTable rows"
via: "signedCount and totalSigners fields on row data"
pattern: "signedCount|totalSigners"
- from: "DocumentsTable"
to: "N/M badge"
via: "conditional render when totalSigners > 0 and status === Sent"
pattern: "totalSigners.*>.*0"
---
<objective>
Add the N/M signed badge to the dashboard documents table for multi-signer documents per D-11, D-12, D-13.
Purpose: MSIGN-09 (dashboard shows per-signer completion status). Agent sees at a glance how many signers have completed for documents that are partially signed.
Output: Dashboard query includes signing token counts; DocumentsTable renders "N/M signed" badge next to Status for multi-signer Sent documents.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/16-multi-signer-ui/16-CONTEXT.md
@.planning/phases/16-multi-signer-ui/16-UI-SPEC.md
<interfaces>
From schema.ts:
```typescript
export const documents = pgTable("documents", {
id: text("id").primaryKey(),
// ...
signers: jsonb("signers").$type<DocumentSigner[]>(),
status: text("status").$type<"Draft" | "Sent" | "Viewed" | "Signed">().default("Draft").notNull(),
// ...
});
export const signingTokens = pgTable('signing_tokens', {
id: text('id').primaryKey(),
documentId: text('document_id').notNull().references(() => documents.id, { onDelete: 'cascade' }),
signerEmail: text('signer_email'),
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at').notNull(),
usedAt: timestamp('used_at'),
});
```
DocumentsTable current row type:
```typescript
type DocumentRow = {
id: string;
name: string;
clientName: string | null;
status: "Draft" | "Sent" | "Viewed" | "Signed";
sentAt: Date | null;
signedAt: Date | null;
clientId: string;
};
```
StatusBadge:
```typescript
export function StatusBadge({ status }: { status: "Draft" | "Sent" | "Viewed" | "Signed" });
```
UI-SPEC badge spec:
- Style: `inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 ml-1.5`
- Text: "N/M signed"
- Only for multi-signer (signers non-empty) AND status "Sent"
- Not for status "Signed" (existing badge sufficient)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add signing token counts to dashboard query</name>
<read_first>
- teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx
- teressa-copeland-homes/src/lib/db/schema.ts (signingTokens table, documents.signers)
- .planning/phases/16-multi-signer-ui/16-CONTEXT.md (D-11, D-12, D-13)
- .planning/phases/16-multi-signer-ui/16-UI-SPEC.md (Component Inventory section 5)
</read_first>
<files>teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx</files>
<action>
Import `signingTokens` from `@/lib/db/schema` and `sql, isNotNull, count` from `drizzle-orm`.
The dashboard currently uses a simple select+leftJoin query. To add signer counts, use a subquery approach to avoid N+1:
After the main `allRows` query, compute signing progress for multi-signer documents. Use a separate query for signing token counts to keep it simple:
```typescript
import { signingTokens } from "@/lib/db/schema";
import { sql } from "drizzle-orm";
// After allRows query, fetch signing token progress for all documents in one query
const tokenCounts = await db
.select({
documentId: signingTokens.documentId,
total: sql<number>`count(*)`.as('total'),
signed: sql<number>`count(${signingTokens.usedAt})`.as('signed'),
})
.from(signingTokens)
.groupBy(signingTokens.documentId);
const tokenMap = new Map(tokenCounts.map(t => [t.documentId, { total: Number(t.total), signed: Number(t.signed) }]));
```
Then augment the rows passed to DocumentsTable:
```typescript
const enrichedRows = filteredRows.map(row => {
const tc = tokenMap.get(row.id);
return {
...row,
signedCount: tc?.signed ?? null,
totalSigners: tc?.total ?? null,
};
});
```
Pass `enrichedRows` to `<DocumentsTable rows={enrichedRows} ...>` instead of `filteredRows`.
Also need to add `signers` to the allRows select to know which documents are multi-signer:
```typescript
signers: documents.signers,
```
And include it in enrichedRows:
```typescript
hasMultipleSigners: Array.isArray(row.signers) && row.signers.length > 0,
```
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- `grep -n "signingTokens" teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx` shows import and query
- `grep -n "signedCount" teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx` shows field enrichment
- `grep -n "totalSigners" teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx` shows field enrichment
- `grep -n "hasMultipleSigners" teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx` shows multi-signer detection
- `npx tsc --noEmit` passes
</acceptance_criteria>
<done>Dashboard query fetches signing token counts per document and passes enriched rows to DocumentsTable</done>
</task>
<task type="auto">
<name>Task 2: Render N/M signed badge in DocumentsTable Status column</name>
<read_first>
- teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx
- teressa-copeland-homes/src/app/portal/_components/StatusBadge.tsx
- .planning/phases/16-multi-signer-ui/16-CONTEXT.md (D-11, D-12, D-13)
- .planning/phases/16-multi-signer-ui/16-UI-SPEC.md (Component Inventory section 5, badge spec)
</read_first>
<files>teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx</files>
<action>
Update the `DocumentRow` type to include optional new fields:
```typescript
type DocumentRow = {
id: string;
name: string;
clientName: string | null;
status: "Draft" | "Sent" | "Viewed" | "Signed";
sentAt: Date | null;
signedAt: Date | null;
clientId: string;
signedCount?: number | null;
totalSigners?: number | null;
hasMultipleSigners?: boolean;
};
```
In the Status `<td>` cell (currently just `<StatusBadge status={row.status} />`), add the N/M badge after the StatusBadge:
```tsx
<td style={{ padding: "0.875rem 1.5rem" }}>
<StatusBadge status={row.status} />
{row.hasMultipleSigners && row.status === 'Sent' && row.totalSigners != null && row.totalSigners > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 ml-1.5">
{row.signedCount ?? 0}/{row.totalSigners} signed
</span>
)}
</td>
```
Per D-12: Only show for multi-signer documents (`hasMultipleSigners`).
Per D-13: Only show for status "Sent" — not for "Signed" (existing Signed badge sufficient).
The UI-SPEC specifies `bg-blue-50 text-blue-700` for the badge (matching the blue theme of Sent status).
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- `grep -n "signedCount" teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx` shows field in type and render
- `grep -n "totalSigners" teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx` shows field in type and render
- `grep -n "hasMultipleSigners" teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx` shows conditional
- `grep -n "bg-blue-50 text-blue-700" teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx` shows badge styles
- `grep -n "ml-1.5" teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx` shows margin from StatusBadge
- `grep -n "signed" teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx` shows "signed" text in badge
- `npx tsc --noEmit` passes
</acceptance_criteria>
<done>
- Multi-signer Sent documents show "N/M signed" badge next to Sent status badge per D-11
- Single-signer documents show no N/M badge per D-12
- Signed documents show only existing "Signed" badge per D-13
- Badge uses exact UI-SPEC classes: `inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 ml-1.5`
- TypeScript compiles cleanly
</done>
</task>
</tasks>
<verification>
- `npx tsc --noEmit` passes
- Dashboard query includes token count subquery
- N/M badge renders only for multi-signer Sent documents
- Badge styling matches UI-SPEC exactly
</verification>
<success_criteria>
- Dashboard shows "1/2 signed" badge for partially-signed multi-signer documents
- No badge for single-signer or fully-signed documents
- Badge computed server-side from signingTokens count
- TypeScript compiles with no errors
</success_criteria>
<output>
After completion, create `.planning/phases/16-multi-signer-ui/16-04-SUMMARY.md`
</output>