chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -146,6 +146,9 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
- Service docstring updated to cite the verified v3 endpoint behaviour + the flat-shape rationale so the next reader doesn't repeat the v1-nested mistake.
- `tsc --noEmit` clean. Verified live: dashboard tile + website-analytics page both render 2,081 pageviews / 726 visitors / 872 visits / 457 bounces over 30d (the real numbers from analytics.portnimara.com). Fixed in this session.
16. **Revenue Breakdown widget removed end-to-end**_src/components/dashboard/{revenue-breakdown-chart.tsx (deleted), widget-registry.tsx, use-analytics.ts}_, _src/app/api/v1/analytics/route.ts_, _src/lib/services/analytics.service.ts_, _tests/integration/analytics-service.test.ts_ — the "Revenue Breakdown" tile (bar chart of invoice totals by status × currency) wasn't aligned with how the org uses invoicing (no client-facing invoicing through the system — only employee expense-sheet PDFs for trip reimbursement) and was redundant once the Pipeline Value tile shipped with a weighted forecast + per-stage breakdown. Removed: widget file, dynamic import, registry entry, `useRevenue` hook, `RevenueBreakdownData` type, `MetricBase` union member, `ALL_METRICS` entry, `SnapshotData` union member, `getRevenueBreakdown` + `computeRevenueBreakdown` service functions, `refreshSnapshotsForPort` revenue branch, route dictionary entry, integration test. `RevenueReportPdf` (separate code path for the reports module) intentionally kept. `tsc --noEmit` clean. Fixed in this session.
17. **Finish CountryFlag rollout — table + filter surfaces**_src/components/shared/country-flag.tsx (shipped this session)_ + _src/components/clients/client-columns.tsx:173_ (nationality column cell — currently renders bare ISO code; should prefix with `<CountryFlag>`) + _src/components/clients/client-filters.tsx_ (nationality filter pill — render flag next to selected country name) + _src/components/yachts/yacht-form.tsx_ (flag the yacht's country if surfaced anywhere outside the CountryCombobox transitive path) + audit any remaining `flagEmoji` or `0x1f1e6` codepoint references with `rg -n "0x1f1e6\|flagEmoji"` → expected 0 hits. Shipped this session: country-combobox + inline-country-field + addresses-editor (replaced existing emoji glyphs, which never rendered on Windows) and added flags to clients-by-country-widget / client-card / client-detail-header / website-analytics realtime + sessions + session-detail. Library: `country-flag-icons` (MIT, ~1-2 KB per flag, dynamically imported on first render, cached). Effort: ~30 min for the remaining surfaces. Captured 2026-05-22 from UAT.
- **SHIPPED in this session:** client-columns nationality cell now renders flag + name. client-filters is a free-text input (no rendered chip surface to flag). No yacht country rendering exists outside CountryCombobox. Final `rg "0x1f1e6\|flagEmoji"` returns 0 hits.
- **Follow-up fix in this session:** the original `CountryFlag` used a template-string dynamic import (`import('country-flag-icons/string/3x2/${code}')`), which silently fails in Next.js's webpack because the package's `exports` field gates each subpath. Symptom: every flag rendered as the muted placeholder box. Replaced with a single lazy `import('country-flag-icons/string/3x2')` that loads the whole index once (~1.6 MB raw / ~400 KB gzip, single chunk shared across the app), caches on a module-level promise, and lookups become synchronous after first render.
---
@@ -521,6 +524,11 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
3. **Pipeline Value tile expanded with per-stage breakdown**_src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session.
4. **"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session.
5. **Bulk + inline berth price editing — backend complete**_src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session.
6. **Cancel-document: choose delete-from-Documenso vs keep-for-audit**_src/lib/services/documents.service.ts (cancelDocument)_ + _src/lib/services/documenso-client.ts (voidDocument)_ + every cancel-document UI surface (interest reservation tab, contract tab, EOI cancel dialog, send-document dialog admin actions, etc.) — today cancel always fires `DELETE /api/v2/envelope/{id}` (or v1 equivalent), which unclogs the Documenso instance but loses the upstream audit trail. UX ask: present the rep with an explicit choice on cancel: (a) **Delete upstream** (current behaviour — frees the Documenso slot, history rendered from CRM `documents` row only) or (b) **Keep for audit** (local row → `status='cancelled'`, no DELETE call; rep can later reopen on Documenso for forensics). Default to (a). Plumb a `cancelMode: 'delete' | 'keep_remote'` param through `cancelDocument` + the route handler; only call `documensoVoid` when mode === 'delete'. ~1-1.5h: service param + UI radio in the existing confirm-cancel dialog + audit-doc-status reflection in the cancelled-doc badge ("Cancelled, kept on Documenso" when keep_remote). Captured 2026-05-22.
7. **Document signature reminders: drop rate-limit when automatic**_src/components/interests/interest-reservation-tab.tsx:740_ ("Reminders are rate-limited (max once per 7 days per signer)") + the underlying remind-signer service. Today both manual and scheduled-auto reminders share the same 7-days-per-signer throttle. The cap is right for manual clicks (avoids harassment) but breaks the auto-cadence cron: if the rules engine wants to nudge a stale signer every 3 days, it gets swallowed. Plumb a `triggeredBy: 'manual' | 'auto'` flag from the caller and skip the rate-limit when `auto` (the cron's own cadence is the throttle). Manual UI keeps the 7-day cap. ~30-45 min: service param + cron caller + UI copy update ("Reminders are rate-limited for manual sends — automatic follow-ups run on the configured cadence"). Captured 2026-05-22.
8. **EOI tab: add upload-draft-then-place-fields option (parity with Contract / Reservation)**_src/components/interests/interest-eoi-tab.tsx_ (no `UploadForSigningDialog` mount yet) + _src/app/api/v1/interests/[id]/upload-for-signing/route.ts_ (`documentTypeSchema` is locked to `'contract' | 'reservation_agreement'`) + _src/lib/services/custom-document-upload.service.ts_ (`CustomDocumentType` union, `targetStage` switch, `dateContractSent` / `reservationDocStatus` branch). EOI currently has two paths — template-generated (`EoiGenerateDialog`, `/template/{id}/generate-document`) and external paper-signed upload (`ExternalEoiUploadDialog`, `markExternallySigned`) — but no upload-draft-then-drag-fields flow like Contract/Reservation. Reps with a bespoke EOI PDF have to either generate from template (loses custom layout) or mark-as-external (no signing). Fix shape: extend the union to `'eoi'`, add an EOI branch to the stage-advance + doc-status switch (`pipelineStage='eoi_sent'`, `eoiDocStatus='sent'`, `dateEoiSent`), wire `<UploadForSigningDialog documentType="eoi">` into the EOI tab next to the existing "Generate EOI" CTA. Effort: ~2-3h including the route validator bump, service branch, UI mount, and a smoke playwright run. Captured 2026-05-22.
9. **Surface per-signer copyable signing URLs on every Documenso-driven doc**_src/components/interests/interest-eoi-tab.tsx_, _src/components/interests/interest-reservation-tab.tsx_, _src/components/interests/interest-contract-tab.tsx_, _src/components/documents/signing-details-dialog.tsx_, _src/components/shared/send-document-dialog.tsx_ — once a document has been created in Documenso, each signer's `signingUrl` is already stored on the `document_signers` row (returned by `/api/v1/documents/{id}/signers`). Today the rep sees Pending / Invited badges but no way to grab a specific signer's signing URL for QA or manual delivery. Add a "Copy signing link" button next to each signer row across every signing-doc tab (EOI / Reservation / Contract / SigningDetails / SendDocument admin panel). Behaviour: button is disabled when `signingUrl` is null (Documenso hadn't returned a URL yet — e.g. send-mode failure); on click, copy to clipboard via `navigator.clipboard.writeText`, toast "Signing link copied" + the truncated URL. Useful for: smoke-testing the signing flow without spamming the rep's inbox, manually pasting a link into a custom email or Slack DM when the auto-send mode failed, and for sales reps who want to QA the look of the page before the customer touches it. ~30-45 min, all UI surface work — backend already exposes the data. Captured 2026-05-22.
10. **Per-template "Send a test" tester for every transactional email the system emits**_src/components/admin/branding/email-preview-card.tsx_ (current sample-only tester), _src/lib/email/templates/_ (all template files), _src/app/api/v1/admin/branding/email-preview/route.ts_ (current endpoint), new endpoint `/api/v1/admin/email/test-template`. Today the admin can send ONE generic branded shell from Branding, plus an SMTP-connectivity ping from Email — but no way to fire a specific template (password reset, EOI invitation, signing reminder, GDPR export ready, portal activation, reminder digest, bounce-back notice, …) to a designated address. Add a per-template tester card: dropdown of every registered template (read from a central template registry exposing `id, label, sampleProps`), recipient email input, "Send test" button. Backend route renders the selected template with realistic sample props (port branding, fake but plausible client/yacht/EOI), pipes through the same sender helper as the real flow, returns delivery status. Goes on the Email admin page next to the existing SMTP test card. Effort: ~2-3h (registry + endpoint + card + sample-prop fixtures for each template). Captured 2026-05-22.
---
@@ -676,6 +684,12 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
- **(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.
9. **Universal "upload file → optionally place signing fields"**_src/components/documents/upload-for-signing-dialog.tsx_ (the existing place-fields step) + _src/components/documents/new-document-menu.tsx_ (Documents Hub upload), _src/components/documents/documents-hub.tsx_ (root + folder upload), _src/components/files/file-upload-zone.tsx_ (the shared dropzone), _src/components/clients/_ + _yachts/_ + _companies/_ document-tab upload surfaces — every modal where a PDF can land should expose an optional "Send for signature?" toggle that swaps the regular file-upload for the field-placement wizard. Avoids the re-upload friction the user currently hits when an arbitrary doc needs signatures. Shape: extract a `<DocumensoFieldPlacementStep>` from `UploadForSigningDialog`, mount it conditionally after the dropzone in every upload modal, and route through `/upload-for-signing` when fields are placed (skip it when only a plain file is uploaded). Backend: extend `CustomDocumentType` to accept `'generic'` (no pipeline-stage advance, no doc-status flip — just files + documents row in `sent` status). Effort: ~8-12h. Captured 2026-05-22.
10. **Comprehensive admin-settings IA audit + regroup**_src/app/(dashboard)/[portSlug]/admin/_ — 41 admin pages today, organically grown: `ai`, `audit`, `backup`, `berths/bulk-add`, `berths/reconcile`, `branding`, `brochures`, `custom-fields`, `documenso`, `duplicates`, `email-templates`, `email`, `errors`, `forms`, `import`, `inquiries`, `invitations`, `monitoring`, `ocr`, `onboarding`, `pipeline-rules`, `ports`, `pulse`, `qualification-criteria`, `reminders`, `reports`, `residential-stages`, `roles`, `sends`, `settings`, `storage`, `tags`, `templates`, `users`, `vocabularies`, `webhooks`, `website-analytics`. Settings are scattered — e.g. test-email lives on Branding, SMTP test on Email, password-reset copy probably in `email-templates`, but the rep has to guess. Audit each page for: (a) what settings live there now, (b) which settings logically belong elsewhere ("right home" test — Documenso send mode currently lives on Documenso, makes sense; per-port email signature would make more sense under Branding than Email), (c) duplicates (vocabularies vs custom-fields vs qualification-criteria overlap on enum tuning). Then propose a regrouped IA — likely fewer top-level pages with clear domain headers (Configuration → Branding, Email, Documenso, Storage, Webhooks; Workflows → Pipeline rules, Reminders, Auto-stage advancement; Catalog → Vocabularies, Tags, Custom fields, Qualification criteria; Operations → Monitoring, Pulse, Audit log, Errors, Backup; Data → Import, Duplicates, Bulk berth tools; Identity → Users, Roles, Invitations, Onboarding). Pair with a new admin index page that groups by domain instead of a flat alphabetical list. Effort: ~1.5-2 days — audit pass + IA proposal review + actual file moves + nav updates + redirect shims for old URLs. Captured 2026-05-22.
- **SHIPPED in this session (Phase 1 + Phase 2):** Full audit + proposal at `docs/admin-ia-proposal.md`. Final IA = 7 domains, 38 pages (down from 41 via three deletes). `admin-sections-browser.tsx` rewritten to the new domain shape (Brand & Communication, Sales workflow, Catalog, Identity & access, Inbox & data quality, Integrations, System & observability). Deleted with redirects: `/admin/ocr``/admin/ai`, `/admin/reports``/[portSlug]/dashboard`, `/admin/invitations``/admin/users` (this last one was already a redirect). Renamed: "Documenso & EOI" → "Signing service (Documenso)". New: `/admin/berths` index page surfacing bulk-add + reconcile sub-tools (which were previously discoverable only via deep links). `<EmailPreviewCard>` on Branding cross-links to `/admin/email` per-template tester. Search-nav-catalog updated (ocr entry removed, berths entry added). tsc clean.
11. **B3 #9 follow-up — UI wiring for universal upload-with-fields**_src/components/documents/upload-for-signing-dialog.tsx_ (`<FieldPlacementStep>` lives inside this monolith — needs extraction into a standalone component the other upload modals can mount conditionally), _src/components/documents/new-document-menu.tsx_ + _src/components/documents/documents-hub.tsx_ + _src/components/files/file-upload-zone.tsx_ + entity-tab upload sites (client/yacht/company doc tabs). **Backend foundations SHIPPED 2026-05-22**: `CustomDocumentType` union now includes `'generic'`; `uploadDocumentForSigning` skips pipeline-stage advance + doc-status flip when generic; route validator accepts the new value; storage path category routes to `signed-source/`. **UI half deferred** to a paired session — needs careful surgery to each upload modal to add the "Send for signature?" toggle + mount the extracted field-placement step. Effort for UI wiring: ~5-7h. Captured 2026-05-22.
12. **Time-period PDF report + chart rendering + deeper data**_src/lib/pdf/reports/dashboard-report.tsx_, _src/lib/services/dashboard-report-data.service.ts_, _src/lib/pdf/reports/types.ts_, new _src/lib/pdf/reports/charts.tsx_, _src/components/reports/export-dashboard-pdf-button.tsx_ (date-range picker). Today's PDF report ignores dateFrom/dateTo for most sections and renders every chart-style widget as a table. User wants: (a) **time-range filter** that scopes EVERY section to a chosen window — new clients in the window, new interests in the window, active interests touching the window, in-progress berths (sold/under-offer transitions in the window), pipeline counts at the start vs end of window, etc.; (b) **chart rendering** — react-pdf supports SVG, so build small SVG generators (`<PipelineFunnel data>`, `<OccupancyTimeline data>`, `<SourceMixDonut data>`) inline OR pre-render via vega-lite/d3-node to PNG and embed; (c) **deeper data per section** — add berths-in-flight (status changes within window), client+interest cohort tables, contact-cadence histogram, document-signing throughput. Shape: extend `DashboardReportData` with `window: {from, to}` and new sub-sections; extend the export-PDF dialog to take a date-range; route handler propagates the window to every per-section resolver. Effort: ~8-12h depending on chart-rendering approach (inline SVG is ~6h, vega-lite pre-render is ~10h with a worker round-trip). Captured 2026-05-22.
- **SHIPPED in this session:** Catalog expanded from 5 ids to 25 — chart variants (pipeline funnel bar, berth status donut, source conversion bar, lead source donut, occupancy timeline line) + period cohorts (new clients/interests, berths sold, deposits received, documents/contracts signed) + value views (pipeline value breakdown, revenue forecast, avg sales cycle, berth demand, country distribution, deal pulse distribution, recent activity). Hand-rolled SVG chart primitives in `src/lib/pdf/reports/charts.tsx` (HorizontalBarChart, DonutChart, LineChart) using @react-pdf/renderer's native Svg/Path/Rect support. Export-dialog grew a date-range picker with Last-30/90-days quick presets, defaults to last 30 days. Route + service plumbing carries dateFrom/dateTo. 11 of 16 pending resolvers landed (new_clients_period, new_interests_period, berths_sold_period via audit log, deposits_received_period, signed_documents_period, contracts_signed_period, berth_demand_ranking, lead_source_donut, client_country_distribution, recent_activity, pipeline_value_breakdown, revenue_forecast, avg_sales_cycle). Still pending (in this session's PENDING_RESOLVER_IDS set): stage_conversion_rates, occupancy_timeline_chart (needs daily buckets), inquiry_inbox_summary, reminders_summary, deal_pulse_distribution (requires the pulse service's dynamic computation, not a simple column query — left as follow-up). Also shipped: PDF logo absolutize for server-side fetch (was empty because @react-pdf/renderer can't fetch path-only URLs server-side), "Dashboard report" → "Report" default name, section-orphan fix (`wrap={false}` + `minPresenceAhead`).
---
@@ -825,6 +839,13 @@ _Functional defects. Tag each with `[critical|high|medium|low]` prefix._
- **Effort:** ~10 min for the move + verify (no code change, just file relocation + manual click-through). Captured 2026-05-21 from UAT.
- **SHIPPED in 2d57417:** route relocated via `git mv` to `src/app/public/supplemental-info/[token]/page.tsx`. URL `/public/supplemental-info/<token>` unchanged (route groups don't affect URLs). Sweep of `src/app/(portal)/` confirmed no other public token routes were similarly nested.
12. **[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `/<entity>/new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/<entity>?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session.
13. **[high] Dashboard widget cross-group reorder silently ignored by the Customize modal** — _src/components/dashboard/customize-widgets-menu.tsx:113-136_ vs _src/components/dashboard/dashboard-shell.tsx:88-90_ — the Customize modal exposes a single flat `SortableContext` over ALL visible widgets, so a rep can drag (e.g.) "My Reminders" (rail) above "Pipeline Funnel" (chart). The new order persists correctly (`setOrder(...)` → `dashboardWidgetOrder` PATCH → optimistic cache update), and `visibleWidgets` recomputes sorted by rank. BUT the shell then re-buckets `visibleWidgets.filter(w => w.group === 'chart' | 'rail' | 'feed')` into three independent slots before rendering — so any cross-group reorder leaves the dashboard visually unchanged. Intra-group reorders DO work (within charts column, within rails aside, within feed). User-perceived bug: "rearranging apps in the customize modal still does not change the order of them."
- **Decision needed** before fixing — two viable directions:
- **(a) Flatten the dashboard layout** to a single ordered grid (drop the chart/rail/feed bucketing). Honour the rep's exact order across the whole page. Implementation: replace the three-block layout in DashboardShell with one auto-fit grid + per-widget span hints on the registry (`{ colSpan: 1 | 2 | 'full' }`); rails would naturally widen to their hinted column count, feed becomes a `col-span-full` row. Bigger UI surgery, but most honest semantics.
- **(b) Scope the Customize modal sortable to per-group sub-lists.** Render three SortableContexts ("Charts", "Rails", "Feed") inside the modal, each with its own drag handles. Cross-group moves disallowed (or shown as a toggle to move a widget between groups). Smaller code change but loses the flexibility the current UI implies.
- **Recommended:** (b) for the short-term fix (matches the actual rendering reality), with (a) parked as a v2 follow-up after we see whether reps actually want the flat layout.
- **Effort:** ~30-45 min for (b); ~3-4 h for (a) including registry schema bump + responsive layout audit. Captured 2026-05-22 from UAT.
- **SHIPPED in this session:** combined approach. At xl viewports the Customize modal renders three region-scoped sortables (Charts / Side rail / Activity) — matches the actual side-by-side dashboard layout. Below xl where the dashboard stacks all three regions into one visual column, the modal renders a single flat sortable so the rep can drag across regions freely. Plus per-viewport saved orders: `userPreferences.dashboardWidgetOrder` (xl/desktop) + new `dashboardWidgetOrderMobile` (stacked), so reps can customize each layout independently. The hook auto-picks the right field based on viewport.
---