# Documents Hub, Reservation Agreements, and Visual Polish (Phase A)

**Status:** Draft — awaiting final review
**Date:** 2026-04-28
**Phase:** A of D (B = Insights & Alerts; C = Website integration; D = Pre-prod ops)

## Overview

Phase A delivers a unified Documents Hub that tracks every signature-based document (EOI, Reservation Agreements, NDAs, ad-hoc uploads), generalises the existing single-purpose EOI dialog into a multi-format create-document wizard, builds the missing CRM-side reservation detail page with an end-to-end agreement workflow, polishes the reminder framework so non-EOI docs auto-remind correctly, and applies a system-wide visual upgrade to the polished-SaaS aesthetic the project already has tokens for.

The project already ships a usable CRM with auth, multi-tenancy, full client/yacht/company/interest/berth/reservation data model, an EOI dual-path (Documenso template + in-app PDF), socket-driven real-time updates, and 130 smoke specs. What's missing for the next release: a single place to see what documents need signing and chase the people who haven't signed.

## Scope boundaries

### In scope (this spec)

- New `/[port]/documents` hub page replacing the existing list
- New `/[port]/documents/[id]` document detail page
- Generalised create-document wizard supporting four template formats (HTML, PDF AcroForm fillable, PDF overlay-positioned, Documenso-rendered) plus ad-hoc PDF upload
- New `/[port]/berth-reservations/[id]` reservation detail page with agreement-generation flow
- Reservation Agreement as a first-class document type with default template seeded
- Email composer extended with attachments and a System-vs-User From selector (admin-gated)
- Reminder framework: per-template cadence, per-doc override, per-doc disable, per-signer manual reminders
- Documenso version-aware abstraction layer covering field placement and document voiding across v1.13.1 and v2.x
- System-wide visual polish: shadow scale, gradient layer, animation tokens, primitive components (`<StatusPill>`, `<KPITile>`, `<EmptyState>`, polished `<PageHeader>`), applied across all list and detail pages
- Mobile-responsive sweep across every page touched
- Comprehensive test coverage: unit, integration, smoke, exhaustive click-through, real-API round-trips, visual baseline regeneration

### Explicitly out of scope (deferred to later phases)

- Analytics dashboard, alert framework, interests-by-berth view, expense duplicate detection (Phase B)
- Website-side integration: `/api/form/[token]/data` prefill endpoint, `/api/webhook/document-signed` callback receiver, public-endpoint shape compat (Phase C)
- NocoDB to Postgres data migration, email deliverability (DKIM/SPF/DMARC), Sentry error reporting, audit log retention, performance baseline at 5k clients / 50k interests, backup/restore automation, production deploy readiness (Phase D)
- Native in-CRM PDF field-placement editor (deferred until upload-path pain emerges; Phase A v1 ships with auto-placed footer signature fields and a "Customize fields in Documenso" link)
- Word `.docx` template upload (deferred; PDF prioritized because Word adds LibreOffice/CloudConvert toolchain dependency without saving the field-placement step)
- Per-interest "silence all reminders" toggle (was implicit in old `interests.reminderEnabled` gating which this spec drops; can be re-added as a bulk action if anyone misses it)

## Information architecture

### URL surface

```
/[port]/documents                         hub (replaces existing list)
/[port]/documents/[id]                    document detail (new)
/[port]/documents/new                     create-document wizard (new)
/[port]/berth-reservations/[id]           reservation detail (new)
/[port]/admin/templates                   existing; extended for new template formats
/[port]/admin/email                       existing; one new toggle
```

### Schema deltas

```
documents — additions:
+ reservation_id          text     null  references berth_reservations(id)
+ reminders_disabled      boolean  default false
+ reminder_cadence_override int    null

document_templates — additions:
+ reminder_cadence_days   int      null     (null = no auto-reminders)
+ template_format         text     default 'html'   ('html'|'pdf_form'|'pdf_overlay'|'documenso_render')
+ source_file_id          text     null  references files(id)
+ documenso_template_id   text     null
+ field_mapping           jsonb    default '{}'   (pdf_form: { acroFieldName: mergeToken })
+ overlay_positions       jsonb    default '[]'   (pdf_overlay: [{token, page, x, y, fontSize}])

document_templates.body_html — relax to nullable (only required when template_format='html')

document_watchers — new table:
  document_id  text  not null  references documents(id) on delete cascade
  user_id      text  not null  references users(id)
  added_by     text  not null  references users(id)
  added_at     timestamptz default now()
  primary key (document_id, user_id)

documents indexes — additions:
+ idx_docs_reservation       on (reservation_id)
+ idx_docs_status_port       on (port_id, status)        — powers tab counts cheaply

document_watchers indexes:
+ idx_doc_watchers_doc       on (document_id)
+ idx_doc_watchers_user      on (user_id)

documents.documentType enum — already includes 'reservation_agreement'; no migration needed
documents.status enum — already accepts 'expired'; no migration needed
documentSigners.status enum — pending|signed|declined; no migration needed
```

Backfill (one statement, safe to run in same migration):

