docs(uat): SHIPPED annotations for session — 12 items closed across all buckets

Brings the master UAT doc in sync with this session's actual ship state.

Annotated (commit SHA after each):
  - Em-dash sweep + lint bump to error (f0dbefc)
  - Berth-list active-interests popover + density tokens (292a8b5)
  - LinkedBerthsList "Add berth" CTA (3999d4b)
  - BulkAddBerthsWizard mooring-exists pre-flight (ca172fa)
  - Email / SMTP admin "Send test email" (7881da6)
  - Smart-search pipeline-stage fuzzy match (d912f02)
  - External-EOI edit-metadata UI (235e064) — closes the (e) sub-item
  - Date-input migration sweep, remaining 14 sites (0c6e7b7)
  - Nested document subfolders foundation only (e91055f)
  - PDF report exporter, full 4-phase build (3b199c2, 47c2ba9, 1cdc2fd, 5a9b5f6)

Yacht ft↔m + click-to-preview on EntityFolderView/HubRootView were
already annotated earlier in the session (5320398, 1f591ff).

The "Remaining" notes on each entry call out what stays parked
(e.g. nested-subfolders phases 2/3 — UploadZone scope radio,
lifecycle hooks, list-query rewrite, tree rendering, backfill).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 21:13:06 +02:00
parent 5a9b5f687f
commit d879188322

View File

