docs(uat): backfill SHIPPED markers across master doc
Previous "annotate plan with per-group SHIPPED commits" pass (6aaccb6) touched only the per-session plan doc; the long-lived alpha-uat-master.md was missing markers for ~20 ships across Groups C-T and two regression catches from the current session. Added markers for:991e222(C21+C22+C23 ft/m + bulk),431375d(D24 wizard ft/m + D25 dock letters + E26 regenerate/resend/history),94c24a1(F28 past-milestones + F29 watchers + G30 invitations merge + H32 email explainer + H33 branded supplemental email),989cc4d(I34 residential header + I35 interests parity + I36 partner forward + I37 auto-link),03a7521(J38 set-X-to-Y + J39 link company + K40 resolver chain),65ff596(L41 upload-for-signing rework),0ddaf46(M42 universal preview),a147cbc(N44/45/46),a7cbee0(O48/52/53/54),0ed03fc(P56 phases 2/3),c14f80a(Q58/59/61),aa1f5d2(R62/T64/T65). Two fresh entries:be261f3LAN-dev fix,adf4e2bdashboard PDF widget split. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
|
||||
> - **Effort:** ~20-30 min. Captured 2026-05-21 from UAT. **Pairs nicely with:** the platform-wide form-error UX work (Bucket 2) — both touch how form content is presented in dialogs.
|
||||
> - **SHIPPED (width + recipient row + textarea) in 203f543:** dialog widened to `max-w-[1400px] w-[95vw]` so the place-fields step gets the room it needs; recipient row swapped from `grid-cols-12` to a flex layout (Name `flex-1`, Email `flex-[2]`, Role `w-40 shrink-0`, delete `shrink-0`); invitation-message textarea bumped from 3 → 6 rows. Step-adaptive sizing skipped — the new wider dialog works for all three steps without per-step gymnastics.
|
||||
> - **ColumnPicker: add "Hide all columns" symmetric to "Show all columns"** — _src/components/shared/column-picker.tsx:58-60 (`showAll()`) + 116-123 (button render)_ — current picker has a "Show all columns" footer item that clears the hidden set. Add a parallel `hideAll()` that sets `hidden = columns.filter(c => !c.alwaysVisible).map(c => c.id)` — hides every toggleable column while preserving `alwaysVisible` ones. Render a "Hide all columns" footer item next to "Show all columns" with the same visibility gate (only shown when ≥1 toggleable column is currently visible, mirroring the `canShowAll` logic). Since column-picker is shared across every DataTable surface (berths, clients, interests, yachts, companies, reservations, invoices, audit-log, expenses), the fix lands platform-wide automatically. ~5 min. Captured 2026-05-21 from UAT. **SHIPPED in 8f42940:** `hideAll()` + symmetric `canHideAll` gate added; both items render under the same separator.
|
||||
> - **OnboardingChecklist: auto-check uses raw setting-row presence, not resolver chain → ports using env fallback or global config never auto-tick + super_admin discoverability gap** — _src/components/admin/onboarding-checklist.tsx:32-105 (STEPS def)_ + _src/lib/services/port-config.ts_ (the resolver chain like `getPortDocumensoConfig`) + new dashboard tile + new topbar banner for the discoverability half. Two linked bugs surfaced UAT 2026-05-21.
|
||||
> - **SHIPPED (core) in 03a7521 (K40):** Resolved endpoint widened to accept `?keys=k1,k2,...` so checklist batch-resolves heterogenous registry keys through port → global → env → default in one round-trip. Captures dominant source per step ("env fallback", "global default", "built-in default") surfaced inline under green tick so super-admins see when a step relies on env rather than per-port override. Compound-key gates report weakest sub-key's source. Topbar banner / dashboard tile / weekly nudge / celebration sub-items remain queued. **OnboardingChecklist: auto-check uses raw setting-row presence, not resolver chain → ports using env fallback or global config never auto-tick + super_admin discoverability gap** — _src/components/admin/onboarding-checklist.tsx:32-105 (STEPS def)_ + _src/lib/services/port-config.ts_ (the resolver chain like `getPortDocumensoConfig`) + new dashboard tile + new topbar banner for the discoverability half. Two linked bugs surfaced UAT 2026-05-21.
|
||||
> - **(a) [bug] Auto-check sentinels are too strict.** Examples:
|
||||
> - Email step (line 46) checks `smtp_host_override` — only fires when port has its own override row. Ports using global SMTP (the common case) never auto-tick even though email works.
|
||||
> - Documenso step (lines 58-63) requires ALL of 4 port-level overrides. Per CLAUDE.md, Documenso supports env fallback (`getPortDocumensoConfig` does `adminValue ?? env.DOCUMENSO_API_KEY`), so a working port using env config registers as not-onboarded forever.
|
||||
@@ -87,6 +87,8 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
|
||||
> - **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 `—` 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.
|
||||
> - **[Captured 2026-05-22] Dev-server unreachable from a phone on the LAN — three bundled fixes shipped in `be261f3`:** (1) `getPortBrandingConfig` normalizes localhost / private-LAN host prefixes (192.168._, 10._, 172.16-31._, 127._, 0.0.0.0) on read so an in-app `<img src>` resolves against current origin; both branding upload routes store path-only going forward; new `absolutizeBrandingUrl()` helper re-absolutizes for email shells; DB backfill stripped the 2 existing rows. (2) Socket.IO client connects via `io()` no-URL (window.location.origin) instead of `NEXT_PUBLIC_APP_URL`; server CORS uses a function that allows localhost + private-LAN in dev, locks to APP_URL in prod (mirrors better-auth trustedOrigins). (3) Portal logout route builds redirect from `req.url` instead of `env.APP_URL`. next.config `allowedDevOrigins` widened from a hardcoded IP to 192.168._ / 10._ / 172.16-31.\* wildcards so HMR works across networks without per-network edit (without HMR the login form's React click handler never hydrates and the form falls back to GET, leaking the password into the URL — the symptom that caught this).
|
||||
> - **[Captured 2026-05-22] Dashboard build crashed with "Module not found: Can't resolve 'fs'" via dashboard-shell → export-dashboard-pdf-button → dashboard-report-data.service → dashboard.service → @/lib/db → postgres. SHIPPED in `adf4e2b`:** split pure data + types (`PDF_DASHBOARD_WIDGET_IDS`, `PDF_DASHBOARD_WIDGETS`, `PdfDashboardWidgetId`, `PdfDashboardWidgetOption`) into new `src/lib/services/dashboard-report-widgets.ts`; client button imports from there; service re-exports from new file for backwards compat. The resolver (`resolveDashboardReportData`) stays in the service module since it's server-only.
|
||||
> - **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.**
|
||||
@@ -148,8 +150,8 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> **[Umami] Follow-ups parked at end of 2026-05-19 build session:**
|
||||
>
|
||||
> - **[Umami] Empty-state nudges on quiet ranges** — _src/components/website-analytics/{top-list.tsx, sessions-list.tsx, weekly-heatmap.tsx, visitor-world-map.tsx}_ — every card currently renders a flat "No data in this range" string when Umami returns nothing. Replace with a guided message that nudges the operator to expand the range — e.g. "No data in the last 7 days. Try 30d or 90d." plus a one-click button that flips the active `DateRange`. The hook stack already accepts a range setter via the URL search params, so this is purely component-level copy + a Button. ~45 min across the 4 cards. Captured 2026-05-19.
|
||||
> - **[Umami] Apple Mail privacy disclaimer copy** — _src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx_ — the "Track email opens" toggle helper text mentions Apple Mail pre-fetch in passing. Promote it to a bullet list under the field so admins can't miss it (Apple Mail Privacy = over-count; image-blocking clients = under-count; pixel won't fire when EMAIL_REDIRECT_TO is set). ~15 min. Captured 2026-05-19.
|
||||
> - **[Umami] Open-rate column on the document_sends list** — _src/components/documents/_ (find the list that renders document*sends rows; might be inside the interest detail Documents tab or in a dedicated sends-list surface), \_src/lib/services/document-sends.service.ts (listSends extension)* — Phase 4b shipped the data (`open_count` + `first_opened_at` on `document_sends`); the list UI doesn't surface it. Add an "Opened" column showing either a check + relative-time ("Opened · 2h ago · 3 opens") or an em-dash. Sort affordance optional. ~1-2 h depending on how many list surfaces exist. Captured 2026-05-19.
|
||||
> - **SHIPPED in a7cbee0 (O52):** Sends-log "Not opened" badge carries inline tooltip explaining Apple Mail's privacy protection routes opens through Apple's proxy and can suppress this signal even when the recipient read the email. **[Umami] Apple Mail privacy disclaimer copy** — _src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx_ — the "Track email opens" toggle helper text mentions Apple Mail pre-fetch in passing. Promote it to a bullet list under the field so admins can't miss it (Apple Mail Privacy = over-count; image-blocking clients = under-count; pixel won't fire when EMAIL_REDIRECT_TO is set). ~15 min. Captured 2026-05-19.
|
||||
> - **SHIPPED in a7cbee0 (O53):** SendRow type extended with `trackOpens` / `openCount` / `firstOpenedAt`; sends-log card renders "Opened × N" badge with first-open timestamp in title, or "Not opened" when tracking on but no opens yet, or no badge when tracking was disabled for that send. **[Umami] Open-rate column on the document_sends list** — _src/components/documents/_ (find the list that renders document*sends rows; might be inside the interest detail Documents tab or in a dedicated sends-list surface), \_src/lib/services/document-sends.service.ts (listSends extension)* — Phase 4b shipped the data (`open_count` + `first_opened_at` on `document_sends`); the list UI doesn't surface it. Add an "Opened" column showing either a check + relative-time ("Opened · 2h ago · 3 opens") or an em-dash. Sort affordance optional. ~1-2 h depending on how many list surfaces exist. Captured 2026-05-19.
|
||||
> - **[Umami] Verify pixel + tracked-link end-to-end with a real send** — _manual_ — flip the admin toggle on (`email_open_tracking_enabled = true` for port-nimara), send a real sales email to your own address, open it in Mail.app and Gmail web, then confirm: (a) `document_send_opens` row appears, (b) `open_count` + `first_opened_at` increment on the parent row, (c) Umami records an `email-opened` event. Same drill for `/q/<slug>` once the composer button (Bucket 3) ships. Cannot be automated — needs a real inbox. Captured 2026-05-19.
|
||||
|
||||
> **Outstanding (gaps on shipped work + rapid UAT capture):**
|
||||
@@ -217,14 +219,14 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **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. **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.
|
||||
> - **SHIPPED in 431375d (E26):** Service: `listTokensForInterest` (latest 20 issuances with expired/consumed flags) + `getTokenForResend` (snapshots token back into issue-shape for re-email). Route: GET lists; POST accepts optional `tokenId` for resend branch (forces sendEmail=true), returns `resent: true/false`. UI: three actions (Generate/Regenerate, Generate+email or "New link+email", Resend current — only with active unconsumed unexpired token) + issuance history list with Active/Submitted/Expired per row. 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:**
|
||||
> - **(a) Service split:** `regenerateSupplementalLink(interestId)` mints a new token + invalidates outstanding ones for the same interest (or keeps them parallel — design call; recommendation: invalidate, so one client only has one valid link at a time and the rep doesn't have to reason about which one is which). `resendSupplementalLinkEmail(tokenId)` emails the named existing token via SMTP without mutating the token table. Two API routes: `POST /api/v1/interests/{id}/supplemental-info-request` for regenerate, `POST /api/v1/interests/{id}/supplemental-info-request/{tokenId}/resend` for resend.
|
||||
> - **(b) UI:** swap the single button for a small action group that surfaces the most recent valid token's metadata (`Issued <relative time> · expires in <N days>`) with two buttons next to it — `Resend email` (primary, fires resend on the existing token) + `Regenerate link` (ghost, mints new). If no valid token exists, show only `Generate link`. Pair this with the "separate generate + send" finding below so the rep can also generate-without-sending (e.g. share through WhatsApp).
|
||||
> - **(c) History:** small expandable section "View past requests" listing the last 3-5 issued tokens with timestamp + status (active / expired / submitted / revoked). Each row gets a "Revoke" action for the active ones (defensive — covers the "we sent it to the wrong email" case). Schema-wise this is just rendering existing rows in the supplemental-info-tokens table.
|
||||
> - **Effort:** ~2-3h end-to-end including the service split, two API routes, UI rework, audit-log entries on each action, and a vitest covering the resend-doesn't-mutate-token guarantee. Captured 2026-05-21 from UAT. Cross-ref: ties into the "link should be reusable, not single-use" + "separate generate link and send email" findings — best done as one coherent rework.
|
||||
> - **Supplemental-info-request: separate "generate link" and "send email"** — _src/components/interests/supplemental-info-request-button.tsx_ — currently one button auto-generates + sends. User wants two steps: button 1 generates + shows the link (rep can copy / share manually); button 2 sends the templated email through SMTP. Backend change: split the existing service into `generateSupplementalLink()` and `sendSupplementalLinkEmail(linkId)`. UI change: replace single-click action with two-step UI showing link state. ~1 h. **SHIPPED in a4e30ea:** API route now accepts `{ sendEmail?: boolean }` (defaults true for back-compat); UI shows two distinct buttons — "Generate link" and "Send by email" (becomes "Regenerate link" + "Generate + email" depending on state). Email body copy also drops the "can only be used once" sentence since PR15 made tokens reusable.
|
||||
> - **Past-milestones strip → expandable history with inline doc preview** — _src/components/interests/interest-tabs.tsx_ (the past-milestones strip at ~line 863) — currently a one-line collapsed summary per past milestone (just title + summary). Reps want to drill into the history of a specific milestone (e.g. see which EOI round was signed, the doc contents, who signed, when). Convert the strip into an accordion: each past milestone expands to show its associated docs + sub-status timeline + inline PDF preview using the existing pdf viewer primitive. Useful for deals with multiple EOI rounds (rework after rejection, re-sent reservation agreements, etc.) where audit trail matters. ~3-4 h.
|
||||
> - **SHIPPED in 94c24a1 (F28):** Past strip on Interest overview becomes an `<Accordion>` — each row collapses to the same one-line summary as before, expands to render the full `<MilestoneSection>` (steps list, sub-status, inline doc actions). Reuses MilestoneSection so no new per-milestone rendering needs maintaining. Past-milestones strip → expandable history with inline doc preview — _src/components/interests/interest-tabs.tsx_ (the past-milestones strip at ~line 863) — currently a one-line collapsed summary per past milestone (just title + summary). Reps want to drill into the history of a specific milestone (e.g. see which EOI round was signed, the doc contents, who signed, when). Convert the strip into an accordion: each past milestone expands to show its associated docs + sub-status timeline + inline PDF preview using the existing pdf viewer primitive. Useful for deals with multiple EOI rounds (rework after rejection, re-sent reservation agreements, etc.) where audit trail matters. ~3-4 h.
|
||||
> - **InterestBerthStatusBanner: name + link the competing deal** — _src/components/interests/interest-berth-status-banner.tsx_ — the banner that surfaces when a linked berth is under offer to a different active deal currently just says "this berth is under offer elsewhere" without identifying which interest. Reps want a small inline detail: client name + deal stage + a link button to the competing interest, so they can size up the situation (e.g. "this lead won't make it, treat ours as backup"). Service-side: extend the `getInterestBerthStatus()` (or equivalent) response with a `competingInterest: { id, clientName, pipelineStage, ... } | null` field, then surface in the banner. Permission-gate the link by `interests.view`. ~1 h. **SHIPPED in 7ecf4ee:** reuses `/berths/[id]/active-interests` endpoint (already-shipped 292a8b5) instead of extending the original `getInterestBerthStatus()` call. One query per conflicting berth via `useQueries`; picks the `isPrimary` competing interest (falls back to first non-self row); renders inline `<Link>` to the competing detail page. Falls through gracefully when the endpoint hasn't resolved yet.
|
||||
> - **Notes Latest-note teaser missing round / stage context pill** — _src/components/interests/interest-tabs.tsx_ (the "Latest note" block around line 1029-1064) — notes created during a specific stage / EOI round should display a small "Round 2" or stage pill next to the timestamp so reps can see at a glance which phase a note belongs to. Currently shows author + time only. Schema: notes table doesn't carry round info today — would need a derived display from the interest's stage at note creation time (cheapest) or a stamped `created_during_stage` column (more reliable). ~45 min for derived display, ~1.5 h with migration for stamped column. (Same need likely applies to all notes lists, not just the Overview teaser.) **SHIPPED (current-stage variant) in 7ecf4ee:** stage-badge chip next to the timestamp using `STAGE_BADGE` colour map. Shows the deal's CURRENT pipelineStage — historical "stage-at-note-time" lookup would need a per-render `audit_logs` read, over-engineered for a context hint. NotesList rows on other surfaces (full Notes tab) deferred until a real need surfaces.
|
||||
> - **Dimensions columns: add ft↔m toggle in the column header (persisted to user prefs); skip per-row entry-unit indicator** — _src/components/berths/berth-columns.tsx:306_, _src/components/yachts/yacht-columns.tsx:102_, _src/components/clients/client-yachts-tab.tsx:63_, _src/components/companies/company-owned-yachts-tab.tsx:106_ (any current/future Dimensions column), plus _new_ `src/lib/utils/dimensions.ts` for the conversion + format helper, and _src/lib/db/schema/users.ts_ `user_profiles.preferences` for the persisted preference key — five table surfaces render "Dimensions" in feet today; reps used to metric units have to convert in their head.
|
||||
@@ -242,7 +244,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **Aggressive alternative (Path 1):** delete the columns + the four `*_usd` schema columns + the import paths if no port ever plans to use them. Decision: defer until we know whether ANY port in the roadmap does transient rentals. For now, hide-by-default is the right call.
|
||||
> - **Bundle with:** the "trim default-visible columns" recommendation in the platform-wide table-density finding below — same audit pass, same author.
|
||||
> - **Effort:** ~5 min (Path: hide-by-default). Captured 2026-05-21 from UAT.
|
||||
> - **Platform-wide table density: cells shrink-wrap content instead of triggering horizontal scroll — columns need min-widths + nowrap defaults** — _src/components/ui/table.tsx:7_ (wrapper is already `overflow-auto`, good ✓) + _src/components/ui/table.tsx (TableCell base — missing `whitespace-nowrap`)_ + _src/components/berths/berth-columns.tsx_ (no `size`/`minSize` on any column except line 447's `size: 48` outlier) + every other DataTable column definition in the app. Surfaced on the berths list (UAT 2026-05-21): with ~14 columns visible, every cell wraps into 3-6 lines because the table tries to fit everything in viewport. Example pain: "Bull bollard type B · 40 ton break load" wraps into 6 lines; "63m × 14.19m (draft 4.42m)" wraps into 3 lines; "Car (3t) to Vessel" wraps into 3 lines. Result: row height bloats to 200px+, the table becomes nearly unusable.
|
||||
> - **SHIPPED in c14f80a (Q59):** DataTable cells default to `whitespace-nowrap` so long values don't wrap into 4-5 lines. Columns needing wrapping override via column def's `meta.wrap = true`. Min-width comes from `column.getSize?.()` when set — opt-in per column rather than sweeping width change. **Platform-wide table density: cells shrink-wrap content instead of triggering horizontal scroll — columns need min-widths + nowrap defaults** — _src/components/ui/table.tsx:7_ (wrapper is already `overflow-auto`, good ✓) + _src/components/ui/table.tsx (TableCell base — missing `whitespace-nowrap`)_ + _src/components/berths/berth-columns.tsx_ (no `size`/`minSize` on any column except line 447's `size: 48` outlier) + every other DataTable column definition in the app. Surfaced on the berths list (UAT 2026-05-21): with ~14 columns visible, every cell wraps into 3-6 lines because the table tries to fit everything in viewport. Example pain: "Bull bollard type B · 40 ton break load" wraps into 6 lines; "63m × 14.19m (draft 4.42m)" wraps into 3 lines; "Car (3t) to Vessel" wraps into 3 lines. Result: row height bloats to 200px+, the table becomes nearly unusable.
|
||||
> - **Fix (platform-wide, single PR):**
|
||||
> - **(a) TableCell base default:** add `whitespace-nowrap` to the base TableCell className in `src/components/ui/table.tsx`. Single-line content stays single-line. Cells that genuinely need wrapping (long note teasers, etc.) opt-out via `className="whitespace-normal"` per-cell.
|
||||
> - **(b) Per-column `min-w-[X]` token system:** define a small set of width tokens in a shared helper based on content type — `colW.short` (status badges, count chips), `colW.medium` (mooring numbers, short labels), `colW.long` (dimensions, addresses), `colW.money` (price columns). Apply via TanStack `size: ...` or via cell className `min-w-[X]`. Reuse across every DataTable.
|
||||
@@ -253,7 +255,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **Berth list "Latest deal stage" column: make sortable by pipeline-stage rank** — _src/components/berths/berth-columns.tsx:273-287_ + _src/lib/services/berths.service.ts:80-120_ — the column currently has `enableSorting: false`; sorts by status / area / active interests / etc. already work via the existing `sortColumn` switch + `customOrderBy` correlated-subquery pattern (see `activeInterestCount` at lines 107-120). latestInterestStage isn't a column on `berths` — it's the highest-ranked active interest's stage, populated in a two-pass post-fetch.
|
||||
> - **Fix:** (a) drop `enableSorting: false` on the column. (b) Add a `'latestInterestStage'` case to the sortColumn switch returning `null` (handled in customOrderBy, like `activeInterestCount`). (c) Add a `stageSort` correlated subquery mirroring `demandSort`: select the rank of the highest-active-stage interest per berth via a `CASE i.pipeline_stage WHEN 'enquiry' THEN 1 WHEN 'qualified' THEN 2 ... WHEN 'contract' THEN 7 END` ladder, then `ORDER BY ... ASC/DESC` per `query.order`. Filter same as demandSort (`port_id`, `archived_at IS NULL`, `outcome IS NULL`). Berths with no active interest → NULL; use `NULLS LAST` (ascending) / flip per direction so they land at the bottom regardless.
|
||||
> - **Effort:** ~45 min. Pure additive — no schema work, no API contract change. Captured 2026-05-18 from UAT. **SHIPPED in ca51000:** column toggles to `enableSorting: true`; service-side adds a `stageSort` correlated subquery via the existing `customOrderBy` pattern (ranking 1=enquiry through 7=contract; NULLS LAST regardless of direction).
|
||||
> - **Berth list: bulk-edit affordance (parity with bulk-add)** — _src/components/berths/berth-list.tsx_ + _berth-columns.tsx_ + _src/lib/services/berths.service.ts_ + _new_ `src/app/api/v1/berths/bulk/route.ts` — bulk-add for berths exists; bulk-edit doesn't, so any cross-row mutation (status flip on a row range, price re-tier on a pontoon, tag application, area rename, archive a season's worth) is a 50× one-row-at-a-time grind. **Cross-reference:** the Bucket 3 finding "Bulk-price editing UI" already shipped the price-specific backend (`POST /api/v1/berths/bulk-update-prices`); this is the broader sibling covering every other column reps want to edit in bulk. Coordinate the two as a single rollout.
|
||||
> - **SHIPPED in 991e222 (C23):** New `POST /api/v1/berths/bulk` mirroring `/interests/bulk` (discriminated union: change*status / change_tenure_type / add_tag / remove_tag / archive; 500-id cap; per-row failure reporting; routes status mutations through `updateBerthStatus` so under-offer/sold transitions still trigger primary auto-link + rules engine). BerthList toolbar wires `bulkActions` on DataTable with confirm dialogs sharing one `bulkMutation` for consistent toast + invalidation. Berth list: bulk-edit affordance (parity with bulk-add) — \_src/components/berths/berth-list.tsx* + _berth-columns.tsx_ + _src/lib/services/berths.service.ts_ + _new_ `src/app/api/v1/berths/bulk/route.ts` — bulk-add for berths exists; bulk-edit doesn't, so any cross-row mutation (status flip on a row range, price re-tier on a pontoon, tag application, area rename, archive a season's worth) is a 50× one-row-at-a-time grind. **Cross-reference:** the Bucket 3 finding "Bulk-price editing UI" already shipped the price-specific backend (`POST /api/v1/berths/bulk-update-prices`); this is the broader sibling covering every other column reps want to edit in bulk. Coordinate the two as a single rollout.
|
||||
> - **Scope:** (a) Row-select infra on `<DataTable />` — checkbox column, "select all on page" / "select all matching filters" header, persistent selection across pagination (~1h, mirror `InterestList`'s `bulkActions` pattern). (b) Bulk-actions bar on ≥1 row selected: change status, change area, set price / % adjust (folds in the already-built endpoint), add/remove tags, archive/restore, export selection CSV — each opens a small confirm/edit dialog (~2-3h). (c) Unified backend `POST /api/v1/berths/bulk` (mirror `/interests/bulk`) taking `{ action, ids, ...args }`, port-validates IDs, per-row transactional with per-row failure summary so the rep sees which of 50 berths failed and why; per-row audit + realtime fan-out; cap 500 IDs (~2-3h incl tests). (d) Each action gated by the appropriate berth perm (`berths.edit`, `berths.update_prices`, `berths.archive`, `tags.manage`); endpoint enforces the most-restrictive perm of the requested action (~30min).
|
||||
> - **Effort:** ~5-7h end-to-end. Captured 2026-05-18 from UAT.
|
||||
> - **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).
|
||||
@@ -265,7 +267,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **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.
|
||||
> - **Effort:** ~20-30 min for the yacht debug + visual-update fix, +30 min if a berth equivalent needs the same logic. Captured 2026-05-18 from UAT.
|
||||
> - **SHIPPED (round-trip lossless + dedup) in 8e9efe5:** three copies of the conversion logic existed (yacht-dimensions.ts canonical, yacht-form.tsx local, yacht-tabs.tsx local) — the form-side ones used 2dp precision so `1 ft → 0.30 m → 0.98 ft` lost data on the round-trip. Consolidated both surfaces onto `feetToMeters`/`metersToFeet` from yacht-dimensions.ts and bumped precision to 4dp. New `tests/unit/yacht-dimensions.test.ts` proves round-trip lossless on the canonical 12.5 ft ↔ 3.81 m UAT case + sweeps 1/5/12.5/25/50/120/250 ft and 0.5/1/3.81/7.62/15.24/36.58 m (29/29 ✓). UI cache-key + InlineEditableField re-render path are independent debug items — flag for separate verification.
|
||||
> - **Merge `/admin/invitations` into `/admin/users` — single "people with access" surface** — _src/app/(dashboard)/[portSlug]/admin/users/page.tsx_, _src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx_ (to be removed), _src/components/admin/users/_, _src/components/admin/admin-sections-browser.tsx:90-95_ (drop the Invitations card from the Access section), _src/lib/services/_ (invitations service likely already separate — keep it) — active users and pending invitations are the same lifecycle (a person who has or should have port access). Splitting them across two admin pages forces admins to bounce between surfaces to answer "who has access here?". Merging gives them one canonical "people" page.
|
||||
> - **SHIPPED in 94c24a1 (G30):** Users page wraps existing UserList + InvitationsManager in Tabs control (Active users / Invitations). Standalone /admin/invitations route returns a redirect to merged page for bookmark back-compat. Removed nav catalog entry + admin-sections-browser tile; extended Users catalog keywords with "invitations / pending invites / onboarding" so command-K still lands on the right surface. Merge `/admin/invitations` into `/admin/users` — single "people with access" surface — _src/app/(dashboard)/[portSlug]/admin/users/page.tsx_, _src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx_ (to be removed), _src/components/admin/users/_, _src/components/admin/admin-sections-browser.tsx:90-95_ (drop the Invitations card from the Access section), _src/lib/services/_ (invitations service likely already separate — keep it) — active users and pending invitations are the same lifecycle (a person who has or should have port access). Splitting them across two admin pages forces admins to bounce between surfaces to answer "who has access here?". Merging gives them one canonical "people" page.
|
||||
> - **Approach:**
|
||||
> - **(a) Page shape:** keep route at `/admin/users`. Add a state filter at the top: `All | Active | Invited (pending) | Disabled | Archived`. Default to `Active`. The existing Users table extends to render invitation rows alongside active users, distinguished by a "Pending" badge + last-sent timestamp + "Resend" / "Revoke" kebab actions. Active-user kebab keeps current actions (edit role, reset password, disable). One unified `+ Invite user` button in the page header opens the existing invitation form. Search across both populations (name / email / role).
|
||||
> - **(b) Data shape:** the users table already returns user rows; extend the list endpoint (or add a parallel one that the page composes) to also yield pending invitations as a discriminated-union row type `{ kind: 'user' | 'invitation', ... }`. Keep the underlying tables separate (no schema change); the page just stitches both query results into one table. Filter at the API layer when `state=active` excludes invitations, etc.
|
||||
@@ -293,7 +295,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **(c) Diagnose path:** add a console.warn / dev-mode toast when the click is swallowed silently so the next person debugging this can see what's happening.
|
||||
> - **Sibling check:** the server-side route comment at lines 21-22 says it "refuses to reveal values resolved from env or default," but the implementation at lines 39-52 just calls `getSetting()` and returns whatever it gets — there's no actual refusal check in the route handler. If `getSetting()` reaches into the env fallback the endpoint would leak env values. Verify the refusal is enforced upstream in `getSetting()` (or in the registry resolver) — if not, that's a separate finding (low/medium severity bug: env secrets leakable via API to anyone with `admin.manage_settings`). Worth running through to confirm.
|
||||
> - **Effort:** ~15 min for (a) UI message + tooltip; ~30 min if the route's env-refusal check needs to be added too. Captured 2026-05-18 from UAT. **SHIPPED (a) in ca51000:** eye toggle now `disabled` + `title` tooltip when value resolved from env/default. Sibling check on the route's env-refusal guard deferred to a security-side follow-up.
|
||||
> - **Email settings page: add explainer copy clarifying why sales send-from and noreply have separate credentials** — _src/app/(dashboard)/[portSlug]/admin/email/page.tsx_ (the page) + _src/components/admin/sales-email-config-card.tsx_ (the sales card) + the existing noreply transport card — the admin page renders two cards with overlapping field names (SMTP host/port/user/pass on both, plus IMAP on the sales card) and zero context for why both exist. Operators reasonably ask "why am I configuring this twice?" The two streams are intentionally separated (per CLAUDE.md "Send-from accounts (sales send-outs)"): sales = human-initiated rep emails with IMAP-bounce-poll monitoring; noreply = fire-and-forget automation (portal invites, password resets, signing reminders, inquiry confirmations). Reasons to keep them separate include sender reputation (mixing transactional volume with human sends hurts deliverability), reply handling (reps need replies in a monitored mailbox; automation shouldn't generate reply threads), and the practical pattern of using a transactional provider (Postmark/SendGrid) for noreply + Google Workspace / Outlook for the sales mailbox.
|
||||
> - **SHIPPED in 94c24a1 (H32):** Email settings page gains an explainer panel above the SMTP cards spelling out why noreply + sales have separate credentials and which workflows ship from each. Existing field titles gained "(noreply)" suffix so the model maps cleanly. Email settings page: add explainer copy clarifying why sales send-from and noreply have separate credentials — _src/app/(dashboard)/[portSlug]/admin/email/page.tsx_ (the page) + _src/components/admin/sales-email-config-card.tsx_ (the sales card) + the existing noreply transport card — the admin page renders two cards with overlapping field names (SMTP host/port/user/pass on both, plus IMAP on the sales card) and zero context for why both exist. Operators reasonably ask "why am I configuring this twice?" The two streams are intentionally separated (per CLAUDE.md "Send-from accounts (sales send-outs)"): sales = human-initiated rep emails with IMAP-bounce-poll monitoring; noreply = fire-and-forget automation (portal invites, password resets, signing reminders, inquiry confirmations). Reasons to keep them separate include sender reputation (mixing transactional volume with human sends hurts deliverability), reply handling (reps need replies in a monitored mailbox; automation shouldn't generate reply threads), and the practical pattern of using a transactional provider (Postmark/SendGrid) for noreply + Google Workspace / Outlook for the sales mailbox.
|
||||
> - **Fix:** add an explanatory header block at the top of the email-settings page (above the two cards) summarizing the split in plain language: 2-3 sentences max + a small table (sales vs noreply, what each sends, why split). Each card's CardDescription gets a one-liner anchoring to its role ("Used for rep-initiated emails (berth PDFs, brochures, manual follow-ups). Replies land in this mailbox and are bounce-monitored via IMAP." / "Used for automated emails (portal invites, password resets, signing reminders). Replies bounce."). Optional: a "Quick setup" toggle/button — "Use one mailbox for both streams" — that auto-mirrors SMTP creds from sales → noreply (or vice versa) for ports that don't need the split. Default state stays split (preserves the design intent for ports that have grown into it).
|
||||
> - **Effort:** ~30 min for the explainer copy + per-card descriptions; +1h for the "Quick setup" mirror affordance if pursued. Captured 2026-05-18 from UAT.
|
||||
> - **Email / SMTP admin: add a "Send test email" affordance** — _src/components/admin/shared/registry-driven-form.tsx_ (or a dedicated email-settings card adjacent to the RegistryDrivenForm) + _src/lib/email/_ (transport) + _new endpoint_ `POST /api/v1/admin/email/test-send` — once an admin configures SMTP creds + From address on the Email Settings page, they have no in-app way to confirm "did I actually wire this up correctly?" without finding a workflow that triggers a real transactional email. Add a "Send test email" button on the email settings card that pops a small dialog: input for destination address (defaults to the operator's own email), optional message body, submit fires the test via the configured transport. Server endpoint returns success / SMTP-error-with-detail so the admin sees exactly why it failed (auth fail, TLS handshake, sender-rejected) without digging into server logs.
|
||||
@@ -323,7 +325,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **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.
|
||||
> - **SHIPPED in 94c24a1 (F29):** Unified create-document wizard gets a Watchers section with multi-select checkbox list backed by `/api/v1/admin/users/picker`. Selected user ids sent in `watchers` array on POST (replaces prior hardcoded `[]`). UI matches post-creation WatchersCard so reps see same identity rows regardless of entry point. 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.
|
||||
> - **Interest's `assignedTo`** — if different from creator, auto-add. The deal owner gets visibility on doc events even if a different rep generated/uploaded.
|
||||
@@ -357,7 +359,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - (a) Structured `signatories: Array<{name, email, role}>` lands on the service input, the API multipart payload, and the dialog UI. Role enum: `client/developer/rep/witness/cc`. Auto-seeds the client row from `interestData.clientName + clientPrimaryEmail` via a signatoriesOverride/null pattern (React-Compiler safe).
|
||||
> - (d-prereq) `document_signers` rows inserted inside the transaction for every non-CC signatory, pre-stamped `status='signed'`, `signedAt=input.signedAt`. The document-detail "X / Y signed" badge now renders the right count.
|
||||
> - **Remaining (b) + (c) + (d) deferred:** developer-default settings, "Email copy" multi-recipient dialog, send pipeline + branded template, role-based email auto-fill beyond the client row — bundles with the broader Documenso send-flow work in Wave 4.
|
||||
> - **UploadForSigningDialog comprehensive rework — 4 linked issues** — _src/components/documents/upload-for-signing-dialog.tsx_ — surfaced together during UAT 2026-05-21 of the Reservation Agreement send flow. All four touch the same dialog and should ship as one coherent pass.
|
||||
> - **SHIPPED (3 of 4 sub-tasks) in 65ff596 (L41):** Dialog width was fixed earlier. Draft persistence: localStorage scoped per interest+documentType (`pn-crm.upload-for-signing.draft.v1:<id>:<type>`), 500ms debounce, PDF blob excluded (rep re-picks on reopen), "Draft saved" + Discard button surface, clears on submit. PDF preview error handling + zoom: onLoadError replaces spinner with failure block; toolbar gains 50–200% zoom in 25% steps (field coords stay in % of page dimensions). Field-placement keyboard shortcuts: Delete/Backspace removes selected field, arrow keys nudge 0.5% (Shift = 5%), ignored when focus is in real input/textarea/contenteditable. **UploadForSigningDialog comprehensive rework — 4 linked issues** — _src/components/documents/upload-for-signing-dialog.tsx_ — surfaced together during UAT 2026-05-21 of the Reservation Agreement send flow. All four touch the same dialog and should ship as one coherent pass.
|
||||
> - **(a) [bug] "Failed to load PDF file" on the place-fields step** — the place-fields step uses `URL.createObjectURL(file)` (line 265) as `fileUrl` and passes it to react-pdf inside `FieldPlacementStep`. `pdf-viewer.tsx:149` `onLoadError` fires when react-pdf can't parse the blob. Likely causes to check: (i) the uploaded file isn't a PDF (PNG, DOCX, etc. — select-file step likely doesn't enforce `application/pdf` mime check); (ii) PDF.js worker URL misconfigured (every PDF fails the same way); (iii) blob revoked too early (`useEffect` cleanup at line 266-270 — though the deps look right); (iv) react-pdf version-incompatible with the worker bundle. **First debug step:** check browser devtools console for the actual error message — currently it's collapsed into a generic "Failed to load PDF file." string. Surface the underlying error to the UI ("Couldn't parse PDF — check that you uploaded a `.pdf` file, not an image or Word doc.") so the rep can self-diagnose.
|
||||
> - **(b) [ux] Dialog way too small for the place-fields step** — dialog is `max-w-5xl` (1024px, line 166) which is fine for the recipients step, but the place-fields step has a 176px-wide field palette + 200px-wide recipients list on the left and only ~650px for the PDF preview on the right. A US Letter page at fit-width in 650px is barely legible, and field placement requires precision. **Fix:** make the dialog adaptive per-step: `max-w-3xl` for select-file + configure-recipients steps (768px is plenty for forms), but expand to `max-w-[1400px]` or `max-w-[95vw]` on the place-fields step where horizontal PDF space matters most. Alternative: full-screen modal pattern for the place-fields step only (escape exits, top bar shows step indicator + Back/Send). Also shrink the field palette from `w-44` (176px) to `w-32` (128px) by using icon-only buttons with tooltips — recovers ~50px of PDF width.
|
||||
> - **(c) [feature gap] PlacedField shape missing `defaultValue` + `fieldMeta` (no UI to configure dropdown options, pre-fills, field labels, validation)** — _line 85-96, PlacedField interface_ — the current shape carries position + type + recipientIndex only. Documenso v2 `field/create-many` accepts per-field metadata that today's UI can't set:
|
||||
@@ -445,11 +447,11 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **Topbar grid sizing observation:** topbar columns are `[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)]` — left slot competes for space with the centered search bar's `minmax(360px,640px)`. When search hits its max width, left slot is squeezed → breadcrumb wraps sooner. Consider bumping to `minmax(0,1.5fr)` OR letting the search shrink below 360px when needed. Optional, evaluate after (a)+(b) land.
|
||||
> - **Effort:** ~15 min for (a), ~45 min for (b), ~10 min for (c). Bundle ~1h. Captured 2026-05-18 from UAT. **SHIPPED (a) in 8fcbe45:** each crumb + its trailing ChevronRight now share a single `<BreadcrumbItem>`; flex-wrap can no longer strand a separator. Ellipsis-collapse (b) + back-chevron alignment (c) parked.
|
||||
> - **BulkAddBerthsWizard: currency field should use `<CurrencySelect>` (already exists, used elsewhere)** — _src/components/admin/bulk-add-berths-wizard.tsx_ (the `priceCurrency` `<Input>` in the apply-to-all row at ~lines 282-290, and the per-row instance below it) — currently a free-text `<Input>` that uppercases on blur, defaulting to `USD`. Reps can type any string (including invalid codes); no auto-complete; no consistency with other forms. The `<CurrencySelect>` component already exists at _src/components/shared/currency-select.tsx_, backed by the curated `SUPPORTED_CURRENCIES` list in _src/lib/utils/currency.ts_, and is used by the single-berth edit form (_berth-form.tsx:414_) + the expense form dialog (_expense-form-dialog.tsx:238_). Quick fix: import `CurrencySelect`, replace both the apply-to-all and per-row currency inputs with the dropdown bound to the same handlers (`applyToAll('priceCurrency', v)` / `setRowField(idx, 'priceCurrency', v)`). ~10 min. Captured 2026-05-18 from UAT. **SHIPPED in 2bcf544.**
|
||||
> - **BulkAddBerthsWizard + single-berth editor: toggleable input units (ft/m) for dimension fields** — _src/components/admin/bulk-add-berths-wizard.tsx_ (the "Width (ft)" / "Length (ft)" / "Draft (ft)" inline-table headers + input parsing), _src/components/berths/berth-form.tsx_ (or equivalent single-edit) — the wizard's column headers and input parsing are hard-coded to feet. The schema supports per-dimension entry-unit discriminators (`lengthUnit`, `widthUnit`, `draftUnit` on `berths`, all defaulting to `'ft'`) plus separate `_M` numeric columns where metres-original values live — but neither the bulk wizard nor the single editor lets the rep pick which unit they're typing in. Reps who think in metres convert manually and the entry-unit discriminator never gets set.
|
||||
> - **SHIPPED (wizard) in 431375d (D24):** Step 2 header gets a small monospaced ft/m button that flips dimension entry unit wizard-wide; cell values stay as-typed, single `inputToFt(v)` helper converts m→ft (3.28084) before posting canonical feet payload; column headers reflect active unit. **SHIPPED (Berth Requirements row on Interest) in 991e222 (C22):** interest-tabs Berth-requirements section honours `interest.desiredLengthUnit`; labels flip to "(m)" when set; on save PATCHes both chosen-unit and canonical counterpart so downstream surfaces (recommender, EOI merge fields) stay in lockstep. **SHIPPED (berth-list column display ft/m persisted to user prefs) in 991e222 (C21):** TablePreferences.dimensionUnit added to user-profiles JSONB; useTablePreferences returns dimensionUnit + setDimensionUnit; new getBerthColumns(unit) factory rewrites dimensions/nominalBoatSize/waterDepth cells (waterDepth converts on-the-fly from canonical meters at 3.2808 ft/m); toolbar gains ft/m toggle. Single-berth editor sweep still parked. BulkAddBerthsWizard + single-berth editor: toggleable input units (ft/m) for dimension fields — _src/components/admin/bulk-add-berths-wizard.tsx_ (the "Width (ft)" / "Length (ft)" / "Draft (ft)" inline-table headers + input parsing), _src/components/berths/berth-form.tsx_ (or equivalent single-edit) — the wizard's column headers and input parsing are hard-coded to feet. The schema supports per-dimension entry-unit discriminators (`lengthUnit`, `widthUnit`, `draftUnit` on `berths`, all defaulting to `'ft'`) plus separate `_M` numeric columns where metres-original values live — but neither the bulk wizard nor the single editor lets the rep pick which unit they're typing in. Reps who think in metres convert manually and the entry-unit discriminator never gets set.
|
||||
> - **Fix:** (a) add a small `ft | m` toggle in the wizard header (and on the single-berth edit form) that flips the column header labels (e.g. "Width (ft)" → "Width (m)") and the parser. The toggle should default to whichever unit the user's `dimensionUnit` preference is set to (see the Dimensions-column-toggle finding earlier — same preference). (b) On submit, if entered unit is `'m'`, convert to ft for the stored numeric (`berths.lengthM` is the canonical metres column; `lengths.lengthFt` would be the feet column — verify the actual column names) AND set `lengthUnit='m'` so downstream document generation honours the rep's original input. Same for width / draft / nominalBoatSize / waterDepth. (c) Reuse the `src/lib/utils/dimensions.ts` helper from the Dimensions-column finding so conversion is centralized.
|
||||
> - **Why this matters beyond UX:** document-generation merge fields (EOI / contract) already pull entry-unit values per `effectiveDimensionUnit` so the legal doc matches the rep's intent. Hard-coding ft on input silently coerces metric reps' values through a mental conversion, then renders the resulting ft figure on documents — losing fidelity for European customers.
|
||||
> - **Effort:** ~1.5-2h end-to-end (wizard toggle + single-form toggle + parser + tests). Coordinate with the Dimensions-display toggle finding so both UI surfaces use the same preference key + helper. Captured 2026-05-18 from UAT.
|
||||
> - **BulkAddBerthsWizard: allow defining new dock/pontoon letters in-flow (or surface the admin path)** — _src/components/admin/bulk-add-berths-wizard.tsx:78_ + _the dock/area model_ — current wizard appears to assume the dock letter already exists (per CLAUDE.md the mooring format is `[A-Z]+\d+` like `A1`, `B12` — the letter prefix is a dock/pontoon identifier). When a rep is adding berths for a _new_ dock, there's no inline way to introduce the new letter; they have to abandon the wizard, create the dock elsewhere, then come back. Two possible models — confirm which one applies in this codebase before building:
|
||||
> - **SHIPPED in 431375d (D25):** Select-of-A–E replaced with a chip group (A-E quick-pick) + free-text "Other…" input for any uppercase letter sequence (AA, BB, F, …). handleGenerate validates against empty/non-uppercase with toast; custom-input path uppercases + strips non-letters as typed so canonical `^[A-Z]+\d+$` regex always matches. BulkAddBerthsWizard: allow defining new dock/pontoon letters in-flow (or surface the admin path) — _src/components/admin/bulk-add-berths-wizard.tsx:78_ + _the dock/area model_ — current wizard appears to assume the dock letter already exists (per CLAUDE.md the mooring format is `[A-Z]+\d+` like `A1`, `B12` — the letter prefix is a dock/pontoon identifier). When a rep is adding berths for a _new_ dock, there's no inline way to introduce the new letter; they have to abandon the wizard, create the dock elsewhere, then come back. Two possible models — confirm which one applies in this codebase before building:
|
||||
> - **(a) Dock letters are free-form / inferred from `berths.mooring_number`** (no separate `docks` table): then the wizard just needs to allow a new letter prefix in its input. UI fix: replace the letter input (or dropdown) with a combobox-style "pick existing or type a new letter" control — same idiom as Tag picker. Backend: nothing — first insert with the new prefix establishes the dock. ~30 min.
|
||||
> - **(b) Docks are a first-class entity** (separate `docks` table with `port_id` + `letter` + metadata like `position`, `pontoon_type`, `power_capacity`): then the wizard needs a "+ New dock" affordance opening a small dock-create dialog (letter + name + optional metadata), then returning to the wizard with the new dock pre-selected. Permission: `berths.manage_docks` (or whichever owns dock metadata). The user's question — "_or is this an admin setting?_" — suggests they're not sure either; if it IS an admin-only concern (docks are infrastructure not data the rep should touch), then keep it admin-side and just surface a contextual link in the wizard ("New dock? Add it in Admin → Docks first → [link]"). ~1-2h depending on the model.
|
||||
> - **Action item:** check whether `docks` / `pontoons` / `marina_sections` table exists in the schema (`grep -r "docks\|pontoons" src/lib/db/schema/`); shape the fix accordingly. If no dedicated table, the wizard fix is trivial; if there is one, decide admin-only vs in-wizard-create with the team. Captured 2026-05-18 from UAT.
|
||||
@@ -462,7 +464,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **(b) Cleaner long-term:** add a "namespace" concept to the breadcrumb generator — segments that exist only as URL parents (residential, admin if applicable, …) render as plain text (`<BreadcrumbPage>`) rather than `<BreadcrumbLink>`. Centralized in `breadcrumbs.tsx`'s `SEGMENT_LABELS` map by extending the value to `{ label, namespace?: boolean }`. ~30 min, fixes the class of problem instead of one instance.
|
||||
> - **Recommendation:** ship (a) now, queue (b) if/when a second namespace-only segment hits the same issue.
|
||||
> - Captured 2026-05-18 from UAT. **SHIPPED (a) in c6dcf49:** new `src/app/(dashboard)/[portSlug]/residential/page.tsx` server-redirects to `/${portSlug}/residential/clients`. (b) namespace concept queued for the second-instance case.
|
||||
> - **Residential client detail header: match the main ClientDetailHeader layout** — _src/components/residential/residential-client-detail-header.tsx_ vs _src/components/clients/client-detail-header.tsx_ — the main client header is rich (`Email` / `Call` / `WhatsApp` deeplink button row using primary contact channels, `PortalInviteButton`, `GdprExportButton`, tag chips, top-right action menu with Bell/reminder + Archive/Restore state-aware + perm-gated hard-delete, archived badge with conditional dialog routing). The residential header (33 lines vs 244) shows only an eyebrow, an inline-editable name, a status badge, and place-of-residence — visually orphaned next to the main client experience.
|
||||
> - **SHIPPED in 989cc4d (I34):** Email / Call / WhatsApp action buttons mirror the main ClientDetailHeader. WhatsApp number resolves from phoneE164 (preferred) or strips free-text phone to digits. Header surfaces "Linked to main client" chip when auto-link matcher (I37) finds a counterpart. Residential client detail header: match the main ClientDetailHeader layout — _src/components/residential/residential-client-detail-header.tsx_ vs _src/components/clients/client-detail-header.tsx_ — the main client header is rich (`Email` / `Call` / `WhatsApp` deeplink button row using primary contact channels, `PortalInviteButton`, `GdprExportButton`, tag chips, top-right action menu with Bell/reminder + Archive/Restore state-aware + perm-gated hard-delete, archived badge with conditional dialog routing). The residential header (33 lines vs 244) shows only an eyebrow, an inline-editable name, a status badge, and place-of-residence — visually orphaned next to the main client experience.
|
||||
> - **Data-model gap to bridge:** residential clients store contacts inline (`email`, `phone`, `phoneE164`, `phoneCountry` columns on `residentialClients`) rather than via the polymorphic `clientContacts` table the main model uses. Action buttons can still be wired by synthesizing a `[{ channel: 'email', value, isPrimary: true }, { channel: 'phone', value: phone, valueE164, isPrimary: true }]` shape from the inline columns. Other features need verification per residential: tags table exists? portal invite (`residential_clients` has no `clientPortalEnabled` flag → likely N/A); GDPR export (yes — applies to any natural person in EU residence; need a `residential-gdpr-export` route if not already there); archive/restore (residential uses its own service; verify the dialog component expects a `residentialClientId` or needs a separate `ResidentialSmartArchiveDialog`).
|
||||
> - **Approach options:**
|
||||
> - **(a) Copy-and-adapt the JSX shape, residential-specific dialogs** — fastest path. Rebuild `residential-client-detail-header.tsx` with the same layout: title row (truncated name + archived badge), meta line (country · added date), action button row (Email / Call / WhatsApp synthesized from inline columns + optional GDPR export), tag chips (if/when residential gets tags), top-right Bell + Archive/Restore + perm-gated hard-delete. Skip features that don't apply to residential (PortalInviteButton). Parallel residential-specific dialogs where the existing client dialogs don't accept a residential type. ~1.5h.
|
||||
@@ -473,14 +475,14 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
||||
> - **Alternative (verbose):** Convert `StageStepper` to a true horizontal stepper layout — text label above each tick, current stage bolded, past stages muted, pending stages greyed. More familiar pattern but takes more vertical space and wraps awkwardly on narrow containers (a client card with 4-5 active interests stacks them all). ~1.5h, including a `compact` prop so the hero variant can keep the dense form.
|
||||
> - **Recommendation:** ship the inline breadcrumb (concise) — solves the "I can't tell what stage this is at without hovering" complaint with minimum visual footprint, and the existing `STAGE_BADGE` colour map provides the per-stage tint for free. Add a `showLabels?: boolean` prop to `StageStepper` so the dense rail-tile variants (`size="xs"`) can opt out. Captured 2026-05-18 from UAT.
|
||||
> - **SHIPPED in e33313b:** chose the "verbose" stepper variant — stage-name row underneath the bar showing every stage's `STAGE_SHORT_LABELS`. Reached stages render in foreground text; future stages in muted/60 so the rep still sees the ladder ahead. `size="xs"` keeps the dense rail-tile variant intact (no labels row). `STAGE_SHORT_LABELS` re-exported through `pipeline-constants.ts`. Inline-breadcrumb chips variant deferred — the verbose row reads better at the typical container widths we see in practice.
|
||||
> - **EntityActivityFeed: rewrite per-row rendering to surface _what_ changed** — _src/components/shared/entity-activity-feed.tsx_ (the shared per-entity timeline used on Clients / Yachts / Berths / Residential / Interest detail pages) — each row currently reads `"<actor> updated the <field>"` with the old→new values dropped on a second line, often null or rendered as a truncated JSON dump. Reps can see _something_ changed but not _what_. Several coordinated fixes — pick the subset that's worth doing in one pass:
|
||||
> - **SHIPPED (sentence rendering) in 03a7521 (J38):** Was `"<actor> updated the X"`; now `"<actor> set X to <value>"` when audit row carries `newValue`. Field-level diff line underneath keeps showing old → new strikethrough for context. Truncates inline value at 60 chars to keep long notes from blowing out the row. Other sub-items (action templates, collapsed-session preview, metadata-based create rows) still parked. EntityActivityFeed: rewrite per-row rendering to surface _what_ changed — _src/components/shared/entity-activity-feed.tsx_ (the shared per-entity timeline used on Clients / Yachts / Berths / Residential / Interest detail pages) — each row currently reads `"<actor> updated the <field>"` with the old→new values dropped on a second line, often null or rendered as a truncated JSON dump. Reps can see _something_ changed but not _what_. Several coordinated fixes — pick the subset that's worth doing in one pass:
|
||||
> - **(a) Bake the value into the sentence line.** Replace `sentence()` (lines 70-77) so when both `fieldChanged` and `newValue` are present the row reads `"<actor> set <field> to <new>"` (with `(was <old>)` appended in muted text on the same line if `oldValue` exists). Eliminates the separate strikethrough line in 80% of cases and reads like a sentence, not a diff. Keeps the separate diff line only for long-form changes (notes body, descriptions) where truncation matters.
|
||||
> - **(b) Type-aware value formatting beyond the four enums already handled.** `formatValueForField()` (lines 48-66) special-cases `pipelineStage`, `source`, `leadCategory`, `outcome`. Extend with: user-FK fields (`assignedTo`, `ownerId`, `createdBy`) resolved to display names via the same bulk-resolution pattern queued in the actor/diff UUID finding above; berth-FK fields (`berthId`, `primaryBerthId`) resolved to mooring number; yacht-FK / company-FK fields resolved to entity name; date columns (`outcomeAt`, `dueDate`, `startDate`) formatted as `MMM d, yyyy`; currency columns (`price`, `total`) formatted via `formatCurrency` with the row's currency code from `metadata`; boolean toggles rendered "enabled" / "disabled" instead of "true" / "false"; JSON / object values get a one-line summary (e.g. address → `"Address updated: 123 Main St → 456 Elm St"` rather than the JSON dump).
|
||||
> - **(c) Compound-action verbs.** The seven `ACTION_VERBS` (lines 26-34) cover only the generic CRUD set. Many real audit-log entries use compound actions (`linked`, `unlinked`, `signed`, `sent`, `viewed`, `archived`, `set_primary`, `merged_into`, `reassigned`, …) that fall back to printing the raw action verb. Audit `audit_logs.action` distinct values for the active port and add a verb + sentence template per case, e.g. `linked` → `"<actor> linked <related-entity-label>"` (reads metadata for the related entity's id + type and renders a clickable link). Templates per action keep the sentence rendering type-safe instead of a giant switch in `sentence()`.
|
||||
> - **(d) Use `metadata` for create rows.** `create` rows currently say `"<actor> created this record"`. Pull the entity's name/mooring/identifier out of `metadata` (or a small lookup if metadata's empty) so it reads `"<actor> created client <Name>"` / `"<actor> created berth <A12>"`.
|
||||
> - **(e) Collapsed-session preview text.** The `SessionGroupItem` collapse (lines 245-260) currently reads `"<actor> made N changes in this session"`. Show a one-line preview of _which_ fields were touched (e.g. `"Matt changed pipeline stage, owner, and 2 more fields"`) so reps can see if the session is worth expanding without clicking.
|
||||
> - Effort: ~2h for (a)+(b)+(d) (the most user-visible wins, all in this one file plus a thin bulk-resolution helper in the activity-feed service). ~1h for (c) (registry of action templates). ~30min for (e). Total ~3.5h for the full bundle, or pick (a)+(b)+(d) as the high-value MVP at ~2h. Captured 2026-05-18 from UAT — same surface as the activity-feed UUID resolution finding above (the bulk-resolution helper introduced for that finding is the prereq for (b)'s user-FK resolution; do these in one pass).
|
||||
> - **Client → Companies tab: add CTA to link or create a company membership** — _src/components/clients/client-companies-tab.tsx_ (the tab, including the EmptyState at lines 44-51 and the table-populated branch at 53-101) — the tab currently shows a list of company memberships pulled from `company_memberships`; the EmptyState literally tells the rep "Add a membership from a company's detail page" — a backwards workflow that forces them to leave the client they're working on, navigate to a company, then come back. The populated view also has no "Add another" affordance.
|
||||
> - **SHIPPED in 03a7521 (J39):** Empty state gains "Link to a company" action; populated state grows top-right "Link to company" button. New `<LinkCompanyDialog>` wraps existing `<CompanyPicker>` + membership-role select + isPrimary checkbox, POSTs to /api/v1/companies/[id]/members. Empty-state copy dropped "Add a membership from a company's detail page". **Client → Companies tab: add CTA to link or create a company membership** — _src/components/clients/client-companies-tab.tsx_ (the tab, including the EmptyState at lines 44-51 and the table-populated branch at 53-101) — the tab currently shows a list of company memberships pulled from `company_memberships`; the EmptyState literally tells the rep "Add a membership from a company's detail page" — a backwards workflow that forces them to leave the client they're working on, navigate to a company, then come back. The populated view also has no "Add another" affordance.
|
||||
> - **Backend ready:** `POST /api/v1/companies/[id]/members` already exists (with corresponding `PATCH` and `DELETE` on `/members/[mid]`, plus `POST /members/[mid]/set-primary`) and accepts a `clientId` in the body. No new schema work needed.
|
||||
> - **UI work:** (1) Add a primary "Link or add company" button in the tab header (next to the `Company affiliations` heading), gated by `memberships.manage`. (2) Sheet with two modes — **(a) Link existing**: combobox-search across companies (use existing `/api/v1/companies/autocomplete`) + role select + isPrimary toggle + optional startDate; on submit calls `POST /api/v1/companies/{selectedCompanyId}/members` with this client's `clientId`. **(b) Create new + link**: opens `CompanyForm` in create mode (drawer-in-drawer or step 2 of the sheet); on successful create, chains the same membership POST. Toast on completion, invalidate `['client', clientId]` so the tab refreshes. (3) Replace the EmptyState's copy with one matching the new CTA ("No company memberships yet — link this client to a company below.") and surface the same button there too. (4) Each row in the populated table gets a kebab menu: "Set as primary" (POST set-primary), "Edit role / dates" (PATCH), "Remove" (DELETE with confirm).
|
||||
> - **Symmetry note:** The "Companies → Members" tab already has the inverse flow (add a client to a company) — same UI primitives should be reusable; consider lifting the membership form into a shared `MembershipForm` if the divergence is small. ~1.5-2 h end-to-end. Captured 2026-05-18 from UAT.
|
||||
@@ -515,11 +517,11 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
|
||||
> **[Umami] Larger follow-ups parked at end of 2026-05-19 build session:**
|
||||
>
|
||||
> - **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
|
||||
> - **SHIPPED in a7cbee0 (O48):** New POST /api/v1/tracked-links mints redirect-link the rep can drop into outgoing email; body { targetUrl, sendId? }, returns { id, slug, targetUrl, url }, gated on `email.send`. `<TrackedLinkComposerButton>` opens a dialog: paste destination → Create → returns public /q/<slug> URL with Copy + "Insert into message" action. Wired into `<SendDocumentDialog>`'s Message body label row. **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
|
||||
> - **[Umami] Marketing-site instrumentation (Phase 4a)** — _separate marketing-site repo, NOT this one_ — adds `umami.track('cta-clicked', {…})`, `umami.track('eoi-page-reached')`, etc. calls on the marketing site so the Events tab + cross-system funnels (Phase 3 + Phase 5) light up. Also adds a `do_not_track` opt-out checkbox to the marketing-site cookie banner so visitors who decline tracking get `localStorage.setItem('umami.disabled', '1')` and skip the script entirely. Needs to be coordinated with whoever owns the marketing-site repo — capture the schema we want them to emit (event names + payload shapes) in `docs/marketing-site-event-catalogue.md` once we know which CRM funnels we actually want to drive. ~4-6 h of marketing-repo work + ~2 h of CRM-side cataloguing. Captured 2026-05-19.
|
||||
> - **[Umami] Events tab (Phase 3)** — _src/components/website-analytics/events-list.tsx (new)_ + new route — Umami's `/api/websites/:id/events` is already wrapped in `umami.service.ts` (`getEvents`, `getEventsStats`, `getEventsSeries`). Surface as a new "Events" tab on the analytics page. BLOCKED on Phase 4a — the tab is empty until the marketing site fires custom events. Cap: ~3-4 h once 4a lands. Captured 2026-05-19.
|
||||
> - **[Umami] Funnels + Journeys (Phase 5)** — _src/components/website-analytics/funnel-builder.tsx (new)_ + _src/components/website-analytics/journey-flow.tsx (new)_ — Umami's `/api/websites/:id/reports/funnel` and `/journey` endpoints are wrapped (`runFunnelReport`, `runJourneyReport`). Funnel builder = pick N steps (URL or event), see per-step conversion. Journey flow = sankey-style visualisation of where visitors go after a chosen entry page. BLOCKED on Phase 4a for the event-driven half. Cap: ~6-8 h. Captured 2026-05-19; deferred to end per earlier scoping.
|
||||
> - **[Umami] Click-to-filter the page from the world map** — _src/components/website-analytics/visitor-world-map.tsx_ + new `country` filter store + thread through every `useUmamiTop*` hook — `VisitorWorldMap` already accepts an `onCountryClick(iso2)` prop that's unused. Wire it to a page-wide country filter (Zustand store or URL search param `country=US`) that scopes every card on the page to that country's data. Mirrors Umami's own click-through behaviour. Cap: ~2-3 h. Captured 2026-05-19.
|
||||
> - **SHIPPED (partial) in a7cbee0 (O54):** `VisitorWorldMap` already supported `onCountryClick`; wired through to copy the `/<portSlug>/clients?nationality=<ISO>` deep-link to clipboard with toast on click. Inline filtering of the analytics view itself stays parked alongside Phase 5 — the useUmami* hooks don't yet accept a country filter. **[Umami] Click-to-filter the page from the world map** — *src/components/website-analytics/visitor-world-map.tsx* + new `country` filter store + thread through every `useUmamiTop*`hook —`VisitorWorldMap`already accepts an`onCountryClick(iso2)`prop that's unused. Wire it to a page-wide country filter (Zustand store or URL search param`country=US`) that scopes every card on the page to that country's data. Mirrors Umami's own click-through behaviour. Cap: ~2-3 h. Captured 2026-05-19.
|
||||
> - **[Umami] Per-rep `identify()` calls for attribution** — _src/components/auth/use-session.tsx (or wherever the session is hydrated)_ + _src/lib/services/umami.service.ts (new `identifyRep` wrapper)_ — call `umami.identify({sessionId, role: 'rep', repId: user.id})` on every authenticated CRM session so Umami's Sessions list can show "this lead came in while Matt was working hours". Privacy-gated: only fires for super-admin / sales-manager / sales-agent roles, never for residential-partner, never for portal-side users. Captured 2026-05-19; deferred as the privacy/value trade-off needs a product call before building.
|
||||
|
||||
0. **SHIPPED in 91be0f9:** Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail. Catalog (`bindable-fields.ts`) + `formFieldSchema.bindTo` allow-list + admin "Bind to" picker; `applySubmission` writes phone/yacht/insert-path diffs that were previously silent; clients endpoint mirrors the existing interest one; `<FieldHistoryProvider>` + inline clock icon next to each editable field on Client + Interest Overview tabs and ContactsEditor (per UX spec d-i). Original spec follows for reference; UI uses a clock icon rather than "i" but the popover content matches.
|
||||
@@ -551,7 +553,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
> - **Type coercion mismatches** — bind path returns a number (`desiredLengthFt`), form field type is `text`. Catalog defines the canonical type per path; template builder validates compatibility at save time.
|
||||
> - **Sensitive fields** (passport, DOB) — `BINDABLE_FIELDS` entries flag `sensitivity: 'pii' | 'public' | 'internal'`; the supplemental form template builder warns / blocks selecting PII fields without explicit admin override (avoids accidental public-form data leak).
|
||||
> - **Effort:** ~12-16h end-to-end. ~2-3h for the catalog + resolver/writer infra. ~2h for the template-builder dropdown UI. ~2-3h for the autofill resolver in the public form service. ~3-4h for the submit diff + history table + audit + writeback. ~2h for the dual-surface UI (interest + client detail history popover). ~1h for sensitive-field gating + edge cases. Captured 2026-05-21 from UAT. **Cross-ref:** ties into the existing supplemental-info-request findings in Bucket 2 (reusable-not-single-use, generate+send split, regenerate+resend) — ship the binding/autofill/history work AFTER those land so the supplemental form is mature enough to carry the additional complexity.
|
||||
1. **Universal in-system preview for every file type (extend FilePreviewDialog beyond PDF + images)** — _src/components/files/file-preview-dialog.tsx:60-120_ — today only `mimeType?.startsWith('image/')` and `mimeType === 'application/pdf'` render; everything else falls through to a blank preview surface (no message, no fallback). User wants every document previewable in-system without forcing a download. Today's gaps: Office documents (.docx / .xlsx / .pptx), plain text (.txt / .csv / .md), email exports (.eml / .msg), video / audio, archives (.zip — see-into).
|
||||
1. **SHIPPED in 0ddaf46 (M42):** FilePreviewDialog now handles seven preview kinds via a single `previewKindFor()` router. Text (.txt/.md/.csv/.tsv/.json/.xml/.log/.yaml/.ini/.html) via new `<TextPreview>` (fetches presigned URL, caps body at 1 MB with banner). Audio/video via native HTML5 with preload="metadata". Office docs (.docx/.xlsx/.pptx/.odt/.ods/.odp) embed via Microsoft hosted Office viewer (presigned URLs carry the token). Unknown mime types render a friendly "preview not supported" block with Download CTA. **Universal in-system preview for every file type (extend FilePreviewDialog beyond PDF + images)** — _src/components/files/file-preview-dialog.tsx:60-120_ — today only `mimeType?.startsWith('image/')` and `mimeType === 'application/pdf'` render; everything else falls through to a blank preview surface (no message, no fallback). User wants every document previewable in-system without forcing a download. Today's gaps: Office documents (.docx / .xlsx / .pptx), plain text (.txt / .csv / .md), email exports (.eml / .msg), video / audio, archives (.zip — see-into).
|
||||
|
||||
- **Coverage tiers:**
|
||||
- **Tier 1 (cheap, native-browser):** plain text (`text/plain`), CSV, Markdown → fetch + render in a styled `<pre>` or via a small markdown renderer (`react-markdown` already a likely dep — verify); video (`video/*`) → `<video controls src=…>`; audio (`audio/*`) → `<audio controls src=…>`. ~1-2h for all four.
|
||||
@@ -575,7 +577,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
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:** ~6–10 h to port the existing 8 components; each is a 50–150 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`. ~2–3 h to ship the UI.
|
||||
3. **Pipeline Value tile should respect dashboard timeframe** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — the dashboard has a Today / 7d / 30d / 90d / Custom filter at the top (`Last 30 days` shown beside the greeting) but the Pipeline Value tile shows an absolute snapshot regardless. Should be constrained to the active timeframe: e.g. "Pipeline as of end of range" + "Revenue actually realized in range" (closed-won × berth price for interests whose `outcome_at` falls in the window). Needs: dashboard-wide timeframe context (Zustand store or React Query keyed by range), forecast/KPI service variants that accept a `range`, and a "realized vs forecast" line in the tile. ~3–4 h.
|
||||
3. **SHIPPED in a147cbc (N44):** Tile accepts optional `range` prop and threads it through /api/v1/dashboard/kpis?range=<slug> + /forecast?range=<slug>. Service functions accept optional {from,to} bounds and scope the pipeline-value SQL to interests created within the window. New `parseRangeSlug` helper inverts `rangeToSlug`. Widget registry forwards the active dashboard range to the tile. **Pipeline Value tile should respect dashboard timeframe** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — the dashboard has a Today / 7d / 30d / 90d / Custom filter at the top (`Last 30 days` shown beside the greeting) but the Pipeline Value tile shows an absolute snapshot regardless. Should be constrained to the active timeframe: e.g. "Pipeline as of end of range" + "Revenue actually realized in range" (closed-won × berth price for interests whose `outcome_at` falls in the window). Needs: dashboard-wide timeframe context (Zustand store or React Query keyed by range), forecast/KPI service variants that accept a `range`, and a "realized vs forecast" line in the tile. ~3–4 h.
|
||||
3a. **Remove `/admin/reports` entirely (redundant with configurable Dashboard) + integrate PDF-report exporter into the Dashboard header** — _src/app/(dashboard)/[portSlug]/admin/reports/page.tsx + src/components/admin/reports-dashboard.tsx_ (DELETE) + _src/components/dashboard/dashboard-shell.tsx (or wherever the dashboard header lives)_ (ADD "Export to PDF" button) + the PDF exporter dialog (next entry). Today's `/admin/reports` page renders Pipeline funnel + Berth occupancy + activity feed — every card is also a Dashboard widget, and the Dashboard is configurable while this page is fixed. Surfaced UAT 2026-05-21 as "feels useless since we have the dashboard" + user follow-up 2026-05-21: "the pdf report exporter we will need to integrate into the dashboard — or make a dedicated reports page with even more charts/stats (though i think this may be redundant)." > - **Decision (locked 2026-05-21):** integrate PDF exporter into the Dashboard, remove `/admin/reports`. Path: (a) delete `src/app/(dashboard)/[portSlug]/admin/reports/page.tsx` + `src/components/admin/reports-dashboard.tsx`; (b) drop the "Reports" link from admin nav + search-nav-catalog (cross-ref the duplicate-key dedupe finding in Bucket 4 — same catalog file); (c) add a redirect from `/admin/reports` → `/dashboard` so any bookmark/external link lands sensibly; (d) add "Export to PDF" button in the Dashboard header (right-hand controls cluster, next to the date-range picker). > - **Why not a dedicated more-charts reports page:** a separate Reports page with "even more charts" inevitably duplicates Dashboard data. Either the Dashboard lags behind, or the Reports page becomes a copy. Better to invest that effort in adding more widgets directly to the Dashboard (which is configurable, so reps who don't want the extra cards can hide them). > - **What if leadership later wants a fixed read-only exec view?** revisit then — by that point we'll know whether reps actually use it or just print the Dashboard. YAGNI for now. > - **Effort:** ~30 min for the route removal + redirect + nav cleanup. PDF exporter itself is feature #3 below — that's where the substantive work is. Captured 2026-05-21 from UAT.
|
||||
4. **Stylized branded PDF report exporter — Dashboard-integrated (locked 2026-05-21)** — _src/components/dashboard/_ (new `<ExportReportDialog>` + Dashboard header trigger) + _src/lib/services/dashboard-report.service.ts (new)_ + the existing `pdfme` (templates) and `pdf-lib` (filling) infra plus per-port branding from `system_settings`. **Location decision locked:** lives on the Dashboard, NOT on a separate `/admin/reports` page (which is being removed — see 3a above).
|
||||
> - **UX flow:**
|
||||
@@ -607,8 +609,8 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
> - **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. ~8–12 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.
|
||||
6. **SHIPPED in 94c24a1 (H33):** Supplemental-info-request email rebuilt to use the shared branded shell (logo + blurred overhead background + max-width 600 table layout) instead of prior plain-HTML page. Per-port branding (logo / primary color / background / header / footer) flows from `getPortBrandingConfig`; CTA button picks up port's primary color. 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. **SHIPPED in 989cc4d (I35):** New ResidentialInterestCard + getResidentialInterestColumns + residentialInterestFilterDefinitions; list page drives DataTable + FilterBar + ColumnPicker + SavedViewsDropdown + bulkActions. List endpoint validator widened (pipelineStage as string OR string[], added source filter). Service post-fetches client names via single IN-list lookup so column 1 renders fullName without N+1. New /api/v1/residential/interests/bulk supports change*stage + archive (100-id cap). Kanban view deferred. 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.
|
||||
> - **Scope breakdown:**
|
||||
> - **(a) Card view + visual parity (highest leverage)** — replace the table-style `<li>`-per-row layout with a `ResidentialInterestCard` mirroring `InterestCard` (header with client name + stage chip + last-activity, body with preferences/notes preview, footer with quick actions). Reuse the existing `<DataTable />` primitive for the table mode so column picker + sort + bulk-select come for free. ~3-4h.
|
||||
> - **(b) Export to PDF + CSV** — match the export affordance the main page has (or, if the main page lacks it, add it to both surfaces in the same pass — captured here so it lands on both). PDF: render rows + summary header via `pdfme` / `pdf-lib` (existing infra per CLAUDE.md), branded with port logo. CSV: server-side endpoint `/api/v1/residential/interests/export?format=csv|pdf` (or client-side generation if the dataset is bounded — residential volumes are typically small). Trigger from a kebab menu on the page header. ~2h.
|
||||
@@ -618,7 +620,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
> - **Recommendation:** ship (a) + (b) in one ~5-6h pass for the high-visibility wins (cards + export). Defer (c) until the residential team complains about filter gaps. Skip (d) unless verified-needed.
|
||||
> - **Companion fix:** see Bucket 1 finding "Residential namespace breadcrumb link is 404" — if the parity work lands a `/residential` landing page, that breadcrumb finding folds into this.
|
||||
> - Captured 2026-05-18 from UAT.
|
||||
8. **Residential inquiry → auto-forward to external partner email(s)** — _src/lib/services/residential.service.ts_ (`createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts:97_ (public intake), _src/lib/services/settings.service.ts_ + admin settings UI, _src/lib/email/templates/_ (new template), BullMQ enqueue — residential clients are managed by an external partner; every new residential inquiry needs to be forwarded automatically to one or more configured email addresses so the partner can act on it.
|
||||
8. **SHIPPED in 989cc4d (I36):** New registry entry `residential_partner_recipients` (comma-separated) under section `residential.partner`. `createResidentialInterest` fires `forwardResidentialInquiryToPartner` after the row lands. Helper uses the same branded shell other transactional emails use. Failures log + never block create. /admin/residential-stages page picks up a registry-driven card so admins manage recipients alongside stages. Residential inquiry → auto-forward to external partner email(s) — _src/lib/services/residential.service.ts_ (`createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts:97_ (public intake), _src/lib/services/settings.service.ts_ + admin settings UI, _src/lib/email/templates/_ (new template), BullMQ enqueue — residential clients are managed by an external partner; every new residential inquiry needs to be forwarded automatically to one or more configured email addresses so the partner can act on it.
|
||||
> - **Settings model:** new per-port `system_settings` keys: `residential_forward_enabled` (bool, default false), `residential_forward_recipients` (JSON array of email addresses — `to`), `residential_forward_cc` (JSON array, optional), `residential_forward_filter` (optional discriminator — e.g. only forward inquiries with certain `source` values or above a price/size threshold; v1 ships without this and forwards everything).
|
||||
> - **Admin UI:** new section in `src/app/(dashboard)/[portSlug]/admin/settings/` ("Residential routing") with: enable toggle, recipient list editor (add/remove emails, drag-reorder, per-row "primary" flag for the To field vs CC), template preview ("Send sample to me"), and a small "Last forwarded N inquiries in the past 7 days" stat for confidence. Permission-gated by `admin.manage_settings`.
|
||||
> - **Email template:** new branded HTML template `residential-inquiry-forwarded.tsx` in `src/lib/email/templates/` matching the existing branded-shell idiom (port logo + table-based layout per CLAUDE.md) — body includes inquiry fields (client name, contact channel, preferences, notes, source, submission timestamp, link to the residential interest in the CRM if the partner has portal access; otherwise a "view in CRM" stub).
|
||||
@@ -627,7 +629,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
> - **Effort:** ~3-4h for settings + template + service hook + BullMQ wiring; +1h for admin UI + sample-send button. Captured 2026-05-18 from UAT.
|
||||
>
|
||||
> - **Related:** see Feature 6 below — auto-link residential to existing main-client records, which fires at the same moment in the create pipeline; build (5) and (6) in one pass so the forwarded email can carry the "matched to existing CRM client X" context if a link was found.
|
||||
9. **Auto-link residential interests to existing main-client records (same person)** — _src/lib/services/residential.service.ts_ (`createResidentialClient` + `createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts_, new schema migration adding _src/lib/db/schema/residential.ts_ join table, _src/components/residential/residential-client-detail-header.tsx_ + _src/components/clients/client-detail-header.tsx_ (surface the link on both sides), new admin/dev script for backfill — when the same person who exists in the main berth client list registers a residential interest (or vice-versa), the two records should auto-link so reps can see the full relationship at a glance.
|
||||
9. **SHIPPED in 989cc4d (I37):** Migration 0080 adds `residential_clients.linked_client_id` (nullable FK, SET NULL on cascade) + partial index. New `findAndLinkMatchingMainClient` service matches by email first (case-insensitive client*contacts lookup) then by E.164 phone; first exact match wins. Fires fire-and-forget from `createResidentialClient`. Header surfaces link via "Linked to main client" chip. Backfill script + reverse-direction link from main ClientDetailHeader stay as follow-ups. Auto-link residential interests to existing main-client records (same person) — \_src/lib/services/residential.service.ts* (`createResidentialClient` + `createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts_, new schema migration adding _src/lib/db/schema/residential.ts_ join table, _src/components/residential/residential-client-detail-header.tsx_ + _src/components/clients/client-detail-header.tsx_ (surface the link on both sides), new admin/dev script for backfill — when the same person who exists in the main berth client list registers a residential interest (or vice-versa), the two records should auto-link so reps can see the full relationship at a glance.
|
||||
> - **Why a link, not a merge:** the two pipelines are operationally distinct (different team handles residential, different lifecycle stages, different downstream services). A hard merge would conflate records that should remain queryable separately. A symbolic link preserves both records while making the relationship discoverable.
|
||||
> - **Schema:** new join table `residential_client_links (id, port_id, residential_client_id, client_id, linked_at, linked_by_user_id, link_method enum('auto_email_match' | 'auto_phone_match' | 'manual'), confidence numeric(3,2), notes text)` — composite unique on `(port_id, residential_client_id, client_id)` so the same pair can't be linked twice. Both FKs ON DELETE CASCADE so dropping either side cleans the link automatically.
|
||||
> - **Match logic** (at residential client/interest create time): normalize the residential `email` to lowercase and check against `client_contacts.value` WHERE `channel='email'`; normalize `phoneE164` and check against `client_contacts.valueE164` WHERE `channel='phone'`. Email match → confidence 0.95 (auto-link, log audit); phone match → confidence 0.80 (auto-link with a "candidate match" badge so the rep can confirm); both match → confidence 0.99. If multiple candidate main-clients match (shared email — family/spouse case), DO NOT auto-link; instead surface all candidates in a UI banner for the rep to pick. Same logic runs in reverse when a new main-client is created (look for matching residential client).
|
||||
@@ -638,10 +640,10 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
|
||||
- **World-map heatmap of Umami visitor origins** — _new file_ `src/components/website-analytics/visitor-world-map.tsx` (heatmap card) + extend _src/lib/services/umami.service.ts_ (already returns `top-country` data via `getMetric(type: 'country')`) + viz lib choice (e.g. `react-simple-maps` + Natural Earth TopoJSON, or `@visx/geo`, or a simple SVG world from D3) — render a world choropleth colour-scaled by visitor count per country, surfaced on the Website Analytics page (and optionally on the dashboard as a separate rail widget). Hover any country to see the visitor count tooltip; click to filter the page's other widgets to that country (uses Umami `filters` query param if we extend the route to support it). Implementation notes: ISO 3166-1 alpha-2 codes map cleanly to country features in Natural Earth; cache the topojson in `public/` to avoid per-load fetch. Bundle weight ~50-80KB gzipped depending on lib choice; dynamic-import to keep it off the dashboard bundle when the widget is collapsed. ~4-6h end-to-end. Companion / overlap candidate: the "Clients by country" widget below — a single map could surface both data sources via a toggle (Umami visitors vs CRM clients/prospects) instead of two separate widgets. Captured 2026-05-18 from UAT.
|
||||
|
||||
7. **"Clients by country" dashboard widget** — _src/components/dashboard/_ (new file `clients-by-country-widget.tsx`), _src/components/dashboard/widget-registry.tsx_, _src/lib/services/dashboard.service.ts_ (or `analytics.service.ts` if it should live in the snapshot-cached family), _new endpoint or extension to `/api/v1/dashboard/...`_ — surface a per-country breakdown of clients (and optionally prospects — interests with `outcome` still open) so leadership can see geographic distribution at a glance. Data shape: aggregate `client_addresses` (or `clients.country` if that column exists) by `country_code` for clients that are non-archived and (for the prospect overlay) join through interests-with-open-outcome. UI options to pick from at build time: (a) compact ranked list with mini bars per row (matches `BerthHeatWidget` / `HotDealsCard` idiom — fits the rail), or (b) a choropleth/world-map (heavier; needs a viz lib like `react-simple-maps` + a topojson; better fit for the chart grid). Pick (a) by default — same footprint as existing rail tiles, no new bundle weight, and clicking a country could deep-link `/clients?country=DE`. Permissioning: gate on `clients.view`. Registry: defaultVisible: true. Effort: ~2-3 h for variant (a) + endpoint + tests; ~6-8 h for variant (b) with a real map. Captured 2026-05-18 from UAT (user request: "add a widget that breaks down prospects/clients by country as a card on the dashboard").
|
||||
8. **Drag-and-drop rearrangable dashboard widgets** — _src/components/dashboard/dashboard-shell.tsx_, _src/components/dashboard/widget-registry.tsx_, _src/hooks/use-dashboard-widgets.ts_ (assumed name), _src/lib/db/schema/users.ts_ (`user_profiles.preferences`), _src/app/api/v1/me/preferences/route.ts_ — today widget order is hard-coded by registry array order, and visibility is the only user-controllable axis (persisted in `user_profiles.preferences.dashboardWidgets` as a `{ [id]: boolean }` map). Reps want to choose **which analytics show where** on their dashboard (e.g. push Pipeline Funnel to the top, demote Berth Status, swap rail order). Approach: (a) introduce a parallel `dashboardWidgetOrder: string[]` preference (ordered list of widget IDs; missing IDs render after the list in registry order so newly-added widgets always surface); (b) extend `useDashboardWidgets` to return `visibleWidgets` already sorted by this order; (c) keep the three-group layout (`chart` / `rail` / `feed`) — drag-reorder is scoped _within_ a group so the rail's narrower min-col doesn't get a chart-sized tile dropped into it (and vice versa) — moving a widget between groups stays a registry-level concern (the move-out-of-rail request that triggered this entry is an example); (d) add `@dnd-kit/core` + `@dnd-kit/sortable` (lightweight, RSC-safe, already shadcn-adjacent); (e) wrap each group's grid in a `SortableContext`, render a small grip handle on each card header that's only visible in "rearrange mode" (toggle in the existing Customize dropdown — keeps casual users from accidentally grabbing tiles); (f) on drop, optimistic-update the preference and PATCH `/api/v1/me/preferences` with the new order array; (g) realtime: not needed (per-user state). Tests: vitest for the order-merge helper, Playwright smoke for drag-drop + persistence across reload. ~4-6 h end-to-end. Captured 2026-05-18 from UAT after moving the Pipeline Value tile from rail → chart group exposed that re-shuffling widgets is currently a code change, not a user action.
|
||||
7. **SHIPPED in a147cbc (N45):** New GET /api/v1/dashboard/clients-by-country groups non-archived clients by nationality*iso. `<ClientsByCountryWidget>` renders compact ranked list with mini-bars; rows link to /clients?nationality=<ISO>. Registered as default-visible rail. **"Clients by country" dashboard widget** — \_src/components/dashboard/* (new file `clients-by-country-widget.tsx`), _src/components/dashboard/widget-registry.tsx_, _src/lib/services/dashboard.service.ts_ (or `analytics.service.ts` if it should live in the snapshot-cached family), _new endpoint or extension to `/api/v1/dashboard/...`_ — surface a per-country breakdown of clients (and optionally prospects — interests with `outcome` still open) so leadership can see geographic distribution at a glance. Data shape: aggregate `client_addresses` (or `clients.country` if that column exists) by `country_code` for clients that are non-archived and (for the prospect overlay) join through interests-with-open-outcome. UI options to pick from at build time: (a) compact ranked list with mini bars per row (matches `BerthHeatWidget` / `HotDealsCard` idiom — fits the rail), or (b) a choropleth/world-map (heavier; needs a viz lib like `react-simple-maps` + a topojson; better fit for the chart grid). Pick (a) by default — same footprint as existing rail tiles, no new bundle weight, and clicking a country could deep-link `/clients?country=DE`. Permissioning: gate on `clients.view`. Registry: defaultVisible: true. Effort: ~2-3 h for variant (a) + endpoint + tests; ~6-8 h for variant (b) with a real map. Captured 2026-05-18 from UAT (user request: "add a widget that breaks down prospects/clients by country as a card on the dashboard").
|
||||
8. **SHIPPED in a147cbc (N46):** New `preferences.dashboardWidgetOrder?: string[]` on user*profiles; `useDashboardWidgets` sorts visibleWidgets by order (unlisted ids fall through to registry order) and exposes `setOrder(nextOrder)` that PATCHes optimistically. DashboardShell wires @dnd-kit/core + sortable: Rearrange toggle turns on per-widget grip handles + sortable-context wraps each group (charts / rails / feed) so drops stay in-group. PointerSensor 8px activation distance, KeyboardSensor for a11y. **Drag-and-drop rearrangable dashboard widgets** — \_src/components/dashboard/dashboard-shell.tsx*, _src/components/dashboard/widget-registry.tsx_, _src/hooks/use-dashboard-widgets.ts_ (assumed name), _src/lib/db/schema/users.ts_ (`user_profiles.preferences`), _src/app/api/v1/me/preferences/route.ts_ — today widget order is hard-coded by registry array order, and visibility is the only user-controllable axis (persisted in `user_profiles.preferences.dashboardWidgets` as a `{ [id]: boolean }` map). Reps want to choose **which analytics show where** on their dashboard (e.g. push Pipeline Funnel to the top, demote Berth Status, swap rail order). Approach: (a) introduce a parallel `dashboardWidgetOrder: string[]` preference (ordered list of widget IDs; missing IDs render after the list in registry order so newly-added widgets always surface); (b) extend `useDashboardWidgets` to return `visibleWidgets` already sorted by this order; (c) keep the three-group layout (`chart` / `rail` / `feed`) — drag-reorder is scoped _within_ a group so the rail's narrower min-col doesn't get a chart-sized tile dropped into it (and vice versa) — moving a widget between groups stays a registry-level concern (the move-out-of-rail request that triggered this entry is an example); (d) add `@dnd-kit/core` + `@dnd-kit/sortable` (lightweight, RSC-safe, already shadcn-adjacent); (e) wrap each group's grid in a `SortableContext`, render a small grip handle on each card header that's only visible in "rearrange mode" (toggle in the existing Customize dropdown — keeps casual users from accidentally grabbing tiles); (f) on drop, optimistic-update the preference and PATCH `/api/v1/me/preferences` with the new order array; (g) realtime: not needed (per-user state). Tests: vitest for the order-merge helper, Playwright smoke for drag-drop + persistence across reload. ~4-6 h end-to-end. Captured 2026-05-18 from UAT after moving the Pipeline Value tile from rail → chart group exposed that re-shuffling widgets is currently a code change, not a user action.
|
||||
9. **AI-assisted action extraction from contact-log entries** — _src/components/interests/interest-contact-log-tab.tsx_, new LLM service — current dialog already has quick-template buttons that seed `"Called the client. Discussed:\n\n• \n\nNext step: "` (and similar for in-person / email) into the summary textarea — soft structure without enforcement. Adding rigid form fields ("Topic", "Next step", "Outcome") risks killing rep adoption (sales reps notoriously avoid form-y CRMs). Better path: keep the freeform textarea + templates exactly as-is, add an **"Extract action items"** button beside Save that LLM-parses the body and returns proposed follow-ups — `create reminder for {datetime}`, `update desiredLengthFt to {n}`, `suggest stage advance to deposit_paid`, etc. Each proposal lands as a confirm-each list; rep approves individually. **AI assists, rep approves** — never silently mutates the record. Scope: ~6-10 h end-to-end (prompt engineering + LLM client + extraction schema + per-action confirm UI + audit logging of accepted/rejected proposals). Privacy considerations: contact-log entries can contain PII / financial details — route through an in-region LLM provider per the existing email/storage approach. Defer until a user is genuinely asking for it; the current template-seed pattern is fine for now.
|
||||
10. **Documenso-first templates: pull templates from Documenso instead of uploading through CRM (admin UI gap)** — _src/components/admin/document-templates/template-form.tsx_ (template create/edit UI, currently uploads source PDF/HTML), _src/lib/db/schema/documents.ts:254_ (`documensoTemplateId` column already exists), _src/lib/services/document-templates.ts:611_ (`pathway: 'documenso-template'` already routes through Documenso), _src/lib/services/documenso-template-sync.service.ts_ (existing per-port EOI sync; needs generalization), _src/lib/services/documenso-client.ts_ (need a `listTemplates()` wrapper) — the schema and signing pathway support Documenso-hosted templates (the CRM stores only the Documenso template ID, Documenso owns rendering), but the admin UI today assumes the source PDF/HTML lives in the CRM. Reps who maintain their templates in Documenso can wire ONE per port (the EOI, via the existing per-port sync) but can't add other types (welcome letter, handover checklist, correspondence) as Documenso-hosted entries without DB-level intervention. Real product gap — closes the "is Documenso the source of truth, or is the CRM?" question for ports that prefer to author in Documenso.
|
||||
10. **SHIPPED (list endpoint + admin route) in aa1f5d2 (R62):** New `listTemplates(portId)` in documenso-client paginates every visible template on configured instance (5-page cap at 100/page = 500 templates). Handles v1 + v2 endpoint shapes; normalises to `{ id, name }`. New GET /api/v1/admin/documenso/templates route exposes list (gated on `admin.manage_settings`). Field-mapping editor + sync-now button + per-template badges stay as picker-UI follow-up. **Documenso-first templates: pull templates from Documenso instead of uploading through CRM (admin UI gap)** — _src/components/admin/document-templates/template-form.tsx_ (template create/edit UI, currently uploads source PDF/HTML), _src/lib/db/schema/documents.ts:254_ (`documensoTemplateId` column already exists), _src/lib/services/document-templates.ts:611_ (`pathway: 'documenso-template'` already routes through Documenso), _src/lib/services/documenso-template-sync.service.ts_ (existing per-port EOI sync; needs generalization), _src/lib/services/documenso-client.ts_ (need a `listTemplates()` wrapper) — the schema and signing pathway support Documenso-hosted templates (the CRM stores only the Documenso template ID, Documenso owns rendering), but the admin UI today assumes the source PDF/HTML lives in the CRM. Reps who maintain their templates in Documenso can wire ONE per port (the EOI, via the existing per-port sync) but can't add other types (welcome letter, handover checklist, correspondence) as Documenso-hosted entries without DB-level intervention. Real product gap — closes the "is Documenso the source of truth, or is the CRM?" question for ports that prefer to author in Documenso.
|
||||
> - **Scope:**
|
||||
> - **(a) Template-source toggle** in `template-form.tsx`: radio between "Upload to CRM" (current behaviour) and "Pull from Documenso". Selecting the latter changes the form below.
|
||||
> - **(b) Documenso template picker** — new combobox that calls a new `GET /api/v1/admin/documenso/templates` endpoint backed by `listTemplates()` (new wrapper in `documenso-client.ts` — v1: `GET /api/v1/templates`; v2: `GET /api/v2/envelope/template`). Lists Documenso-side templates by name + id; selecting one populates `documensoTemplateId` and `templateFormat='documenso_render'`. Cache the list for ~5 minutes per port.
|
||||
@@ -657,7 +659,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
- **[Deferred — depends on Bucket 3 #7 contact-log action extraction] Contact-log AI admin section on `/admin/ai`** — when "AI-assisted action extraction from contact-log entries" (Bucket 3 #7) ships, add its admin controls to `/admin/ai`: provider override, prompt-template editor, per-call budget cap, accepted/rejected proposal stats. Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2 + Bucket 3 #7 "AI-assisted action extraction from contact-log entries". Captured 2026-05-18 from UAT.
|
||||
- **[Deferred — no design exists] AI inquiry-intake parsing admin section on `/admin/ai`** — if/when AI-assisted inquiry intake parsing is built (e.g. LLM normalizes inbound web-form / email inquiries into structured fields before the rep sees them), surface its admin controls on `/admin/ai`: provider override, confidence threshold for auto-accept vs human-review, fallback behaviour when the AI tier fails, per-call budget cap. No design or scope exists for this feature today — captured as a placeholder so the thought isn't lost when the AI-feature page expands. Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2. Captured 2026-05-18 from UAT.
|
||||
|
||||
8. **Platform-wide error message audit for prod debuggability** — _cross-cutting_ — triggered by the Documenso-config diagnosis loop: the user got a generic 502 + "Invalid token" upstream message when the real cause was "no Documenso creds entered for this port (silently fell back to a stale env value)." Operators in prod can't see logs the way we can in dev; the error surface should self-describe. Two layers of work:
|
||||
8. **SHIPPED (Documenso 401/403 path — foundation) in c14f80a (Q61):** `<PortDocumensoConfig>` gains `apiKeySource` + `apiUrlSource` ('port' | 'global' | 'env' | 'default' | 'none'). `getPortDocumensoConfig` populates them based on which layer of the resolver chain produced the value. documenso-client's `<ResolvedCreds>` exposes the source flags; the 401/403 branch surfaces them in `DOCUMENSO_AUTH_FAILURE` internalMessage so operators see "api key source: env, port: <id>" instead of generic `path → 401` body. Same pattern can extend to S3 / Redis / IMAP in follow-ups. **Platform-wide error message audit for prod debuggability** — _cross-cutting_ — triggered by the Documenso-config diagnosis loop: the user got a generic 502 + "Invalid token" upstream message when the real cause was "no Documenso creds entered for this port (silently fell back to a stale env value)." Operators in prod can't see logs the way we can in dev; the error surface should self-describe. Two layers of work:
|
||||
- **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError` _before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client` `resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern.
|
||||
- **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it.
|
||||
- Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy.
|
||||
@@ -677,8 +679,8 @@ _Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
||||
>
|
||||
> 0. **[high] All file downloads land with a blob-UUID filename + no extension** — _src/components/dashboard/chart-card.tsx:34_ (PNG/CSV exports), _src/app/(dashboard)/[portSlug]/expenses/page.tsx:95_ (CSV/XLSX export), _src/components/clients/client-files-tab.tsx:42_, _src/components/companies/company-files-tab.tsx:42_, _src/components/interests/interest-documents-tab.tsx:72_, _src/components/interests/interest-eoi-tab.tsx:597_, _src/components/admin/backup-admin-panel.tsx:90_ — 7 separate download sites share a near-identical anchor-click pattern that creates `<a download="<name>">`, calls `.click()`, and revokes the URL — but **the anchor is never appended to the document**, so Chromium-based browsers (Comet/Arc/Chrome) silently ignore the `download` attribute and fall back to using the blob URL's UUID for the filename (no extension). Captured UAT screenshot: dashboard chart "Download PNG" lands as `939c78df-48cc-466c-a22e-53e9dea69294` 35.5 KB instead of `<chart-name>.png`. Fix: extract a single `triggerBlobDownload(blob, filename)` helper into `src/lib/utils/download.ts` that (1) `document.body.appendChild(a)`, (2) `a.click()`, (3) `a.remove()`, (4) `URL.revokeObjectURL(url)` on a microtask/next-tick so Chrome has time to read the URL. Refactor all 7 call sites to import it; delete the local copies (and the chart-card-local `triggerBlobDownload` declared at chart-card.tsx:34). ~20-30 min including manual verification of each download surface. **Affects every file-export flow** — bumping severity to high. Captured 2026-05-18 from UAT. **SHIPPED in 2d57417:** added `src/lib/utils/download.ts` with `triggerBlobDownload(blob, filename)` (DOM-attached anchor + deferred URL revoke) + sibling `triggerUrlDownload(url, filename)` for presigned-URL paths; refactored all 7 call sites, dropped the chart-card-local copy.
|
||||
|
||||
1. **[high] Duplicate row for berth E17 in port-nimara** — DB: two `berths` rows with `mooring_number='E17'`, both with `price=NULL`. The canonical mooring format is meant to be unique per port (see CLAUDE.md "Mooring number canonical format"). Surfaced by the dashboard tile via the new "berth price missing" chip but the root cause is missing/leaked unique constraint. Recommend: dedupe + add partial unique index on `(port_id, mooring_number) WHERE archived_at IS NULL`. Deferred per session call (warning-only UI ships now).
|
||||
2. **[medium] Stage advance allowed without berth price** — Service-level: `changeInterestStage` lets an interest reach EOI/Reservation/Deposit Paid/Contract on a primary berth whose `price` is NULL. EOI doc generation downstream presumably renders blank/$0 for the quote field. Cross-port impact unknown. Recommend: add a `ValidationError("Berth price must be set before advancing past Qualified")` gate in `changeInterestStage` for stages eoi+. Deferred per session call.
|
||||
1. **SHIPPED in aa1f5d2 (T64):** Migration 0082 deduplicates any existing (port_id, mooring_number) collisions by archiving all but the canonical row (prefers price-bearing rows, then earliest-created; archived rows carry explicit `archive_reason`). Adds partial unique index `uniq_berths_port_mooring_active` on (port_id, mooring_number) WHERE archived_at IS NULL so archived moorings can be reissued but live duplicates can't be created in the first place. **[high] Duplicate row for berth E17 in port-nimara** — DB: two `berths` rows with `mooring_number='E17'`, both with `price=NULL`. The canonical mooring format is meant to be unique per port (see CLAUDE.md "Mooring number canonical format"). Surfaced by the dashboard tile via the new "berth price missing" chip but the root cause is missing/leaked unique constraint. Recommend: dedupe + add partial unique index on `(port_id, mooring_number) WHERE archived_at IS NULL`. Deferred per session call (warning-only UI ships now).
|
||||
2. **SHIPPED in aa1f5d2 (T65):** `changeInterestStage` now blocks any non-override transition into eoi / reservation / deposit_paid / contract when primary berth has no price (NULL or 0). Override path (sales-manager fix) stays open and records reason in audit log per existing override-reason gate. **[medium] Stage advance allowed without berth price** — Service-level: `changeInterestStage` lets an interest reach EOI/Reservation/Deposit Paid/Contract on a primary berth whose `price` is NULL. EOI doc generation downstream presumably renders blank/$0 for the quote field. Cross-port impact unknown. Recommend: add a `ValidationError("Berth price must be set before advancing past Qualified")` gate in `changeInterestStage` for stages eoi+. Deferred per session call.
|
||||
3. **[medium] Smart search renders duplicate React keys for `/admin/templates` — console warning + potential render glitch** — _src/lib/services/search-nav-catalog.ts:89-94 + 275-280_ + _src/components/search/command-search.tsx:587_. Two entries in the nav catalog both point at `/:portSlug/admin/templates` ("settings" category at line 89 with EOI/Documenso keywords, "admin" category at line 275 with PDF/email-template keywords). The search renderer keys rows by `navigation:${href}` → React fires the "Encountered two children with the same key" warning. Visible in console as `navigation:/port-nimara/admin/templates`. Behavior is unsupported — could cause omitted/duplicated rows.
|
||||
- **Fix (layered):**
|
||||
- **(a) Catalog dedupe:** merge the two entries — keep the "settings" one (line 89, matches surrounding /admin/branding + /admin/storage cluster), absorb the admin-version's keywords (`'pdf templates'`, `'email templates'`, `'merge fields'`, `'eoi template'`), delete the duplicate at line 275.
|
||||
@@ -783,8 +785,8 @@ _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.
|
||||
- **SHIPPED (foundation only — phase 1/3) in e91055f, phases 2/3 in 0ed03fc:** 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>`. **Phases 2/3 SHIPPED in 0ed03fc:** UploadZone scope radio (`<FileUploadZone>` accepts optional `interestId`; when set, fieldset renders "File at: ⦿ This deal | ◯ Client-level"; default deal-scope so deal-specific docs don't bleed across historical interests of the client; interest FK forwarded only when "This deal" selected). Outcome → folder rename hook: `renameInterestFolderForOutcome(interestId, portId, outcome)` strips prior outcome suffix then appends (Won)/(Lost)/(Cancelled); fired fire-and-forget from `interests.service.setInterestOutcome` via dynamic import (circular-dep dodge); no-op when folder hasn't been created yet. Backfill script: `scripts/backfill-nested-document-folders.ts` iterates every (port_id, interest_id) pair in `files` with non-null interest_id and calls `ensureEntityFolder`; idempotent via per-port advisory lock (FNV-1a of port_id); dry-run by default, `--apply` to commit. **Still deferred:** `listFilesAggregatedByEntity` rewrite for "This deal" vs "From client" subheadings (UI polish; per-row filing already correct); Documents Hub tree rendering for nested interest folders (rows exist with parent_id; tree component picks them up automatically).
|
||||
9. **SHIPPED in c14f80a (Q58):** `<SelectTrigger>` now accepts `size?: 'default' | 'sm'`; default = `h-11` so trigger matches Input's h-11 default. Existing compact call sites (FilterBar, dense table headers) opt back in via `size="sm"`. Nothing else breaks. **[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.
|
||||
- **Effort:** ~1h for the component change + audit + sweeping the explicit `size="sm"` overrides. Higher upside: enforces visual parity for every future form. Captured 2026-05-21 from UAT.
|
||||
|
||||
Reference in New Issue
Block a user