```sql
UPDATE document_templates SET reminder_cadence_days = 1 WHERE template_type = 'eoi';
```

This preserves the existing 1-day-effective reminder cadence for existing EOI templates. Admins can edit per-template later.

After running migration on a dev/staging server, restart `next dev` to flush postgres.js prepared-statement cache (existing project convention).

### Polymorphic ownership pattern

Documents already use the multi-FK pattern (`interest_id`, `client_id`, `yacht_id`, `company_id` as separate nullable columns). Adding `reservation_id` matches this. No conversion to polymorphic discriminator columns despite yachts and invoices using that pattern; staying consistent with the existing documents shape avoids a destructive migration.

### Service-layer changes

- `documents.service.ts`:
  - `createFromWizard(portId, data, meta)` — dispatches across template/upload paths
  - `createFromUpload(portId, data, meta)` — new upload-driven path; calls Documenso `createDocument`, stores file in MinIO via `files` service, mirrors to `documents` + `documentSigners`, optionally calls `sendDocument` if `sendImmediately`
  - `cancelDocument(documentId, portId, meta)` — user-initiated cancel; calls Documenso void, updates DB status, logs event
  - `composeSignedDocEmail(documentId, portId)` — returns prefilled `{ to, cc, subject, body, attachments, defaultSenderType }` for the composer
  - `getDocumentDetail(id, portId)` — single-roundtrip aggregator returning doc + signers + events + watchers + linked-entity summary

- `document-templates.ts`:
  - `generateAndSign` extended for new `template_format` values
  - `fillAcroForm(sourceFile, fieldMapping, mergeContext)` — pdf-lib AcroForm fill
  - `drawOverlay(sourceFile, overlayPositions, mergeContext)` — pdf-lib text-draw at positions
  - Documenso-render path uses existing `generateDocumentFromTemplate`

- `documenso-client.ts`:
  - `placeFields(docId, fields, portId?)` — version-aware bulk field placement
  - `placeDefaultSignatureFields(docId, recipientIds, portId?)` — auto-position one SIGNATURE per recipient at footer
  - `voidDocument(docId, portId?)` — version-aware doc void/delete
  - Coordinate normalization helpers (caller passes percent 0-100; converted to pixels for v1 using cached page dimensions)

- `document-reminders.ts`:
  - `sendReminderIfAllowed(documentId, portId, options?)` — extended signature with optional `signerId` and `auto: boolean`
  - `processReminderQueue(portId)` — query rewritten around `documents.reminder_cadence_override ?? template.reminder_cadence_days`; drops `interests.reminderEnabled` gating

- `notifications.service.ts`:
  - `notifyDocumentEvent(docId, eventType)` — fans out to creator + entity-assignee + watchers; existing socket events keep firing

- New: `reservation-agreement-context.ts`:
  - `buildReservationAgreementContext(reservationId, portId)` — joins reservation -> client + yacht + berth -> port; returns context shape for template merge

- `email-compose.service.ts`:
  - Validator extended: `{ senderType: 'system'|'user', accountId? (when user), attachments[] }`
  - System path: calls `lib/email/index.ts → sendEmail()` with `portId` + attachments; logs `documentEvents` row `signed_doc_emailed`; skips `email_messages`/`email_threads` writes
  - User path: existing flow, with attachments resolution from `files` table
  - Port-isolation: cross-port `fileId` returns 403

- `lib/email/index.ts`:
  - `SendEmailOptions.attachments?: Array<{ fileId, filename? }>` — fetches files from MinIO, passes to nodemailer

## Documents hub page

Replaces existing `/[port]/documents` list.

### Layout

```
[ Header strip: title, KPI sub-line, "+ New document" button ]

[ Tabs: All | Awaiting them (count) | Awaiting me (count) | Completed | Expired ]

[ Search · Type · Status · Sent · Watcher filter chips · saved-view selector · overflow ]

[ Table:
    checkbox | Document | Type pill | Subject pill | Status (X/Y signed + dot) | Sent
    ▾ expand row inline to show signers + watchers strip
]

[ Sticky bulk-action bar appears when ≥1 row checked:
    "N selected" | Remind unsigned | Cancel | Export | pagination
]
```

### Tab queries

- All — every document in port
- Awaiting them — `status IN ('sent','partially_signed')` AND has pending signer != current user
- Awaiting me — at least one `documentSigners` row matching `signer_email = current user email` AND `status = 'pending'`
- Completed — `status IN ('completed','signed')`
- Expired — `status = 'expired'` OR (`status IN ('sent','partially_signed')` AND `expires_at < now()`)

Counts run cheap thanks to `idx_docs_status_port`.

### Filters and saved views

- Search: fuzzy match on title, subject name, signer email
- Type: multi-select doc types
- Status: multi-select status enum
- Sent: date-range chips (Today, 7d, 30d, custom)
- Watcher: filter by watching user
- "Signature-based only" chip defaults to ON; toggle off to see non-signed docs (welcome letters etc.) as well, rendered with a "Delivered" pill
- Saved-view integration: filter combos save to existing `saved_views` table