@@ -86,6 +86,7 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
> - **Icon-only buttons inconsistent — ~50% have `aria-label`, rest have nothing or only `sr-only` text.** Add `jsx-a11y/control-has-associated-label` lint rule + sweep. ~1h.
> - **Sweep: remove em-dashes from all user-facing copy (toast messages, button labels, helper text, banners, dialog descriptions, empty states)** — em-dashes (`—`) feel AI-generated and add visual noise; user reads them as "Claude wrote this." Replace with periods, commas, colons, or simple hyphens depending on context. **Scope:** _src/components_ (every UI string), _src/lib/email/templates_ (email body copy), _src/lib/templates_ (merge-field labels + EOI body), _src/app_ (page-level copy), public form copy, error messages from `src/lib/errors`. **Out of scope (keep em-dashes):** code comments, JSDoc, audit-log entries, structured logging, this UAT findings doc itself (internal docs are fine). **Method:** grep `—` across `src/`, manually triage each match (some are inside JSX, some inside string literals); replace per context. Heuristic: if a user could see the character, replace it. **Effort:** ~2-3h depending on hit count (rough estimate 200-400 instances). Captured 2026-05-21 from UAT. **Going forward:** add an ESLint rule banning `—` in JSX text + string literals inside `src/components` so new code doesn't reintroduce them.
> - **SHIPPED (lint guard only) in 52342ee:** `no-restricted-syntax` rule on `JSXText[value=/—/]` scoped to `src/components` + `src/app`, set to `warn`. 111 existing instances flagged as warnings — sweep remains parked.
> - **SHIPPED (full sweep) in f0dbefc:** 176 em-dashes replaced with " - " across 49 files in `src/components` + `src/app`, skipping pure-comment lines (// /\* \* \*/). Two `&mdash;` HTML entity cases (system-monitoring-dashboard + interest-stage-picker) caught separately. Lint rule bumped from `warn` → `error` so new code reintroducing em-dashes in JSX text fails the gate. Templates / audit-logs / structured logging stayed untouched per scope.
> - **Custom-field form: "Sort Order" needs an explainer tooltip — example of a broader gap** — _src/components/admin/custom-fields/custom-field-form.tsx:298-308_ — surfaces a specific instance of a platform-wide gap: see the next finding for the full sweep. **SHIPPED in 552b966:** Sort Order now uses the FieldLabel primitive (PR4.2) with explainer tooltip. First adoption of the primitive; platform-wide sweep remains parked.
> - **DocumentList DocRow kebab: add "Download" action** — _src/components/documents/document-list.tsx:86-109_ — current kebab has Send-for-Signing (draft only), Move-to-folder, Delete. No Download. Reps reviewing a signed doc from the interest's documents tab have to navigate into the document detail to download. Add a `<DropdownMenuItem>` at the top of the menu when `doc.signedFileId` is set (or `doc.fileId` for non-Documenso docs like manual uploads), wired to the same `apiFetch('/api/v1/files/[id]/download')` + anchor-click pattern used elsewhere. Permission-gate by `files.download` if that perm exists. ~10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee:** DocRow now renders Download at the top of the kebab when `signedFileId` is set; wired via the existing `triggerUrlDownload` helper from PR1.
> - **InterestEoiTab "Open" link too ambiguous — relabel to "Open in Documents"** — _src/components/interests/interest-eoi-tab.tsx:163_ — the link in the EOI history list goes to `/${portSlug}/documents/${d.id}` (Documents Hub doc detail) but the label just says "Open" + an external-link icon. Rep can't tell where it goes until they hover. Change to `Open in Documents` (or `View in Documents`). Apply the same idiom anywhere else a cross-section navigation link uses bare "Open" — quick grep + sweep. ~5 min. Captured 2026-05-21 from UAT. **SHIPPED in c6dcf49.**
@@ -199,6 +200,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
> - **Service-side:** extend the berths-list response to include `topActiveInterests: Array<{interestId, clientId, clientName, pipelineStage, isPrimary, isInEoiBundle, isSpecificInterest, createdAt}>` (cap at top 5, "View all" link in the popover footer when > 5). Single query that returns this alongside the count via `array_agg` in the existing correlated subquery — no N+1.
> - **Permission gating:** the popover row's "Open interest →" link respects `interests.view`. Client name link respects `clients.view`. Hide entire popover when neither perm is held (count chip becomes static for view-only roles).
> - **Effort:** ~2-3h end-to-end (service extension + popover component + stage-color logic + tests). Captured 2026-05-21 from UAT.
> - **SHIPPED in 292a8b5:** new `GET /api/v1/berths/[id]/active-interests` endpoint returns up to 20 non-archived non-terminal interests linked to the berth (client name, stage, primary/EOI-bundle flags), sorted most-recently-updated first. New `<ActiveInterestsPopover>` lazy-loads on click (30s stale); rows link to the interest detail with a stage-badge tint + primary-star icon. Berth-columns cell now wraps the count number in the popover trigger. Stage-by-highest-stage cell border-tint deferred; popover delivers the same info with one click instead. **Also shipped (companion task — table density):** `DataTable` gained a `density: 'comfortable' \| 'compact'` prop; berth-list toolbar exposes a Rows3/Rows4 toggle that persists per-entity to `user_profiles.preferences.tablePreferences[entityType].density`.
> - **Interest Overview Email + Phone rows: combobox picker across client's contacts + quick-add new contact** — _src/components/interests/interest-tabs.tsx:958-1000 (Email + Phone EditableRow blocks)_ + _src/components/interests/interest-tabs.tsx:122-129 (`clientPrimaryEmail/Phone[ContactId]` types)_ + _src/lib/services/interests.service.ts (getInterestById)_ + _src/lib/services/client-contacts.service.ts_ + _new_ component `<ClientContactPicker channel="email|phone" clientId={...} selectedContactId={...} onSelect={...} />`. Today's surface shows ONLY the client's primary email + primary phone via inline editor. Two real gaps surfaced UAT 2026-05-21:
> - **Gap 1 — empty state has no quick-add:** when client has no primary contact for the channel, line 976-978 renders `<span>—</span>` with no affordance. Rep has to navigate to the Client Detail's Contacts tab to add one. Should expose `+ Add email` / `+ Add phone` inline that POSTs a new `client_contacts` row + marks isPrimary=true.
> - **Gap 2 — no multi-contact picker:** clients with multiple contacts per channel (e.g. 3 emails — personal, work, assistant) get only the primary shown. Rep can't pick which one applies to THIS deal. Picker needs a dropdown listing every contact for the channel, pre-selecting the current primary, with each row showing the value + label (Personal / Work / etc.) + a `Set as primary` action + a `+ Add new email` / `+ Add new phone` row at the bottom that POSTs a new client_contacts row.
@@ -212,7 +214,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
> - **ft ↔ m unit switching on Berth Requirements** — _src/components/interests/interest-tabs.tsx_ — the three inline-editable dim rows hard-code `(ft)` in the label. The interest already carries `desiredLengthUnit` ('ft' | 'm'); other surfaces (BerthRecommenderPanel) honour it. Add a small unit toggle that flips the rendered display (and converts on save so the canonical `desired*Ft` column stays in feet). Same pattern as elsewhere in the app (per CLAUDE.md mooring/berth dims model). ~30-45 min.
> - **Client Overview should summarize current interest's requirements** — _src/components/clients/_ — one-line "current interest needs X × Y × Z" summary on the client detail Overview tab; reps currently have to drill into Interests tab to see what a client wants. ~30 min.
> - **Duplicate Reminder surfaces on Interest Overview** — _src/components/interests/interest-tabs.tsx_ — the legacy "Reminder" panel (driven by `interest.reminderEnabled / reminderDays / reminderLastFired`) and the new "REMINDERS" section (driven by the `reminders` table via the bell-in-header) both render on the same tab and tell different stories. The legacy field still drives a real backend worker (`processFollowUpReminders` in `reminders.service.ts:428` — creates auto-follow-up reminders when no activity in N days), so we can't just delete the field. Approach: hide the legacy "Reminder" panel from the OverviewTab grid; surface the recurring-follow-up config either as a slim row inside the REMINDERS section or as a setting on the interest detail header. Keep the worker untouched. ~1 h. **SHIPPED in f39f0aa:** legacy panel hidden from Overview; worker untouched. Surfacing the recurring-follow-up config on the detail header is parked.
> - **LinkedBerthsList: no "add another berth" affordance from the card** — _src/components/interests/linked-berths-list.tsx_ — multi-berth interests are first-class (`interest_berths` is the source of truth per CLAUDE.md) but the LinkedBerthsList card doesn't expose an inline "Add a berth" button. Reps have to use the BerthRecommenderPanel below — discoverability gap. Add a CTA button to the card header (gated by `berths.edit`) that opens a picker / sheet to add another `interest_berths` row. ~45 min.
> - **LinkedBerthsList: no "add another berth" affordance from the card** — _src/components/interests/linked-berths-list.tsx_ — multi-berth interests are first-class (`interest_berths` is the source of truth per CLAUDE.md) but the LinkedBerthsList card doesn't expose an inline "Add a berth" button. Reps have to use the BerthRecommenderPanel below — discoverability gap. Add a CTA button to the card header (gated by `berths.edit`) that opens a picker / sheet to add another `interest_berths` row. ~45 min. **SHIPPED in 3999d4b:** "Add berth" button on the card header; opens a `<Dialog>` with a Command-primitive searchable picker backed by `/api/v1/berths/options`. Berths already linked are filtered out client-side so reps can't double-add. Mutation hits `POST /api/v1/interests/[id]/berths` with `isSpecificInterest` flag; invalidates interest-berths + berth-recommendations caches so the row appears immediately and the recommender drops the just-added berth.
> - **Supplemental-info-request: link should be reusable, not single-use** — _src/lib/services/supplemental-info_ (token model) — current email says "can only be used once"; user wants it valid until expiry so a partial submission can be revisited. Drop the single-use guard, keep TTL gate. Audit the public endpoint to ensure no token-fingerprint reuse risk before lifting the limit. ~30 min. **SHIPPED in b74fc56:** `applySubmission` drops the `isNull(consumedAt)` filter; TTL is the sole validity check. Public form's "already submitted" lockout screen replaced with a soft amber banner noting that re-submission overwrites the previous data. `consumedAt` still stamped for last-submitted context.
> - **Supplemental-info-request: distinct Regenerate vs Resend actions + issue history** — _src/components/interests/supplemental-info-request-button.tsx:83_ (the current "Resend" label) + _src/lib/services/_ (the issue endpoint that today mints a new token on every POST) — once the link becomes reusable-until-expiry (per the "should be reusable, not single-use" finding above), the single "Resend" button conflates two semantically different actions: (a) mint a NEW token (invalidates the previous one — needed when the old one expired, was leaked, or the client deleted the email), and (b) re-email the EXISTING still-valid token (needed when the client just lost the email — same token, same form-state, just push through SMTP again so they can pick up where they left off). The current implementation always does (a) — the "Resend" copy is misleading. Plus once we have reusable tokens, the rep loses visibility into "what token did we send when?" — the inline `link` state only holds the last-minted one.
> - **Fix:**
@@ -256,6 +258,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
> - **BulkAddBerthsWizard: block proceed when any mooring already exists in the port** — _src/components/admin/bulk-add-berths-wizard.tsx_ + _src/lib/services/berths.service.ts_ (new pre-flight check) — the wizard's review/preview step should validate the to-be-added mooring numbers against existing rows for the port and block "Submit" if any duplicates are found (rather than relying on a DB-constraint error mid-insert, which today doesn't even fire because there's no partial unique index on `(port_id, mooring_number)` — see Bucket 4 #1 "Duplicate E17 row" which captured the missing constraint).
> - **Fix:** (a) on entering the preview step, fire a `GET /api/v1/berths/check-moorings?port=<id>&moorings=A1,A2,...` (cap ~500 per call) that returns `{ existing: [{ mooringNumber, id }] }`. (b) If non-empty, show an inline error panel listing the conflicts (linked to the existing berths) and disable Submit; offer a "Remove conflicts and continue" button that drops the dupes from the wizard payload before re-enabling Submit. (c) Pair this with the partial unique index fix from Bucket 4 #1 so the DB-level guard exists as a backstop — UI validation prevents the friction; DB constraint prevents the silent dup. (d) Same pre-flight should run on per-row "single add" flow for parity.
> - **Effort:** ~1.5h (endpoint + index + UI panel + tests). Captured 2026-05-18 from UAT.
> - **SHIPPED in ca172fa:** `POST /api/v1/berths/check-duplicates` accepts up to 500 mooring numbers, returns the subset that already exist as non-archived berths (canonical `^[A-Z]+\d+$` regex validation, `berths.import` perm). Wizard fires the check during the Step 1 → Step 2 transition with a "Checking…" Continue button. Step 2 banner lists first 8 duplicates + a "Remove all duplicates" action; duplicate rows render amber-tinted with a "Dup" pill. Submit disabled while any dup remains, tooltip explains. Partial unique index `(port_id, mooring_number) WHERE archived_at IS NULL` follow-up (Bucket 4 #1) parked.
> - **Yacht Overview: verify bidirectional ft↔m auto-convert is visually reflecting (logic exists; UI may not be updating)** — _src/components/yachts/yacht-tabs.tsx:99-137_ (`saveDimension`) + _src/components/yachts/yacht-tabs.tsx:68-80_ (`useYachtPatch` cache invalidation) — the bidirectional auto-conversion IS already implemented: `saveDimension()` patches both the primary field and the converted counterpart in one PATCH, and `onSuccess` invalidates `['yachts', yachtId]`. User report ("needs to autofill with auto-converted measurements") suggests the UI isn't visually updating after save — most likely the parent that passes the `yacht` prop into `OverviewTab` either (a) doesn't share the `['yachts', yachtId]` cache key (invalidation fires, no consumer refetches), (b) is hydrated via server-component `initialData` with no client refetch, or (c) the `InlineEditableField` for the counterpart memoizes its initial value and doesn't re-render when the upstream prop changes.
> - **Verify path:** (i) confirm the yacht detail page's `useQuery` cache key matches `['yachts', yachtId]` exactly — any mismatch (`['yacht']` singular, `['yacht-detail']` wrapper) makes the invalidation a no-op. (ii) Confirm `staleTime` / `refetchOnMount` allow refetch on cache bust. (iii) If the parent refetches but the field still doesn't visually update, force-re-render via `key={yacht.lengthM}` on the counterpart InlineEditableField.
> - **Apply to sibling surfaces:** the same bidirectional save belongs on **berth detail OverviewTab** — berth schema has `lengthM`/`widthM`/`draftM` + `_unit` discriminators and likely shows the same dual ft/m sections (verify); copy the `saveDimension()` pattern. Use the shared `src/lib/utils/dimensions.ts` helper from the earlier Dimensions-column toggle finding so the conversion ratio is centralized.
@@ -297,6 +300,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
> - **Honour the dev `EMAIL_REDIRECT_TO`** — same as production transactional emails: if set, prefix subject and reroute so QA doesn't spam users.
> - **Cross-ref:** related to the Documenso-config diagnosis loop (Bucket 3 #8 platform-wide error message audit) — same pattern of "configure-then-verify-without-real-workflow." Apply the same idiom to other integrations: Documenso test-send, S3 ping, Redis ping, IMAP test-connect.
> - **Effort:** ~1.5h for email (UI + endpoint + audit + dev-redirect honour). +1-2h each for the sibling integration test-ping buttons if pursued in the same pass. Captured 2026-05-18 from UAT.
> - **SHIPPED in 7881da6:** `POST /api/v1/admin/email/test-send` route gated on `admin.manage_settings`; reuses `sendEmail(..., ctx.portId)` so per-port SMTP overrides + `EMAIL_REDIRECT_TO` honour are exercised the same way a real notification would. Sends a minimal plaintext-only message (no logo, no branded shell) so the failure mode is pure transport — distinct from the branding-page "Send a test" which exercises the rendering pipeline. New `<SmtpTestSendCard>` on `/admin/email` between the SMTP overrides form and the SalesEmailConfigCard. Errors surface inline below the input (auth failure, ENOTFOUND, connection refused, etc.) rather than as a passing toast — whole point is to read them. Sibling test-ping buttons for Documenso / S3 / Redis / IMAP parked.
> - **YachtPicker: opening returns no yachts (empty `q` → empty list); should return a default list** — _src/app/api/v1/yachts/autocomplete/handlers.ts:10-12_ + _src/components/yachts/yacht-picker.tsx:56-60_ — the autocomplete handler short-circuits with `{ data: [] }` when `q` is empty: `if (!q) { return NextResponse.json({ data: [] }); }`. The picker fires the query the moment it opens with `debounced=''` → user opens, sees empty state, has to start typing before any options appear. Dead-end UX.
> - **Fix:** (a) handler: when `q` is empty, return the top 20-30 yachts for the port (most-recently-updated default; if `ownerType`/`ownerId` query params are provided, filter server-side to that owner). Trivial — just drop the early-return and pass `q` as optional to the `autocomplete()` service, which defaults to an empty search term meaning "no name filter". (b) Picker: extend the query string to include the owner filter so server-side filtering works (currently the picker filters client-side post-fetch, which means a yacht owned by someone other than the current `ownerFilter` may not even reach the client if it's outside the default-20). (c) UX nicety: the picker's `placeholder` could include "or search…" so the user knows typing also works.
> - **Effort:** ~30 min. Captured 2026-05-18 from UAT. **SHIPPED (a) in 2bcf544:** autocomplete handler drops the early-return; service returns top 20 most-recently-updated yachts when `q` is empty. (b) owner-side server filtering remains client-side as before; (c) deferred.
@@ -317,6 +321,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
> - **Frontend:** new section in the command-search dropdown rendered above the text-match "Interests" section. Borrow the existing `SectionHeading` + result-row idioms. Use the existing `berthLabel` helper (the same one used in the DocumentDetail Interest link fix and the external-EOI title default) so naming is consistent across surfaces.
> - **Alias catalog (lives next to `STAGE_LABELS`):** add a small `STAGE_SEARCH_ALIASES: Record<PipelineStage, string[]>` map for non-obvious matches ("hot lead" → qualified? probably not, leave it conservative). Aliases stay short and unambiguous — prefer false negatives over false positives.
> - **Effort:** ~2-3h end-to-end (backend section + fuzzy ranker + frontend render + alias catalog + a vitest covering "res" matching reservation but not "reservation_signed" if that's a thing). Captured 2026-05-21 from UAT.
> - **SHIPPED in d912f02:** new `searchStages(portId, query, limit)` in search.service.ts. Three match flavours (case-insensitive): (1) modern label token-prefix on `STAGE_LABELS`, (2) substring on the raw enum slug, (3) legacy-alias prefix via `LEGACY_STAGE_REMAP` so "eoi_sent" / "deposit_10pct" / "contract_signed" still land on the modern 7-stage equivalent. Each result carries a live `COUNT(*)` of non-archived interests in that stage (single grouped query). New `StageSuggestionResult` bucket added to `SearchResults` + `BucketType` union; gated on `interests.view`. Command-search dropdown renders matched rows under a "Stages" header pointing at `/interests?pipelineStage=<stage>`. Mobile overlay reuses `buildFlatRows` so the same surface appears on mobile. Sample-interests-per-stage variant from the original design was deferred — the count + filter-link does the same job with one click vs. inline preview.
> - **Watchers configurable at document creation time (currently post-creation only)** — _src/components/documents/eoi-generate-dialog.tsx_, _src/components/documents/upload-for-signing-dialog.tsx_, _src/components/interests/external-eoi-upload-dialog.tsx_, _src/components/documents/create-document-wizard.tsx:157_ (hardcoded `watchers: []`), _src/lib/services/documents.service.ts (create paths)_, _src/lib/services/document-watchers.service.ts_ (or wherever the watcher CRUD lives). Today watchers can only be added AFTER creation via WatchersCard on the document detail. Reps usually know upfront who needs visibility (manager, developer, legal) and shouldn't have to navigate to the doc after creating it.
> - **Server-side defaults (fires on every create path):**
> - **Creating user** — always auto-added. The person who just created the doc almost certainly wants notifications about events on it.
@@ -563,6 +568,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
> - **Call sites to migrate (22 files found via `grep "datetime-local|type=\"date\""`):** `src/app/(dashboard)/[portSlug]/invoices/new/page.tsx`, `src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx`, `src/components/berths/berth-form.tsx`, `src/components/invoices/invoice-detail.tsx`, `src/components/yachts/yacht-transfer-dialog.tsx`, `src/components/reservations/reservation-detail.tsx`, `src/components/reservations/berth-reserve-dialog.tsx`, `src/components/expenses/expense-form-dialog.tsx`, `src/components/admin/audit/audit-log-list.tsx`, `src/components/shared/inline-editable-field.tsx`, `src/components/shared/filter-bar.tsx`, `src/components/scan/scan-shell.tsx`, `src/components/dashboard/date-range-picker.tsx`, `src/components/interests/payments-section.tsx`, `src/components/interests/interest-tabs.tsx` (incl. the `MilestoneAdvanceButton` popover at line 318), `src/components/interests/interest-contact-log-tab.tsx`, `src/components/interests/external-eoi-upload-dialog.tsx`, `src/components/reminders/snooze-dialog.tsx`, `src/components/companies/add-membership-dialog.tsx`, `src/components/reminders/reminder-form.tsx`, `src/components/companies/company-form.tsx`, `src/components/reports/generate-report-form.tsx`. Several callers (e.g. `filter-bar.tsx`, `inline-editable-field.tsx`, `date-range-picker.tsx`) wrap the input and need slightly more care — small refactor of the wrapper, not a 1-line swap.
> - **Effort:** ~45 min to build the two wrappers + `useIsMobile` (if needed); ~2-3h to sweep all 22 call sites + visual verification in browser. Total ~3-4h. Captured 2026-05-21 from UAT.
> - **SHIPPED (primitives + highest-leverage migrations) in 8f42940:** `<DatePicker>` + `<DateTimePicker>` land in `src/components/ui`. Migrated: `MilestoneAdvanceButton` (Interest backfill UX), `reminder-form`, `snooze-dialog`, `external-eoi-upload-dialog`, `payments-section`. **Remaining ~17 sites parked** for a follow-up sweep — several use react-hook-form `register` patterns that need the controlled-value migration done carefully (expense-form-dialog, invoice/new, reservation/berth-reserve dialogs, company/yacht/audit forms, etc.).
> - **SHIPPED (remaining 14 sites) in 0c6e7b7:** completed the sweep. Migrated: `audit-log-list.tsx`, `reports/generate-report-form.tsx`, `scan/scan-shell.tsx`, `reservations/reservation-detail.tsx`, `shared/filter-bar.tsx`, `berths/berth-form.tsx`, `reservations/berth-reserve-dialog.tsx`, `companies/add-membership-dialog.tsx`, `yachts/yacht-transfer-dialog.tsx`, `invoices/invoice-detail.tsx`, `expenses/expense-form-dialog.tsx`, `companies/company-form.tsx`, `interests/interest-contact-log-tab.tsx` (datetime-local x2). RHF `register` sites wrapped with `<Controller>` + the picker's `value`/`onChange` bridge; Date-typed schemas (expenseDate, incorporationDate) get an inline Date↔YYYY-MM-DD bridge. Skipped because they ARE primitives or internal date variants: `ui/date-picker.tsx`, `ui/date-time-picker.tsx`, `shared/inline-editable-field.tsx`, `dashboard/date-range-picker.tsx` (own popover with min/max gating). Removed 4 now-unused `Input` imports.
1. **Platform-wide chart library migration: recharts → ECharts**_src/components/dashboard/_ + _src/components/website-analytics/_ + _src/components/berths/_ — we now run two chart libraries side-by-side: ECharts (just adopted for the world choropleth + tree-shaken, canvas renderer, d3-geo projection) and recharts (everything else: berth-status donut, occupancy-timeline line, pipeline-funnel bar, lead-source pie, source-conversion bar, berth-heat-widget bars, pageviews-vs-sessions area, pipeline-value-tile mini-bars — ~8+ components). **Trade-off analysis (done 2026-05-19 during analytics build):** ECharts wins on visual polish (better default styling, smoother animations, native legend/tooltip behaviour), comprehensive chart types (sunbursts, sankeys, parallel coords, heatmaps, geo all out of the box), and canvas-renderer performance on dense series; recharts wins on React-idiom (declarative `<Area>` / `<Bar>` children vs imperative option objects) and bundle size for the very simplest charts. **Migration cost:** ~610 h to port the existing 8 components; each is a 50150 LOC swap from `<ResponsiveContainer><AreaChart>…` to an `<ReactEChartsCore option={…} />` with tree-shaken module imports. **Pre-reqs already in place:** `transpilePackages: ['echarts', 'zrender', 'echarts-for-react']` added to `next.config.ts`, `d3-geo` installed, dynamic-import + canvas-renderer pattern proven on the world map. **Recommendation:** do as a single coordinated pass (consistency wins over piecemeal), gated on a free afternoon — none of the existing recharts components are buggy, this is purely about platform-wide visual + capability parity with the new analytics surfaces. Captured 2026-05-19 during the Umami flesh-out work.
2. **Bulk-price editing UI**_src/components/berths/_, _src/components/berths/berth-columns.tsx_ — backend shipped this session (new `berths.update_prices` permission across schema + 6 role maps + admin UI + factories; validators `updateBerthPriceSchema` + `bulkUpdateBerthPricesSchema`; services `updateBerthPrice` + `bulkUpdateBerthPrices` — both per-row audited with `fieldChanged='price'`; routes `PATCH /api/v1/berths/[id]/price` + `POST /api/v1/berths/bulk-update-prices`, ≤500 berths per batch). UI work pending: (a) wire `InlineEditableField` into the price cell of `berth-columns.tsx` (click → input → PATCH) gated by `can('berths', 'update_prices')`; (b) add `bulk-price-edit-sheet.tsx` (right-side Sheet, per-row inputs, "Set all to" + "Apply % adjust" shortcuts) wired to `bulkActions` on the `<DataTable />` in `berth-list.tsx`. ~23 h to ship the UI.
@@ -590,6 +596,13 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
> - **Charts as PNG fallback** — pdfme can't render Recharts/ECharts components natively. Server-side: render each chosen widget to a PNG via a headless renderer (puppeteer or playwright running against the same chart components), then embed the PNG in the pdfme template. Pre-cache PNGs per widget per range to avoid regenerating on every export.
> - **Export-history table** (`exported_reports`): id, port_id, user_id, file_id, widgets_included, date_range_from, date_range_to, title, created_at. Reps can re-download past exports without regenerating.
> - **Effort:** ~10-14h end-to-end. ~3h for the dialog + widget toggles + modal. ~3-4h for the server-side composition + pdfme template fragments per widget. ~2-3h for chart-to-PNG rendering pipeline. ~1-2h for the export-history table + list UI. ~1-2h for the per-widget template definitions. Captured 2026-05-21 from UAT. **Cross-ref:** 3a (location decision); existing branding infra (per CLAUDE.md); chart-library migration to ECharts (Bucket 3 #00) — if that lands first, the PNG-rendering pipeline gets simpler (ECharts has a native server-side PNG export via canvas).
> - **SHIPPED (full 4-phase build) across 3b199c2, 47c2ba9, 1cdc2fd, 5a9b5f6:**
> - **Stack decision:** dropped pdfme in favor of `@react-pdf/renderer` (already a dep). Rationale: React component model matches the codebase, server-side `renderToBuffer` slots into the existing `/api/v1/*` route pattern, no Chromium dep (smaller image, lower memory), built-in `<PDFViewer>` for the preview modal. Charts render as data tables here — printed reports prioritize the actual numbers over chart shapes, accessible to screen readers, holds up if OCR-scanned. Recharts-as-SVG embedding can be added later if needed; Puppeteer hybrid stays available as a fallback.
> - **Phase A (3b199c2):** foundation. `BrandedReportDocument` page wrapper (logo + title + footer with port name + page numbers), `makeReportStyles(branding)` keyed off the port's primary color with a luminance check for the accent foreground (AA contrast on dark brands). `DashboardReport` with KPI grid + per-widget tables (KPI overview / pipeline funnel / berth status / source conversion / hot deals). Server-side data fetchers via the existing `dashboard.service.ts` — only the selected widget IDs trigger their fetch. `POST /api/v1/reports/generate` with zod-validated discriminated-union schema, `reports.export` perm gate, audit log on success, RFC 5987 Content-Disposition for unicode filenames. UI: `<ExportDashboardPdfButton>` on the Dashboard header. 3 unit tests prove the renderer emits `%PDF-` bytes.
> - **Phase B (47c2ba9):** clients / berths / interests list reports. Shared `<ReportTable>` zebra-striped primitive with no-break rows. Three data resolvers (`resolveClientReportData`/`resolveBerthReportData`/`resolveInterestReportData`) in `src/lib/services/list-report-data.service.ts` with primary-email/phone subqueries for clients, primary-berth left-join for interests, all capped at 1000 rows with "Showing top N of <total>" notice. Route schema widened to 4-arm discriminated union with exhaustiveness `_exhaustive: never` check. `<ExportListPdfButton>` reusable component wired into ClientList / BerthList / InterestList toolbars. 3 more render tests.
> - **Phase C (1cdc2fd):** saved templates. Migration `0079_report_templates.sql` + drizzle schema with sibling-name uniqueness scoped `(port_id, kind, LOWER(name))`. CRUD service + REST routes (`GET/POST /api/v1/reports/templates`, `GET/PATCH/DELETE /api/v1/reports/templates/[id]`) with `reports.export` perm + audit. `<SavedTemplatesPicker>` reusable component wired into both export dialogs — apply a template hydrates the form (widget selection / filters / title); save-as-template inline expands to a name input.
> - **Phase D (5a9b5f6):** preview modal. `<PdfPreviewModal>` POSTs the current form payload, renders the returned Blob in a sandboxed iframe via `URL.createObjectURL`, caches the Blob so the Download button doesn't re-fetch. Re-fetches when the rep tweaks config and re-opens preview. Object URL revoked on close + unmount. Eye button between Cancel and Download on both dialogs. Memoised previewPayload prevents unrelated re-renders from refiring the fetch.
> - **Final shape:** 4 report kinds, per-port logo + primary-color branding, customizable widget picker (dashboard) + include-archived toggle (lists), custom title, save-as-template, apply saved template, preview modal with cached Blob for download, 1000-row export cap, `reports.export` perm, audit-logged, RFC 5987 unicode filenames. **1454/1454 vitest pass; 6 PDF render tests included.** Manual end-to-end (open dashboard → preview → download) is the next gate.
5. **Web analytics integration (companion to #3)**_new feature_ — per-port web analytics provider config in admin (GA4 / Plausible / Umami / Cloudflare), surfaced as widgets on the dashboard and ingestable into the branded PDF report. Needs: settings UI, provider adapter layer (`src/lib/integrations/analytics/`), dashboard widgets, and inclusion in the report exporter. ~812 h.
6. **Supplemental-info-request email: branded HTML styling**_src/lib/email/templates/_ — the email is plain HTML (logo missing, no header card, no blurred background), inconsistent with the other branded transactional emails (portal activation / reset / login wrap content in a `BrandedAuthShell`-equivalent HTML layout per CLAUDE.md). Rebuild the template to match the table-based, max-width 600, logo + blurred overhead background look, pulling port branding from `system_settings`. ~1-2 h.
7. **Residential interests list: visual + functional parity with the main InterestList**_src/components/residential/residential-interests-list.tsx_ vs _src/components/interests/interest-list.tsx_ + _interest-card.tsx_ + _interest-columns.tsx_ + _interest-filters.tsx_ — the residential interests page today is a slim search + stage-filter list (~200 lines). The main InterestList (~700 lines + supporting files) carries the bulk of the product idiom: card / table / kanban view modes (kanban is desktop-only), `usePaginatedQuery` with sort + saved views, full `FilterBar` (search, stage, tags, owner, source, date ranges), `ColumnPicker` for table mode, bulk actions wired to `/interests/bulk` (archive, change stage, add/remove tag), realtime invalidation across multiple event names, per-row archive flow, kebab actions, `InterestCard` rich row component. Reps switching between berth interests and residential interests today get two visually-divergent experiences for what is effectively the same conceptual surface.
@@ -715,6 +728,7 @@ _Functional defects. Tag each with `[critical|high|medium|low]` prefix._
- (d) Service splits metadata write (always: `dateEoiSigned ?? signedAt ?? now()`, `eoiStatus='signed'`) from stage advance (gated on past-EOI). Also fires `evaluateRule('eoi_signed', …)` so berth rules stay in lockstep.
- **Default title** for the external-EOI dialog now derives `External EOI — <Client> — <berth range> — <date>` via the existing `formatBerthRange` helper; rep can override.
- **(e) Edit-metadata UI deferred** to a later wave so it can share infra with the broader signing-flow rework (queued as task #14).
- **SHIPPED (e) in 235e064:** new `updateExternalEoiMetadata` service function patches `documents.title`, `documents.notes`, `interests.dateEoiSigned`, and full-replaces `document_signers` (rows with `id` are UPDATEd, rows without are INSERTed, existing rows whose id isn't in the array are DELETEd). Refuses Documenso-managed docs (`isManualUpload=false`) and non-EOI types with ConflictError. New `PATCH /api/v1/documents/[id]/metadata` route. `<ExternalEoiEditDialog>` mirrors the upload-dialog's signatory affordance (name + email + role + add/remove) plus title / signed date / notes. Document detail page gains an "Edit metadata" button (Pencil icon) that renders only when `isManualUpload && documentType === 'eoi'`. Edit trail recorded in `document_events` as `metadata_updated`.
7. **[high] Expense form: zod refine on `receiptFileIds` fires invisibly — Create button does nothing because the error renders nowhere** — _src/components/expenses/expense-form-dialog.tsx:64-77_ (form registers `useForm` + `zodResolver(createExpenseSchema)`) + _src/lib/validators/expenses.ts:40-47_ (schema-level `.refine()` requiring `receiptFileIds.length > 0 || noReceiptAcknowledged === true`, attached to `path: ['receiptFileIds']`). The form keeps `uploadedReceipt` + `noReceipt` in local React state, never injecting them into the form values via `setValue`. They're spliced into the payload INSIDE `onSubmit` (lines 188-189) — but `onSubmit` is never reached because validation fails first: zodResolver sees `receiptFileIds: undefined` in form values, the refine fails, `errors.receiptFileIds` is set. The form has NO `{errors.receiptFileIds && <p>...}` block, so the error is invisible. Browser scrolls to top of failed form. User reports "I filled everything in and uploaded a receipt — clicking Create does nothing."
- **Fix (recommended — single source of truth in react-hook-form):**
- When `handleFileChange` succeeds: `setValue('receiptFileIds', [uploadedReceipt.id], { shouldValidate: true })`.
@@ -766,6 +780,7 @@ _Functional defects. Tag each with `[critical|high|medium|low]` prefix._
- Files with only `client_id` set pre-feature stay at client-folder level — no interest scope retroactively (can't infer which interest they belonged to).
- One-off script `pnpm tsx scripts/backfill-nested-document-folders.ts --apply` — idempotent, per-port advisory-locked.
- **Effort:** ~6-8h end-to-end (migration + service rewrites + folder-name derivation + upload-zone affordance + tree rendering + lifecycle hooks + backfill + tests). Bundles bug #4 — both touch the same code paths. Captured 2026-05-21 from UAT.
- **SHIPPED (foundation only — phase 1/3) in e91055f:** migration `0078_files_interest_id.sql` adds `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL + indexes `idx_files_interest` + `idx_files_port_interest`. Drizzle schema picks up the column + `interestId` field. `EntityType` widened to include `'interest'` — `ensureEntityFolder('interest', ...)` recursively ensures the parent client folder first so the tree reads `Clients/<Name>/Deal <mooringNumber>/` nested. `resolveEntityDisplayName` derives the deal label from the primary berth via dynamic-import of `getPrimaryBerth` (circular-dep dodge), falling back to `Deal <YYYY-MM-DD>`. **Remaining (phase 2/3):** UploadZone `scopeOptions` radio for "This deal" vs "Client-level", lifecycle hook for interest outcome → folder rename ("Deal A1-A3 (Won)"), `listFilesAggregatedByEntity` rewrite to surface "This deal" / "From client" subheadings, Documents Hub tree rendering for nested interest folders, backfill script for existing files with `entity_type='interest'` but no `interest_id`.
9. **[medium] SelectTrigger height (`h-9`) doesn't match Input height (`h-11`) — platform-wide visual inconsistency** — _src/components/ui/select.tsx:22_ (SelectTrigger default `h-9` = 36px) + _src/components/ui/input.tsx:18_ (Input default `h-11` = 44px). Every form where an Input sits next to a Select has an 8px height mismatch. Surfaced specifically on _src/components/expenses/expense-form-dialog.tsx:222-247_ (the Amount + Currency two-column row) but affects ALL such combinations across the platform. Fixing locally with `className="h-11"` on each call site is a sweep over dozens of spots and creates drift the next time someone copies the pattern.
- **Fix (platform-wide):** introduce a `size` variant on SelectTrigger mirroring Button's idiom — `<SelectTrigger size="default" | "sm">`. Default to `"default"` = `h-11` so it pairs with the Input default out of the box. Migrate explicitly-compact uses (filter bars, dense table headers) to pass `size="sm"` = `h-9` to preserve their current density.
- **Audit step:** grep every `<SelectTrigger>` and `<Select>` call site; flag the ones in compact contexts (FilterBar, DataTable header dropdowns, dense admin lists) for the `size="sm"` override; everything else inherits the new h-11 default.