### Row anatomy

- Collapsed: name (links to detail), type pill (colored per type), subject pill (links to entity), status indicator (X/Y signed with progress dot), sent age
- Expanded: per-signer rows with email, status pill, sent timestamp, signed timestamp, `[Remind]` and overflow `[...]` (resend invite, copy signing link, skip — skip is UI-only flag, not implemented in v1)
- Watchers strip at bottom of expansion: chips + `+ Add watcher` autocomplete
- Hover: row gets soft brand-soft gradient bg

### Real-time

Subscribes to existing `documents.service.ts`-emitted socket events: `document:created`, `document:updated`, `document:deleted`, `document:sent`, `document:completed`, `document:expired`, `document:cancelled`, `document:rejected`, `document:signer:signed`, `document:signer:opened`. All already fire today.

### Empty states

- No docs yet: illustration + 1-line explanation + `[+ New document]` CTA
- Filtered empty: "No docs match these filters. Clear filters?"

### Mobile (< 768px)

- Tabs collapse into `<select>`
- Filters collapse behind `[Filters]` button into a sheet
- Rows stack as cards: title + status + age, expand to show signers
- "+ New document" floats as FAB bottom-right

## Document detail page

New `/[port]/documents/[id]` page. No detail page exists today.

### Layout

```
[ Breadcrumb: All documents ]

[ Header strip with gradient: title (editable inline), type pill, status pill, subtitle (subject link, creator, age) ]

[ Action bar — context-aware ]

[ Two-column body:
    Left (2fr):
      Signers panel (vertical list, replaces existing horizontal SigningProgress)
      Linked entity card
    Right (1fr):
      Watchers panel (chips + add)
      Activity timeline (from documentEvents)
      Notes (auto-saving editable text)
      Preview (PDF; tabbed Original/Signed when completed)
]
```

### Action bar by status

- `draft` — `[Send for signing]` `[Edit signers]` `[Delete]`
- `sent | partially_signed` — `[Send reminder to all]` `[Resend invite]` `[Cancel]`
- `completed` — `[Download signed PDF]` `[Email signed PDF to all signatories]`
- `cancelled | rejected | expired` — `[Duplicate]`
- Always `[...]` overflow: Duplicate, Move to other entity, View Documenso URL, Audit log

### Signers panel (vertical, replaces horizontal stepper)

Per-row:

- Numbered status circle (pending grey, signed green, declined red)
- Name, email, role
- Sent age, last-reminded age, signed timestamp
- `[Remind]` button — disabled with countdown if cooldown active (24h-or-cadence) for auto mode; bypassed in manual mode
- `[Copy signing link]` — copies `signingUrl` (hosted Documenso); overflow offers "Copy embed link" if `embeddedUrl` present (used by website embed at `/sign/[type]/[token]`)
- `[...]` overflow: Resend invite, View signing history, Replace email (draft only)
- Sequential mode: only current pending signer's `[Remind]` active; others greyed with tooltip

### Send-signed-PDF email flow

Action visible only when `status='completed' AND signedFileId IS NOT NULL`.

Click opens email composer drawer prefilled:

- From: dropdown defaulting to System (port-config noreply identity); Personal accounts available only when port admin enables `email.allowPersonalAccountSends`
- To: union of `documentSigners.signerEmail` for the doc
- Cc: empty; "Cc watchers" toggle adds users from `document_watchers`
- Subject: `"Signed {document type} — {document title}"`
- Body: from `signed_doc_completion` per-port template (new template type; default seeded for new ports)
- Attachments: signed PDF auto-attached from `documents.signedFileId` (chip with filename + size; removable)

Send dispatch:

- System path: `lib/email/index.ts → sendEmail()` with portId + attachments; writes `documentEvents` row; skips email_messages/threads writes (no IMAP sync expected)
- User path: `email-compose.service.ts` existing flow; writes email_messages + thread; subject to `allowPersonalAccountSends` gate (server-side enforces 403 on user senderType when toggle off)

### Backend additions

- `POST /api/v1/documents/[id]/cancel` — calls `cancelDocument` service; service calls Documenso void via new client function
- `POST /api/v1/documents/[id]/remind` — accepts optional `{ signerId }`; passes `auto: false` to service
- `GET /api/v1/documents/[id]/watchers` — list
- `POST /api/v1/documents/[id]/watchers` — add `{ userId }`
- `DELETE /api/v1/documents/[id]/watchers/[userId]` — remove
- `POST /api/v1/documents/[id]/compose-completion-email` — returns prefilled draft

## Create-document wizard

Replaces `<EoiGenerateDialog>`. Single drawer/dialog, three steps.

### Step 1 — Type and source

```
Render:  ●  Generate the PDF here (using template format below)
         ○  Use a Documenso-stored template (Documenso renders + signs)

Format (when "Generate the PDF here" selected):
         ●  HTML (write inline)
         ○  PDF (AcroForm fillable upload)
         ○  PDF (overlay positioning)

Template:  [ pick from port's templates of selected format ]
   OR
Upload PDF:  [ drop or pick file; preview renders inline ]

Document type: [ auto-derived from template, or picked from DOCUMENT_TYPES enum ]
```

Signing destination is always Documenso. The "Render in CRM" vs "Render in Documenso" axis is about PDF generation only.

### Step 2 — Recipients

```
Attached to: [ Interest #142 — Smith family  Change ]
   ↑ pre-filled if launched from a detail page

Signers: (hidden for documenso-render path; signers embedded in template)
   ① name  email  role       [✕]
   ② name  email  role       [✕]
   [+ Add signer] (autocomplete from clients/companies/users; or manual entry)
   Drag to reorder; signing-order assigned by row position

Signing mode: ● Sequential   ○ Parallel

Watchers (optional): [chips] [+ Add watcher] (CRM users)

Reminder cadence:
   ●  Use template default (every 7 days)
   ○  Override:  [_____] days
   ○  Disable for this document

[ For upload path only ]
☑  Auto-place signature fields at footer (default; refine later in Documenso)
```

### Step 3 — Review and send

```
Title: [ EOI — Smith family ____________ ]   (editable; default rendered from merge tokens)
Notes (internal): [_____________]
Preview: [ rendered PDF inline · 4 pages · scrollable ]
Signing-order banner (multi-signer in-app/upload only): "Sequential — Carol must sign before Bob" [Switch to parallel]
[← Back] [Save as draft] [Send →]
```

Save as draft → status='draft'; `[Send for signing]` available later from detail page. Send → calls Documenso, status='sent', socket event fires.

### Documenso version-aware field placement

For upload path, `placeDefaultSignatureFields` auto-positions one SIGNATURE per recipient at last-page footer (staggered to avoid overlap). User can refine in Documenso via "Customize fields in Documenso" link on detail page.

`placeFields` and `placeDefaultSignatureFields` in `documenso-client.ts` hide v1/v2 differences:

- v1: `POST /api/v1/documents/{id}/fields` per field; pixel coordinates; requires page dimension lookup
- v2: `POST /api/v2/envelope/field/create-many` bulk; percentage 0-100 coordinates; rich `fieldMeta`
- Caller passes percentage; abstraction converts for v1 using cached page dimensions

### `createDocumentSchema` extension

```ts
export const createDocumentSchema = z.object({
  source: z.enum(['template', 'upload']),
  templateId: z.string().uuid().optional(),
  uploadedFileId: z.string().uuid().optional(),

  documentType: z.enum(DOCUMENT_TYPES),
  title: z.string().min(1).max(200),
  notes: z.string().optional(),

  // Subject (exactly one required)
  interestId: z.string().uuid().optional(),
  reservationId: z.string().uuid().optional(),
  clientId: z.string().uuid().optional(),
  companyId: z.string().uuid().optional(),
  yachtId: z.string().uuid().optional(),

  // Signers (required when render=in-app or source=upload)
  signers: z.array(z.object({
    signerName: z.string().min(1),
    signerEmail: z.string().email(),
    signerRole: z.enum(['client', 'sales', 'approver', 'developer', 'other']),
    signingOrder: z.number().int().min(1),
  })).optional(),
  signingMode: z.enum(['sequential', 'parallel']).default('sequential'),

  pathway: z.enum(['documenso-template', 'inapp', 'upload']).optional(),

  watchers: z.array(z.string().uuid()).optional(),

  reminderCadenceOverride: z.number().int().min(1).max(365).nullable().optional(),
  remindersDisabled: z.boolean().default(false),

  autoPlaceFields: z.boolean().default(true),

  sendImmediately: z.boolean().default(true),
}).refine(...one-subject-FK-required...);
```

## Template formats

### Authoring paths

| Format                       | Authoring                                                                                   | Merge fields                                        | Best for                                         |
| ---------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------ |
| HTML (existing)              | Inline rich-text editor with merge tokens                                                   | Server-side substitution, rendered to PDF via pdfme | Welcome letters, acknowledgments, correspondence |
| PDF (AcroForm fillable)      | Admin uploads fillable PDF; UI scans AcroForm field names; admin maps each to a merge token | pdf-lib fills form at gen time                      | EOI, Reservation Agreement, NDA                  |
| PDF (overlay positioning)    | Admin uploads any PDF; UI specifies merge token positions per page+x+y+fontSize             | pdf-lib draws text over PDF at positions            | Quick wins where preparing AcroForm is overkill  |
| Documenso template reference | Admin enters Documenso template ID + label                                                  | None in CRM; Documenso owns it                      | Documenso-rendered signing flows                 |

### Generator dispatch

```ts
switch (template.template_format) {
  case 'html':            generatePdf(template.body_html, mergeContext);
  case 'pdf_form':        fillAcroForm(template.source_file_id, template.field_mapping, mergeContext);
  case 'pdf_overlay':     drawOverlay(template.source_file_id, template.overlay_positions, mergeContext);
  case 'documenso_render': documenso.generateDocumentFromTemplate(template.documenso_template_id, ...);
}
```

All four formats end at Documenso for signing — only PDF generation location differs. Non-signature templates (welcome letters etc.) skip the upload-to-Documenso step entirely; they render to PDF then get emailed.

### Admin template editor extension

Format picker added to `/admin/templates` editor:

- For PDF (AcroForm): file upload field, then two-column mapping UI (AcroForm field names ↔ merge tokens autocomplete from existing `MERGE_FIELDS` catalog)
- For PDF (overlay): file upload, then per-token form with page/x/y/fontSize inputs (visual placement editor deferred)
- For Documenso template: single text input + Test connection button calling `getDocumensoTemplate`
- For HTML: existing inline editor unchanged

### Word (.docx) deferred

Reasons: LibreOffice headless adds significant install/memory/security surface; CloudConvert adds paid dependency and third-party data exposure; `docxtemplater` merge syntax incompatible with existing `{{token}}` convention; field placement still needs PDF flow afterwards. If marinas push back, the feasible path is `.docx → server-side conversion → PDF → existing AcroForm/overlay flow`. Not worth the engineering until requested.

## Reservation agreements as a doc type

### What differs from EOI's pattern

| Aspect                | EOI                           | Reservation Agreement                                                                      |
| --------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
| Subject FK            | `interestId`                  | `reservationId`                                                                            |
| Default template      | Documenso EOI per port        | Documenso reservation_agreement per port (seeded)                                          |
| Default signers       | client + sales/approver       | client + port admin                                                                        |
| Trigger               | Manual on interest detail     | Manual on reservation detail                                                               |
| Lifecycle integration | None                          | Active reservations without an agreement get flagged in dashboard alert                    |
| Final-PDF storage     | `documents.signedFileId` only | `documents.signedFileId` AND mirrored to `berth_reservations.contractFileId` on completion |

### New CRM-side reservation detail page

`/[port]/berth-reservations/[id]` doesn't exist today (only the portal's `/portal/my-reservations`). Phase A builds it.

Layout:

```
[ Header: "Reservation #88 · M/Y Tate"  status pill  subtitle: berth, client, dates, tenure ]
[ Action bar: Activate | Generate agreement | Cancel | ... ]
[ Two columns:
    Left:  Reservation details card
           Linked interest card
           Activity timeline
    Right: Agreement card (state-dependent: no agreement / in-flight / completed)
]
```

Agreement card states:

- No agreement yet: warning + `[Generate agreement →]`
- In-flight (sent/partially_signed): "X/Y signed", per-signer status, `[View document →]` `[Send reminder]` `[Cancel]`
- Completed: "Completed YYYY-MM-DD", `[Download signed PDF]` `[Email to all signatories]`, "Signed contract attached to reservation."

Generate-agreement button launches the wizard with prefills:

- `documentType='reservation_agreement'`
- `templateId=<port's default>`
- `reservationId=<current>`
- Default signers from linked client + configurable port-admin user
- Wizard step 1 pre-validated; user lands on step 2

### Backend additions

- Merge field catalog extended in `src/lib/templates/merge-fields.ts`:
  - `{{reservation.startDate}}` `{{reservation.endDate}}` `{{reservation.tenureType}}` `{{reservation.termSummary}}` `{{reservation.signedDate}}`
- New service `reservation-agreement-context.ts.buildReservationAgreementContext(reservationId, portId)`
- New seeder for default `reservation_agreement` template on port creation (HTML format; admins can switch to AcroForm/overlay later); template stored at `assets/templates/reservation-agreement-default.html`
- Webhook handler extension: `handleDocumentCompleted` detects `documentType='reservation_agreement'` and sets `berth_reservations.contractFileId = doc.signedFileId` for the linked reservation
- Dashboard alert query: active reservations without a completed agreement (LEFT JOIN against documents filtered on type+status); rows surface as a warning card

### Trade-off

`berth_reservations.contractFileId` becomes a denormalized convenience pointer duplicated with `documents.signedFileId` for the linked reservation. Updating it on completion costs one extra UPDATE. Benefit: anyone querying reservations directly (portal "My Reservations") doesn't need to join through documents to know which file is the contract.

## Reminder framework polish

### Problems with today's logic

1. Eligibility gated by `interests.reminderEnabled` — reservation agreements, NDAs, ad-hoc upload docs (no interest link) never auto-remind
2. Hardcoded 24h cooldown — effective cadence is 1 day; can't slow down for low-urgency docs
3. Always reminds lowest-pending signer — parallel-signing docs can't nudge a specific signer
4. No per-doc disable

### New eligibility logic

```
function isReminderDue(doc, template, lastReminderAt) {
  if (!['sent','partially_signed'].includes(doc.status)) return false;
  if (doc.documenso_id == null) return false;
  if (doc.reminders_disabled) return false;

  const effectiveCadence = doc.reminder_cadence_override ?? template.reminder_cadence_days;
  if (effectiveCadence === null) return false;

  if (lastReminderAt == null) return true;
  return (now - lastReminderAt) >= effectiveCadence * 24h;
}
```

`processReminderQueue` query rewritten:

```sql
SELECT d.* FROM documents d
LEFT JOIN document_templates t ON t.id = d.template_id
WHERE d.port_id = $1
  AND d.status IN ('sent','partially_signed')
  AND d.documenso_id IS NOT NULL
  AND d.reminders_disabled = false
  AND COALESCE(d.reminder_cadence_override, t.reminder_cadence_days) IS NOT NULL;
```

`interests.reminderEnabled` is dropped from the gating logic but the column stays for now (no migration). Future cleanup PR can drop the column.

### `sendReminderIfAllowed` extended signature

```ts
export async function sendReminderIfAllowed(
  documentId: string,
  portId: string,
  options: {
    auto?: boolean; // true = cron; false (default) = manual
    signerId?: string; // optional — target a specific pending signer
  } = {},
): Promise<{ sent: boolean; reason?: string; signerId?: string }>;
```

Behaviour matrix:

| Mode        | 9-16 window | Cadence cooldown | Manual cooldown          |
| ----------- | ----------- | ---------------- | ------------------------ |
| auto: true  | enforced    | enforced         | n/a                      |
| auto: false | bypassed    | bypassed         | 30s client-side debounce |

Per-signer logic:

- If `signerId` provided in sequential-mode doc, signer must be the lowest-pending signer (otherwise reason='Signer is not next in sequence')
- In parallel-mode doc, any pending signer can be reminded independently
- Returns `{ sent, reason }` so caller can show toast on skip

### Admin and per-doc UI

Admin `/admin/templates` editor:

```
Auto-reminders for this template:
  ☑  Enabled    Cadence: every [_____] days  (1-365; default 7)
  ☐  Disabled (manual reminders only)
```

Doc detail page (Section 3) "Reminders" panel under signers, with edit drawer for per-doc override.

## Visual polish system

### Token additions

```
--radius-sm: 0.375rem (existing)
--radius-md: 0.5rem (NEW — default cards)
--radius-lg: 0.625rem (NEW — sheets, dialogs)
--radius-xl: 0.875rem (NEW — KPI tiles, hero strips)

--shadow-xs: 0 1px 2px 0 rgb(15 23 42 / 0.04)
--shadow-sm: 0 2px 4px -1px rgb(15 23 42 / 0.06)
--shadow-md: 0 4px 12px -2px rgb(15 23 42 / 0.08)
--shadow-lg: 0 12px 32px -8px rgb(15 23 42 / 0.12)
--shadow-glow: 0 0 0 4px rgb(58 123 200 / 0.12)

--gradient-brand:        linear-gradient(135deg, #3a7bc8 0%, #2f6ab5 100%)
--gradient-brand-soft:   linear-gradient(135deg, #d8e5f4 0%, #ffffff 100%)
--gradient-success:      linear-gradient(135deg, #e8f5e9 0%, #ffffff 100%)
--gradient-warning:      linear-gradient(135deg, #fef3c7 0%, #ffffff 100%)

--ease-spring:  cubic-bezier(0.34, 1.56, 0.64, 1)
--ease-smooth:  cubic-bezier(0.4, 0, 0.2, 1)
--duration-fast: 150ms
--duration-base: 200ms
--duration-slow: 300ms
```

All exposed as Tailwind utilities.

### Existing token foundation (already in place; not changing)

- Full HSL shadcn token system (primary, secondary, muted, accent, destructive, border, input, ring, popover, card)
- Brand palette `brand` (50-700, default `#3a7bc8`)
- Navy palette `navy` (50-600, default `#1e2844` for sidebar)
- Maritime accents: `sage`, `mint`, `teal`, `purple` with light/default/dark variants
- Semantic `success` / `warning` with bg+border
- Recharts chart-1 through chart-6 token system
- Dark mode wired
- Sidebar tokens separate from main palette

### New primitive components

- `<StatusPill status="...">` — colored-by-state pill (pending grey, sent brand, partial teal, completed success, expired warning, rejected destructive, cancelled muted-darker, active success, archived muted)
- `<KPITile title value delta sparkline?>` — rounded-xl, shadow-sm, gradient-brand-soft border-top accent stripe; recharts mini sparkline using `--chart-1`
- `<EmptyState icon title body actions>` — large icon in brand-soft circle, title, body, action buttons
- `<PageHeader>` polished — gradient-brand-soft background, eyebrow optional, KPI sub-line, primary action right-aligned

### Component pattern updates

- List rows: hover gradient (subtle brand-soft 4% opacity), shadow-xs lift, animation `transition-all duration-base ease-smooth`; row-update from socket events animates 1s fade-in highlight
- Detail pages: two-column responsive grammar (header strip → 2fr main + 1fr side; cards stack vertical < 768px)
- Sidebar (already dark navy): active item gets 4px brand left-edge stripe instead of bg shift; section headers smaller-caps + brand-200 text
- Topbar: search inset shadow + brand focus ring; "+ New" trigger gets `bg-gradient-brand`; notification bell gets badge spring animation; user avatar gets shadow-sm + 2px white ring
- Forms: focus ring uses `--shadow-glow`; primary submit buttons get `bg-gradient-brand` with hover scale-1.01; inline validation gets destructive-bg pill with caret pointing up

### Loading skeleton system

- List pages: 8 skeleton rows matching column widths with subtle pulse
- Detail pages: header strip skeleton + 2-column section skeletons
- Dashboard: KPI tile skeletons + chart skeletons
- Replaces today's mix of "Loading..." text and spinners

### Mobile responsive (full sweep)

Breakpoints:

- < 640px (phone): single column, sticky bottom action bar, sheet overlays for filters
- 640-1024px (tablet): single column with wider gutters, side column under main
- ≥ 1024px (desktop): full two-column

Per-page rules:

- List tables → card stack < 768px
- Detail page header collapses subtitle to "Show more"
- Tabs collapse to `<select>` < 640px
- Sidebar slides over content < 1024px
- Primary "+ New" actions float as FAB bottom-right < 640px

## Test plan

### Unit (`tests/unit/`)

- `document-reminders-cadence.test.ts` — `isReminderDue` math; manual-vs-auto window/cooldown bypass
- `documenso-place-fields.test.ts` — v1/v2 dispatch (mocked HTTP); coord normalization; default field staggering for 1/2/3/5 recipients
- `email-attachments-resolver.test.ts` — fileId → MinIO buffer; cross-port 403; 10 MB cap warning

### Integration (`tests/integration/`)

- Extend `document-templates-generate-and-sign.test.ts` — new template formats (`pdf_form`, `pdf_overlay`, `documenso_render`); upload-path test
- New `document-watchers.test.ts` — add/remove endpoints; notification fan-out; port isolation
- New `document-cancel.test.ts` — user-initiated cancel; mocked Documenso void; status + event log; reject 409 if completed
- New `reservation-agreement-contract-mirror.test.ts` — `handleDocumentCompleted` mirrors `signedFileId` to `berth_reservations.contractFileId` only for `reservation_agreement` type
- New `reminder-cron-cadence.test.ts` — seed varied templates; simulated time advance; assert correct docs reminded

### E2E smoke (`tests/e2e/smoke/`)

- Extend `04-documents.spec.ts` — hub tabs, expand row, per-signer remind with cooldown, type/status filters, saved-view round-trip, bulk-remind with per-row toast reasons
- Extend `05-eoi-generate.spec.ts` — wizard invocation prefills (template, interest); existing flow regression
- New `27-document-create-wizard.spec.ts` — template path full flow; upload path full flow; watcher addition; reminder-override radios produce correct DB state
- New `28-reservation-agreements.spec.ts` — reservation detail → Generate agreement → wizard prefilled → Send → agreement section state transitions; post-completion contract attached + email button visible
- New `29-email-attachments.spec.ts` — system path send (documentEvents row, no email_messages); user path send when toggle on (email_messages with attachment_file_ids); cross-port 403

### E2E exhaustive (`tests/e2e/exhaustive/`) — click-everything sweep

- New `10-documents-hub.spec.ts` — crawl each tab, filter dropdowns, saved-view, expand row, signer-row buttons, bulk-action bar
- New `11-document-detail.spec.ts` — crawl in three states (draft/sent/completed); watcher add/remove; notes auto-save; preview download; "Email signed PDF" launch
- New `12-document-create-wizard.spec.ts` — crawl each wizard step under both template and upload paths; picker dropdowns, signer add/remove, drag-handle, reminder-cadence radios
- New `13-reservation-detail.spec.ts` — crawl in three states (pending no agreement / agreement-in-flight / agreement-completed); Activate/Cancel/Generate buttons; inline notes
- New `14-email-composer.spec.ts` — crawl composer drawer with attachments; From dropdown; attach button; recipient chips
- Extend exhaustive `05-eoi-generate.spec.ts` — parallel-mode + signing-order edge cases (greyed-out reminder buttons; out-of-order remind rejection)

### E2E real-API (`tests/e2e/realapi/`)

Each spec gates on env vars; clean skip if missing.

- Extend `documenso-real-api.spec.ts`:
  - Generate from Documenso template (real send) and assert in real Documenso
  - Generate from in-app PDF AcroForm fill, upload to real Documenso, assert
  - Generate from upload path with auto-placed signature fields, assert fields visible in Documenso
  - v1 and v2 explicit version-flag tests (via `DOCUMENSO_API_VERSION`)
  - Manually sign in real Documenso (or simulate webhook) and assert local DB updates
  - Cancel real in-flight doc, assert local + remote state
  - Send reminder via real Documenso, assert HTTP + documentEvents row

- New `smtp-system-send.spec.ts` — system-path send → IMAP fetch → assert subject + attachment; verify port-config from-identity; cleanup via IMAP delete
- New `smtp-user-send.spec.ts` — user-path send (requires connected account, allowPersonalAccountSends=true) → IMAP fetch → email_messages row with attachment_file_ids
- New `minio-file-lifecycle.spec.ts` — upload, list, preview, download (byte-equal), delete; port isolation; mime-type validation
- New `documenso-webhook-ingress.spec.ts` — requires cloudflared tunnel; configure tunnel URL as Documenso webhook target; trigger doc completion; assert webhook fires + handler updates DB; verify timing-safe secret check rejects wrong secret with 401; verify event normalisation (uppercase enum + lowercase-dotted both accepted)
- New `email-attachments-roundtrip.spec.ts` — compose with fileId attachment; SMTP send; IMAP fetch; assert attachment bytes match; reject cross-port fileId with 403 before SMTP touched

### Visual baselines (`tests/e2e/visual/`)

`snapshots.spec.ts-snapshots/` regenerated as polish ships per page; one PR per surface group, baselines reviewed in PR diff. New baselines added: documents hub, doc detail, create-document wizard (each step), reservation detail, email composer with attachments.

### Test data fixtures

`global-setup.ts` extended with:

- Seed default `reservation_agreement` template (HTML format)
- Seed default `signed_doc_completion` template
- Seed one in-flight EOI doc with two pending signers (for hub-tab tests)
- Seed one `berth_reservation` with `status='active'` and no agreement (for lifecycle alert query)

### CI vs local runs

| Project                                        | When                                                                                                        |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `setup` + `smoke` (~14 min)                    | Every PR via CI                                                                                             |
| `exhaustive` (with new click-everything specs) | Every PR via CI; ~25 min budget                                                                             |
| `visual`                                       | Every PR; baselines reviewed in PR diffs                                                                    |
| `realapi`                                      | Locally before merging touch-points; pre-release; not on CI (avoids burning Documenso quota and SMTP costs) |

## Build sequence

| #     | Title                                             | Effort | Depends on     |
| ----- | ------------------------------------------------- | ------ | -------------- |
| 1     | Data model + service skeletons                    | 1d     | —              |
| 2     | Documenso v1/v2 abstraction layer                 | 1d     | —              |
| 3     | Visual primitives + token additions               | 1.5d   | —              |
| 4     | Documents hub page                                | 2d     | 1, 3           |
| 5     | Document detail page                              | 2d     | 1, 3           |
| 6     | Create-document wizard + new template formats     | 2.5d   | 1, 2, 3        |
| 7     | Reservation detail + agreement flow               | 1.5d   | 1, 6           |
| 8     | Email composer attachments + From selector        | 1d     | 1, 3           |
| 9     | Reminder framework polish                         | 1d     | 1              |
| 10a-e | Visual polish sweep (5 PRs across surface groups) | 3-4d   | 3              |
| 11    | Real-API integration tests                        | 1.5d   | 2, 4-9 shipped |

### Critical path

```
1 → 2 → 6 → 7         (data model → Documenso → wizard → reservation)
1 → 3 → 4 → 5 → 9     (data model → primitives → hub → detail → reminders)
                       1 → 8                  (composer)
                       3 → 10a-e (sweep)
                       all → 11 (realapi)
```

Wall-clock minimum ~9 days; realistic with overhead ~17 days; calendar ~3.5-5 weeks.

### Acceptance gates per PR

- `pnpm tsc --noEmit` and `pnpm lint` clean
- Vitest unit + integration green
- Playwright smoke green for surface touched
- Visual baselines regenerated and reviewed in PR diff
- For PRs touching external integrations (2, 6 upload, 7 contract mirror, 8 SMTP, 11): relevant `realapi` spec verified locally before merge

### Risk register

| Risk                                                       | Mitigation                                                                                |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Documenso v2 endpoint shape drifts from docs               | PR2 validates against real v2 instance during dev; realapi spec re-runs nightly post-ship |
| Visual polish scope creeps                                 | One PR per surface group (10a-e), each independently shippable                            |
| Cron migration changes effective behaviour                 | Backfill sets EOI cadence to 1 day matching today's effective; run on staging first       |
| Mobile responsive regressions                              | Visual baselines include phone-viewport snapshots; PR10e is the responsive sweep          |
| EOI dialog → wizard migration breaks "Generate EOI" button | Wizard launched with prefills from interest detail; PR6 includes regression spec          |
| AcroForm template format confuses non-technical admins     | HTML default; inline help; default templates seeded                                       |
| Phase A wall-clock past 5 weeks                            | Tier-2 sweep items + optional realapi specs deferrable to follow-up release               |

## Glossary

- **Documenso** — open-source document signing service, self-hosted instance at `signatures.portnimara.dev`
- **EOI** — Expression of Interest, a pre-reservation signed document
- **Reservation Agreement** — contract signed when a berth reservation is committed
- **Hub** — the new `/[port]/documents` page
- **Watcher** — a CRM user added to a doc to receive notifications on signature events without being a signer themselves
- **Signing order** — sequential index across signers; sequential mode requires lower order to sign first; parallel mode lets all sign concurrently
- **Cadence** — interval in days between auto-reminders to unsigned signers
- **System send / User send** — email dispatch identity: System uses port-config noreply SMTP; User uses connected personal email account (gated by admin toggle)
- **Render location** — where the PDF is generated (CRM-local via HTML/AcroForm/overlay, or in Documenso). Signing is always Documenso; render location is independent.
