feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers
Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.
UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
Aggregated helpers in notes.service mirror the listFor*Aggregated
symmetric-reach joins. yacht-tabs + company-tabs render the
badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
`width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
fixed inset-0 pin so long forms scroll naturally). Form picks up
port branding (logoUrl + backgroundUrl + appName) via
loadByToken. Address fields completed (street + city + region +
postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
emits toast.success with action link to the destination entity
or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
(5 types visible at once).
Launch infra:
- UTM column wiring (Init 1b step 4): migration
0089_website_submissions_utm.sql adds utm_source/medium/campaign/
term/content + composite index (port_id, utm_source, received_at)
for per-campaign rollups. website-inquiries intake accepts the
five fields. Residential intake intentionally untouched per audit
scope.
- Invoicing module gate (Init 1c spike): new
invoices-module.service + invoices layout guard + registry entry
invoices_module_enabled (default false). Audit conclusion in
launch-readiness.md: payments table is canonical money path;
/invoices flow is parallel infrastructure now hidden by default.
Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
New route-labels.ts + use-smart-back hook +
navigation-history-tracker so back falls through to the parent
route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
breadcrumb-store kept for back-compat consumers but the
breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
upload-receipts, reports kind, tenancies detail, analytics
metric, client detail) migrated.
Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
the reports gap audit (cross-cutting filter set, Marketing +
Financial blockers, custom builder remaining entities, scheduled
CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
(each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
29
CLAUDE.md
29
CLAUDE.md
@@ -57,7 +57,8 @@ Reach for these before grinding through tasks manually:
|
||||
- `Explore` for any codebase search that would take > 3 queries
|
||||
- `feature-dev:code-explorer` / `code-architect` / `code-reviewer` for new feature work
|
||||
- **Doctrine**: skills override default behavior except user instructions in this file. If a CLAUDE.md rule conflicts with a skill, this file wins.
|
||||
- **Manual UAT — currently active doc**: `docs/superpowers/audits/active-uat.md` is the **live** findings doc. Every UAT finding the user surfaces in chat lands here regardless of which session captures it. Persists across sessions until the user explicitly says to wrap the round and archive — at which point rename to `YYYY-MM-DD-uat.md` and start a fresh `active-uat.md`. Buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged). Tag every entry with status: `OPEN | IN PROGRESS | SHIPPED in <hash> | QUEUED | BLOCKED`. Don't ask the format each time. Historical: `alpha-uat-master.md` was the previous master through 2026-05-26 drain.
|
||||
- **Pre-launch tracker**: `docs/launch-readiness.md` is the master pre-launch tracker for the beta phase. Append every launch-blocking initiative or sub-task there with status tags (`OPEN | IN PROGRESS | SHIPPED in <hash> | BLOCKED | DEFERRED`). Read it at the start of any non-trivial task.
|
||||
- **Manual UAT — currently active doc**: `docs/superpowers/audits/active-uat.md` is the **live** findings doc. Every UAT finding the user surfaces in chat lands here regardless of which session captures it. Persists across sessions until the user explicitly says to wrap the round and archive — at which point rename to `YYYY-MM-DD-uat.md` and start a fresh `active-uat.md`. Buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged). Tag every entry with status: `OPEN | IN PROGRESS | SHIPPED in <hash> | QUEUED | BLOCKED`. Don't ask the format each time.
|
||||
|
||||
## Tech stack (non-obvious choices)
|
||||
|
||||
@@ -209,10 +210,30 @@ Vitest covers unit + integration with mocked externals (`tests/unit/`, `tests/in
|
||||
|
||||
Numbered specs (`01-CONSOLIDATED-SYSTEM-SPEC.md` … `15-DESIGN-TOKENS.md`) in repo root carry the detailed architecture decisions, schema docs, API catalog, and sequence.
|
||||
|
||||
Active plans of record under `docs/`:
|
||||
### Beta-phase tracker (read this first)
|
||||
|
||||
We are in pre-launch beta. **`docs/launch-readiness.md` is the canonical
|
||||
home for every outstanding initiative we need to ship before
|
||||
production cutover.** Read it at the start of any non-trivial task to
|
||||
see what's in flight, what's blocked, and what's been deferred. Append
|
||||
new launch-blocking items there (status tags: `OPEN | IN PROGRESS |
|
||||
SHIPPED in <hash> | BLOCKED | DEFERRED`) — do NOT create a new
|
||||
parallel audit doc. Companion files:
|
||||
|
||||
- `docs/launch-readiness.md` — the master pre-launch tracker (5+
|
||||
initiatives: reports overhaul, marketing pipeline cutover, invoicing
|
||||
audit, codebase + security audit, website integration, e2e testing,
|
||||
data migration)
|
||||
- `docs/reports-content-spec.md` — working spec for the reports
|
||||
initiative (per-category KPIs / charts / tables); referenced by
|
||||
`launch-readiness.md` Initiative 1
|
||||
- `docs/superpowers/audits/active-uat.md` — live UAT findings the user
|
||||
surfaces in chat; persists across sessions until explicit wrap
|
||||
- `docs/BACKLOG.md` — long-tail backlog index (post-launch and
|
||||
general)
|
||||
|
||||
### Domain reference docs
|
||||
|
||||
- `docs/MASTER-PLAN-2026-05-18.md` — current 7-phase post-audit plan
|
||||
- `docs/BACKLOG.md` — single entry point for everything outstanding
|
||||
- `docs/berth-recommender-and-pdf-plan.md` — berths + PDF + send-outs bundle
|
||||
- `docs/eoi-documenso-field-mapping.md` — canonical EoiContext ↔ Documenso/AcroForm mapping
|
||||
- `docs/documenso-integration-audit.md` — full Documenso v1/v2 quirks reference
|
||||
|
||||
517
docs/launch-readiness.md
Normal file
517
docs/launch-readiness.md
Normal file
@@ -0,0 +1,517 @@
|
||||
# Launch Readiness — Pre-Prod Initiative
|
||||
|
||||
> **Scope:** the user enumerated five launch-blocking initiatives on
|
||||
> 2026-05-27. This doc is the single home for all of them so we can
|
||||
> track progress without losing items between sessions. Companion to
|
||||
> `docs/superpowers/audits/active-uat.md` (which keeps the live UAT
|
||||
> findings) and `docs/BACKLOG.md` (master backlog index).
|
||||
>
|
||||
> Status tags per item: `OPEN | IN PROGRESS | SHIPPED in <hash> | BLOCKED | DEFERRED`.
|
||||
|
||||
## Initiative 1 — Reports overhaul
|
||||
|
||||
**Status:** IN PROGRESS · Active phase
|
||||
|
||||
Goals (per user, 2026-05-27):
|
||||
|
||||
- Cover all four report categories: **Sales performance**, **Financial**,
|
||||
**Marketing / funnel**, **Operational**.
|
||||
- Template system: load template → modify → re-save OR save as new.
|
||||
- Rich data density: more charts, more graphs, more KPIs.
|
||||
- Output formats: **PDF + CSV + Excel** for each report.
|
||||
- Scheduled reports: cron-driven; auto-email is **optional** (so the
|
||||
admin can schedule a run without forcing an email blast).
|
||||
- Custom builder: full ad-hoc (pick entity, columns, filters, group-by),
|
||||
save as template — but quality-first; we don't ship a janky composer.
|
||||
- UI/UX: stunning, fluid, beautiful. Within the existing white/navy
|
||||
brand language — no off-brand experimental themes.
|
||||
|
||||
Decisions locked (2026-05-27):
|
||||
|
||||
- **Currency**: port branding default
|
||||
- **Rep visibility**: port-scoped admin setting (default depends on
|
||||
team size; PN is single-rep so default = full team)
|
||||
- **AR aging buckets**: standard 30-day (current / 1-30 / 31-60 /
|
||||
61-90 / 90+)
|
||||
- **Custom builder entity scope**: all 10 entities
|
||||
- **Pulse data**: fold into Sales report
|
||||
- **Inquiry-link audit**: yes, audit + fix; no website-repo edits
|
||||
required for the audit itself (link logic is server-side)
|
||||
- **Scope cut for launch**: Sales + Operational ship first as
|
||||
fully-functional reports; Marketing + Financial ship in tandem with
|
||||
their data sources being wired (see Initiatives 2c + 2d below).
|
||||
|
||||
Phases (status snapshot 2026-05-27):
|
||||
|
||||
1. ✅ Foundation + UX overhaul — landing page (within existing
|
||||
design system); charts library audit done; ExcelJS installed.
|
||||
2. ✅ Sales Performance + Operational builders — full report pages
|
||||
with KPIs / charts / tables; client-side Export to CSV + Excel +
|
||||
PDF; server-side PDF endpoint for branded output. _See gaps
|
||||
below._
|
||||
3. ❌ Marketing report — NOT BUILT. Pending Init 1b cutover.
|
||||
4. ❌ Financial report — NOT BUILT. Pending Init 1c decision on
|
||||
whether to enable the invoices module (currently default OFF).
|
||||
5. ⚠️ Custom (ad-hoc) report builder — partial ship.
|
||||
6. ✅ Scheduled reports with optional emailing — BullMQ poll +
|
||||
render path live; recipients optional; PDF-only output.
|
||||
7. ✅ Templates — load / modify / save / save-as / URL deep-link.
|
||||
|
||||
Open considerations carried forward:
|
||||
|
||||
- **Chart library mix.** Project already has `recharts` (simple bar/line/pie)
|
||||
and `echarts` (heatmaps, funnels, complex). Lean on each where it fits;
|
||||
don't add a third unless something specific is missing.
|
||||
- **PDF cover-page treatment.** Each report PDF should open with a
|
||||
branded cover (port logo, title, date range, generated-on stamp). Reuse
|
||||
the existing `branded-document.tsx` shell.
|
||||
|
||||
Working spec: `docs/reports-content-spec.md` (per-category KPIs +
|
||||
charts + tables proposed; updated as we walk through each).
|
||||
|
||||
### Reports — what's left (gap audit 2026-05-27)
|
||||
|
||||
Comparing the working spec against shipped code, here's the bucketed
|
||||
backlog. **Items marked LAUNCH-BLOCK** are needed for the beta cutover;
|
||||
everything else is post-launch polish unless promoted.
|
||||
|
||||
#### Cross-cutting capabilities (apply to every report)
|
||||
|
||||
- ❌ **Period comparison toggle** — "this period vs prior period" delta
|
||||
arrows on KPI cards. Spec calls for it on every report. Not on any.
|
||||
- ❌ **Rep multi-select filter** — exists implicitly via the single-rep
|
||||
leaderboard collapse, but no explicit multi-select dropdown.
|
||||
- ❌ **Source multi-select filter** — Sales has lead-category + outcome
|
||||
filters; the spec also calls for a generic `source` filter (website /
|
||||
referral / broker / manual) on every report.
|
||||
- ❌ **Empty-state copy per report** — currently shows a skeleton; spec
|
||||
wants a "this report needs data first" hint pointing at the right
|
||||
onboarding step.
|
||||
|
||||
#### Phase 2 — Sales report gaps
|
||||
|
||||
- ❌ **Operational-style filter set on Sales** — beyond stage / lead-cat /
|
||||
outcome that shipped, the cross-cutting filters above (period
|
||||
comparison, rep multi-select, source multi-select) are missing.
|
||||
|
||||
#### Phase 2 — Operational report gaps
|
||||
|
||||
- ❌ **Operational-specific filters**: berth area · tenure type ·
|
||||
document type · status filter. None of the four exist. The spec calls
|
||||
these out as drill-down affordances for the heatmap + tables.
|
||||
|
||||
#### Phase 3 — Marketing report (LAUNCH-BLOCK if Marketing is in beta scope)
|
||||
|
||||
Not built. Spec at `docs/reports-content-spec.md` § Report 03 calls for:
|
||||
|
||||
- 6 KPIs (inquiries, inquiry→interest %, inquiry→EOI %, inquiry→won %,
|
||||
top source, avg time-to-respond)
|
||||
- 6 charts (inquiries by source donut, source ROI stacked bar, full
|
||||
funnel, conversion trend, country geo map via `react-simple-maps`,
|
||||
time-to-respond histogram)
|
||||
- 3 tables (top-converting sources, recent inquiries, stuck inquiries)
|
||||
- Filters: specific source, mooring, UTM campaign
|
||||
|
||||
**Blocker:** depends on the website actually sending UTM params (Init
|
||||
1b step 4 — CRM-side shipped, website-side pending) AND on inquiry
|
||||
data flowing from the new intake endpoint (Init 1b step 1 — pending
|
||||
website env flip).
|
||||
|
||||
#### Phase 4 — Financial report (LAUNCH-BLOCK if Financial is in beta scope)
|
||||
|
||||
Not built. Spec at `docs/reports-content-spec.md` § Report 02 calls for:
|
||||
|
||||
- 7 KPIs (revenue collected, pipeline value, deposits, outstanding AR,
|
||||
overdue AR, expenses, net contribution)
|
||||
- 6 charts (revenue by month stacked, quarterly/yearly toggle, EOI →
|
||||
Deposit → Contract funnel, AR aging, cash flow line, expense
|
||||
breakdown donut)
|
||||
- 4 tables (outstanding invoices, recent payments, refund/write-off
|
||||
log, expense ledger)
|
||||
- Filters: invoice kind, payment status, currency, billing entity type
|
||||
|
||||
**Blocker:** depends on the invoices module being in use. Per Init 1c
|
||||
spike, the module is default OFF and the canonical money path is the
|
||||
per-interest Payments tab. **Decision needed**: ship Financial with
|
||||
data from `payments` only (no invoice surface) OR flip invoices module
|
||||
on for PN + train rep + ship Financial. Today the report would be 90%
|
||||
empty.
|
||||
|
||||
#### Phase 5 — Custom builder gaps
|
||||
|
||||
v1 ships 4 entities; full spec wants 10 + advanced composition.
|
||||
|
||||
- ❌ **Missing entities**: yachts, companies, invoices, expenses,
|
||||
documents, websiteSubmissions, payments. Each is a registry-only
|
||||
extension — add a `CustomEntityDefinition` to
|
||||
`src/lib/reports/custom/registry.ts`. ~30 min per entity.
|
||||
- ❌ **Filters beyond date range** — spec wants per-column filter rows
|
||||
(column → operator → value, AND/OR between rows). Today only the
|
||||
date range filter exists.
|
||||
- ❌ **Group by + aggregate** — single group-by dimension + per-column
|
||||
aggregate (count / sum / avg / min / max). Today only a flat list.
|
||||
- ❌ **Column sort** — pick a column + direction. Today rows return
|
||||
with the registry's hardcoded `orderBy`.
|
||||
- ❌ **Live preview as you build** — spec wants debounced re-render on
|
||||
filter / column change. Today the rep clicks "Run query" to fetch.
|
||||
- ❌ **Column whitelist per role** — PII columns (`email`, `phone`)
|
||||
should be gated by `clients.view_pii`. Today all listed columns are
|
||||
available to anyone with `reports.export`.
|
||||
- ❌ **Run-once vs Save-as-template** — the spec asks for three buttons
|
||||
on save (Run once / Save as template / Update existing). Today only
|
||||
the template-save path exists.
|
||||
|
||||
#### Phase 6 — Scheduled runs gaps
|
||||
|
||||
- ❌ **Custom cron strings** — three hardcoded cadences (weekly Mon 9 ·
|
||||
monthly 1st 9 · quarterly 1st 9). Spec implies arbitrary cron.
|
||||
`nextRunFor` in `report-schedules.service.ts` switches on the enum;
|
||||
extend to support a `cron_expression` mode.
|
||||
- ❌ **Scheduled CSV / XLSX** — only PDF is wired through the worker
|
||||
(`renderStandaloneReportRun` in `report-render.service.ts`). For
|
||||
CSV/XLSX, the worker would need to either run the existing client-side
|
||||
exporter server-side (drop ExcelJS into the worker bundle) or build
|
||||
format-specific server renderers.
|
||||
|
||||
#### Phase 7 — Templates gaps
|
||||
|
||||
- ❌ **"Modified ●" indicator** — when the rep changes view state after
|
||||
loading a template, the active-template badge currently just clears.
|
||||
Spec wants a visible "modified" marker so they know they've drifted.
|
||||
- ❌ **Personal vs port-wide scope** — schema has the `visibility`
|
||||
column with `'private' | 'team'` but the UI always saves as port-wide.
|
||||
The Save dialog needs a scope picker.
|
||||
- ❌ **"Owned by" attribution** — templates with `visibility='team'`
|
||||
should show creator name. Schema captures `createdBy`; UI doesn't
|
||||
surface it.
|
||||
- ❌ **Promote-to-port-wide affordance** — once shipped, a "Share with
|
||||
team" action on personal templates that flips visibility.
|
||||
|
||||
#### Net launch-readiness for reports
|
||||
|
||||
If the launch scope is **Sales + Operational only**, reports are
|
||||
launch-ready with the polish items above as post-launch follow-ups.
|
||||
|
||||
If the launch scope includes **Marketing + Financial**, both reports
|
||||
need to be built AND their data plumbing finished (Init 1b website
|
||||
flip + UTM forwarding for Marketing; invoices module + rep training
|
||||
for Financial).
|
||||
|
||||
The cross-cutting filter set (period comparison, rep / source
|
||||
multi-select, empty-state copy) is the highest-value polish that's
|
||||
visible on every report — call it ~6-8 hours of work spread across
|
||||
both shipped report pages + the shared FilterBar component.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 1b — Marketing data pipeline cutover
|
||||
|
||||
**Status:** OPEN · Blocks the Marketing report
|
||||
|
||||
The CRM has the **full infrastructure** for marketing intake +
|
||||
attribution; it's just not connected end-to-end.
|
||||
|
||||
What's built:
|
||||
|
||||
- **Email-open pixel tracking**: `src/app/api/public/email-pixel/[sendId]/route.ts`
|
||||
- `src/lib/email/tracking-pixel.ts`. Sales sends with
|
||||
`trackOpens=true` get a 1×1 pixel; opens record to
|
||||
`email_send_opens` and cross-post to Umami.
|
||||
- **Umami integration**: `@umami/node` installed; `src/lib/services/umami.service.ts`
|
||||
is the wrapper. Outcome events (EOI sent, deposit received, etc.)
|
||||
already cross-post into Umami.
|
||||
- **Website inquiry intake endpoint**: `/api/public/website-inquiries`
|
||||
in the CRM, paired with `/api/public/residential-inquiries`. Both
|
||||
validate + dual-write into `website_submissions`.
|
||||
- **Website posting code**: `Port Nimara/Website/server/utils/crmIntake.ts:72`
|
||||
has the matching POST. Just needs the env var to point at the new
|
||||
CRM.
|
||||
|
||||
What's NOT connected yet:
|
||||
|
||||
1. **Website env `CRM_INTAKE_URL`** still points at the old portal (or
|
||||
isn't set). Flipping this is a ~5-min config change inside the
|
||||
website Nuxt deploy. After flip, every website inquiry lands in
|
||||
`website_submissions` + auto-routes to the inquiry-triage queue.
|
||||
2. **Backfill of historical inquiries** from the old portal so the
|
||||
Marketing report has launch-day history rather than starting from
|
||||
zero. Reads from `client_portal_v2`'s inquiry table, inserts into
|
||||
`website_submissions` with original `receivedAt` timestamps,
|
||||
re-links to existing CRM clients via dedup (email/phone).
|
||||
3. **Umami funnel events on the marketing site itself**. The Umami
|
||||
project exists; what's unclear is whether the marketing site is
|
||||
firing `event:` calls on key actions (form submitted, brochure
|
||||
downloaded, virtual-tour started). Audit needed.
|
||||
4. **UTM column wiring**. ✅ CRM-side SHIPPED — migration `0089_website_submissions_utm.sql`
|
||||
adds `utm_source / utm_medium / utm_campaign / utm_term / utm_content`
|
||||
to `website_submissions` plus a `(port_id, utm_source, received_at)`
|
||||
composite index for per-campaign rollups. `/api/public/website-inquiries`
|
||||
accepts the five fields in the request body and persists them on
|
||||
insert. **Pending website-side change**: the marketing site's
|
||||
`crmIntake.ts` POST must forward UTM params from the form's query
|
||||
string / cookies. **Pending residential parity**: residential
|
||||
inquiries (`/api/public/residential-inquiries`) don't go through
|
||||
`website_submissions`; if Marketing report needs UTM attribution on
|
||||
residential leads too, add the same columns to `residential_clients`
|
||||
in a follow-up.
|
||||
|
||||
Sequencing:
|
||||
|
||||
- Step 1 is the cutover unblock (do during launch window itself).
|
||||
- Step 2 is part of Initiative 5 (data migration).
|
||||
- Step 3 is a website-side audit (Initiative 3).
|
||||
- Step 4 is a small CRM-side schema add (one migration + 4 column
|
||||
reads). Decision pending: ship at launch or defer to Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 1c — Invoicing audit-and-finish
|
||||
|
||||
**Status:** SPIKE COMPLETE · Module-toggle shipped · Financial report deferred
|
||||
|
||||
### Audit findings (2026-05-27 spike)
|
||||
|
||||
The CRM has two parallel money-receiving flows in active code:
|
||||
|
||||
1. **`payments` table — canonical, in active use.** Schema comment at
|
||||
`src/lib/db/schema/pipeline.ts:75` is unambiguous: "The CRM does
|
||||
NOT generate invoices — clients pay banks directly. We record that
|
||||
money was received." Linked to `interests`. `recordPayment`
|
||||
auto-advances pipeline to `deposit_paid` when the cumulative
|
||||
deposit total hits `depositExpectedAmount`. This is the surface
|
||||
reps actually use; payments are recorded from the per-interest
|
||||
**Payments** tab.
|
||||
2. **`invoices` + `invoice_line_items` table — orphaned in the UI.**
|
||||
Full builder (line items, PDF, send, mark-paid) exists at
|
||||
`/[portSlug]/invoices/new`. The sidebar nav entry was removed
|
||||
earlier; only the page itself can link to `invoices/new`. Dev DB
|
||||
has zero rows. The standalone surface is parallel infrastructure
|
||||
for the rare case where an operator wants to invoice a client
|
||||
directly from the CRM, plus the employee-expense-report flow
|
||||
(`expenses → invoices` PDF).
|
||||
|
||||
### Decision (per the existing "intentionally manual elsewhere" branch)
|
||||
|
||||
Ship a port-level module toggle, default OFF, identical pattern to
|
||||
the Tenancies and Expenses toggles. The Financial report stays
|
||||
deferred from launch since the canonical Payments tab feeds the
|
||||
Sales report (which is shipping) — separate Financial dashboard adds
|
||||
no value when there's no second money-receiving flow.
|
||||
|
||||
**What shipped (2026-05-27):**
|
||||
|
||||
- `system_settings` registry entry `invoices_module_enabled` (boolean,
|
||||
port-scoped, default `false`) — added to
|
||||
`src/lib/settings/registry.ts`.
|
||||
- New module-gate service `src/lib/services/invoices-module.service.ts`
|
||||
with `isInvoicesModuleEnabled(portId)` (same shape as
|
||||
`isExpensesModuleEnabled`).
|
||||
- Layout-level guard at `src/app/(dashboard)/[portSlug]/invoices/layout.tsx`
|
||||
— every `/invoices/*` route renders `<ModuleDisabledPage>` when the
|
||||
port hasn't opted in. Admins can flip on from Admin → Settings;
|
||||
historical rows preserved.
|
||||
|
||||
**What's NOT changed:**
|
||||
|
||||
- API endpoints (`/api/v1/invoices/*`) still respond — historical PDF
|
||||
links + send-flow webhooks keep resolving regardless of the toggle.
|
||||
- The `payments` flow is untouched and continues to be the canonical
|
||||
money-received path.
|
||||
- The expense → invoice flow (employee expense reports) is
|
||||
unaffected since employee-expense PDFs flow through a different
|
||||
surface (`/expenses`) that lives behind its own module gate.
|
||||
|
||||
**Follow-up:** if the user later wants per-port branded
|
||||
client-facing invoicing from inside the CRM, the surface is ready to
|
||||
turn on with no schema work — just flip `invoices_module_enabled = true`.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 2 — Multi-agent codebase audit
|
||||
|
||||
**Status:** OPEN · Awaiting kickoff
|
||||
|
||||
User ask: "deep, multi-agent audit of all routes, naming, text, UX, and
|
||||
… dig through the entire code of everything in the system (especially
|
||||
related to the sales process) and find any issues in the logic or how
|
||||
the functionality interacts with each other, how data is shared and
|
||||
persists where needed. Also a deep security audit."
|
||||
|
||||
Audit dimensions (use one specialised agent per dimension, in parallel):
|
||||
|
||||
| # | Dimension | Specialised agent | Output |
|
||||
| --- | ----------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | **Sales pipeline logic** | `feature-dev:code-explorer` | Trace every stage transition; verify auto-advance rules, EOI gating, deposit handling, contract signing. Look for stale enum references (the 9→7 stage migration left some bugs). |
|
||||
| 2 | **Cross-entity data flow** | `feature-dev:code-explorer` | Map polymorphic ownership (yacht/company), interest_berths (multi-berth), document folders (aggregated projection), notes (4-table dispatch). Find divergence between docs and code. |
|
||||
| 3 | **Security** | `security-review` (existing skill) | OWASP API Top 10, auth bypass, IDOR, injection, secret leakage, GDPR exposure. Multi-tenant boundary checks (port_id at every join). |
|
||||
| 4 | **API surface consistency** | `code-review:code-review` | `{ data: T }` envelope adherence, `errorResponse(error)` usage, `parseBody(req, schema)` usage, 204 vs JSON, withAuth+withPermission composition. |
|
||||
| 5 | **UI/UX consistency** | `frontend-design:frontend-design` review | Visual inconsistencies, copy/text issues, accessibility, mobile parity, brand drift, em-dashes, generic SaaS slop. |
|
||||
| 6 | **Schema vs code divergence** | `feature-dev:code-explorer` | Migrations vs Drizzle schema files vs service helpers — find any column the DB has that no service touches, or any service field with no migration. |
|
||||
| 7 | **Documenso integration** | `feature-dev:code-explorer` | Full v1↔v2 path coverage, webhook idempotency, template field mapping, EOI generation (both pathways), error recovery. |
|
||||
| 8 | **Storage & file lifecycle** | `feature-dev:code-explorer` | S3↔filesystem switching, file orphans, signed-URL expiry, GDPR export coverage, magic-byte validation everywhere. |
|
||||
|
||||
Coordination:
|
||||
|
||||
- Use a **single coordinator session** that fans out via `Agent` /
|
||||
`TaskCreate` with `subagent_type` set per dimension. Each agent writes
|
||||
findings to a per-dimension scratch file under
|
||||
`docs/audits/2026-05-27/<dimension>.md`, then the coordinator
|
||||
consolidates into a single triage doc with severity tags.
|
||||
- Pass `model: "opus"` on every agent spawn — Sonnet/Haiku context
|
||||
windows compact too fast under MCP baseline (per memory
|
||||
`feedback_subagent_context_bloat`).
|
||||
|
||||
Output: `docs/audits/2026-05-27/findings-master.md` with per-finding
|
||||
severity (`CRITICAL | HIGH | MED | LOW`), file:line refs, and
|
||||
recommended fix. Critical + High get fixed before launch.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 3 — Marketing website integration
|
||||
|
||||
**Status:** OPEN · Needs scope clarification
|
||||
|
||||
User ask: "make our relevant edits to the marketing website to prepare
|
||||
for the deployment and integration of our new system."
|
||||
|
||||
The marketing site lives in `/Users/matt/Repos/Port Nimara/Website`
|
||||
(separate Nuxt repo). Integration touch points the CRM exposes today:
|
||||
|
||||
- **`/api/public/berths`** + **`/api/public/berths/[mooringNumber]`** —
|
||||
feeds the marketing site's berth list / detail. Status precedence
|
||||
Sold > Under Offer > Available is already wired.
|
||||
- **`/api/public/health`** — dual-mode health check; the website should
|
||||
call the authenticated variant (with `WEBSITE_INTAKE_SECRET`) on
|
||||
startup so it refuses to start when pointed at the wrong CRM env.
|
||||
- **`/api/public/website-inquiries`** — intake endpoint for the contact
|
||||
form; dual-writes inquiry into the CRM.
|
||||
- **Inquiry email ownership** — at cutover, inquiry emails move from
|
||||
the website to the CRM (per memory
|
||||
`project_email_ownership_at_cutover`). Templates + settings keys
|
||||
already exist; berth public endpoint + admin recipient UI still
|
||||
needed (per existing memory).
|
||||
- **Cover photography + branding assets** — the new system uses
|
||||
`branding_email_background_url` etc.; ensure the website assets
|
||||
match.
|
||||
|
||||
Open work (needs user input on priority):
|
||||
|
||||
- Wire the website's contact form to `/api/public/website-inquiries`
|
||||
with the new payload shape.
|
||||
- Add the `WEBSITE_INTAKE_SECRET` to the website's env, point at the
|
||||
authenticated `/api/public/health`.
|
||||
- Update berth-detail page to consume the new `/api/public/berths/...`
|
||||
shape (the JSON mirrors the legacy NocoDB shape so this should be
|
||||
a no-op — VERIFY).
|
||||
- Replace any hard-coded "noreply@portnimara.com" sender on the
|
||||
website side with the CRM-controlled From address (so per-port
|
||||
branding wins).
|
||||
- Confirm the website's caching headers don't fight ours
|
||||
(`s-maxage=300, stale-while-revalidate=60` on berth endpoints).
|
||||
|
||||
---
|
||||
|
||||
## Initiative 4 — End-to-end testing
|
||||
|
||||
**Status:** OPEN · Needs scope clarification
|
||||
|
||||
User ask: "end to end testing of all sales functions, generating
|
||||
EOIs/documents (especially), ensuring all UX/UI is fluid, beautiful,
|
||||
relevant and helps the user go through the sales process effortlessly."
|
||||
|
||||
Existing infrastructure (per `CLAUDE.md`):
|
||||
|
||||
- `tests/e2e/smoke` — fast click-through (~10 min, ~125 specs)
|
||||
- `tests/e2e/exhaustive` — deeper UI coverage
|
||||
- `tests/e2e/destructive` — archive/delete/cancel paths
|
||||
- `tests/e2e/realapi` — opt-in real Documenso + IMAP round-trip
|
||||
- `tests/e2e/visual` — pixel-diff baselines
|
||||
|
||||
Pre-launch test gaps to fill (proposed):
|
||||
|
||||
1. **End-to-end sales journey** (single Playwright spec, real-API): new
|
||||
inquiry → qualified → EOI generated (Documenso) → client signs →
|
||||
developer countersigns → reservation → deposit recorded → contract
|
||||
generated → contract signed → tenancy auto-created → berth marked
|
||||
sold. Assert every stage transition + every email fires.
|
||||
2. **EOI generation parity** between both pathways (in-app
|
||||
`fill-eoi-form` vs Documenso template). Same `EoiContext` should
|
||||
produce equivalent PDFs.
|
||||
3. **Multi-berth EOI rendering** — berth range formatter assertion
|
||||
(`A1-A3, B5-B7` from `interest_berths`).
|
||||
4. **Documenso webhook idempotency** — replay the same `DOCUMENT_COMPLETED`
|
||||
webhook three times; assert single `files.folder_id` write + no
|
||||
duplicate audit-log rows.
|
||||
5. **Storage backend swap** — switch port to filesystem, generate EOI,
|
||||
verify file lands; switch back to S3, confirm migrate script moves
|
||||
the blob correctly.
|
||||
6. **Visual snapshot refresh** for the new Reports UI + back-button
|
||||
smart-back changes (this conversation).
|
||||
7. **Mobile parity** for the entire sales journey (different Playwright
|
||||
project or `--config` variant).
|
||||
|
||||
Each gap above becomes one or two new spec files. Coordinate with
|
||||
Initiative 2's audit so we don't double-test.
|
||||
|
||||
---
|
||||
|
||||
## Initiative 5 — Data migration (legacy → new)
|
||||
|
||||
**Status:** OPEN · High effort · Likely blocker for cutover
|
||||
|
||||
User ask: "start pulling all existing prod data from the old system and
|
||||
connected systems (we'll have to backfill the EOIs by pulling them
|
||||
through MinIO — it's a fucking mess so I'll really need your help
|
||||
automating/speeding up that process) and initiate a preliminary switch
|
||||
over."
|
||||
|
||||
Sources to drain:
|
||||
|
||||
| Source | Storage | Entities | Notes |
|
||||
| ------------------------------- | ------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| Old NocoDB tables | Postgres / NocoDB | Clients, yachts, companies, interests, berths, EOIs (metadata) | Already imported in earlier migration; verify currency vs prod NocoDB. |
|
||||
| Old portal (`client_portal_v2`) | Nuxt + Postgres | Portal users, signing history, sent invitations | Need to confirm what hasn't been migrated yet. |
|
||||
| MinIO (legacy bucket) | Object storage | EOI PDFs (signed + unsigned), receipts, contracts | The "fucking mess" — naming is inconsistent, organisation unclear, need to map each blob back to its CRM entity. |
|
||||
| Documenso v1 (live) | Documenso server | In-flight signing envelopes + signed PDFs | Migration question: do we cut new EOIs to v2 and let v1 envelopes finish, or migrate the in-flight? |
|
||||
| Email archives | IMAP / mail server | Inquiry replies, signing reminders, deposit confirmations | Probably out of scope for cutover (read-only history). |
|
||||
|
||||
Migration script plan (write under `scripts/migration/`):
|
||||
|
||||
1. **`probe-minio.ts`** — scan the legacy MinIO bucket, list every blob,
|
||||
try to extract a client / interest / berth identifier from filename
|
||||
patterns. Produce `docs/migration/minio-blob-inventory.csv` with
|
||||
`key, size_bytes, mime, probable_entity_type, probable_entity_id, confidence`.
|
||||
2. **`backfill-eoi-pdfs.ts`** — for each inventoried blob with confidence
|
||||
≥ HIGH, copy from legacy MinIO into the new storage backend, create a
|
||||
matching `files` row + `documents` row, deposit into the right
|
||||
entity folder via the existing `ensureEntityFolder` helper. Idempotent
|
||||
via `legacy_minio_key` column (add via migration if missing).
|
||||
3. **`reconcile-nocodb.ts`** — diff the live NocoDB tables against our
|
||||
imported state; report rows added/changed/deleted since last import.
|
||||
4. **`preflight-cutover.sh`** — orchestrator script that runs the three
|
||||
above in order, writes a final report.
|
||||
|
||||
Cutover plan:
|
||||
|
||||
1. Freeze writes on the old system (NocoDB read-only, portal
|
||||
maintenance page).
|
||||
2. Run `preflight-cutover.sh` against frozen sources.
|
||||
3. Manual reconciliation of probe-minio rows where confidence < HIGH
|
||||
(likely a few hundred blobs — the user explicitly flagged this is
|
||||
manual labour, automation helps but doesn't replace it).
|
||||
4. DNS / website pointer flip.
|
||||
5. Watch error_events for 24h; rollback plan = re-enable old system
|
||||
writes and stop the cutover commit.
|
||||
|
||||
---
|
||||
|
||||
## Cross-initiative open questions
|
||||
|
||||
- **When to wrap the launch audit doc.** I'd suggest: after Initiative
|
||||
2's findings are triaged AND Initiatives 3-5 reach IN PROGRESS. At
|
||||
that point this file becomes the launch-day-runbook.
|
||||
- **Who's the launch sponsor / decision-maker?** Different from
|
||||
"user / matt"? Affects who signs off on cutover.
|
||||
- **Soft launch vs hard cutover?** Hard cutover is simpler operationally
|
||||
but risky; soft launch (parallel writes for a week) is safer but
|
||||
requires the old system to keep accepting writes for longer.
|
||||
@@ -36,7 +36,8 @@
|
||||
|
||||
### Dialog primitive default too narrow → bump platform-wide
|
||||
|
||||
- **`OPEN`** — _src/components/ui/dialog.tsx_ (DialogContent base default).
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/ui/dialog.tsx_ (DialogContent base default).
|
||||
- **Fix applied:** default bumped from `sm:max-w-xl lg:max-w-3xl` to `sm:max-w-2xl lg:max-w-4xl`. Confirm dialogs override DOWN with `sm:max-w-md`; PDF preview / signing dialogs override UP with `lg:max-w-5xl` or `lg:max-w-[min(95vw,1400px)]`.
|
||||
- **Symptom:** Dialog primitive's default is `sm:max-w-lg` (512px), which is far too narrow for most content (forms, file previews, signing details). Even the earlier per-dialog `lg:max-w-4xl` bump only fixed the dialogs I explicitly migrated; everything still using the default — including FilePreviewDialog (which overrides to `max-w-4xl` but PDFs are unreadable at that width) — stays cramped on desktop.
|
||||
- **Fix:** bump the Dialog primitive base to `sm:max-w-2xl lg:max-w-4xl` so every Dialog gets a sane wide-screen default. Per-dialog overrides ride on top for cases that need wider (PDF preview) or narrower (confirm dialogs).
|
||||
|
||||
@@ -272,7 +273,8 @@
|
||||
|
||||
### Recommender tier contradicts berth status
|
||||
|
||||
- **`OPEN`** — _src/lib/services/berth-recommender.service.ts:223_ (`classifyTier`) + _src/components/interests/berth-recommender-panel.tsx_ (card render).
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/lib/services/berth-recommender.service.ts:223_ (`classifyTier`) + _src/components/interests/berth-recommender-panel.tsx_ (card render).
|
||||
- **Fix applied:** `TierInputs.status` propagated end-to-end. `classifyTier` now collapses the contradiction: `status='sold'` → D, `status='under_offer'` (with or without active interest rows) → C, otherwise existing rules. `RawRow.status` already feeds in via `classifyTier(r)`.
|
||||
- **Symptom:** berth D39 shows both `Under Offer` (status pill) AND `Open` (recommender tier). The tooltip definition contradicts itself: "Open: never had an interest, ready for new prospects."
|
||||
- **Root cause:** `classifyTier` only reads from `interest_berths` aggregates (active count / lost count / max active stage). A berth whose `berths.status` column says `Under Offer` — set manually by an admin, imported from NocoDB, or left over from a stale row — has zero entries in interest_berths if no active interest is currently driving the status, so the tier classifier returns A (Open). The two signals come from different sources and aren't reconciled.
|
||||
- **Fix:** add `berthStatus` to `TierInputs` and bias `classifyTier`:
|
||||
@@ -284,7 +286,8 @@
|
||||
|
||||
### Berth occupancy info — surface competing interest on every non-available status
|
||||
|
||||
- **`OPEN`** — _src/components/interests/linked-berths-list.tsx_ LinkedBerthRowItem + _src/components/interests/berth-recommender-panel.tsx_ (recommendation cards at ~line 184) + _src/components/interests/interest-berth-status-banner.tsx_ (deal-level banner — already shipped).
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/berths/berth-occupancy-chip.tsx_ (shared chip) + adopted in _linked-berths-list_ (LinkedBerthRowItem) + _berth-recommender-panel_ (recommendation cards) + _interest-berth-status-banner_ (deal-level banner).
|
||||
- **Fix applied:** new `<BerthOccupancyChip berthId excludeInterestId={currentInterestId} />` reuses `/api/v1/berths/[id]/active-interests`. Renders inline on every non-available status surface (linked-berths list, recommender cards, deal banner). Hides when the only competing interest is the current one.
|
||||
- **React-grab anchors:** `<span>Under Offer</span>` in StatusPill in LinkedBerthRowItem; same pill in the recommender card body.
|
||||
- **Symptom:** anywhere a berth's status renders as "Under Offer" / "Sold" / "Reserved" the rep currently has no idea WHO is responsible for that status. They have to navigate to the berth detail page (or guess) to find the competing interest or the closed-deal client.
|
||||
- **Fix:** reuse the existing `/api/v1/berths/[id]/active-interests` endpoint (shipped for the columns popover + `InterestBerthStatusBanner`) and surface the top competing interest inline on every non-available status surface. Show client name + stage pill + a link to the competing interest detail. Hide when the only competing interest is the current one (self-conflict makes no sense to flag).
|
||||
@@ -303,9 +306,8 @@
|
||||
|
||||
### Notes tab header count doesn't aggregate
|
||||
|
||||
- **`OPEN`** — _src/lib/services/clients.service.ts:441-444_ (clientNotes count) + similar in yachts.service / companies.service.
|
||||
- **Symptom:** rep adds a note to the client's linked yacht; client detail's Notes tab badge still reads "0" because the count only includes direct `client_notes` rows. The NotesList itself shows the aggregated yacht / company / interest notes correctly — the tab badge is the only piece that's not aggregated.
|
||||
- **Fix:** extend the count to mirror the aggregator's symmetric-reach. For a client: `count(client_notes WHERE client_id=X) + count(yacht_notes WHERE current_owner_type='client' AND current_owner_id=X) + count(company_notes joined via company_memberships) + count(interest_notes joined via interests.client_id)`. Mirror for yacht / company tab counts. ~1–2h with vitest for the join logic.
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/lib/services/notes.service.ts_ (new `countFor{Client,Yacht,Company}Aggregated`) + _clients.service.ts_, _yachts.service.ts_, _companies.service.ts_ (wired into `getById` responses) + _yacht-tabs.tsx_, _company-tabs.tsx_ (badge prop).
|
||||
- **Fix applied:** new symmetric-reach count helpers in `notes.service.ts` mirror the existing `listFor*Aggregated` joins. Client tab counts client + interest + yacht (owned) + company (active membership) notes; yacht tab counts yacht + polymorphic-owner + linked-interest notes; company tab counts company + owned-yacht + their-interest notes. `getYachtById` / `getCompanyById` now return `noteCount`; tab definitions render the badge.
|
||||
|
||||
### Admin toggle to disable Tenancies entirely
|
||||
|
||||
@@ -361,21 +363,21 @@
|
||||
|
||||
### Tag chips missing wherever StageStepper renders
|
||||
|
||||
- **`OPEN`** — _src/components/clients/client-pipeline-summary.tsx_ (StageStepper component + ClientInterestRow type + useClientInterests query) + _src/components/clients/client-interests-tab.tsx_ (InterestRowItem) + every other call site that renders `<StageStepper>`.
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/clients/client-pipeline-summary.tsx_ + _src/components/clients/client-interests-tab.tsx_.
|
||||
- **Fix applied:** every StageStepper call site (Overview top-deal block, Overview interest list, Interests-tab row item, Interests-tab detail panel) renders a tag-chip strip under the stepper. ClientInterestRow type carries `tags?: Array<{ id, name, color }>` and the interests list endpoint resolves the join in a single batch.
|
||||
- **React-grab anchor:** `<div class="flex-1 truncate...">Qual.</div>` in StageStepper in InterestRowItem in ClientInterestsTab.
|
||||
- **Symptom:** the InterestRowItem cards show berth label + stage badge + stepper, but no tag chips. Tags are first-class on interests everywhere else (detail page, list view) — the same chips should follow the StageStepper everywhere it appears so reps see "Hot lead / VIP / Returning client" context at a glance without drilling in.
|
||||
- **Fix:** (a) extend `ClientInterestRow` with `tags?: Array<{ id, name, color }>` and surface from `useClientInterests` (`/api/v1/interests?clientId=X`). (b) Render a small tag-chip strip just above or below the StageStepper in InterestRowItem + every other StageStepper call site (currently `client-interests-tab.tsx:88, 263`, `client-pipeline-summary.tsx:224, 340`). (c) Cap to ~3 chips with a "+N" overflow indicator so long tag lists don't blow up the row height.
|
||||
|
||||
### New-document "Upload file" — unclear where the file lands
|
||||
|
||||
- **`OPEN`** — _src/components/documents/new-document-menu.tsx_ (the "Upload file" action) + _src/components/files/file-upload-zone.tsx_ (no post-upload navigation cue).
|
||||
- **React-grab anchor:** `<button>...New document</button>` in DocumentsHub PageHeader.
|
||||
- **Symptom:** clicking "Upload file" opens a dialog whose description says "File will be added to the current folder" — but doesn't name the folder. After upload, the dialog closes silently; the file appears somewhere but the rep has no toast / navigation cue confirming it landed. If they uploaded from the hub root with a different folder selected in the sidebar, they may not realize the file went to the selected folder rather than the root.
|
||||
- **Fix:** (a) Dialog description names the destination folder explicitly ("File will be added to **Clients / Matthew Ciaccio / Deal A1-A3**" with breadcrumb chain). (b) Post-upload toast: "Uploaded `<filename>` → \[folder breadcrumb\]" with a "View folder" action that selects that folder in the hub. (c) Optionally auto-navigate to the destination folder on success when the upload originated from a different folder (defer this — toast may be enough).
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/new-document-menu.tsx_ (Upload file dialog's `onUploadComplete`).
|
||||
- **Fix applied:** per-file completion now emits a `toast.success('Uploaded <filename>')` with an action link. When the upload happened under an entity (clients/companies/yachts) the action navigates to that entity's detail page; otherwise it opens the destination folder via `/documents?folderId=…`. Still deferred (lower priority): naming the destination folder verbatim in the pre-upload dialog description.
|
||||
|
||||
### Recent files — no link to folder or attached entity
|
||||
|
||||
- **`OPEN`** — _src/components/documents/documents-hub.tsx_ HubRootView (the "Recent files" panel) + _src/lib/services/files.service.ts_ (list response shape).
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/documents/hub-root-view.tsx_ + _src/lib/services/files.ts_ (`listFiles`).
|
||||
- **Fix applied:** each row in the Recent files panel shows a folder chip (linking to `/documents?folderId=…`) and an entity badge (Interest / Client / Yacht / Company → entity detail page). `listFiles` already resolves `folderName / clientName / yachtName / companyName / interestSummary` in a single batched lookup so no N+1 cost.
|
||||
- **React-grab anchor:** `<h3 class="flex items-cent...">Recent files</h3>` in HubRootView.
|
||||
- **Symptom:** each recent-file row only shows filename + size + date; the rep has to remember which client / interest the file belongs to. No CTA to jump into the parent folder either.
|
||||
- **Fix:** extend row payload with `{ folderId, folderName, clientId, clientName, interestId, interestBerthLabel }`. Render a small badge column showing the attached entity (client name or interest's berth label, like the EntityFolderView pattern already shipped). Right-hand action gains an icon button "Open folder" that navigates to the folder view in Documents Hub.
|
||||
@@ -386,27 +388,23 @@
|
||||
|
||||
### Supplemental-info form — no port branding, no logo on top
|
||||
|
||||
- **`OPEN`** — _src/app/public/supplemental-info/[token]/page.tsx_ + _src/components/shared/branded-auth-shell.tsx_ + _src/lib/services/supplemental-forms.service.ts_ (loadByToken).
|
||||
- **Symptom:** opening a token link lands on the form with no logo / branding — the page sits OUTSIDE the `(portal)` route group (relocated 2026-05-21 to dodge the portal kill-switch) and no `<AuthBrandingProvider>` wraps it, so `BrandedAuthShell` falls back to neutral.
|
||||
- **Fix:** extend `loadByToken` to also return `branding: { logoUrl, backgroundUrl, appName }` resolved via `getPortBrandingConfig(token.portId)`; the API surfaces it; the page passes it to `BrandedAuthShell` via the explicit `branding` prop.
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_ + _src/components/shared/branded-auth-shell.tsx_ + _src/lib/services/supplemental-forms.service.ts_ (loadByToken).
|
||||
- **Fix applied:** `loadByToken` now returns `port: { name, logoUrl, backgroundUrl }` via `getPortBrandingConfig(token.portId)`. Page passes that directly to `BrandedAuthShell` via the explicit `branding` prop so the logo + backdrop render regardless of the route-group context.
|
||||
|
||||
### Supplemental-info form — extends edge-to-edge on long forms
|
||||
|
||||
- **`OPEN`** — _src/components/shared/branded-auth-shell.tsx_.
|
||||
- **Symptom:** the form's card is taller than the viewport, but the shell uses `fixed inset-0 flex items-center` (right for short auth surfaces), so the form content butts against the top/bottom of the viewport with no breathing room.
|
||||
- **Fix:** switch this page to a scrollable layout — natural page flow with padding above/below the card, logo at the top, footer note at the bottom. Don't touch the existing auth surfaces (login / reset-password / set-password / activate); they keep the `fixed inset-0` shell.
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/shared/branded-auth-shell.tsx_.
|
||||
- **Fix applied:** added a `width?: 'sm' | 'md'` prop. `'md'` widens the card to `max-w-xl` and swaps the `fixed inset-0` viewport pin for a normal `min-h-dvh` page scroll, so a 20+ field form scrolls naturally on mobile instead of clipping under the rubber-band cap. Login surfaces stay on `'sm'` (default) with the original pinned-and-centered shell.
|
||||
|
||||
### Supplemental-info form — address fields incomplete
|
||||
|
||||
- **`OPEN`** — _src/app/public/supplemental-info/[token]/page.tsx_ + _src/app/api/public/supplemental-info/[token]/route.ts_ + _src/lib/services/supplemental-forms.service.ts_.
|
||||
- **Symptom:** form has a single "Address" textarea + Country combobox. The CRM's `client_addresses` table holds: street, city, subdivisionIso (region/state), postalCode, countryIso. The form drops most of those.
|
||||
- **Fix:** match the CRM shape — separate inputs for street + city + subdivision (SubdivisionCombobox) + postal code + country. Plumb through prefill API + submission validator + applySubmission diff/persist.
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_ + _src/app/api/public/supplemental-info/[token]/route.ts_ + _src/lib/services/supplemental-forms.service.ts_.
|
||||
- **Fix applied:** form now exposes street + city + region/state + postal code + country as separate inputs, mirroring the `client_addresses` shape. `loadByToken` returns the existing values for prefill; the API schema accepts the new fields; `applySubmission` diffs + writes them per-column with field-history entries.
|
||||
|
||||
### Supplemental-info form — no context about where details land
|
||||
|
||||
- **`OPEN`** — _src/app/public/supplemental-info/[token]/page.tsx_.
|
||||
- **Symptom:** form header says "A few details before we draft your EOI" but doesn't surface that the exact values entered will appear on the legal document. Setting that expectation up-front reduces support questions about why we're asking.
|
||||
- **Fix:** add a small info banner ("These details will appear on your Expression of Interest document") near the top, soft tone, doesn't disrupt the flow.
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_.
|
||||
- **Fix applied:** added the port name as an eyebrow above the title ("PORT NIMARA") and a clarifying line in the intro: "Submissions go straight to the team handling your application." The success state also references the port name explicitly.
|
||||
|
||||
### Marketing-site form parity — primary surface lives on the website
|
||||
|
||||
@@ -416,7 +414,8 @@
|
||||
|
||||
### Interest OverviewTab — inherit empty fields from client + visually denote
|
||||
|
||||
- **`OPEN`** — _src/components/interests/interest-tabs.tsx_ (OverviewTab) + _src/lib/services/interests.service.ts_ (response payload).
|
||||
- **`SHIPPED locally (not yet committed)`** — _src/components/interests/interest-tabs.tsx_ (OverviewTab + EditableRow).
|
||||
- **Fix applied:** EditableRow gains an `inheritedFrom?: 'client' | 'yacht' | 'company'` prop that renders a small "from client" / "from yacht" pill next to the label. Wired on the Email + Phone rows so reps know edits propagate to the client-level contacts table. Yacht-dimension inheritance was already in place via the `yachtDimensions` payload + per-axis "from yacht" pill in the desired-dimensions block; both inheritance signals now use the same visual language.
|
||||
- **React-grab anchor:** `<div class="space-y-1" />` in OverviewTab, inside the TabsContent presence wrapper.
|
||||
- **Symptom:** the OverviewTab shows interest-level fields that, when empty, render as " - ". If the client (or linked yacht for dimensions) already has those details on file, the rep has to navigate to the client / yacht to see them. Adds friction + risks reps re-asking the client.
|
||||
- **Fix:** when an interest field is null but the client/yacht has it filled, render the inherited value with a small visual cue (e.g. italic + a "from client" or "from yacht" pill). Editing in place should write to the interest's own column (override). Specific candidates:
|
||||
@@ -494,7 +493,8 @@
|
||||
|
||||
### Automate signing — single button that cascades invites + emails the completed doc (REFINED with signing-order awareness)
|
||||
|
||||
- **`OPEN`** — feature request, no implementation. Refined below to handle both sequential AND concurrent modes properly.
|
||||
- **`SHIPPED locally (not yet committed)`** — committed earlier this UAT round as commit `fe5f98d` (`feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast`).
|
||||
- **Where it lives:** `src/lib/services/signing-automation.service.ts` orchestrates the kickoff + cascade. `documents.automation_mode` column tracks `'manual' | 'sequential_auto' | 'concurrent_auto'` (migration `0088_documents_automation_mode.sql`). Webhook handler in `src/app/api/webhooks/documenso/route.ts` reads automation mode on each recipient-signed event and fires the next invite when in `sequential_auto`.
|
||||
- **Files implicated (once built):**
|
||||
- _src/components/documents/active-eoi-card.tsx_ (new "Automate signing" button + state visualisation).
|
||||
- _src/lib/services/documents.service.ts_ (new `automateSigning(documentId, portId)` orchestrator).
|
||||
@@ -541,7 +541,13 @@
|
||||
|
||||
### `/documents/new` CreateDocumentWizard — confusing, redundant pathways
|
||||
|
||||
- **`OPEN`** — _src/components/documents/create-document-wizard.tsx_ + _src/components/documents/new-document-menu.tsx_ + _src/app/(dashboard)/[portSlug]/documents/new/page.tsx_.
|
||||
- **`MOSTLY SHIPPED locally (not yet committed) — remaining: convert page to dialog`**
|
||||
- **What shipped (per commit `2107480` `feat(wizard-refactor): drop inapp pathway + upload branch + per-port template defaults + mark-signed dropdown`):**
|
||||
- Wizard upload branch removed; `source: 'template'` hard-coded.
|
||||
- `pathway: 'documenso-template'` hard-coded; `inapp` removed.
|
||||
- Doc-type-driven template defaults: `/api/v1/documents/template-defaults` returns the per-port `documenso_eoi_template_id` / `documenso_reservation_template_id` / `documenso_contract_template_id`; wizard auto-fills the picker when the rep selects a doc type.
|
||||
- "Mark as signed (offline)" dropdown item exists in NewDocumentMenu (line 113 of new-document-menu.tsx).
|
||||
- **Remaining:** drop the `/documents/new` route in favour of a `<GenerateDocumentDialog>` modal opened from the dropdown — architectural change, deferred until the rest of the launch stabilises.
|
||||
- **React-grab anchor:** `<section class="rounded-md bord..." />` in CreateDocumentWizard in NewDocumentPage.
|
||||
|
||||
**Current state — three flows wired three different ways:**
|
||||
@@ -583,15 +589,14 @@ The catch: most ports will have ~3 templates total (EOI, Reservation Agreement,
|
||||
|
||||
### CreateDocumentWizard — Reminders/Watchers/Signers leak into upload-only flow
|
||||
|
||||
- **`OPEN`** — _src/components/documents/create-document-wizard.tsx_ (the Signers + Reminders + Watchers sections render unconditionally regardless of source).
|
||||
- **React-grab anchor:** `<section class="rounded-md bord..." />` in CreateDocumentWizard.
|
||||
- **Symptom:** the wizard currently shows Signers / Reminders / Watchers sections for every source path. For a flow-3 upload (rep already has the signed PDF, just wants to file it + tag it to an entity), none of those apply — there's no Documenso envelope, no signing event to remind about, no in-flight workflow to watch. The wizard's sections are written for the Documenso-driven flows and bleed into the upload case where they're noise.
|
||||
- **Fix:** hide Signers + Reminders + Watchers when the rep's path is "upload a finished PDF that's already signed offline." Keep them visible for generate-via-template (flow 1) and upload-with-fields-for-signing (flow 2). Today the wizard conflates flow 2 with flow 3 via the misleading "Upload a finished PDF" label — the larger refactor (above) splits them; this fix piggybacks on that split.
|
||||
- **Bundle with:** the wider wizard refactor — same set of edits. Don't ship this in isolation; the section visibility logic depends on the source-path being unambiguous.
|
||||
- **`SUPERSEDED`** — _src/components/documents/create-document-wizard.tsx_ (wizard is generation-only since 2026-05-26 refactor; `source: 'template'` hard-coded, upload branch removed).
|
||||
- **Reason:** the 2026-05-26 wizard refactor cut the upload branch entirely. The wizard is now purely "generate from template → Documenso" so Signers / Reminders / Watchers always apply. Offline-signed upload flows live elsewhere (per-interest external-upload dialogs, generic FileUploadZone). No longer a leak to fix.
|
||||
|
||||
### CreateDocumentWizard subject picker — needs at-a-glance entity scan
|
||||
|
||||
- **`OPEN`** — _src/components/documents/create-document-wizard.tsx:328-370_ (the `grid grid-cols-[max-content_1fr]` subject row).
|
||||
- **`PARTIALLY SHIPPED locally (not yet committed)`** — _src/components/documents/create-document-wizard.tsx_ (subject row).
|
||||
- **Fix applied:** type dropdown → segmented button strip (Interest / Tenancy / Client / Company / Yacht), all 5 types visible at once so the rep clicks once instead of opening a dropdown. Picker below still adapts per-type (existing pickers reused as-is).
|
||||
- **Deferred:** the fully-unified search ("type 'matt'" → mixed-type results) needs a new `<SubjectCombobox>` against `/api/v1/search`. The segmented strip is the high-value 80% fix; the unified search lands when the wider wizard refactor goes through.
|
||||
- **React-grab anchor:** `<div class="grid grid-cols-..." />` in CreateDocumentWizard.
|
||||
- **Symptom:** picking the document subject means choosing a type (Client / Company / Yacht / Interest / Tenancy) THEN searching that one type's picker. Reps don't think in terms of "what type is the recipient" — they think "I need to send this to deal X" or "this is for client Y." The two-step type-then-picker requires the rep to know the answer to the type question before they can search.
|
||||
- **Fix proposal:** replace the type+picker pair with a single unified search field (same idiom as the global Command-search). Typing surfaces matching clients/companies/yachts/interests/tenancies inline, each row carrying its type label as a badge. Recent interactions surface first when the input is empty. The chosen entity sets both `subjectType` and `subjectId` in one click.
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"imapflow": "^1.3.3",
|
||||
"ioredis": "^5.10.1",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
|
||||
415
pnpm-lock.yaml
generated
415
pnpm-lock.yaml
generated
@@ -166,6 +166,9 @@ importers:
|
||||
embla-carousel-react:
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0(react@19.2.6)
|
||||
exceljs:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
imapflow:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3
|
||||
@@ -972,6 +975,12 @@ packages:
|
||||
resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
|
||||
|
||||
'@fast-csv/format@4.3.5':
|
||||
resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==}
|
||||
|
||||
'@fast-csv/parse@4.3.6':
|
||||
resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==}
|
||||
|
||||
'@fastify/otel@0.18.0':
|
||||
resolution: {integrity: sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA==}
|
||||
peerDependencies:
|
||||
@@ -3080,6 +3089,9 @@ packages:
|
||||
'@types/mysql@2.15.27':
|
||||
resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==}
|
||||
|
||||
'@types/node@14.18.63':
|
||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||
|
||||
'@types/node@20.19.41':
|
||||
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
||||
|
||||
@@ -3504,10 +3516,22 @@ packages:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
archiver-utils@3.0.4:
|
||||
resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
archiver@5.3.2:
|
||||
resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
archiver@7.0.1:
|
||||
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3740,14 +3764,27 @@ packages:
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
big-integer@1.6.52:
|
||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
binary@0.3.0:
|
||||
resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==}
|
||||
|
||||
bippy@0.5.40:
|
||||
resolution: {integrity: sha512-3QPSDG5tgd7FCIkcKsUqmbGdlHxBxP7II5drXPNp1I0rpA9+4+/VjQOigDTbzeqTi6Xkaf1Dq7x4E8vbOuwOkA==}
|
||||
peerDependencies:
|
||||
react: '>=17.0.1'
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
block-stream2@2.1.0:
|
||||
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
|
||||
|
||||
bluebird@3.4.7:
|
||||
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
|
||||
|
||||
bmp-js@0.1.0:
|
||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||
|
||||
@@ -3792,6 +3829,9 @@ packages:
|
||||
resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
buffer-crc32@1.0.0:
|
||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -3799,9 +3839,20 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
buffer-indexof-polyfill@1.0.2:
|
||||
resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
buffers@0.1.1:
|
||||
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
|
||||
engines: {node: '>=0.2.0'}
|
||||
|
||||
bullmq@5.76.8:
|
||||
resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@@ -3829,6 +3880,9 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chainsaw@0.1.0:
|
||||
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3927,6 +3981,10 @@ packages:
|
||||
commondir@1.0.1:
|
||||
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
|
||||
|
||||
compress-commons@4.1.2:
|
||||
resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
compress-commons@6.0.2:
|
||||
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3967,6 +4025,10 @@ packages:
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crc32-stream@4.0.3:
|
||||
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
crc32-stream@6.0.0:
|
||||
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -4074,6 +4136,9 @@ packages:
|
||||
dateformat@4.6.3:
|
||||
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||
|
||||
dayjs@1.11.21:
|
||||
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
|
||||
|
||||
debounce-fn@6.0.0:
|
||||
resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4295,6 +4360,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
duplexer2@0.1.4:
|
||||
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
|
||||
|
||||
duplexer@0.1.2:
|
||||
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
|
||||
|
||||
@@ -4587,6 +4655,10 @@ packages:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
exceljs@4.4.0:
|
||||
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -4594,6 +4666,10 @@ packages:
|
||||
fast-copy@4.0.3:
|
||||
resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==}
|
||||
|
||||
fast-csv@4.3.6:
|
||||
resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -4692,6 +4768,12 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
fs.realpath@1.0.0:
|
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -4702,6 +4784,11 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fstream@1.0.12:
|
||||
resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==}
|
||||
engines: {node: '>=0.6'}
|
||||
deprecated: This package is no longer supported.
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
@@ -4768,6 +4855,10 @@ packages:
|
||||
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globals@11.12.0:
|
||||
resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4895,6 +4986,9 @@ packages:
|
||||
imapflow@1.3.3:
|
||||
resolution: {integrity: sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
@@ -4916,6 +5010,10 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
@@ -5204,6 +5302,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -5248,6 +5349,9 @@ packages:
|
||||
libqp@2.1.1:
|
||||
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -5336,6 +5440,9 @@ packages:
|
||||
engines: {node: '>=22.22.1'}
|
||||
hasBin: true
|
||||
|
||||
listenercount@1.0.1:
|
||||
resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==}
|
||||
|
||||
listr2@10.2.1:
|
||||
resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==}
|
||||
engines: {node: '>=22.13.0'}
|
||||
@@ -5356,12 +5463,49 @@ packages:
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.difference@4.5.0:
|
||||
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
|
||||
|
||||
lodash.escaperegexp@4.1.2:
|
||||
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||
|
||||
lodash.flatten@4.4.0:
|
||||
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
|
||||
|
||||
lodash.groupby@4.6.0:
|
||||
resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==}
|
||||
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash.isfunction@3.0.9:
|
||||
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
|
||||
|
||||
lodash.isnil@4.0.0:
|
||||
resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.isundefined@3.0.1:
|
||||
resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash.union@4.6.0:
|
||||
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
|
||||
|
||||
lodash.uniq@4.5.0:
|
||||
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
@@ -5501,6 +5645,10 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
module-details-from-path@1.0.4:
|
||||
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
|
||||
|
||||
@@ -5830,6 +5978,10 @@ packages:
|
||||
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-is-absolute@1.0.1:
|
||||
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6246,6 +6398,11 @@ packages:
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rimraf@2.7.1:
|
||||
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rolldown@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -6291,6 +6448,10 @@ packages:
|
||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
saxes@5.0.1:
|
||||
resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
@@ -6340,6 +6501,9 @@ packages:
|
||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@@ -6627,6 +6791,10 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-stream@3.2.0:
|
||||
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
|
||||
|
||||
@@ -6729,6 +6897,10 @@ packages:
|
||||
resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==}
|
||||
hasBin: true
|
||||
|
||||
tmp@0.2.6:
|
||||
resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
@@ -6752,6 +6924,9 @@ packages:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
traverse@0.3.9:
|
||||
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
|
||||
|
||||
ts-api-utils@2.5.0:
|
||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||
engines: {node: '>=18.12'}
|
||||
@@ -6867,6 +7042,9 @@ packages:
|
||||
unrs-resolver@1.11.1:
|
||||
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
|
||||
|
||||
unzipper@0.10.14:
|
||||
resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -7230,6 +7408,10 @@ packages:
|
||||
yoga-layout@3.2.1:
|
||||
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
|
||||
|
||||
zip-stream@4.1.1:
|
||||
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
zip-stream@6.0.1:
|
||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -7773,6 +7955,25 @@ snapshots:
|
||||
|
||||
'@faker-js/faker@10.4.0': {}
|
||||
|
||||
'@fast-csv/format@4.3.5':
|
||||
dependencies:
|
||||
'@types/node': 14.18.63
|
||||
lodash.escaperegexp: 4.1.2
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isequal: 4.5.0
|
||||
lodash.isfunction: 3.0.9
|
||||
lodash.isnil: 4.0.0
|
||||
|
||||
'@fast-csv/parse@4.3.6':
|
||||
dependencies:
|
||||
'@types/node': 14.18.63
|
||||
lodash.escaperegexp: 4.1.2
|
||||
lodash.groupby: 4.6.0
|
||||
lodash.isfunction: 3.0.9
|
||||
lodash.isnil: 4.0.0
|
||||
lodash.isundefined: 3.0.1
|
||||
lodash.uniq: 4.5.0
|
||||
|
||||
'@fastify/otel@0.18.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
@@ -9781,6 +9982,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@types/node@14.18.63': {}
|
||||
|
||||
'@types/node@20.19.41':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -10238,6 +10441,32 @@ snapshots:
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.11
|
||||
lazystream: 1.0.1
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.difference: 4.5.0
|
||||
lodash.flatten: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.union: 4.6.0
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 2.3.8
|
||||
|
||||
archiver-utils@3.0.4:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.11
|
||||
lazystream: 1.0.1
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.difference: 4.5.0
|
||||
lodash.flatten: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.union: 4.6.0
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 3.6.2
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
dependencies:
|
||||
glob: 10.5.0
|
||||
@@ -10248,6 +10477,16 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
archiver@5.3.2:
|
||||
dependencies:
|
||||
archiver-utils: 2.1.0
|
||||
async: 3.2.6
|
||||
buffer-crc32: 0.2.13
|
||||
readable-stream: 3.6.2
|
||||
readdir-glob: 1.1.3
|
||||
tar-stream: 2.2.0
|
||||
zip-stream: 4.1.1
|
||||
|
||||
archiver@7.0.1:
|
||||
dependencies:
|
||||
archiver-utils: 5.0.2
|
||||
@@ -10462,14 +10701,29 @@ snapshots:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
big-integer@1.6.52: {}
|
||||
|
||||
binary@0.3.0:
|
||||
dependencies:
|
||||
buffers: 0.1.1
|
||||
chainsaw: 0.1.0
|
||||
|
||||
bippy@0.5.40(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
block-stream2@2.1.0:
|
||||
dependencies:
|
||||
readable-stream: 3.6.2
|
||||
|
||||
bluebird@3.4.7: {}
|
||||
|
||||
bmp-js@0.1.0: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
@@ -10523,15 +10777,26 @@ snapshots:
|
||||
bson@7.2.0:
|
||||
optional: true
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-crc32@1.0.0: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer-indexof-polyfill@1.0.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buffers@0.1.1: {}
|
||||
|
||||
bullmq@5.76.8:
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
@@ -10566,6 +10831,10 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chainsaw@0.1.0:
|
||||
dependencies:
|
||||
traverse: 0.3.9
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -10644,6 +10913,13 @@ snapshots:
|
||||
|
||||
commondir@1.0.1: {}
|
||||
|
||||
compress-commons@4.1.2:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
crc32-stream: 4.0.3
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 3.6.2
|
||||
|
||||
compress-commons@6.0.2:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
@@ -10691,6 +10967,11 @@ snapshots:
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@4.0.3:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 3.6.2
|
||||
|
||||
crc32-stream@6.0.0:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
@@ -10805,6 +11086,8 @@ snapshots:
|
||||
|
||||
dateformat@4.6.3: {}
|
||||
|
||||
dayjs@1.11.21: {}
|
||||
|
||||
debounce-fn@6.0.0:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
@@ -10919,6 +11202,10 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
duplexer2@0.1.4:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
duplexer@0.1.2: {}
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
@@ -11385,10 +11672,27 @@ snapshots:
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
exceljs@4.4.0:
|
||||
dependencies:
|
||||
archiver: 5.3.2
|
||||
dayjs: 1.11.21
|
||||
fast-csv: 4.3.6
|
||||
jszip: 3.10.1
|
||||
readable-stream: 3.6.2
|
||||
saxes: 5.0.1
|
||||
tmp: 0.2.6
|
||||
unzipper: 0.10.14
|
||||
uuid: 8.3.2
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-copy@4.0.3: {}
|
||||
|
||||
fast-csv@4.3.6:
|
||||
dependencies:
|
||||
'@fast-csv/format': 4.3.5
|
||||
'@fast-csv/parse': 4.3.6
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
@@ -11488,12 +11792,23 @@ snapshots:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
fstream@1.0.12:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
inherits: 2.0.4
|
||||
mkdirp: 0.5.6
|
||||
rimraf: 2.7.1
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
function.prototype.name@1.1.8:
|
||||
@@ -11580,6 +11895,15 @@ snapshots:
|
||||
minipass: 7.1.3
|
||||
path-scurry: 2.0.2
|
||||
|
||||
glob@7.2.3:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 3.1.5
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
globals@11.12.0: {}
|
||||
|
||||
globals@14.0.0: {}
|
||||
@@ -11703,6 +12027,8 @@ snapshots:
|
||||
pino: 10.3.1
|
||||
socks: 2.8.8
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.7: {}
|
||||
@@ -11728,6 +12054,11 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
inflight@1.0.6:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
wrappy: 1.0.2
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
@@ -12024,6 +12355,13 @@ snapshots:
|
||||
object.assign: 4.1.7
|
||||
object.values: 1.2.1
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -12069,6 +12407,10 @@ snapshots:
|
||||
|
||||
libqp@2.1.1: {}
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -12138,6 +12480,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
yaml: 2.8.4
|
||||
|
||||
listenercount@1.0.1: {}
|
||||
|
||||
listr2@10.2.1:
|
||||
dependencies:
|
||||
cli-truncate: 5.2.0
|
||||
@@ -12158,10 +12502,34 @@ snapshots:
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.difference@4.5.0: {}
|
||||
|
||||
lodash.escaperegexp@4.1.2: {}
|
||||
|
||||
lodash.flatten@4.4.0: {}
|
||||
|
||||
lodash.groupby@4.6.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isfunction@3.0.9: {}
|
||||
|
||||
lodash.isnil@4.0.0: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isundefined@3.0.1: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.union@4.6.0: {}
|
||||
|
||||
lodash.uniq@4.5.0: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
log-symbols@7.0.1:
|
||||
@@ -12302,6 +12670,10 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
module-details-from-path@1.0.4: {}
|
||||
|
||||
mongodb-connection-string-url@7.0.1:
|
||||
@@ -12602,6 +12974,8 @@ snapshots:
|
||||
|
||||
path-expression-matcher@1.5.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
@@ -13059,6 +13433,10 @@ snapshots:
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rimraf@2.7.1:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.122.0
|
||||
@@ -13149,6 +13527,10 @@ snapshots:
|
||||
|
||||
sax@1.6.0: {}
|
||||
|
||||
saxes@5.0.1:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
@@ -13200,6 +13582,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
@@ -13553,6 +13937,14 @@ snapshots:
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
end-of-stream: 1.4.5
|
||||
fs-constants: 1.0.0
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tar-stream@3.2.0:
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
@@ -13642,6 +14034,8 @@ snapshots:
|
||||
dependencies:
|
||||
tldts-core: 7.0.30
|
||||
|
||||
tmp@0.2.6: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
@@ -13663,6 +14057,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
traverse@0.3.9: {}
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
@@ -13810,6 +14206,19 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
|
||||
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
|
||||
|
||||
unzipper@0.10.14:
|
||||
dependencies:
|
||||
big-integer: 1.6.52
|
||||
binary: 0.3.0
|
||||
bluebird: 3.4.7
|
||||
buffer-indexof-polyfill: 1.0.2
|
||||
duplexer2: 0.1.4
|
||||
fstream: 1.0.12
|
||||
graceful-fs: 4.2.11
|
||||
listenercount: 1.0.1
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
@@ -14169,6 +14578,12 @@ snapshots:
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zip-stream@4.1.1:
|
||||
dependencies:
|
||||
archiver-utils: 3.0.4
|
||||
compress-commons: 4.1.2
|
||||
readable-stream: 3.6.2
|
||||
|
||||
zip-stream@6.0.1:
|
||||
dependencies:
|
||||
archiver-utils: 5.0.2
|
||||
|
||||
@@ -4,17 +4,18 @@ import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, Copy, Wrench } from 'lucide-react';
|
||||
import { Copy, Wrench } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||||
import type { LikelyCulprit } from '@/lib/error-classifier';
|
||||
|
||||
@@ -36,6 +37,17 @@ export default function ErrorEventDetailPage() {
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const requestId = params?.requestId ?? '';
|
||||
|
||||
// Smart-back target: send the user back to the error list, not the
|
||||
// generic Administration page that URL-derivation would land on.
|
||||
useBreadcrumbHint(
|
||||
portSlug
|
||||
? {
|
||||
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
|
||||
current: `Error ${requestId.slice(0, 8)}…`,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const query = useQuery<DetailResponse>({
|
||||
queryKey: ['admin', 'error-events', requestId],
|
||||
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
|
||||
@@ -71,15 +83,6 @@ export default function ErrorEventDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors` as Route}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back to error list
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}…</h1>
|
||||
<Badge
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ArrowLeft, BookOpen, Search } from 'lucide-react';
|
||||
|
||||
import type { Route } from 'next';
|
||||
import { BookOpen, Search } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { ERROR_CODES } from '@/lib/error-codes';
|
||||
|
||||
/**
|
||||
@@ -27,6 +24,17 @@ export default function ErrorCodeReferencePage() {
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Smart-back target: send the user back to the error inspector, not
|
||||
// the generic Administration page URL-derivation would land on.
|
||||
useBreadcrumbHint(
|
||||
portSlug
|
||||
? {
|
||||
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
|
||||
current: 'Error code reference',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = Object.entries(ERROR_CODES) as Array<
|
||||
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
|
||||
@@ -53,15 +61,6 @@ export default function ErrorCodeReferencePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors` as Route}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back to error inspector
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
|
||||
43
src/app/(dashboard)/[portSlug]/expenses/layout.tsx
Normal file
43
src/app/(dashboard)/[portSlug]/expenses/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
|
||||
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
|
||||
|
||||
interface ExpensesLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout-level gate for the entire /expenses subtree (list, scan,
|
||||
* detail). When the port has expenses_module_enabled = false, every
|
||||
* route under /expenses renders the ModuleDisabledPage instead of the
|
||||
* real content. This is the route-level half of the "hybrid hide+block"
|
||||
* model (the sidebar entries are independently hidden via
|
||||
* expensesModuleByPort on the SSR-resolved sidebar prop).
|
||||
*
|
||||
* Using a layout rather than per-page guards means: (a) one place to
|
||||
* change the gate logic, (b) nested routes (scan, [id]) are covered
|
||||
* automatically, (c) the children subtree never mounts when disabled,
|
||||
* so its data-fetching effects don't fire.
|
||||
*/
|
||||
export default async function ExpensesLayout({ children, params }: ExpensesLayoutProps) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(portsTable.slug, portSlug),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!port) return children;
|
||||
const enabled = await isExpensesModuleEnabled(port.id);
|
||||
if (enabled) return children;
|
||||
return (
|
||||
<ModuleDisabledPage
|
||||
moduleName="Expenses"
|
||||
description="Expense tracking and receipt upload are turned off for this port. Previously-recorded expense rows are preserved and will reappear when the module is re-enabled."
|
||||
settingsHref={`/${portSlug}/admin/settings`}
|
||||
fallbackHref={`/${portSlug}/dashboard`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/app/(dashboard)/[portSlug]/invoices/layout.tsx
Normal file
42
src/app/(dashboard)/[portSlug]/invoices/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { isInvoicesModuleEnabled } from '@/lib/services/invoices-module.service';
|
||||
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
|
||||
|
||||
interface InvoicesLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout-level gate for the standalone `/invoices` flow (list, new,
|
||||
* detail, upload-receipts). Default is OFF: the canonical "money
|
||||
* received" path in this CRM is the per-interest Payments tab; the
|
||||
* standalone invoicing surface only matters when an operator wants to
|
||||
* generate client-facing invoices from the CRM itself (rare). Admins
|
||||
* can opt in from Admin → Operations.
|
||||
*
|
||||
* Existing invoice rows are preserved when the module is disabled —
|
||||
* the API endpoints still respond so historical PDF links / webhook
|
||||
* callbacks keep resolving. Only the UI is hidden.
|
||||
*/
|
||||
export default async function InvoicesLayout({ children, params }: InvoicesLayoutProps) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(portsTable.slug, portSlug),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!port) return children;
|
||||
const enabled = await isInvoicesModuleEnabled(port.id);
|
||||
if (enabled) return children;
|
||||
return (
|
||||
<ModuleDisabledPage
|
||||
moduleName="Standalone invoicing"
|
||||
description="The standalone /invoices flow is turned off for this port. The canonical money-received path here is the per-interest Payments tab — that's where to record deposits and balance payments. Existing invoice rows are preserved and will reappear when the module is re-enabled."
|
||||
settingsHref={`/${portSlug}/admin/settings`}
|
||||
fallbackHref={`/${portSlug}/dashboard`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
|
||||
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'How to upload receipts',
|
||||
@@ -12,5 +17,23 @@ export default async function UploadReceiptsPage({
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
// Mirrors the /expenses layout gate: the receipt-upload explainer is
|
||||
// part of the expenses surface, so the same port-scoped toggle
|
||||
// blocks it. /invoices/upload-receipts isn't under /expenses, so a
|
||||
// separate gate is needed here.
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(portsTable.slug, portSlug),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (port && !(await isExpensesModuleEnabled(port.id))) {
|
||||
return (
|
||||
<ModuleDisabledPage
|
||||
moduleName="Expenses"
|
||||
description="Receipt upload is part of the Expenses module, which is turned off for this port."
|
||||
settingsHref={`/${portSlug}/admin/settings`}
|
||||
fallbackHref={`/${portSlug}/dashboard`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <UploadReceiptsGuide portSlug={portSlug} />;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
||||
import { classifyFormFactor } from '@/lib/form-factor';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const headerList = await headers();
|
||||
@@ -89,6 +90,24 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
);
|
||||
const tenanciesModuleByPort: Record<string, boolean> = Object.fromEntries(tenanciesModuleEntries);
|
||||
|
||||
// Per-port expenses-module gate. Defaults to enabled (the registry's
|
||||
// default) so existing ports keep the feature on deploy. Resolved
|
||||
// server-side so the sidebar SSRs without flicker when an admin has
|
||||
// turned the feature off for a tenant.
|
||||
const expensesModuleEntries = await Promise.all(
|
||||
ports.map(async (p) => {
|
||||
try {
|
||||
return [p.id, await isExpensesModuleEnabled(p.id)] as const;
|
||||
} catch {
|
||||
// Conservative default on lookup failure: keep the feature
|
||||
// visible so a transient DB hiccup doesn't hide the module
|
||||
// for a port that actually has it enabled.
|
||||
return [p.id, true] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const expensesModuleByPort: Record<string, boolean> = Object.fromEntries(expensesModuleEntries);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider
|
||||
@@ -116,6 +135,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
ports={ports}
|
||||
portLogoUrls={portLogoUrls}
|
||||
tenanciesModuleByPort={tenanciesModuleByPort}
|
||||
expensesModuleByPort={expensesModuleByPort}
|
||||
initialFormFactor={initialFormFactor}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -28,6 +28,11 @@ export async function GET(
|
||||
const submissionSchema = z.object({
|
||||
fullName: z.string().min(1).max(200),
|
||||
address: z.string().max(500).nullable().optional(),
|
||||
city: z.string().max(120).nullable().optional(),
|
||||
/** ISO-3166-2 subdivision code (e.g. 'PL-MZ'). Accept up to 8 chars
|
||||
* so we cover ISO codes that include alpha-3 country prefixes. */
|
||||
subdivisionIso: z.string().max(8).nullable().optional(),
|
||||
postalCode: z.string().max(20).nullable().optional(),
|
||||
country: z.string().length(2).nullable().optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
phoneE164: z
|
||||
@@ -52,6 +57,9 @@ export async function POST(
|
||||
await applySubmission(token, {
|
||||
fullName: body.fullName,
|
||||
address: body.address ?? null,
|
||||
city: body.city ?? null,
|
||||
subdivisionIso: body.subdivisionIso ?? null,
|
||||
postalCode: body.postalCode ?? null,
|
||||
country: body.country ?? null,
|
||||
email: body.email ?? null,
|
||||
phoneE164: body.phoneE164 ?? null,
|
||||
|
||||
@@ -49,6 +49,14 @@ const SubmissionSchema = z.object({
|
||||
/** Defaults to port-nimara since that's currently the only port with a
|
||||
* public marketing site. Future ports can override per-submission. */
|
||||
port_slug: z.string().default('port-nimara'),
|
||||
/** UTM attribution. Opportunistic — the website's tracker pulls
|
||||
* these from the query string (or referrer) at submit time. Capped
|
||||
* at 200 chars per part to defend against pathological strings. */
|
||||
utm_source: z.string().max(200).nullable().optional(),
|
||||
utm_medium: z.string().max(200).nullable().optional(),
|
||||
utm_campaign: z.string().max(200).nullable().optional(),
|
||||
utm_term: z.string().max(200).nullable().optional(),
|
||||
utm_content: z.string().max(200).nullable().optional(),
|
||||
});
|
||||
|
||||
function verifySecret(header: string | null): boolean {
|
||||
@@ -142,6 +150,11 @@ export async function POST(req: NextRequest) {
|
||||
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
||||
sourceIp: ip,
|
||||
userAgent: req.headers.get('user-agent') ?? null,
|
||||
utmSource: parsed.utm_source ?? null,
|
||||
utmMedium: parsed.utm_medium ?? null,
|
||||
utmCampaign: parsed.utm_campaign ?? null,
|
||||
utmTerm: parsed.utm_term ?? null,
|
||||
utmContent: parsed.utm_content ?? null,
|
||||
})
|
||||
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
|
||||
.returning({ id: websiteSubmissions.id });
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import { renderShell } from '@/lib/email/shell';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { findTestTemplate } from '@/lib/email/test-registry';
|
||||
|
||||
const SAMPLE_SUBJECT_SUFFIX = ' - branding preview';
|
||||
|
||||
@@ -51,13 +56,44 @@ function buildSampleEmail(branding: {
|
||||
return { subject, html };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one of the registered transactional templates with realistic
|
||||
* sample fixtures + the current port's branding. Falls back to the
|
||||
* generic "branding preview" sample when no templateId is supplied
|
||||
* (preserves the original card behaviour).
|
||||
*/
|
||||
async function renderTemplatePreview(
|
||||
portId: string,
|
||||
templateId: string | null,
|
||||
): Promise<{ subject: string; html: string }> {
|
||||
if (!templateId) {
|
||||
const branding = await getPortBrandingConfig(portId);
|
||||
return buildSampleEmail(branding);
|
||||
}
|
||||
const template = findTestTemplate(templateId);
|
||||
if (!template) throw new ValidationError(`Unknown templateId: ${templateId}`);
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
if (!port) throw new NotFoundError('Port');
|
||||
const branding = await getBrandingShell(portId);
|
||||
const rendered = await template.render({
|
||||
recipientName: 'Sample Recipient',
|
||||
recipientEmail: 'sample@example.com',
|
||||
portName: port.name,
|
||||
portUrl: `https://${port.slug}.example`,
|
||||
branding,
|
||||
});
|
||||
return { subject: rendered.subject, html: rendered.html };
|
||||
}
|
||||
|
||||
// GET - return the sample email rendered with the current port's branding.
|
||||
// Optional ?templateId=<id> renders a real transactional template instead
|
||||
// of the generic sample (e.g. crm_invite, portal_activation, signing_*).
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.portId) throw new ValidationError('No active port');
|
||||
const branding = await getPortBrandingConfig(ctx.portId);
|
||||
const { subject, html } = buildSampleEmail(branding);
|
||||
const templateId = new URL(req.url).searchParams.get('templateId');
|
||||
const { subject, html } = await renderTemplatePreview(ctx.portId, templateId);
|
||||
return NextResponse.json({ data: { subject, html } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
@@ -67,6 +103,8 @@ export const GET = withAuth(
|
||||
|
||||
const sendTestSchema = z.object({
|
||||
recipient: z.string().email('Enter a valid email address'),
|
||||
/** Optional - same templateId surface as the GET endpoint. */
|
||||
templateId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// POST - actually send the sample email to a single recipient.
|
||||
@@ -74,9 +112,8 @@ export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.portId) throw new ValidationError('No active port');
|
||||
const { recipient } = await parseBody(req, sendTestSchema);
|
||||
const branding = await getPortBrandingConfig(ctx.portId);
|
||||
const { subject, html } = buildSampleEmail(branding);
|
||||
const { recipient, templateId } = await parseBody(req, sendTestSchema);
|
||||
const { subject, html } = await renderTemplatePreview(ctx.portId, templateId ?? null);
|
||||
await sendEmail(recipient, subject, html);
|
||||
return NextResponse.json({ data: { sent: true, recipient } });
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import { findTestTemplate, TEST_TEMPLATES } from '@/lib/email/test-registry';
|
||||
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
@@ -63,11 +64,17 @@ export const POST = withAuth(
|
||||
// No publicUrl column on `ports` yet - synthesise a plausible URL
|
||||
// from the slug so the sample renders with a "real-looking" base.
|
||||
const portUrl = `https://${port.slug}.example`;
|
||||
// Resolve the per-port branding shell (logo, blur background, accent,
|
||||
// header/footer HTML) so the preview matches the live production look
|
||||
// - without this the email falls back to the neutral default shell
|
||||
// and admins see a logo-less, background-less email.
|
||||
const branding = await getBrandingShell(ctx.portId);
|
||||
const rendered = await template.render({
|
||||
recipientName: 'Sample Recipient',
|
||||
recipientEmail: body.recipient,
|
||||
portName: port.name,
|
||||
portUrl,
|
||||
branding,
|
||||
});
|
||||
|
||||
// Subject prefix makes it visually unambiguous in the recipient's
|
||||
|
||||
@@ -15,10 +15,16 @@ import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface PrefillData {
|
||||
token: { expiresAt: string; consumed: boolean };
|
||||
port: {
|
||||
name: string;
|
||||
logoUrl: string | null;
|
||||
backgroundUrl: string | null;
|
||||
};
|
||||
client: {
|
||||
fullName: string;
|
||||
streetAddress: string | null;
|
||||
city: string | null;
|
||||
subdivisionIso: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
primaryEmail: string | null;
|
||||
@@ -48,6 +54,9 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
// Form fields
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [region, setRegion] = useState('');
|
||||
const [postalCode, setPostalCode] = useState('');
|
||||
const [country, setCountry] = useState<CountryCode | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState<PhoneInputValue | null>(null);
|
||||
@@ -74,6 +83,9 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
setData(payload.data);
|
||||
setFullName(payload.data.client.fullName ?? '');
|
||||
setAddress(payload.data.client.streetAddress ?? '');
|
||||
setCity(payload.data.client.city ?? '');
|
||||
setRegion(payload.data.client.subdivisionIso ?? '');
|
||||
setPostalCode(payload.data.client.postalCode ?? '');
|
||||
setCountry((payload.data.client.country as CountryCode | null) ?? null);
|
||||
setEmail(payload.data.client.primaryEmail ?? '');
|
||||
if (payload.data.client.primaryPhone) {
|
||||
@@ -114,6 +126,9 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
body: JSON.stringify({
|
||||
fullName: fullName.trim(),
|
||||
address: address.trim() || null,
|
||||
city: city.trim() || null,
|
||||
subdivisionIso: region.trim() || null,
|
||||
postalCode: postalCode.trim() || null,
|
||||
country: country ?? null,
|
||||
email: email.trim() || null,
|
||||
phoneE164: phone?.e164 ?? null,
|
||||
@@ -137,9 +152,20 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Branding prop fed into BrandedAuthShell. Falls back to nulls until
|
||||
// `data` lands; once it lands the shell re-renders with the resolved
|
||||
// port logo + backdrop.
|
||||
const branding = data
|
||||
? {
|
||||
logoUrl: data.port.logoUrl,
|
||||
backgroundUrl: data.port.backgroundUrl,
|
||||
appName: data.port.name,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<BrandedAuthShell width="md" branding={branding}>
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="sr-only">Loading</span>
|
||||
@@ -150,7 +176,7 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<BrandedAuthShell width="md" branding={branding}>
|
||||
<div role="alert" aria-live="assertive" className="text-center space-y-2 py-6">
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
@@ -158,19 +184,15 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Tokens are now reusable until expiry - the consumed flag is kept
|
||||
// so the form can show a soft "you've submitted this before" banner
|
||||
// (and prefill the entered values) without locking the recipient out
|
||||
// of updating their details.
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<BrandedAuthShell width="md" branding={branding}>
|
||||
<div className="text-center space-y-3 py-6">
|
||||
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
|
||||
<h1 className="text-lg font-semibold">Thanks, got it</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your details have been sent to the team. Watch your inbox for your EOI document shortly.
|
||||
Your details have been sent to the team at {data?.port.name ?? 'the marina'}. Watch your
|
||||
inbox for your EOI document shortly.
|
||||
</p>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
@@ -178,13 +200,17 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<BrandedAuthShell width="md" branding={branding}>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="space-y-2 text-center">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{data?.port.name}
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold">A few details before we draft your EOI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We've pre-filled what we have on file. Please review, correct anything that's
|
||||
wrong, and add what's missing.
|
||||
wrong, and add what's missing. Submissions go straight to the team handling your
|
||||
application.
|
||||
</p>
|
||||
{data?.token.consumed ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
@@ -227,19 +253,45 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="address">Address</Label>
|
||||
<Label htmlFor="address">Street address</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
rows={2}
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="Street, city, postal code"
|
||||
placeholder="Apartment, suite, building, street"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Country</Label>
|
||||
<CountryCombobox value={country} onChange={(c) => setCountry(c ?? null)} clearable />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="city">City</Label>
|
||||
<Input id="city" value={city} onChange={(e) => setCity(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="postalCode">Postal code</Label>
|
||||
<Input
|
||||
id="postalCode"
|
||||
value={postalCode}
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="region">Region / state</Label>
|
||||
<Input
|
||||
id="region"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Country</Label>
|
||||
<CountryCombobox value={country} onChange={(c) => setCountry(c ?? null)} clearable />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -299,7 +351,7 @@ export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
This link is private to you and expires after one use.
|
||||
This link is private to you. You can come back and update your details until it expires.
|
||||
</p>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
|
||||
@@ -372,6 +372,14 @@ const GROUPS: AdminGroup[] = [
|
||||
keywords: [
|
||||
'client portal',
|
||||
'client portal enabled',
|
||||
'tenancies',
|
||||
'tenancies module',
|
||||
'tenancy',
|
||||
'tenancy tracker',
|
||||
'lease',
|
||||
'lease windows',
|
||||
'renewals',
|
||||
'transfers',
|
||||
'ai',
|
||||
'ai interest scoring',
|
||||
'ai email drafts',
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import { ArrowRight, Eye, Send } from 'lucide-react';
|
||||
import { ArrowRight, Eye, RefreshCw, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface PreviewResponse {
|
||||
data: { subject: string; html: string };
|
||||
}
|
||||
|
||||
interface TemplateRegistryResponse {
|
||||
data: Array<{ id: string; label: string; description: string }>;
|
||||
}
|
||||
|
||||
// Sentinel value for the Generic preview option. Select forbids empty-string
|
||||
// values, so we use a literal that the GET handler doesn't recognise as a
|
||||
// templateId and falls through to the generic sample.
|
||||
const GENERIC_VALUE = '__generic__';
|
||||
|
||||
/**
|
||||
* Live preview of the branded transactional email shell plus a
|
||||
* "send a test" affordance. Both use the current port's branding so
|
||||
@@ -31,18 +47,45 @@ export function EmailPreviewCard() {
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [templates, setTemplates] = useState<TemplateRegistryResponse['data']>([]);
|
||||
const [templateId, setTemplateId] = useState<string>(GENERIC_VALUE);
|
||||
|
||||
async function refreshPreview() {
|
||||
setLoadingPreview(true);
|
||||
try {
|
||||
const res = await apiFetch<PreviewResponse>('/api/v1/admin/branding/email-preview');
|
||||
setSubject(res.data.subject);
|
||||
setHtml(res.data.html);
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Preview failed');
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
}
|
||||
// Pull the registry once on mount so the dropdown reflects every
|
||||
// transactional template the system can emit. The same list backs the
|
||||
// per-template tester on /admin/email - this card surfaces it inline
|
||||
// with the branding controls so the visual feedback loop is tight.
|
||||
useEffect(() => {
|
||||
apiFetch<TemplateRegistryResponse>('/api/v1/admin/email/test-template')
|
||||
.then((res) => setTemplates(res.data))
|
||||
.catch(() => {
|
||||
/* non-fatal - the generic preview still works without the registry */
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshPreview = useCallback(
|
||||
async (id: string = templateId) => {
|
||||
setLoadingPreview(true);
|
||||
try {
|
||||
const qs = id === GENERIC_VALUE ? '' : `?templateId=${encodeURIComponent(id)}`;
|
||||
const res = await apiFetch<PreviewResponse>(`/api/v1/admin/branding/email-preview${qs}`);
|
||||
setSubject(res.data.subject);
|
||||
setHtml(res.data.html);
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Preview failed');
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
}
|
||||
},
|
||||
[templateId],
|
||||
);
|
||||
|
||||
function onTemplateChange(next: string) {
|
||||
setTemplateId(next);
|
||||
// Auto-refresh when the user picks a different template so they don't
|
||||
// have to chase a separate "Refresh" click for every selection. Save +
|
||||
// re-render of branding fields still requires the visible Refresh
|
||||
// button (we can't auto-detect a save).
|
||||
void refreshPreview(next);
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
@@ -51,7 +94,10 @@ export function EmailPreviewCard() {
|
||||
try {
|
||||
await apiFetch('/api/v1/admin/branding/email-preview', {
|
||||
method: 'POST',
|
||||
body: { recipient: testEmail },
|
||||
body: {
|
||||
recipient: testEmail,
|
||||
templateId: templateId === GENERIC_VALUE ? null : templateId,
|
||||
},
|
||||
});
|
||||
toast.success(`Test email queued to ${testEmail}`);
|
||||
} catch (err: unknown) {
|
||||
@@ -68,17 +114,45 @@ export function EmailPreviewCard() {
|
||||
<div>
|
||||
<CardTitle>Preview & test</CardTitle>
|
||||
<CardDescription>
|
||||
Renders a sample transactional email with the current port's branding. Save
|
||||
changes first, then refresh the preview to see them.
|
||||
Renders a sample transactional email with the current port's branding. Switch
|
||||
templates to see how each one looks. Save branding changes first, then click Refresh
|
||||
to pick up the new logo / colour / background.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={refreshPreview} disabled={loadingPreview}>
|
||||
<Eye className="mr-1.5 h-4 w-4" />
|
||||
{loadingPreview ? 'Loading…' : html ? 'Refresh preview' : 'Show preview'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refreshPreview()}
|
||||
disabled={loadingPreview}
|
||||
>
|
||||
{html ? (
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
)}
|
||||
{loadingPreview ? 'Loading…' : html ? 'Refresh' : 'Show preview'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor="template-select" className="text-xs font-medium text-muted-foreground">
|
||||
Template
|
||||
</Label>
|
||||
<Select value={templateId} onValueChange={onTemplateChange}>
|
||||
<SelectTrigger id="template-select" className="w-full sm:w-[320px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={GENERIC_VALUE}>Generic sample (branding only)</SelectItem>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{html ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -55,6 +55,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'expenses_module_enabled',
|
||||
label: 'Expenses Module',
|
||||
description:
|
||||
'Enable the expenses + receipt-upload surface for this port. Disabling hides both sidebar entries (Expenses and How to upload receipts) and blocks the routes with a "module disabled" page, so bookmarks land somewhere meaningful instead of 404-ing. Previously-recorded expense rows are preserved if you re-enable.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'ai_interest_scoring',
|
||||
label: 'AI Interest Scoring',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import { useState } from 'react';
|
||||
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
@@ -43,6 +44,8 @@ interface ClientDetailHeaderProps {
|
||||
|
||||
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [hardDeleteOpen, setHardDeleteOpen] = useState(false);
|
||||
const [reminderOpen, setReminderOpen] = useState(false);
|
||||
@@ -248,7 +251,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
onOpenChange={setHardDeleteOpen}
|
||||
clientId={client.id}
|
||||
clientName={client.fullName}
|
||||
onDeleted={() => router.back()}
|
||||
onDeleted={() => router.push(`/${portSlug}/clients` as Route)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -48,6 +48,7 @@ interface CompanyTabsCompany {
|
||||
notes: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
addresses?: Address[];
|
||||
noteCount?: number;
|
||||
}
|
||||
|
||||
interface CompanyTabsOptions {
|
||||
@@ -223,6 +224,7 @@ export function getCompanyTabs({
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
badge: company.noteCount,
|
||||
content: (
|
||||
<NotesList
|
||||
aggregate
|
||||
|
||||
@@ -298,27 +298,42 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
<Label className="text-xs">Internal notes</Label>
|
||||
<Input value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-[max-content_1fr] gap-2">
|
||||
<Select
|
||||
value={subjectType}
|
||||
onValueChange={(v) => {
|
||||
setSubjectType(v as typeof subjectType);
|
||||
// Reset subject id when the type changes - pickers are
|
||||
// type-specific and old ids belong to the wrong table.
|
||||
setSubjectId('');
|
||||
}}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Subject</Label>
|
||||
{/* Segmented type picker — all 5 types visible at once so
|
||||
the rep can scan and click rather than open a dropdown.
|
||||
Picker below adapts to the chosen type. */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Subject type"
|
||||
className="inline-flex flex-wrap gap-1 rounded-md border bg-muted/30 p-0.5"
|
||||
>
|
||||
<SelectTrigger className="h-9 w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUBJECT_TYPES.map((s) => (
|
||||
<SelectItem key={s.key} value={s.key}>
|
||||
{SUBJECT_TYPES.map((s) => {
|
||||
const active = s.key === subjectType;
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
setSubjectType(s.key);
|
||||
// Reset subject id when the type changes — pickers
|
||||
// are type-specific and old ids belong to the
|
||||
// wrong table.
|
||||
setSubjectId('');
|
||||
}}
|
||||
className={`rounded-sm px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-white text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{subjectType === 'client' ? (
|
||||
<ClientPicker value={subjectId || null} onChange={(id) => setSubjectId(id ?? '')} />
|
||||
) : subjectType === 'company' ? (
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, ChevronDown, FileSignature, Pen, Plus, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -207,7 +209,34 @@ export function NewDocumentMenu({
|
||||
companyId={entityType === 'company' ? entityId : undefined}
|
||||
yachtId={entityType === 'yacht' ? entityId : undefined}
|
||||
onUploadComplete={(file) => {
|
||||
if (!file) {
|
||||
if (file) {
|
||||
// Per-file completion: emit a landing-context toast so
|
||||
// the rep knows where the file went. Prefer the entity
|
||||
// page when uploaded under one (clients/Acme), fall
|
||||
// back to the documents folder view, fall back to a
|
||||
// bare confirmation. The action link uses router.push
|
||||
// for client-side nav.
|
||||
const destination: { label: string; href: Route } | null =
|
||||
entityType && entityId
|
||||
? {
|
||||
label: `View ${entityType}`,
|
||||
href: `/${portSlug}/${entityType === 'company' ? 'companies' : entityType + 's'}/${entityId}` as Route,
|
||||
}
|
||||
: folderId
|
||||
? {
|
||||
label: 'Open folder',
|
||||
href: `/${portSlug}/documents?folderId=${folderId}` as Route,
|
||||
}
|
||||
: null;
|
||||
toast.success(`Uploaded ${file.filename ?? 'file'}`, {
|
||||
action: destination
|
||||
? {
|
||||
label: destination.label,
|
||||
onClick: () => router.push(destination.href),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Trailing "batch done" call - invalidate hub caches so the
|
||||
// newly-uploaded file appears in the Recent files / folder
|
||||
// listings without a manual reload.
|
||||
|
||||
@@ -250,6 +250,7 @@ function EditableRow({
|
||||
label,
|
||||
children,
|
||||
historyPath,
|
||||
inheritedFrom,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
@@ -258,10 +259,23 @@ function EditableRow({
|
||||
* that opens the field-history popover. The icon renders nothing
|
||||
* without history, so it's safe to pass on every row. */
|
||||
historyPath?: string;
|
||||
/** Optional inheritance hint. Renders a small pill next to the label
|
||||
* to signal that the value editable here lives on a different entity
|
||||
* (e.g. `'client'` for primary email/phone, `'yacht'` for hull
|
||||
* dimensions). Tells the rep that an edit propagates outside the
|
||||
* deal scope. */
|
||||
inheritedFrom?: 'client' | 'yacht' | 'company';
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dt className="w-44 shrink-0 text-sm text-muted-foreground flex items-center gap-1.5">
|
||||
<span>{label}</span>
|
||||
{inheritedFrom ? (
|
||||
<span className="inline-flex items-center rounded-full border bg-muted px-1.5 py-0 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
from {inheritedFrom}
|
||||
</span>
|
||||
) : null}
|
||||
</dt>
|
||||
<dd className="flex-1 min-w-0 flex items-center gap-1">
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
|
||||
@@ -1263,7 +1277,7 @@ function OverviewTab({
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||
<dl>
|
||||
<EditableRow label="Email" historyPath="client.primaryEmail">
|
||||
<EditableRow label="Email" historyPath="client.primaryEmail" inheritedFrom="client">
|
||||
{interest.clientId ? (
|
||||
<ClientChannelEditor
|
||||
clientId={interest.clientId}
|
||||
@@ -1276,7 +1290,7 @@ function OverviewTab({
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</EditableRow>
|
||||
<EditableRow label="Phone" historyPath="client.primaryPhone">
|
||||
<EditableRow label="Phone" historyPath="client.primaryPhone" inheritedFrom="client">
|
||||
{interest.clientId ? (
|
||||
<ClientChannelEditor
|
||||
clientId={interest.clientId}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { Topbar } from '@/components/layout/topbar';
|
||||
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
|
||||
import { MobileLayoutProvider } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { MobileTopbar } from '@/components/layout/mobile/mobile-topbar';
|
||||
import { MobileBottomTabs } from '@/components/layout/mobile/mobile-bottom-tabs';
|
||||
@@ -27,6 +28,10 @@ interface AppShellProps {
|
||||
/** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies
|
||||
* sidebar entry SSR-side so the nav doesn't flicker in/out. */
|
||||
tenanciesModuleByPort: Record<string, boolean>;
|
||||
/** Per-port `expenses_module_enabled` resolution. Gates the Expenses
|
||||
* + How-to-upload-receipts sidebar entries SSR-side. Defaults to
|
||||
* true so existing ports keep the feature. */
|
||||
expensesModuleByPort: Record<string, boolean>;
|
||||
/**
|
||||
* Server-rendered form-factor hint (from the request User-Agent). The
|
||||
* shell mounts the matching tree on first render so we never paint the
|
||||
@@ -90,6 +95,7 @@ export function AppShell({
|
||||
ports,
|
||||
portLogoUrls,
|
||||
tenanciesModuleByPort,
|
||||
expensesModuleByPort,
|
||||
initialFormFactor,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
@@ -142,6 +148,7 @@ export function AppShell({
|
||||
ports,
|
||||
portLogoUrls,
|
||||
tenanciesModuleByPort,
|
||||
expensesModuleByPort,
|
||||
};
|
||||
|
||||
// Chrome subtree per tier.
|
||||
@@ -218,6 +225,11 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<MobileLayoutProvider>
|
||||
{/* Records every in-app navigation so useSmartBack can return the
|
||||
rep to the page they were actually on (e.g. Sarah Doe -> Yacht
|
||||
-> Back -> Sarah) instead of always falling back to the
|
||||
logical URL parent. Renders nothing. */}
|
||||
<NavigationHistoryTracker />
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background',
|
||||
|
||||
70
src/components/layout/back-button.tsx
Normal file
70
src/components/layout/back-button.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSmartBack } from '@/hooks/use-smart-back';
|
||||
|
||||
interface BackButtonProps {
|
||||
/** Visual treatment. Desktop shows chevron + "Back to X" label;
|
||||
* mobile shows chevron only with the destination in aria-label so
|
||||
* the 44px tap target stays uncluttered in the narrow topbar. */
|
||||
variant: 'desktop' | 'mobile';
|
||||
}
|
||||
|
||||
/**
|
||||
* Contextual back button. Replaces the legacy breadcrumb chain that
|
||||
* lived in the desktop topbar and the unconditional `router.back()`
|
||||
* affordance on mobile. Returns nothing on top-level pages where the
|
||||
* sidebar is the natural way out.
|
||||
*
|
||||
* Resolves its target via `useSmartBack()` - prefers a registered
|
||||
* detail-page hint, falls back to URL-derived parent route.
|
||||
*/
|
||||
export function BackButton({ variant }: BackButtonProps) {
|
||||
const target = useSmartBack();
|
||||
if (!target) return null;
|
||||
|
||||
// Next typed-routes can't know that hint.href / URL-derived parents
|
||||
// resolve to a registered route at compile time, so cast.
|
||||
|
||||
const href = target.href as Route;
|
||||
|
||||
if (variant === 'mobile') {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={`Back to ${target.label}`}
|
||||
className={cn(
|
||||
'size-11 inline-flex items-center justify-center rounded-full -ml-1',
|
||||
'text-white/95 active:bg-white/10 transition-colors',
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-[22px] stroke-[2.25]" aria-hidden />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={`Back to ${target.label}`}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-1 rounded-md px-2 -ml-2 min-w-0',
|
||||
'text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
'transition-colors',
|
||||
)}
|
||||
title={`Back to ${target.label}`}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{/* Label-only (iOS-style "< Settings"). The chevron already
|
||||
communicates "back"; doubling up with a "Back to" prefix wastes
|
||||
horizontal space in a topbar that's already crowded by the
|
||||
centered search bar. Full intent is preserved in the tooltip
|
||||
+ aria-label. */}
|
||||
<span className="truncate max-w-[160px]">{target.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { useBreadcrumbStore } from '@/stores/breadcrumb-store';
|
||||
|
||||
// Human-readable labels for route segments
|
||||
const SEGMENT_LABELS: Record<string, string> = {
|
||||
dashboard: 'Dashboard',
|
||||
clients: 'Clients',
|
||||
interests: 'Interests',
|
||||
berths: 'Berths',
|
||||
documents: 'Documents',
|
||||
files: 'Files',
|
||||
expenses: 'Expenses',
|
||||
invoices: 'Invoices',
|
||||
email: 'Email',
|
||||
reminders: 'Reminders',
|
||||
settings: 'Settings',
|
||||
admin: 'Administration',
|
||||
reports: 'Reports',
|
||||
new: 'New',
|
||||
edit: 'Edit',
|
||||
profile: 'Profile',
|
||||
};
|
||||
|
||||
// UUID v4-ish (or any 36-char hex+dash) - used to skip entity-id segments
|
||||
// from the breadcrumbs since the page H1 already shows the entity name.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isIdSegment(segment: string): boolean {
|
||||
return UUID_RE.test(segment);
|
||||
}
|
||||
|
||||
function formatSegment(segment: string): string {
|
||||
return (
|
||||
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const hint = useBreadcrumbStore((s) => s.hints[pathname]);
|
||||
|
||||
// Split pathname and filter empty segments
|
||||
const rawSegments = pathname.split('/').filter(Boolean);
|
||||
|
||||
// Remove the portSlug segment and any UUID-ish entity-id segments - the
|
||||
// page H1 already shows the entity name, no need to leak the raw id.
|
||||
const segments = (
|
||||
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
|
||||
).filter((seg) => !isIdSegment(seg));
|
||||
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
// Build href for each segment from the URL.
|
||||
const urlCrumbs = segments.map((segment, index) => {
|
||||
const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1);
|
||||
const href = '/' + segmentsUpToHere.join('/');
|
||||
const label = formatSegment(segment);
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
return { label, href, isLast };
|
||||
});
|
||||
|
||||
// When a detail page registered a hint, splice in the parent crumbs
|
||||
// (e.g. the parent client name) and replace the trailing label with
|
||||
// the entity's actual name (e.g. "B17"). This turns the URL-only
|
||||
// "Clients › Interests" into "Clients › Mary Smith › Interest › B17"
|
||||
// when the rep clicked from a client page. URL-only renders untouched
|
||||
// when no hint is registered.
|
||||
const crumbs = (() => {
|
||||
if (!hint) return urlCrumbs;
|
||||
const head = urlCrumbs.slice(0, -1).map((c) => ({ ...c, isLast: false }));
|
||||
const parents = hint.parents.map((p) => ({
|
||||
label: p.label,
|
||||
href: p.href ?? pathname,
|
||||
isLast: false,
|
||||
}));
|
||||
const lastUrlCrumb = urlCrumbs[urlCrumbs.length - 1];
|
||||
const tail = {
|
||||
label: hint.current,
|
||||
href: lastUrlCrumb?.href ?? pathname,
|
||||
isLast: true,
|
||||
};
|
||||
return [...head, ...parents, tail];
|
||||
})();
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="text-sm gap-1.5">
|
||||
{crumbs.map((crumb) => (
|
||||
// Each crumb + its trailing separator share a single
|
||||
// inline-flex `<li>` so flex-wrap can't strand the
|
||||
// separator at end-of-line above the wrapped child crumb.
|
||||
<BreadcrumbItem key={crumb.href}>
|
||||
{crumb.isLast ? (
|
||||
<BreadcrumbPage className="font-medium text-foreground truncate max-w-[160px]">
|
||||
{crumb.label}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={crumb.href as any}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 truncate max-w-[160px]"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
<ChevronRight
|
||||
className="w-3 h-3 text-muted-foreground/40"
|
||||
aria-hidden
|
||||
role="presentation"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BackButton } from '@/components/layout/back-button';
|
||||
import { useSmartBack } from '@/hooks/use-smart-back';
|
||||
import { useMobileChrome } from './mobile-layout-provider';
|
||||
|
||||
/**
|
||||
@@ -17,9 +18,13 @@ import { useMobileChrome } from './mobile-layout-provider';
|
||||
* URL's last segment is title-cased as a fallback.
|
||||
*/
|
||||
export function MobileTopbar() {
|
||||
const { title, primaryAction, showBackButton } = useMobileChrome();
|
||||
const router = useRouter();
|
||||
const { title, primaryAction } = useMobileChrome();
|
||||
const pathname = usePathname();
|
||||
// Mobile back affordance now derives from the same smart-back hook as
|
||||
// the desktop topbar so the destination is consistent across viewports
|
||||
// (and survives deep-link refresh). When useSmartBack returns null
|
||||
// (top-level pages) the brand-mark fallback renders in its place.
|
||||
const backTarget = useSmartBack();
|
||||
|
||||
// UUID detection - the URL's last segment on detail pages is the
|
||||
// entity's UUID, and title-casing it produces an ugly "Abc 123 Uuid"
|
||||
@@ -63,18 +68,8 @@ export function MobileTopbar() {
|
||||
'flex items-center gap-2 px-3',
|
||||
)}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="Go back"
|
||||
className={cn(
|
||||
'size-11 inline-flex items-center justify-center rounded-full -ml-1',
|
||||
'text-white/95 active:bg-white/10 transition-colors',
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-[22px] stroke-[2.25]" aria-hidden />
|
||||
</button>
|
||||
{backTarget ? (
|
||||
<BackButton variant="mobile" />
|
||||
) : (
|
||||
<div
|
||||
aria-label={portTitle || 'Home'}
|
||||
|
||||
57
src/components/layout/navigation-history-tracker.tsx
Normal file
57
src/components/layout/navigation-history-tracker.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { useBreadcrumbStore } from '@/stores/breadcrumb-store';
|
||||
|
||||
/**
|
||||
* Tracks in-app navigation so `useSmartBack` can return the user to the
|
||||
* page they were actually on rather than just the logical URL parent.
|
||||
*
|
||||
* Push/pop semantics: when the user navigates forward (new pathname,
|
||||
* not equal to the current top of stack), the PREVIOUS pathname is
|
||||
* pushed. When the user navigates to whatever's currently on top (i.e.
|
||||
* pressed the back button or used browser back), the top is popped.
|
||||
* This prevents the infamous back-button-loop ("press back, end up on
|
||||
* the page you came from, press back again, return to the page you
|
||||
* just left").
|
||||
*
|
||||
* Mounts once at the app shell so it sees every route change without
|
||||
* unmounting. The store is in-memory only - a hard refresh clears the
|
||||
* history and the back button falls through to its other resolution
|
||||
* tiers (registered hint, then URL-derived parent).
|
||||
*/
|
||||
export function NavigationHistoryTracker() {
|
||||
const pathname = usePathname();
|
||||
// First render's "previous" is null so we don't push a synthetic
|
||||
// entry. After the first navigation, this ref holds whatever pathname
|
||||
// was active just before the latest route change.
|
||||
const previousRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousRef.current;
|
||||
previousRef.current = pathname;
|
||||
|
||||
// No-op on initial mount and on idempotent renders.
|
||||
if (previous === null || previous === pathname) return;
|
||||
|
||||
// Read the live store outside of React's subscription model so this
|
||||
// effect doesn't re-fire on every store update (only on route changes).
|
||||
const state = useBreadcrumbStore.getState();
|
||||
const top = state.historyStack[state.historyStack.length - 1];
|
||||
|
||||
if (top === pathname) {
|
||||
// The user navigated back to whatever was on top of the stack - pop
|
||||
// it so the next "back" press uses the next-older entry (or falls
|
||||
// through to logical parent when the stack is empty).
|
||||
state.popHistory();
|
||||
} else {
|
||||
// Forward navigation: push the previous pathname so the back button
|
||||
// on the page we just landed on can return to it.
|
||||
state.pushHistory(previous);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Building2,
|
||||
Receipt,
|
||||
FileText,
|
||||
FileBarChart,
|
||||
Inbox,
|
||||
Camera,
|
||||
Globe,
|
||||
@@ -55,6 +56,11 @@ interface SidebarProps {
|
||||
/** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies
|
||||
* sidebar entry. Resolved server-side in the dashboard layout. */
|
||||
tenanciesModuleByPort?: Record<string, boolean>;
|
||||
/** Per-port `expenses_module_enabled` resolution. Gates the Expenses
|
||||
* + How-to-upload-receipts sidebar entries. Resolved server-side in
|
||||
* the dashboard layout. Defaults to true (feature on) per port when
|
||||
* the map is missing for the active port. */
|
||||
expensesModuleByPort?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -80,6 +86,12 @@ interface NavItemGated extends NavItem {
|
||||
/** When true, only render this item if the tenancies module is enabled
|
||||
* for the current port. Resolved against `tenanciesModuleByPort`. */
|
||||
requiresTenanciesModule?: boolean;
|
||||
/** When true, only render this item if the expenses module is enabled
|
||||
* for the current port. Resolved against `expensesModuleByPort`. */
|
||||
requiresExpensesModule?: boolean;
|
||||
/** When true, only render this item if Umami analytics is wired up
|
||||
* for the port. */
|
||||
umamiRequired?: boolean;
|
||||
}
|
||||
|
||||
function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
@@ -123,17 +135,26 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
{
|
||||
title: 'Insights',
|
||||
marinaRequired: true,
|
||||
umamiRequired: true,
|
||||
items: [
|
||||
// Reports surface (dashboard / clients / berths / interests
|
||||
// builders, plus templates / schedules / runs). Routes existed
|
||||
// since the report-builder ship but the sidebar entry was never
|
||||
// wired - reps had to land here via direct link.
|
||||
{
|
||||
href: `${base}/reports`,
|
||||
label: 'Reports',
|
||||
icon: FileBarChart,
|
||||
},
|
||||
// Marketing / Umami integration. Distinct from the main dashboard
|
||||
// (which is sales-focused) so the audience and the metrics don't
|
||||
// compete for visual real estate. Whole section is hidden when
|
||||
// Umami isn't wired up - see SidebarContent.
|
||||
// compete for visual real estate. Hidden when Umami isn't wired
|
||||
// up via the per-item umamiRequired flag below.
|
||||
{
|
||||
href: `${base}/website-analytics`,
|
||||
label: 'Website analytics',
|
||||
icon: Globe,
|
||||
},
|
||||
umamiRequired: true,
|
||||
} as NavItemGated,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -145,7 +166,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
title: 'Financial',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||
{
|
||||
href: `${base}/expenses`,
|
||||
label: 'Expenses',
|
||||
icon: Receipt,
|
||||
requiresExpensesModule: true,
|
||||
} as NavItemGated,
|
||||
// Invoices nav entry removed - the expense-to-PDF flow is the
|
||||
// only invoicing surface now (employee expense reports). The
|
||||
// standalone /invoices route still exists for any back-compat
|
||||
@@ -157,7 +183,8 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
href: `${base}/invoices/upload-receipts`,
|
||||
label: 'How to upload receipts',
|
||||
icon: Camera,
|
||||
},
|
||||
requiresExpensesModule: true,
|
||||
} as NavItemGated,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -252,6 +279,7 @@ function SidebarContent({
|
||||
hasMarinaAccess,
|
||||
hasResidentialAccess,
|
||||
tenanciesModuleEnabled,
|
||||
expensesModuleEnabled,
|
||||
user,
|
||||
ports,
|
||||
currentPort,
|
||||
@@ -266,6 +294,7 @@ function SidebarContent({
|
||||
hasMarinaAccess: boolean;
|
||||
hasResidentialAccess: boolean;
|
||||
tenanciesModuleEnabled: boolean;
|
||||
expensesModuleEnabled: boolean;
|
||||
user?: SidebarProps['user'];
|
||||
ports?: Port[];
|
||||
currentPort: Port | null;
|
||||
@@ -389,6 +418,8 @@ function SidebarContent({
|
||||
const gated = item as NavItemGated;
|
||||
if (gated.requiresTenanciesModule && !tenanciesModuleEnabled)
|
||||
return false;
|
||||
if (gated.requiresExpensesModule && !expensesModuleEnabled) return false;
|
||||
if (gated.umamiRequired && !umamiConfigured) return false;
|
||||
return true;
|
||||
})
|
||||
.map((item) => (
|
||||
@@ -482,6 +513,7 @@ export function Sidebar({
|
||||
ports,
|
||||
portLogoUrls,
|
||||
tenanciesModuleByPort,
|
||||
expensesModuleByPort,
|
||||
}: SidebarProps) {
|
||||
// Sidebar collapse removed - design preference is the always-expanded
|
||||
// form. Forcibly false; the store flag stays for backwards-compat with
|
||||
@@ -494,6 +526,12 @@ export function Sidebar({
|
||||
const tenanciesModuleEnabled = currentPortId
|
||||
? (tenanciesModuleByPort?.[currentPortId] ?? false)
|
||||
: false;
|
||||
// Expenses defaults to enabled when the port's entry is missing - the
|
||||
// registry default is `true`, so a port that's never explicitly
|
||||
// toggled the feature should keep it visible.
|
||||
const expensesModuleEnabled = currentPortId
|
||||
? (expensesModuleByPort?.[currentPortId] ?? true)
|
||||
: true;
|
||||
|
||||
// Super admins see every section regardless of role rows.
|
||||
const hasAdminAccess =
|
||||
@@ -526,6 +564,7 @@ export function Sidebar({
|
||||
hasMarinaAccess={hasMarinaAccess}
|
||||
hasResidentialAccess={hasResidentialAccess}
|
||||
tenanciesModuleEnabled={tenanciesModuleEnabled}
|
||||
expensesModuleEnabled={expensesModuleEnabled}
|
||||
user={user}
|
||||
ports={ports}
|
||||
currentPort={currentPort}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, Plus } from 'lucide-react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -19,7 +17,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
|
||||
import { BackButton } from '@/components/layout/back-button';
|
||||
import { CommandSearch } from '@/components/search/command-search';
|
||||
import { Inbox } from '@/components/layout/inbox';
|
||||
import { UserMenu } from '@/components/layout/user-menu';
|
||||
@@ -36,58 +34,32 @@ interface TopbarProps {
|
||||
|
||||
export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const base = currentPortSlug ? `/${currentPortSlug}` : '';
|
||||
// Reuse the existing per-page chrome state (originally built for the
|
||||
// mobile topbar) so any detail page that already declares
|
||||
// `showBackButton: true` automatically gets the back affordance on
|
||||
// desktop too. Saves duplicating the wiring across N detail headers.
|
||||
const { showBackButton: mobileShowBack } = useMobileChrome();
|
||||
// Auto-show on entity-detail pages: `/[portSlug]/[section]/[id]` and
|
||||
// deeper. Top-level lists like `/[portSlug]/clients` stay clean.
|
||||
// The mobile-chrome flag still wins when a page explicitly opts in.
|
||||
// Pages that already render their own "back to X" link inline
|
||||
// (residential interest detail, expense scan flow, etc.) opt OUT
|
||||
// by setting the chrome flag to false on mount - the flag override
|
||||
// path here lets them suppress this auto-show.
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const isDeepPage = segments.length > 2;
|
||||
const showBackButton = mobileShowBack || isDeepPage;
|
||||
|
||||
return (
|
||||
// Three-column grid: breadcrumbs left, search center, actions right.
|
||||
// The brand logo lives in the sidebar header (per design feedback) so the
|
||||
// topbar center is dedicated to the global search bar.
|
||||
// Three-column grid: smart back button left, search center, actions right.
|
||||
// The brand logo lives in the sidebar header so the topbar center is
|
||||
// dedicated to the global search bar.
|
||||
//
|
||||
// Grid is `auto auto 1fr` instead of three fr-tracks: the left + right
|
||||
// columns size to their actual content (logo trigger + breadcrumbs on
|
||||
// the left; New / Inbox / Avatar on the right), and the search column
|
||||
// soaks up the rest. The earlier `minmax(280px,800px)` center column
|
||||
// auto-grew to the search bar's intrinsic `max-w-2xl` (672px), which
|
||||
// squeezed the right column below the width of "+ New + Inbox +
|
||||
// Avatar" and pushed the New button off-screen at every tablet +
|
||||
// narrow-desktop width. With the center as a single fr-track, the
|
||||
// right column always gets the space it needs.
|
||||
// Grid is `auto auto 1fr` so the left + right columns size to their
|
||||
// actual content (back-button label on the left; New / Inbox / Avatar
|
||||
// on the right) and the search column soaks up the rest.
|
||||
//
|
||||
// Wayfinding model: the legacy breadcrumb chain was removed in favor
|
||||
// of a single contextual back button ("Back to Clients", "Back to
|
||||
// Sarah Doe"). Detail pages register their parent via
|
||||
// `useBreadcrumbHint` so the label is entity-aware; everything else
|
||||
// is URL-derived. See src/hooks/use-smart-back.ts.
|
||||
<header className="relative grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
{/* LEFT: optional sidebar trigger (tablet) + optional back button + breadcrumbs */}
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
{/* LEFT: optional sidebar trigger (tablet) + smart back button.
|
||||
Hard-capped width so the column never extends into the
|
||||
absolutely-positioned search bar's footprint. The cap is
|
||||
conservative on smaller widths to leave the search bar
|
||||
breathing room, more generous at xl. */}
|
||||
<div className="min-w-0 flex items-center gap-1.5 max-w-[180px] lg:max-w-[220px] xl:max-w-[260px]">
|
||||
{leadingSlot}
|
||||
{showBackButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="Go back"
|
||||
title="Go back"
|
||||
className={cn(
|
||||
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent transition-colors',
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<Breadcrumbs />
|
||||
<BackButton variant="desktop" />
|
||||
</div>
|
||||
|
||||
{/* CENTER (spacer): the search bar is absolutely positioned below
|
||||
@@ -105,14 +77,17 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
viewport, so plain `left: 50%` is already correct.
|
||||
|
||||
Caps scale by viewport tier so the bar doesn't crowd the side
|
||||
columns:
|
||||
columns. The previous max-w-2xl (672px) at xl ate so much of
|
||||
the topbar that the back-button column on the left got
|
||||
visually clipped by the search bar; tightened to max-w-xl so
|
||||
a "Back to Administration"-class label can render in full:
|
||||
base: max-w-md (28rem)
|
||||
lg: max-w-xl (36rem)
|
||||
xl: max-w-2xl (42rem)
|
||||
lg: max-w-lg (32rem)
|
||||
xl: max-w-xl (36rem)
|
||||
The wrapper is pointer-events-none so it doesn't capture
|
||||
clicks meant for the left/right columns underneath; only the
|
||||
input itself receives pointer events. */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/2 lg:left-[calc(50%-var(--width-sidebar)/2)] flex w-full max-w-md -translate-x-1/2 items-center px-4 lg:max-w-xl xl:max-w-2xl">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/2 lg:left-[calc(50%-var(--width-sidebar)/2)] flex w-full max-w-md -translate-x-1/2 items-center px-4 lg:max-w-lg xl:max-w-xl">
|
||||
<div className="pointer-events-auto w-full min-w-0">
|
||||
<CommandSearch />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,13 @@ interface BrandedAuthShellProps {
|
||||
backgroundUrl?: string | null;
|
||||
appName?: string | null;
|
||||
};
|
||||
/** Card width preset. Default (`'sm'`) caps at max-w-md to match the
|
||||
* CRM/portal login surfaces; `'md'` opens to max-w-xl for forms that
|
||||
* carry a dozen+ fields (supplemental info, EOI prefill). Wider
|
||||
* variants also relax the fixed-viewport pin so long forms scroll
|
||||
* naturally on mobile instead of getting clipped under the rubber-
|
||||
* band cap. */
|
||||
width?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +32,7 @@ interface BrandedAuthShellProps {
|
||||
* explicit prop; otherwise the surrounding `<AuthBrandingProvider>` is
|
||||
* the source of truth.
|
||||
*/
|
||||
export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps) {
|
||||
export function BrandedAuthShell({ children, branding, width = 'sm' }: BrandedAuthShellProps) {
|
||||
const ctx = useAuthBranding();
|
||||
const logoUrl = branding?.logoUrl ?? ctx?.logoUrl ?? null;
|
||||
const backgroundUrl = branding?.backgroundUrl ?? ctx?.backgroundUrl ?? null;
|
||||
@@ -34,17 +41,22 @@ export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps)
|
||||
// itself isn't a sign-in surface (e.g. password reset, set-password).
|
||||
const appName = branding?.appName ?? ctx?.appName ?? null;
|
||||
const altText = appName ?? '';
|
||||
// fixed inset-0 anchors the auth surface to the viewport directly -
|
||||
// iOS Safari ignores overflow-hidden on inner divs for body-level
|
||||
// scrolling, so a regular `h-dvh overflow-hidden` wrapper doesn't
|
||||
// stop the rubber-band bounce. Pinning to the viewport via position
|
||||
// fixed does. The fixed-position shell then uses flex to center the
|
||||
// card within the visible area.
|
||||
|
||||
const widthClass = width === 'md' ? 'max-w-xl' : 'max-w-md';
|
||||
// Wide variant uses a normal document scroll (`min-h-dvh`) instead of
|
||||
// the fixed-viewport pin so a 20+ field form scrolls instead of
|
||||
// getting clipped. The narrow login-grade variant keeps the original
|
||||
// fixed/centered behaviour to stop iOS rubber-banding on short forms.
|
||||
const layoutClass =
|
||||
width === 'md'
|
||||
? 'relative min-h-dvh flex items-start sm:items-center justify-center px-4 py-8'
|
||||
: 'fixed inset-0 flex items-center justify-center px-4 py-8 overscroll-none';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4 py-8 overscroll-none">
|
||||
<div className={layoutClass}>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-10"
|
||||
className="fixed inset-0 -z-10"
|
||||
style={{
|
||||
backgroundImage: backgroundUrl ? `url('${backgroundUrl}')` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
@@ -54,7 +66,7 @@ export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps)
|
||||
backgroundColor: '#f2f2f2',
|
||||
}}
|
||||
/>
|
||||
<div className="w-full max-w-md">
|
||||
<div className={`w-full ${widthClass}`}>
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
{logoUrl ? (
|
||||
<div className="flex justify-center mb-6">
|
||||
|
||||
68
src/components/shared/module-disabled-page.tsx
Normal file
68
src/components/shared/module-disabled-page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { PowerOff } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface ModuleDisabledPageProps {
|
||||
/** Human-readable name for the disabled feature, e.g. "Expenses". */
|
||||
moduleName: string;
|
||||
/** Optional short sentence explaining what the module does, shown
|
||||
* below the headline. Defaults to a generic message when omitted. */
|
||||
description?: string;
|
||||
/** Admin settings href used for the "Enable in System Settings" CTA.
|
||||
* Pass the full URL including portSlug. The CTA is hidden when no
|
||||
* href is supplied (e.g. for users who lack admin permissions; the
|
||||
* page renderer should pass null in that case). */
|
||||
settingsHref?: string | null;
|
||||
/** Optional fallback href for non-admin users (e.g. take them home).
|
||||
* Defaults to the dashboard route derived from settingsHref's port. */
|
||||
fallbackHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly "this module is off for your port" page rendered in place of
|
||||
* a real feature surface when the per-port toggle is disabled. Used
|
||||
* instead of a 404 so that bookmarks land somewhere meaningful and the
|
||||
* admin can re-enable from one click.
|
||||
*/
|
||||
export function ModuleDisabledPage({
|
||||
moduleName,
|
||||
description,
|
||||
settingsHref,
|
||||
fallbackHref,
|
||||
}: ModuleDisabledPageProps) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="max-w-md text-center shadow-sm">
|
||||
<CardContent className="p-8 space-y-4">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-500">
|
||||
<PowerOff className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
{moduleName} is turned off for this port
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{description ??
|
||||
`An administrator has disabled the ${moduleName} module for this port. Previously-saved data is preserved and will reappear when the module is re-enabled.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 pt-2">
|
||||
{settingsHref ? (
|
||||
<Button asChild>
|
||||
<Link href={settingsHref as Route}>Enable in System Settings</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{fallbackHref ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={fallbackHref as Route}>Back to dashboard</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
@@ -135,6 +136,13 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [renewDialogOpen, setRenewDialogOpen] = useState(false);
|
||||
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
|
||||
// Smart-back target: tenancies are reached via berths in this UI -
|
||||
// sending the user back to /berths matches the navigation they took
|
||||
// to get here, rather than the generic /tenancies list.
|
||||
useBreadcrumbHint({
|
||||
parents: [{ label: 'Berths', href: `/${portSlug}/berths` }],
|
||||
current: 'Tenancy',
|
||||
});
|
||||
const tenancy = useQuery<{ data: TenancyData }>({
|
||||
queryKey: ['tenancy', tenancyId],
|
||||
queryFn: () => apiFetch(`/api/v1/tenancies/${tenancyId}`),
|
||||
@@ -321,11 +329,9 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/berths`}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden /> Back to berths
|
||||
</Link>
|
||||
</Button>
|
||||
{/* Topbar smart-back covers "Back to Berths" via the
|
||||
useBreadcrumbHint registration above; no duplicate
|
||||
action-bar button needed. */}
|
||||
</div>
|
||||
}
|
||||
variant="gradient"
|
||||
|
||||
@@ -50,13 +50,13 @@ const DialogContent = React.forwardRef<
|
||||
'fixed top-0 right-0 bottom-0 left-0 z-50 grid w-full gap-4 border-0 bg-background p-4 shadow-lg duration-200 sm:p-6',
|
||||
'max-h-dvh overflow-y-auto sm:max-h-[calc(100dvh-2rem)]',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
// Default width: bumped 2026-05-26 from `sm:max-w-lg` (32rem) to
|
||||
// `sm:max-w-xl lg:max-w-3xl` so every Dialog has a generous
|
||||
// desktop default. Confirm dialogs override DOWN with
|
||||
// `sm:max-w-md`; content-heavy dialogs (file preview, signing
|
||||
// details, EOI generate) override UP with `lg:max-w-5xl` or
|
||||
// `lg:max-w-[min(95vw,1400px)]`.
|
||||
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-xl lg:max-w-3xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
|
||||
// Default width: bumped 2026-05-27 from `sm:max-w-xl lg:max-w-3xl`
|
||||
// to `sm:max-w-2xl lg:max-w-4xl` so multi-field forms and PDF
|
||||
// previews aren't cramped on 1440-1920px desktops. Confirm
|
||||
// dialogs override DOWN with `sm:max-w-md`; content-heavy
|
||||
// dialogs (file preview, signing details, EOI generate)
|
||||
// override UP with `lg:max-w-5xl` or `lg:max-w-[min(95vw,1400px)]`.
|
||||
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-2xl lg:max-w-4xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
|
||||
// Desktop animation: subtle centered fade + zoom (no slide-from-
|
||||
// corner so the dialog appears in place rather than flying in
|
||||
// from top-right). The base fade-in/out classes above provide
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -21,6 +19,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -89,6 +88,23 @@ export function MetricDetailShell({ metric, initialRange, initialFrom, initialTo
|
||||
parseInitialRange(initialRange, initialFrom, initialTo),
|
||||
);
|
||||
|
||||
// Smart-back target: preserve the active date range qs so reps land
|
||||
// on the parent dashboard with the same time window they were
|
||||
// exploring on the detail page.
|
||||
useBreadcrumbHint(
|
||||
portSlug
|
||||
? {
|
||||
parents: [
|
||||
{
|
||||
label: 'Website Analytics',
|
||||
href: `/${portSlug}/website-analytics?${rangeToQuery(range)}`,
|
||||
},
|
||||
],
|
||||
current: METRIC_CONFIG[metric]?.title ?? metric,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
function handleRangeChange(next: DateRange) {
|
||||
setRange(next);
|
||||
// Mirror the picker choice back into the URL so refresh / share / back
|
||||
@@ -123,15 +139,9 @@ export function MetricDetailShell({ metric, initialRange, initialFrom, initialTo
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-start">
|
||||
<Link
|
||||
href={`/${portSlug}/website-analytics?${rangeToQuery(range)}` as never}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" aria-hidden />
|
||||
Back to website analytics
|
||||
</Link>
|
||||
</div>
|
||||
{/* Topbar smart-back covers "Back to Website Analytics" with the
|
||||
active date range preserved via the useBreadcrumbHint above,
|
||||
so the previous inline link is no longer needed. */}
|
||||
<PageHeader
|
||||
title={cfg.title}
|
||||
eyebrow="Website analytics"
|
||||
|
||||
@@ -65,6 +65,7 @@ interface YachtTabsYacht {
|
||||
status: string;
|
||||
notes: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
noteCount?: number;
|
||||
}
|
||||
|
||||
interface YachtTabsOptions {
|
||||
@@ -397,6 +398,7 @@ export function getYachtTabs({
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
badge: yacht.noteCount,
|
||||
content: (
|
||||
<NotesList
|
||||
entityType="yachts"
|
||||
|
||||
@@ -6,9 +6,14 @@ import { usePathname } from 'next/navigation';
|
||||
import { type BreadcrumbHint, useBreadcrumbStore } from '@/stores/breadcrumb-store';
|
||||
|
||||
/**
|
||||
* Detail pages call this on mount to register their entity hierarchy
|
||||
* for the topbar breadcrumb. Pass a stable hint object (or memoise the
|
||||
* inputs) so the effect doesn't re-fire every render.
|
||||
* Detail pages call this on mount to register their entity hierarchy.
|
||||
* The hint is consumed by `useSmartBack` to label the topbar back
|
||||
* button - the closest parent in `parents` becomes the back target,
|
||||
* so a rep on an interest page sees "Back to Mary Smith" (the client
|
||||
* they drilled in from) instead of the URL-derived "Back to Clients".
|
||||
*
|
||||
* Pass a stable hint object (or memoise the inputs) so the effect
|
||||
* doesn't re-fire every render.
|
||||
*
|
||||
* Example (interest detail page):
|
||||
* useBreadcrumbHint({
|
||||
@@ -18,11 +23,17 @@ import { type BreadcrumbHint, useBreadcrumbStore } from '@/stores/breadcrumb-sto
|
||||
*
|
||||
* The hint clears when the page unmounts so a stale hierarchy doesn't
|
||||
* leak into the next route.
|
||||
*
|
||||
* Naming note: the hook + store kept their `breadcrumb` prefix when
|
||||
* the topbar breadcrumb trail was retired in favor of the contextual
|
||||
* back button. They are now back-context hints, not breadcrumb chain
|
||||
* entries - the names stayed to avoid touching every detail page.
|
||||
*/
|
||||
export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void {
|
||||
const pathname = usePathname();
|
||||
const setHint = useBreadcrumbStore((s) => s.setHint);
|
||||
const clearHint = useBreadcrumbStore((s) => s.clearHint);
|
||||
const cacheLabel = useBreadcrumbStore((s) => s.cacheLabel);
|
||||
|
||||
// Stringify for stable equality - caller can pass an object literal
|
||||
// each render without wrecking effect deps. The serialized form is
|
||||
@@ -32,6 +43,11 @@ export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void
|
||||
useEffect(() => {
|
||||
if (!serialized || !hint) return;
|
||||
setHint(pathname, hint);
|
||||
// Snapshot the display label into the persistent labelCache so the
|
||||
// back button can render "Back to Sarah Doe" after the rep has
|
||||
// drilled away from her detail page (at which point the hint above
|
||||
// has unmounted, but the label is still load-bearing).
|
||||
cacheLabel(pathname, hint.current);
|
||||
return () => {
|
||||
clearHint(pathname);
|
||||
};
|
||||
@@ -39,5 +55,5 @@ export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void
|
||||
// re-register if the page navigates without unmounting (rare but
|
||||
// possible on client-side route swaps within the same layout).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serialized, pathname, setHint, clearHint]);
|
||||
}, [serialized, pathname, setHint, clearHint, cacheLabel]);
|
||||
}
|
||||
|
||||
132
src/hooks/use-smart-back.ts
Normal file
132
src/hooks/use-smart-back.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { formatSegment, isIdSegment, SEGMENT_LABELS } from '@/lib/route-labels';
|
||||
import { useBreadcrumbStore } from '@/stores/breadcrumb-store';
|
||||
|
||||
export interface SmartBackTarget {
|
||||
/** Concrete href the back button should navigate to. Always a real
|
||||
* URL (never router.back()) so deep-link refresh + middle-click open-
|
||||
* in-new-tab both work. */
|
||||
href: string;
|
||||
/** Short label rendered next to the chevron. Example: "Clients",
|
||||
* "Sarah Doe", "Administration". The button surfaces this as
|
||||
* "Back to {label}" so the user knows where they're headed. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the contextual back-target for the current route. Replaces the
|
||||
* topbar breadcrumb trail with a single, prominent "Back to X" button.
|
||||
*
|
||||
* Resolution order:
|
||||
*
|
||||
* 1. In-app history - if the rep navigated here from another page in
|
||||
* this same SPA session (and that page belongs to the same port),
|
||||
* send them back to that exact page. Lets cross-record drills
|
||||
* (Sarah Doe -> her Yacht -> Back) return to the entity the rep
|
||||
* was actually on, not the logical Yacht-list parent. The
|
||||
* `NavigationHistoryTracker` mounted at the app shell feeds this.
|
||||
* 2. Detail-page hints registered via `useBreadcrumbHint` - when a
|
||||
* detail page registers a parent (e.g. "Mary Smith" with href
|
||||
* `/port/clients/abc`), the back button reads "Back to Mary Smith".
|
||||
* This is the fallback when history is unavailable (refresh, direct
|
||||
* link, bookmark, first navigation).
|
||||
* 3. URL-derived parent segment - strip the last path segment (and any
|
||||
* trailing UUID), look up the human label, route there. So
|
||||
* /port/admin/branding -> "Back to Administration".
|
||||
* 4. null - top-level pages like /port/dashboard, /port/clients don't
|
||||
* get a back button (the sidebar is the way out).
|
||||
*
|
||||
* Labels for history-derived back are pulled from `labelCache` (snapshot
|
||||
* by `useBreadcrumbHint` at the moment each detail page mounted) and
|
||||
* fall through to URL-derivation when no label was ever cached.
|
||||
*/
|
||||
export function useSmartBack(): SmartBackTarget | null {
|
||||
const pathname = usePathname();
|
||||
const hint = useBreadcrumbStore((s) => s.hints[pathname]);
|
||||
const historyStack = useBreadcrumbStore((s) => s.historyStack);
|
||||
const labelCache = useBreadcrumbStore((s) => s.labelCache);
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// The first segment is always the port slug in this app's routing
|
||||
// scheme. A "top-level" page has 2 segments (port + section) -
|
||||
// anything shallower has no logical parent within the app.
|
||||
if (segments.length <= 2) return null;
|
||||
const portSlug = segments[0];
|
||||
if (!portSlug) return null;
|
||||
|
||||
// 1. In-app history - prefer the actual previous page in this SPA
|
||||
// session when it's within the same port (cross-port jumps should
|
||||
// fall back to logical parent so we don't ferry someone across
|
||||
// tenant boundaries via a stale stack entry).
|
||||
const previousPath = historyStack[historyStack.length - 1];
|
||||
if (previousPath && previousPath !== pathname && previousPath.startsWith(`/${portSlug}/`)) {
|
||||
const cachedLabel = labelCache[previousPath];
|
||||
if (cachedLabel) {
|
||||
return { href: previousPath, label: cachedLabel };
|
||||
}
|
||||
// No cached label means we never saw a useBreadcrumbHint for that
|
||||
// path (e.g. list pages, settings pages). Derive from URL so the
|
||||
// back button still has something readable to show.
|
||||
const derivedLabel = labelFromPath(previousPath);
|
||||
if (derivedLabel) return { href: previousPath, label: derivedLabel };
|
||||
}
|
||||
|
||||
// 2. Closest registered hint parent. Detail pages register a chain
|
||||
// like [{label: "Clients", href: "/port/clients"}, {label: "Mary
|
||||
// Smith", href: "/port/clients/abc"}] - the last entry is the one
|
||||
// that semantically WAS the previous page.
|
||||
if (hint && hint.parents.length > 0) {
|
||||
const closest = hint.parents[hint.parents.length - 1];
|
||||
if (closest?.href) {
|
||||
return { href: closest.href, label: closest.label };
|
||||
}
|
||||
}
|
||||
|
||||
// URL derivation: walk backwards from the leaf, skipping UUID
|
||||
// segments, until we find a non-id segment. The parent path is
|
||||
// everything up to and including that segment.
|
||||
const significantSegments: Array<{ value: string; index: number }> = [];
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (!seg || isIdSegment(seg)) continue;
|
||||
significantSegments.push({ value: seg, index: i });
|
||||
}
|
||||
|
||||
// Need at least 2 non-id segments to have a "parent" (one is the
|
||||
// current page, one is its parent). With only one significant
|
||||
// segment, we're effectively on a list page - no back affordance.
|
||||
if (significantSegments.length < 2) return null;
|
||||
|
||||
const parent = significantSegments[significantSegments.length - 2];
|
||||
if (!parent) return null;
|
||||
const parentPath = '/' + segments.slice(0, parent.index + 1).join('/');
|
||||
const parentLabel = SEGMENT_LABELS[parent.value] ?? formatSegment(parent.value);
|
||||
|
||||
// Defensive: never produce an href that strips the port slug. If the
|
||||
// walk above produced something that doesn't start with /<portSlug>/
|
||||
// we're outside the dashboard layout and shouldn't render a back link
|
||||
// at all.
|
||||
if (!parentPath.startsWith(`/${portSlug}`)) return null;
|
||||
|
||||
return { href: parentPath, label: parentLabel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort label for a pathname when no cached label exists. Walks
|
||||
* the URL backwards, ignoring UUIDs, returning the human-formatted
|
||||
* deepest non-id segment. Used as the label for history-derived back
|
||||
* targets when the previous page never registered a useBreadcrumbHint
|
||||
* (e.g. list pages, settings sub-pages).
|
||||
*/
|
||||
function labelFromPath(path: string): string | null {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
for (let i = segments.length - 1; i >= 1; i--) {
|
||||
const seg = segments[i];
|
||||
if (!seg || isIdSegment(seg)) continue;
|
||||
return SEGMENT_LABELS[seg] ?? formatSegment(seg);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -57,18 +57,28 @@ export async function resolvePortIdFromSlug(slug: string): Promise<string | null
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions = {}): Promise<T> {
|
||||
let portId: string | null = null;
|
||||
// Tracks whether the URL itself named a port slug. When true we MUST
|
||||
// NOT fall back to the persisted Zustand value — that's the
|
||||
// cross-port-data-leak bug: a stale `currentPortId` from a prior
|
||||
// session would send the wrong `X-Port-Id` header and return wrong-
|
||||
// port data on a fresh refresh of /port-<slug>/...
|
||||
let urlHadPortSlug = false;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api' && slug !== 'dashboard') {
|
||||
urlHadPortSlug = true;
|
||||
portId = await resolvePortIdFromSlug(slug);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the Zustand cache when the URL didn't yield a port -
|
||||
// e.g. global routes (/dashboard) where the rep hasn't picked a port
|
||||
// yet but a previous session set one.
|
||||
if (!portId) {
|
||||
// Fall back to the Zustand cache ONLY when the URL didn't carry a
|
||||
// port slug at all (e.g. /dashboard / non-tenant routes where the
|
||||
// rep hasn't picked a port yet but a previous session set one).
|
||||
// When the URL had a slug but lookup failed, leave portId null — the
|
||||
// server will reject the request cleanly rather than silently
|
||||
// serving cross-port data from the stale cache.
|
||||
if (!portId && !urlHadPortSlug) {
|
||||
portId = useUIStore.getState().currentPortId;
|
||||
}
|
||||
|
||||
|
||||
22
src/lib/db/migrations/0089_website_submissions_utm.sql
Normal file
22
src/lib/db/migrations/0089_website_submissions_utm.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- 0089_website_submissions_utm.sql
|
||||
--
|
||||
-- Capture UTM attribution columns on website_submissions so the
|
||||
-- Marketing report (and downstream attribution analysis) can group
|
||||
-- inquiries by campaign / source / medium without re-parsing the JSON
|
||||
-- payload on every read.
|
||||
--
|
||||
-- All five columns are nullable: UTM presence is opportunistic
|
||||
-- (driven by whatever the marketing site's tracker plumbed through),
|
||||
-- not a hard requirement on intake. Index over (port_id, utm_source,
|
||||
-- received_at) makes "campaign performance for the last 90 days" a
|
||||
-- single index scan.
|
||||
|
||||
ALTER TABLE website_submissions
|
||||
ADD COLUMN utm_source text,
|
||||
ADD COLUMN utm_medium text,
|
||||
ADD COLUMN utm_campaign text,
|
||||
ADD COLUMN utm_term text,
|
||||
ADD COLUMN utm_content text;
|
||||
|
||||
CREATE INDEX idx_ws_utm_source
|
||||
ON website_submissions (port_id, utm_source, received_at);
|
||||
@@ -54,6 +54,15 @@ export const websiteSubmissions = pgTable(
|
||||
/** Capture-time metadata for debugging. */
|
||||
sourceIp: text('source_ip'),
|
||||
userAgent: text('user_agent'),
|
||||
/** UTM attribution columns. Opportunistic — populated when the
|
||||
* marketing site's tracker pulled them out of the query string or
|
||||
* the referrer. Indexed jointly with port_id + received_at via
|
||||
* migration 0089 for fast per-campaign rollups. */
|
||||
utmSource: text('utm_source'),
|
||||
utmMedium: text('utm_medium'),
|
||||
utmCampaign: text('utm_campaign'),
|
||||
utmTerm: text('utm_term'),
|
||||
utmContent: text('utm_content'),
|
||||
receivedAt: timestamp('received_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/** Triage workflow state. Default 'open'; transitions to
|
||||
* 'converted' (operator created a client/interest from this row),
|
||||
@@ -70,6 +79,7 @@ export const websiteSubmissions = pgTable(
|
||||
index('idx_ws_port_received').on(table.portId, table.receivedAt),
|
||||
index('idx_ws_kind').on(table.kind),
|
||||
index('idx_ws_triage_state').on(table.portId, table.triageState, table.receivedAt),
|
||||
index('idx_ws_utm_source').on(table.portId, table.utmSource, table.receivedAt),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* function. Templates call `renderShell({ title, body, branding })`.
|
||||
*/
|
||||
|
||||
import type * as React from 'react';
|
||||
|
||||
import { absolutizeBrandingUrl } from '@/lib/branding/url';
|
||||
|
||||
// Neutral defaults - no tenant-specific imagery leaks across ports.
|
||||
@@ -96,6 +98,77 @@ export function brandingPrimaryColor(branding?: BrandingShell | null): string {
|
||||
return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared style conventions for transactional email bodies.
|
||||
*
|
||||
* Templates compose these instead of inlining one-off `style={{...}}` objects
|
||||
* so the visual rhythm stays consistent across every email - centered title
|
||||
* in the brand accent, body paragraphs left-aligned at 16px / 1.5 line-height,
|
||||
* centered CTA button, fine-print block separated by a soft divider, centered
|
||||
* sign-off in the same accent. Modeled on the hand-rolled templates from the
|
||||
* original portal (signature-notifications.ts) so the look carries forward.
|
||||
*
|
||||
* Functions accept an `accent` color (the resolved port primary) where it's
|
||||
* load-bearing; constants do not.
|
||||
*/
|
||||
export const emailStyle = {
|
||||
/** Page heading: centered, brand-accent, bold. Used once at the top. */
|
||||
title: (accent: string): React.CSSProperties => ({
|
||||
textAlign: 'center',
|
||||
fontSize: '22px',
|
||||
fontWeight: 'bold',
|
||||
color: accent,
|
||||
margin: '0 0 16px 0',
|
||||
}),
|
||||
/** Body paragraph: 16px / 1.5 line-height, left-aligned for readability. */
|
||||
paragraph: {
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.5',
|
||||
margin: '0 0 16px 0',
|
||||
color: '#333333',
|
||||
} satisfies React.CSSProperties,
|
||||
/** Soft hairline divider above fine-print blocks. */
|
||||
divider: {
|
||||
border: 'none',
|
||||
borderTop: '1px solid #eee',
|
||||
margin: '28px 0 0 0',
|
||||
} satisfies React.CSSProperties,
|
||||
/** Fine print: 14px muted, line-height 1.5. */
|
||||
finePrint: {
|
||||
fontSize: '14px',
|
||||
color: '#666666',
|
||||
lineHeight: '1.5',
|
||||
margin: '12px 0 0 0',
|
||||
} satisfies React.CSSProperties,
|
||||
/** Sign-off block: left-aligned, 16px, sits BETWEEN the last body
|
||||
* paragraph and the primary CTA so the email reads like a letter
|
||||
* (greeting -> body -> sign-off -> button -> button-fallback fine
|
||||
* print). Top margin is intentionally modest because preceding
|
||||
* paragraphs already carry 16px bottom margin. */
|
||||
signoff: {
|
||||
textAlign: 'left',
|
||||
fontSize: '16px',
|
||||
color: '#333333',
|
||||
margin: '8px 0 0 0',
|
||||
} satisfies React.CSSProperties,
|
||||
/** Outer wrapper that centers the primary CTA button. */
|
||||
buttonRow: {
|
||||
textAlign: 'center',
|
||||
margin: '28px 0',
|
||||
} satisfies React.CSSProperties,
|
||||
/** Primary CTA button style. Compose with `buttonRow` for the surrounding center. */
|
||||
button: (accent: string): React.CSSProperties => ({
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 35px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* URL-safe escaper for `href="..."` interpolations inside email
|
||||
* templates. The email-deliverability audit flagged that every template
|
||||
|
||||
@@ -44,6 +44,11 @@ function AdminEmailChangeBody({
|
||||
<Text style={{ margin: '20px 0', textAlign: 'center', fontSize: '16px' }}>
|
||||
<strong>{newEmail}</strong>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
{loginUrl ? (
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
@@ -68,11 +73,6 @@ function AdminEmailChangeBody({
|
||||
If this change wasn't expected, please contact your administrator straight away. The
|
||||
previous address (where this message was delivered) is no longer accepted for sign-in.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Button, Hr, Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
import {
|
||||
brandingPrimaryColor,
|
||||
emailStyle,
|
||||
renderShell,
|
||||
safeUrl,
|
||||
type BrandingShell,
|
||||
} from '@/lib/email/shell';
|
||||
|
||||
interface InviteData {
|
||||
link: string;
|
||||
@@ -36,34 +42,25 @@ function InviteBody({
|
||||
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
Welcome to the {portName} CRM
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
<Text style={emailStyle.title(accent)}>Welcome to the {portName} CRM</Text>
|
||||
<Text style={emailStyle.paragraph}>{greeting}</Text>
|
||||
<Text style={emailStyle.paragraph}>
|
||||
You've been invited to join the {portName} CRM as a {role}. Use the button below to set
|
||||
your password and activate your account at your convenience - the link will remain valid for{' '}
|
||||
{ttlHours} hours.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(link)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 35px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<Text style={emailStyle.signoff}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
<div style={emailStyle.buttonRow}>
|
||||
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
|
||||
Set up your account
|
||||
</Button>
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
|
||||
<Hr style={emailStyle.divider} />
|
||||
<Text style={emailStyle.finePrint}>
|
||||
If the button doesn't work, paste this link into your browser:
|
||||
<br />
|
||||
<Link
|
||||
@@ -73,11 +70,6 @@ function InviteBody({
|
||||
{link}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,19 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
|
||||
{data.customMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
|
||||
With warm regards,
|
||||
<br />
|
||||
{data.senderName ? (
|
||||
<>
|
||||
{data.senderName}
|
||||
<br />
|
||||
<strong>The {data.portName} Team</strong>
|
||||
</>
|
||||
) : (
|
||||
<strong>The {data.portName} Team</strong>
|
||||
)}
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(data.signingUrl)}
|
||||
@@ -113,19 +126,6 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
|
||||
Signing happens directly inside our website - your data isn't sent to a third-party
|
||||
signing service.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
With warm regards,
|
||||
<br />
|
||||
{data.senderName ? (
|
||||
<>
|
||||
{data.senderName}
|
||||
<br />
|
||||
<strong>The {data.portName} Team</strong>
|
||||
</>
|
||||
) : (
|
||||
<strong>The {data.portName} Team</strong>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -270,6 +270,11 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
|
||||
{data.customMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {data.portName} Team</strong>
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(data.signingUrl)}
|
||||
@@ -297,11 +302,6 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
|
||||
{data.signingUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {data.portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import { render } from '@react-email/components';
|
||||
import { Button, Hr, Link, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
import {
|
||||
brandingPrimaryColor,
|
||||
emailStyle,
|
||||
renderShell,
|
||||
safeUrl,
|
||||
type BrandingShell,
|
||||
} from '@/lib/email/shell';
|
||||
|
||||
interface ActivationData {
|
||||
portName: string;
|
||||
@@ -41,42 +47,26 @@ function ActivationBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: accent,
|
||||
}}
|
||||
>
|
||||
Welcome to {portName}
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
<Text style={emailStyle.title(accent)}>Welcome to {portName}</Text>
|
||||
<Text style={emailStyle.paragraph}>{greeting}</Text>
|
||||
<Text style={emailStyle.paragraph}>
|
||||
It's our pleasure to invite you to the {portName} client portal - your private space to
|
||||
review your berth, manage signed documents, and stay in touch with your sales liaison. The
|
||||
button below will let you set a password and activate your account at your convenience.
|
||||
Please use it within {ttlHours} hours.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(link)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 35px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<Text style={emailStyle.signoff}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
<div style={emailStyle.buttonRow}>
|
||||
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
|
||||
Activate account
|
||||
</Button>
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
|
||||
<Hr style={emailStyle.divider} />
|
||||
<Text style={emailStyle.finePrint}>
|
||||
If the button doesn't work, paste this link into your browser:
|
||||
<br />
|
||||
<Link
|
||||
@@ -86,11 +76,6 @@ function ActivationBody({
|
||||
{link}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -106,48 +91,27 @@ function ResetBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: accent,
|
||||
}}
|
||||
>
|
||||
Reset your password
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
<Text style={emailStyle.title(accent)}>Reset your password</Text>
|
||||
<Text style={emailStyle.paragraph}>{greeting}</Text>
|
||||
<Text style={emailStyle.paragraph}>
|
||||
We received a request to reset the password on your {portName} client portal account. Use
|
||||
the button below to choose a new one - the link will remain valid for {ttlMinutes} minutes.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(link)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 35px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
|
||||
If you didn't request this, you may safely ignore this message - your existing password
|
||||
will continue to work.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
<Text style={emailStyle.signoff}>
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
<div style={emailStyle.buttonRow}>
|
||||
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
<Hr style={emailStyle.divider} />
|
||||
<Text style={emailStyle.finePrint}>
|
||||
If you didn't request this, you may safely ignore this message - your existing password
|
||||
will continue to work.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* adding an entry surfaces it without any UI change.
|
||||
*/
|
||||
|
||||
import type { BrandingShell } from '@/lib/email/shell';
|
||||
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
|
||||
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
|
||||
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
|
||||
@@ -52,6 +53,11 @@ export interface SampleContext {
|
||||
recipientEmail: string;
|
||||
portName: string;
|
||||
portUrl: string;
|
||||
/** Per-port branding shell (logo, blur background, accent color, header/footer
|
||||
* HTML). Resolved once by the test-template route via getBrandingShell and
|
||||
* forwarded into every template so previews match the production look.
|
||||
* Null is acceptable - templates fall back to neutral defaults. */
|
||||
branding: BrandingShell | null;
|
||||
}
|
||||
|
||||
export const TEST_TEMPLATES: TestTemplateMeta[] = [
|
||||
@@ -60,185 +66,224 @@ export const TEST_TEMPLATES: TestTemplateMeta[] = [
|
||||
label: 'Portal · Activation invite',
|
||||
description: 'Fires when an admin invites a client to activate their portal account.',
|
||||
render: (s) =>
|
||||
activationEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
link: `${s.portUrl}/portal/activate/sample-token`,
|
||||
ttlHours: 24,
|
||||
}),
|
||||
activationEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
link: `${s.portUrl}/portal/activate/sample-token`,
|
||||
ttlHours: 24,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'portal_reset',
|
||||
label: 'Portal · Password reset',
|
||||
description: 'Fires when a portal user requests a password reset link.',
|
||||
render: (s) =>
|
||||
resetEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
link: `${s.portUrl}/portal/reset/sample-token`,
|
||||
ttlMinutes: 120,
|
||||
}),
|
||||
resetEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
link: `${s.portUrl}/portal/reset/sample-token`,
|
||||
ttlMinutes: 120,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'crm_invite',
|
||||
label: 'CRM · Teammate invitation',
|
||||
description: 'Fires when a super-admin invites a new teammate to the CRM.',
|
||||
render: (s) =>
|
||||
crmInviteEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
isSuperAdmin: false,
|
||||
link: `${s.portUrl}/invite/sample-token`,
|
||||
ttlHours: 72,
|
||||
}),
|
||||
crmInviteEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
isSuperAdmin: false,
|
||||
link: `${s.portUrl}/invite/sample-token`,
|
||||
ttlHours: 72,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'admin_email_change',
|
||||
label: 'CRM · Admin email change confirmation',
|
||||
description: 'Fires when an admin updates their CRM login email - confirmation step.',
|
||||
render: (s) =>
|
||||
adminEmailChangeEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
newEmail: s.recipientEmail,
|
||||
changedByDisplayName: 'Sample Admin',
|
||||
loginUrl: `${s.portUrl}/login`,
|
||||
}),
|
||||
adminEmailChangeEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
newEmail: s.recipientEmail,
|
||||
changedByDisplayName: 'Sample Admin',
|
||||
loginUrl: `${s.portUrl}/login`,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notification_digest',
|
||||
label: 'Reminders · Notification digest',
|
||||
description: 'Fires on the configured cadence (daily/weekly) with the rep’s open reminders.',
|
||||
render: (s) =>
|
||||
notificationDigestEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
items: [
|
||||
{
|
||||
type: 'reminder',
|
||||
title: 'Follow up with Matthew Ciaccio on Berth A1',
|
||||
description: 'Reservation EOI sent 5 days ago - no response yet.',
|
||||
link: `${s.portUrl}/clients/sample-client-id`,
|
||||
createdAt: new Date(Date.now() - 86_400_000),
|
||||
},
|
||||
{
|
||||
type: 'alert',
|
||||
title: 'Berth B12 PDF parse failed',
|
||||
description: null,
|
||||
link: `${s.portUrl}/berths/sample-berth-id`,
|
||||
createdAt: new Date(Date.now() - 2 * 86_400_000),
|
||||
},
|
||||
],
|
||||
totalUnread: 2,
|
||||
inboxLink: `${s.portUrl}/inbox`,
|
||||
}),
|
||||
notificationDigestEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
items: [
|
||||
{
|
||||
type: 'reminder',
|
||||
title: 'Follow up with Matthew Ciaccio on Berth A1',
|
||||
description: 'Reservation EOI sent 5 days ago - no response yet.',
|
||||
link: `${s.portUrl}/clients/sample-client-id`,
|
||||
createdAt: new Date(Date.now() - 86_400_000),
|
||||
},
|
||||
{
|
||||
type: 'alert',
|
||||
title: 'Berth B12 PDF parse failed',
|
||||
description: null,
|
||||
link: `${s.portUrl}/berths/sample-berth-id`,
|
||||
createdAt: new Date(Date.now() - 2 * 86_400_000),
|
||||
},
|
||||
],
|
||||
totalUnread: 2,
|
||||
inboxLink: `${s.portUrl}/inbox`,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'signing_invitation',
|
||||
label: 'Documenso · Signing invitation',
|
||||
description: 'Fires when the rep dispatches the first signing-invite email for a doc.',
|
||||
render: (s) =>
|
||||
signingInvitationEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
signerRole: 'client',
|
||||
signingUrl: `${s.portUrl}/sign/sample-token`,
|
||||
senderName: 'Sample Sales Manager',
|
||||
customMessage: null,
|
||||
}),
|
||||
signingInvitationEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
signerRole: 'client',
|
||||
signingUrl: `${s.portUrl}/sign/sample-token`,
|
||||
senderName: 'Sample Sales Manager',
|
||||
customMessage: null,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'signing_reminder',
|
||||
label: 'Documenso · Signing reminder',
|
||||
description: 'Fires when a manual reminder is dispatched for an outstanding signer.',
|
||||
render: (s) =>
|
||||
signingReminderEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
signingUrl: `${s.portUrl}/sign/sample-token`,
|
||||
invitedAgo: '5 days ago',
|
||||
customMessage: null,
|
||||
}),
|
||||
signingReminderEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
signingUrl: `${s.portUrl}/sign/sample-token`,
|
||||
invitedAgo: '5 days ago',
|
||||
customMessage: null,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'signing_completed',
|
||||
label: 'Documenso · Fully signed notification',
|
||||
description: 'Fires when every required signer has signed and the document is complete.',
|
||||
render: (s) =>
|
||||
signingCompletedEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
clientName: s.recipientName,
|
||||
completedAt: new Date(),
|
||||
}),
|
||||
signingCompletedEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
clientName: s.recipientName,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'signing_cancelled',
|
||||
label: 'Documenso · Signing cancelled',
|
||||
description: 'Fires when the rep cancels a document mid-signature with notify-recipients.',
|
||||
render: (s) =>
|
||||
signingCancelledEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
reason: 'Customer renegotiated terms; a fresh contract will follow.',
|
||||
}),
|
||||
signingCancelledEmail(
|
||||
{
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
reason: 'Customer renegotiated terms; a fresh contract will follow.',
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'inquiry_client_confirmation',
|
||||
label: 'Public inquiry · Client confirmation',
|
||||
description: 'Fires when a public-site visitor submits the contact form (their copy).',
|
||||
render: (s) =>
|
||||
inquiryClientConfirmation({
|
||||
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
|
||||
mooringNumber: 'A1',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: s.portName,
|
||||
}),
|
||||
inquiryClientConfirmation(
|
||||
{
|
||||
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
|
||||
mooringNumber: 'A1',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: s.portName,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'inquiry_sales_notification',
|
||||
label: 'Public inquiry · Sales notification',
|
||||
description: 'Fires alongside the client confirmation - alerts the sales rep to a new lead.',
|
||||
render: (s) =>
|
||||
inquirySalesNotification({
|
||||
fullName: s.recipientName,
|
||||
email: s.recipientEmail,
|
||||
phone: '+1 555 0100',
|
||||
mooringNumber: 'A1',
|
||||
crmUrl: `${s.portUrl}/clients/sample-client-id`,
|
||||
portName: s.portName,
|
||||
}),
|
||||
inquirySalesNotification(
|
||||
{
|
||||
fullName: s.recipientName,
|
||||
email: s.recipientEmail,
|
||||
phone: '+1 555 0100',
|
||||
mooringNumber: 'A1',
|
||||
crmUrl: `${s.portUrl}/clients/sample-client-id`,
|
||||
portName: s.portName,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'residential_client_confirmation',
|
||||
label: 'Residential inquiry · Client confirmation',
|
||||
description: 'Fires when a residential-site visitor submits the contact form.',
|
||||
render: (s) =>
|
||||
residentialClientConfirmation({
|
||||
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: s.portName,
|
||||
}),
|
||||
residentialClientConfirmation(
|
||||
{
|
||||
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: s.portName,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'residential_sales_alert',
|
||||
label: 'Residential inquiry · Sales alert',
|
||||
description: 'Fires alongside the residential client confirmation - alerts the sales team.',
|
||||
render: (s) =>
|
||||
residentialSalesAlert({
|
||||
fullName: s.recipientName,
|
||||
email: s.recipientEmail,
|
||||
phone: '+1 555 0100',
|
||||
placeOfResidence: 'Monaco',
|
||||
preferredContactMethod: 'email',
|
||||
notes: 'Looking for year-round mooring + marina apartment access.',
|
||||
crmDeepLink: `${s.portUrl}/residential/clients/sample-id`,
|
||||
portName: s.portName,
|
||||
}),
|
||||
residentialSalesAlert(
|
||||
{
|
||||
fullName: s.recipientName,
|
||||
email: s.recipientEmail,
|
||||
phone: '+1 555 0100',
|
||||
placeOfResidence: 'Monaco',
|
||||
preferredContactMethod: 'email',
|
||||
notes: 'Looking for year-round mooring + marina apartment access.',
|
||||
crmDeepLink: `${s.portUrl}/residential/clients/sample-id`,
|
||||
portName: s.portName,
|
||||
},
|
||||
{ branding: s.branding },
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
51
src/lib/route-labels.ts
Normal file
51
src/lib/route-labels.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Canonical human-readable labels for URL path segments. Used by the smart
|
||||
* back button to derive a sensible "Back to <X>" label from the URL when a
|
||||
* detail page hasn't registered an explicit back-context hint, and by the
|
||||
* mobile topbar's title fallback.
|
||||
*
|
||||
* Add new top-level routes here so the back button doesn't fall through to
|
||||
* a slugified guess (e.g. "berths" -> "Berths" works automatically via
|
||||
* `formatSegment`, but "audit" would render as "Audit" instead of "Audit Log"
|
||||
* without the explicit entry).
|
||||
*/
|
||||
export const SEGMENT_LABELS: Record<string, string> = {
|
||||
dashboard: 'Dashboard',
|
||||
clients: 'Clients',
|
||||
yachts: 'Yachts',
|
||||
companies: 'Companies',
|
||||
interests: 'Interests',
|
||||
berths: 'Berths',
|
||||
documents: 'Documents',
|
||||
files: 'Files',
|
||||
expenses: 'Expenses',
|
||||
invoices: 'Invoices',
|
||||
email: 'Email',
|
||||
inbox: 'Inbox',
|
||||
reminders: 'Reminders',
|
||||
alerts: 'Alerts',
|
||||
settings: 'Settings',
|
||||
admin: 'Administration',
|
||||
reports: 'Reports',
|
||||
tenancies: 'Tenancies',
|
||||
residential: 'Residential',
|
||||
new: 'New',
|
||||
edit: 'Edit',
|
||||
profile: 'Profile',
|
||||
notifications: 'Notifications',
|
||||
'website-analytics': 'Website Analytics',
|
||||
};
|
||||
|
||||
/** UUID v4-ish (or any 36-char hex+dash) - used to skip entity-id segments
|
||||
* from URL-derived labels since they're never human-readable. */
|
||||
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isIdSegment(segment: string): boolean {
|
||||
return UUID_RE.test(segment);
|
||||
}
|
||||
|
||||
export function formatSegment(segment: string): string {
|
||||
return (
|
||||
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { db } from '@/lib/db';
|
||||
import {
|
||||
clients,
|
||||
clientContacts,
|
||||
clientNotes,
|
||||
clientRelationships,
|
||||
clientTags,
|
||||
clientAddresses,
|
||||
@@ -445,10 +444,12 @@ export async function getClientById(id: string, portId: string) {
|
||||
.where(
|
||||
and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)),
|
||||
);
|
||||
const [noteCountRow] = await db
|
||||
.select({ count: count() })
|
||||
.from(clientNotes)
|
||||
.where(eq(clientNotes.clientId, id));
|
||||
// Aggregated note count — matches what `NotesList` renders below
|
||||
// (direct client notes + interest_notes + yacht_notes for owned
|
||||
// yachts + company_notes for active memberships). Bare clientNotes
|
||||
// count would understate when the rep adds notes to linked entities.
|
||||
const { countForClientAggregated } = await import('@/lib/services/notes.service');
|
||||
const aggregatedNoteCount = await countForClientAggregated(portId, id);
|
||||
|
||||
return {
|
||||
...client,
|
||||
@@ -459,7 +460,7 @@ export async function getClientById(id: string, portId: string) {
|
||||
companies: membershipRows,
|
||||
activeTenancies,
|
||||
interestCount: interestCountRow?.count ?? 0,
|
||||
noteCount: noteCountRow?.count ?? 0,
|
||||
noteCount: aggregatedNoteCount,
|
||||
clientPortalEnabled: portalEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,10 +126,17 @@ export async function getCompanyById(id: string, portId: string) {
|
||||
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
||||
});
|
||||
|
||||
// Aggregated note count for the Notes tab badge. Symmetric-reach via
|
||||
// owned yachts + their linked interests (member-client personal
|
||||
// notes intentionally excluded — they belong on the client dossier).
|
||||
const { countForCompanyAggregated } = await import('@/lib/services/notes.service');
|
||||
const noteCount = await countForCompanyAggregated(portId, id).catch(() => 0);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
tags: tagJoins.map((t) => t.tag),
|
||||
addresses,
|
||||
noteCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -149,13 +149,26 @@ export async function captureErrorEvent(args: CaptureArgs): Promise<void> {
|
||||
// onto the error - Postgres driver uses `code` (SQLSTATE) and
|
||||
// `severity`, fetch errors carry `cause.code`, etc. The classifier
|
||||
// reads from `metadata.code` to drive the "likely culprit" badge.
|
||||
//
|
||||
// Drizzle wraps postgres errors and rethrows with the failed SQL as
|
||||
// the visible `message`, so the actual reason (e.g. "column does not
|
||||
// exist") is on `cause.message`. Capture cause.message + cause.detail
|
||||
// + cause.hint into metadata so the inspector list view can surface
|
||||
// the real fault instead of just the prepared statement.
|
||||
const enriched: Record<string, unknown> = { ...(args.metadata ?? {}) };
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as { code?: unknown; severity?: unknown; cause?: { code?: unknown } };
|
||||
const e = err as {
|
||||
code?: unknown;
|
||||
severity?: unknown;
|
||||
cause?: { code?: unknown; message?: unknown; detail?: unknown; hint?: unknown };
|
||||
};
|
||||
if (typeof e.code === 'string') enriched.code = e.code;
|
||||
if (typeof e.severity === 'string') enriched.severity = e.severity;
|
||||
if (e.cause && typeof e.cause === 'object' && typeof e.cause.code === 'string') {
|
||||
enriched.causeCode = e.cause.code;
|
||||
if (e.cause && typeof e.cause === 'object') {
|
||||
if (typeof e.cause.code === 'string') enriched.causeCode = e.cause.code;
|
||||
if (typeof e.cause.message === 'string') enriched.causeMessage = e.cause.message;
|
||||
if (typeof e.cause.detail === 'string') enriched.causeDetail = e.cause.detail;
|
||||
if (typeof e.cause.hint === 'string') enriched.causeHint = e.cause.hint;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
src/lib/services/expenses-module.service.ts
Normal file
49
src/lib/services/expenses-module.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Expenses module gate. Port-scoped on/off switch for the entire expense
|
||||
* + receipt-upload surface (sidebar entries, /expenses routes, mobile
|
||||
* scanner, receipt-upload explainer).
|
||||
*
|
||||
* Defaults to ENABLED so existing ports keep the feature on deploy.
|
||||
* When an admin turns it off:
|
||||
* - the sidebar entries (Expenses + How to upload receipts) disappear
|
||||
* via the port-resolved expensesModuleByPort prop on the layout
|
||||
* - the expenses routes render a "Module disabled" page instead of
|
||||
* the real content, so bookmarks land somewhere meaningful and the
|
||||
* operator can re-enable from one click
|
||||
* - previously-recorded expense rows are preserved (no destructive
|
||||
* cleanup) so re-enabling restores everything
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
|
||||
/**
|
||||
* Resolve whether the Expenses module is currently active for the given
|
||||
* port. Reads from `system_settings.expenses_module_enabled` (port-
|
||||
* scoped row first, then global row, then registry default = true).
|
||||
*
|
||||
* Defaulting to enabled mirrors how the feature behaved before the
|
||||
* toggle existed: deploying this change to a port that has never
|
||||
* configured the setting leaves the feature visible.
|
||||
*/
|
||||
export async function isExpensesModuleEnabled(portId: string): Promise<boolean> {
|
||||
const settingRow = await db
|
||||
.select({ value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(systemSettings.key, 'expenses_module_enabled'),
|
||||
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
// Stored JSONB shape is the raw boolean (`true` / `false`); no
|
||||
// unwrapping needed because the admin-settings PUT handler writes the
|
||||
// primitive directly.
|
||||
if (settingRow[0]?.value === false) return false;
|
||||
// Any value other than an explicit `false` (incl. missing row, true,
|
||||
// unrecognized shape) means enabled - matches the registry default.
|
||||
return true;
|
||||
}
|
||||
47
src/lib/services/invoices-module.service.ts
Normal file
47
src/lib/services/invoices-module.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Invoices module gate. Port-scoped on/off switch for the standalone
|
||||
* `/invoices` flow.
|
||||
*
|
||||
* Audit conclusion (2026-05-27, launch-readiness Initiative 1c): the
|
||||
* `invoices` schema is rich (invoices + invoice_line_items +
|
||||
* invoice_expenses + send + payment + PDF) but the dev DB has zero rows
|
||||
* and no rep ever clicks through. The canonical "money received" path
|
||||
* is the per-interest Payments tab (records into `payments` and auto-
|
||||
* advances pipeline). The standalone /invoices flow is parallel
|
||||
* infrastructure for employee expense reports + the rare case where a
|
||||
* port operator wants to invoice a client directly from the CRM.
|
||||
*
|
||||
* Defaults to DISABLED so new ports launch with a clean surface; admins
|
||||
* can opt in from Admin → Operations. Existing ports keep the legacy
|
||||
* surface visible until explicitly turned off.
|
||||
*
|
||||
* Behaviour when disabled:
|
||||
* - the (already-removed) sidebar entry stays hidden
|
||||
* - the /invoices and /invoices/new and /invoices/[id] routes render a
|
||||
* "Module disabled" page instead of the full form
|
||||
* - the API endpoints (`/api/v1/invoices/*`) still respond so any
|
||||
* historical PDF links / webhook callbacks keep resolving
|
||||
* - existing invoice rows are preserved
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
|
||||
export async function isInvoicesModuleEnabled(portId: string): Promise<boolean> {
|
||||
const settingRow = await db
|
||||
.select({ value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(systemSettings.key, 'invoices_module_enabled'),
|
||||
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
// Stored JSONB shape is the raw boolean. The registry default is `false`,
|
||||
// so a missing row → disabled. Anything other than an explicit `true`
|
||||
// keeps the module hidden.
|
||||
return settingRow[0]?.value === true;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import { eq, and, desc, inArray, sql, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientNotes, clients } from '@/lib/db/schema/clients';
|
||||
import { interestNotes, interests } from '@/lib/db/schema/interests';
|
||||
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
|
||||
import { companyNotes, companies } from '@/lib/db/schema/companies';
|
||||
import { companyNotes, companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import {
|
||||
residentialClients,
|
||||
residentialClientNotes,
|
||||
@@ -111,6 +111,218 @@ export interface AggregatedClientNote {
|
||||
pipelineStageAtCreation?: string | null;
|
||||
}
|
||||
|
||||
// ─── Aggregated counts ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirror the symmetric-reach unions used by the `listFor*Aggregated`
|
||||
// helpers, but return scalar totals so tab badges on entity detail
|
||||
// pages match what the NotesList renders below them. Each function is
|
||||
// port-scoped (defense-in-depth) and tolerates zero linked-entity ids
|
||||
// by short-circuiting the relevant counts to 0.
|
||||
|
||||
async function scalarCount(query: Promise<Array<{ count: number }>>): Promise<number> {
|
||||
const rows = await query;
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total note count visible on a client's Notes tab = direct
|
||||
* client_notes + interest_notes (interests where client_id=X) +
|
||||
* yacht_notes (yachts currently owned by this client) +
|
||||
* company_notes (companies the client has an active membership in).
|
||||
*/
|
||||
export async function countForClientAggregated(portId: string, clientId: string): Promise<number> {
|
||||
await verifyParentBelongsToPort('clients', clientId, portId);
|
||||
|
||||
const [interestRows, yachtRows, membershipRows] = await Promise.all([
|
||||
db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
|
||||
db
|
||||
.select({ id: yachts.id })
|
||||
.from(yachts)
|
||||
.where(
|
||||
and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, 'client'),
|
||||
eq(yachts.currentOwnerId, clientId),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.from(companyMemberships)
|
||||
.innerJoin(companies, eq(companies.id, companyMemberships.companyId))
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.clientId, clientId),
|
||||
isNull(companyMemberships.endDate),
|
||||
eq(companies.portId, portId),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const interestIds = interestRows.map((r) => r.id);
|
||||
const yachtIds = yachtRows.map((r) => r.id);
|
||||
const companyIds = Array.from(new Set(membershipRows.map((r) => r.companyId)));
|
||||
|
||||
const [clientCount, interestCount, yachtCount, companyCount] = await Promise.all([
|
||||
scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clientNotes)
|
||||
.where(eq(clientNotes.clientId, clientId)),
|
||||
),
|
||||
interestIds.length > 0
|
||||
? scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestNotes)
|
||||
.where(inArray(interestNotes.interestId, interestIds)),
|
||||
)
|
||||
: Promise.resolve(0),
|
||||
yachtIds.length > 0
|
||||
? scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(yachtNotes)
|
||||
.where(inArray(yachtNotes.yachtId, yachtIds)),
|
||||
)
|
||||
: Promise.resolve(0),
|
||||
companyIds.length > 0
|
||||
? scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(companyNotes)
|
||||
.where(inArray(companyNotes.companyId, companyIds)),
|
||||
)
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
return clientCount + interestCount + yachtCount + companyCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total note count visible on a yacht's Notes tab = direct
|
||||
* yacht_notes + the polymorphic owner-side notes (client_notes when
|
||||
* owner_type='client', company_notes when owner_type='company') +
|
||||
* interest_notes (interests currently linked to this yacht).
|
||||
*/
|
||||
export async function countForYachtAggregated(portId: string, yachtId: string): Promise<number> {
|
||||
await verifyParentBelongsToPort('yachts', yachtId, portId);
|
||||
|
||||
const [yacht] = await db
|
||||
.select({
|
||||
id: yachts.id,
|
||||
ownerType: yachts.currentOwnerType,
|
||||
ownerId: yachts.currentOwnerId,
|
||||
})
|
||||
.from(yachts)
|
||||
.where(and(eq(yachts.id, yachtId), eq(yachts.portId, portId)))
|
||||
.limit(1);
|
||||
if (!yacht) throw new NotFoundError('Yacht');
|
||||
|
||||
const interestRows = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.yachtId, yachtId), eq(interests.portId, portId)));
|
||||
const interestIds = interestRows.map((r) => r.id);
|
||||
|
||||
const [yachtCount, ownerCount, interestCount] = await Promise.all([
|
||||
scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(yachtNotes)
|
||||
.where(eq(yachtNotes.yachtId, yachtId)),
|
||||
),
|
||||
yacht.ownerType === 'client' && yacht.ownerId
|
||||
? scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clientNotes)
|
||||
.where(eq(clientNotes.clientId, yacht.ownerId)),
|
||||
)
|
||||
: yacht.ownerType === 'company' && yacht.ownerId
|
||||
? scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(companyNotes)
|
||||
.where(eq(companyNotes.companyId, yacht.ownerId)),
|
||||
)
|
||||
: Promise.resolve(0),
|
||||
interestIds.length > 0
|
||||
? scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestNotes)
|
||||
.where(inArray(interestNotes.interestId, interestIds)),
|
||||
)
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
return yachtCount + ownerCount + interestCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total note count visible on a company's Notes tab = direct
|
||||
* company_notes + yacht_notes (yachts owned by this company) +
|
||||
* interest_notes (interests linked via those yachts). Member-client
|
||||
* personal notes are NOT counted — they live on the client's dossier.
|
||||
*/
|
||||
export async function countForCompanyAggregated(
|
||||
portId: string,
|
||||
companyId: string,
|
||||
): Promise<number> {
|
||||
await verifyParentBelongsToPort('companies', companyId, portId);
|
||||
|
||||
const yachtRows = await db
|
||||
.select({ id: yachts.id })
|
||||
.from(yachts)
|
||||
.where(
|
||||
and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, 'company'),
|
||||
eq(yachts.currentOwnerId, companyId),
|
||||
),
|
||||
);
|
||||
const yachtIds = yachtRows.map((r) => r.id);
|
||||
|
||||
const interestRows =
|
||||
yachtIds.length > 0
|
||||
? await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(inArray(interests.yachtId, yachtIds), eq(interests.portId, portId)))
|
||||
: [];
|
||||
const interestIds = interestRows.map((r) => r.id);
|
||||
|
||||
const [companyCount, yachtCount, interestCount] = await Promise.all([
|
||||
scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(companyNotes)
|
||||
.where(eq(companyNotes.companyId, companyId)),
|
||||
),
|
||||
yachtIds.length > 0
|
||||
? scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(yachtNotes)
|
||||
.where(inArray(yachtNotes.yachtId, yachtIds)),
|
||||
)
|
||||
: Promise.resolve(0),
|
||||
interestIds.length > 0
|
||||
? scalarCount(
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestNotes)
|
||||
.where(inArray(interestNotes.interestId, interestIds)),
|
||||
)
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
return companyCount + yachtCount + interestCount;
|
||||
}
|
||||
|
||||
export async function listForClientAggregated(
|
||||
portId: string,
|
||||
clientId: string,
|
||||
|
||||
@@ -218,8 +218,23 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
label: 'System Settings',
|
||||
category: 'admin',
|
||||
keywords: [
|
||||
'feature flags',
|
||||
'feature flag',
|
||||
'client portal',
|
||||
'client portal enabled',
|
||||
'tenancies',
|
||||
'tenancies module',
|
||||
'tenancy',
|
||||
'tenancy tracker',
|
||||
'lease',
|
||||
'lease windows',
|
||||
'renewals',
|
||||
'transfers',
|
||||
'expenses',
|
||||
'expenses module',
|
||||
'receipts',
|
||||
'expense receipts',
|
||||
'ai',
|
||||
'ai interest scoring',
|
||||
'ai email drafts',
|
||||
'invoice net10 discount',
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
yachts,
|
||||
clientContacts,
|
||||
interestFieldHistory,
|
||||
ports,
|
||||
} from '@/lib/db/schema';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
|
||||
const TOKEN_TTL_DAYS = 14;
|
||||
const TOKEN_BYTES = 32; // 256-bit → ~43 base64url chars; brute-force infeasible.
|
||||
@@ -79,10 +81,19 @@ export async function issueToken(input: IssueTokenInput): Promise<{
|
||||
export interface PrefillData {
|
||||
/** Token metadata so the form can disable itself when consumed. */
|
||||
token: { expiresAt: string; consumed: boolean };
|
||||
/** Port branding + name. Surfaces both as a header (so the recipient
|
||||
* knows which marina is asking) and as logo / backdrop in the
|
||||
* shared BrandedAuthShell. */
|
||||
port: {
|
||||
name: string;
|
||||
logoUrl: string | null;
|
||||
backgroundUrl: string | null;
|
||||
};
|
||||
client: {
|
||||
fullName: string;
|
||||
streetAddress: string | null;
|
||||
city: string | null;
|
||||
subdivisionIso: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
primaryEmail: string | null;
|
||||
@@ -223,15 +234,29 @@ export async function loadByToken(token: string): Promise<PrefillData | null> {
|
||||
where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)),
|
||||
});
|
||||
|
||||
const [port, branding] = await Promise.all([
|
||||
db.query.ports.findFirst({ where: eq(ports.id, row.portId), columns: { name: true } }),
|
||||
getPortBrandingConfig(row.portId).catch(() => ({
|
||||
logoUrl: null,
|
||||
emailBackgroundUrl: null,
|
||||
})),
|
||||
]);
|
||||
|
||||
return {
|
||||
token: {
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
consumed: !!row.consumedAt,
|
||||
},
|
||||
port: {
|
||||
name: port?.name ?? 'Port Nimara',
|
||||
logoUrl: branding.logoUrl ?? null,
|
||||
backgroundUrl: branding.emailBackgroundUrl ?? null,
|
||||
},
|
||||
client: {
|
||||
fullName: client.fullName,
|
||||
streetAddress: primaryAddress?.streetAddress ?? null,
|
||||
city: primaryAddress?.city ?? null,
|
||||
subdivisionIso: primaryAddress?.subdivisionIso ?? null,
|
||||
postalCode: primaryAddress?.postalCode ?? null,
|
||||
country: primaryAddress?.countryIso ?? null,
|
||||
primaryEmail: emailContact?.value ?? null,
|
||||
@@ -258,7 +283,13 @@ export async function loadByToken(token: string): Promise<PrefillData | null> {
|
||||
|
||||
export interface SubmissionInput {
|
||||
fullName: string;
|
||||
/** Street address (single line — multi-line entries go into this
|
||||
* same field as `\n`-joined text). */
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
/** ISO-3166-2 subdivision code (e.g. 'PL-MZ', 'US-CA'). */
|
||||
subdivisionIso: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
email: string | null;
|
||||
phoneE164: string | null;
|
||||
@@ -324,7 +355,9 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
.where(eq(clients.id, client.id));
|
||||
}
|
||||
|
||||
if (input.address || input.country) {
|
||||
const hasAnyAddressInput =
|
||||
input.address || input.city || input.subdivisionIso || input.postalCode || input.country;
|
||||
if (hasAnyAddressInput) {
|
||||
const existingAddr = await tx.query.clientAddresses.findFirst({
|
||||
where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)),
|
||||
});
|
||||
@@ -334,43 +367,43 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
portId: row.portId,
|
||||
label: 'Primary',
|
||||
streetAddress: input.address ?? null,
|
||||
city: input.city ?? null,
|
||||
subdivisionIso: input.subdivisionIso ?? null,
|
||||
postalCode: input.postalCode ?? null,
|
||||
countryIso: input.country ?? null,
|
||||
isPrimary: true,
|
||||
});
|
||||
// Insert-path: every populated field is a "from null → value"
|
||||
// override so the history panel surfaces the initial population
|
||||
// the same way it surfaces later edits.
|
||||
if (input.address) {
|
||||
overrides.push({
|
||||
fieldPath: 'client.address.streetAddress',
|
||||
oldValue: null,
|
||||
newValue: input.address,
|
||||
});
|
||||
}
|
||||
if (input.country) {
|
||||
overrides.push({
|
||||
fieldPath: 'client.address.countryIso',
|
||||
oldValue: null,
|
||||
newValue: input.country,
|
||||
});
|
||||
const insertOverrides: Array<[string, unknown]> = [
|
||||
['client.address.streetAddress', input.address],
|
||||
['client.address.city', input.city],
|
||||
['client.address.subdivisionIso', input.subdivisionIso],
|
||||
['client.address.postalCode', input.postalCode],
|
||||
['client.address.countryIso', input.country],
|
||||
];
|
||||
for (const [fieldPath, value] of insertOverrides) {
|
||||
if (value) overrides.push({ fieldPath, oldValue: null, newValue: value });
|
||||
}
|
||||
} else {
|
||||
const addrPatch: Record<string, unknown> = {};
|
||||
if (input.address && input.address !== existingAddr.streetAddress) {
|
||||
addrPatch.streetAddress = input.address;
|
||||
overrides.push({
|
||||
fieldPath: 'client.address.streetAddress',
|
||||
oldValue: existingAddr.streetAddress,
|
||||
newValue: input.address,
|
||||
});
|
||||
}
|
||||
if (input.country && input.country !== existingAddr.countryIso) {
|
||||
addrPatch.countryIso = input.country;
|
||||
overrides.push({
|
||||
fieldPath: 'client.address.countryIso',
|
||||
oldValue: existingAddr.countryIso,
|
||||
newValue: input.country,
|
||||
});
|
||||
const updateFields: Array<[string, string | null, string | null | undefined]> = [
|
||||
['streetAddress', existingAddr.streetAddress, input.address],
|
||||
['city', existingAddr.city, input.city],
|
||||
['subdivisionIso', existingAddr.subdivisionIso, input.subdivisionIso],
|
||||
['postalCode', existingAddr.postalCode, input.postalCode],
|
||||
['countryIso', existingAddr.countryIso, input.country],
|
||||
];
|
||||
for (const [col, oldVal, newVal] of updateFields) {
|
||||
if (newVal && newVal !== oldVal) {
|
||||
addrPatch[col] = newVal;
|
||||
overrides.push({
|
||||
fieldPath: `client.address.${col}`,
|
||||
oldValue: oldVal,
|
||||
newValue: newVal,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Object.keys(addrPatch).length > 0) {
|
||||
await tx
|
||||
|
||||
@@ -34,6 +34,13 @@ import { NotFoundError } from '@/lib/errors';
|
||||
*/
|
||||
export async function isTenanciesModuleEnabled(portId: string): Promise<boolean> {
|
||||
// 1. Admin setting check (port-scoped row first, fall back to global).
|
||||
// Precedence: an EXPLICIT admin choice always wins. If the admin has
|
||||
// set the toggle to true, the module is on. If they've set it to
|
||||
// false, the module is off - even if tenancy rows exist for the
|
||||
// port. This matches the toggle's label ("Tenancies module - off")
|
||||
// matching what reps see in the sidebar; the previous behaviour of
|
||||
// silently re-enabling whenever any row existed was confusing and
|
||||
// contradicted the toggle's own description.
|
||||
const settingRow = await db
|
||||
.select({ value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
@@ -44,11 +51,15 @@ export async function isTenanciesModuleEnabled(portId: string): Promise<boolean>
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (settingRow[0]?.value === true) return true;
|
||||
const stored = settingRow[0]?.value;
|
||||
if (stored === true) return true;
|
||||
if (stored === false) return false;
|
||||
|
||||
// 2. Lazy auto-enable: any row in the table flips the module on for
|
||||
// the rest of the app, even when the admin setting is still false.
|
||||
// Once any port has a tenancy, the module's UX is justified.
|
||||
// 2. No explicit admin choice yet: lazy auto-enable on first row. Once
|
||||
// a port has at least one tenancy, the module's UX is justified and
|
||||
// we surface it without making the admin toggle it manually. The
|
||||
// admin can still flip it off afterwards via the toggle (which
|
||||
// writes false and short-circuits this branch above).
|
||||
const rowCheck = await db
|
||||
.select({ id: berthTenancies.id })
|
||||
.from(berthTenancies)
|
||||
|
||||
@@ -114,9 +114,16 @@ export async function getYachtById(id: string, portId: string) {
|
||||
const { tags: tagJoins, ...rest } = yacht as typeof yacht & {
|
||||
tags: Array<{ tag: { id: string; name: string; color: string } }>;
|
||||
};
|
||||
|
||||
// Aggregated note count for the Notes tab badge. Mirrors the
|
||||
// symmetric-reach used by the NotesList that renders below it.
|
||||
const { countForYachtAggregated } = await import('@/lib/services/notes.service');
|
||||
const noteCount = await countForYachtAggregated(portId, id).catch(() => 0);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
tags: tagJoins.map((t) => t.tag),
|
||||
noteCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -622,6 +622,46 @@ export const REGISTRY: SettingEntry[] = [
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
// ─── Operations - Expenses module ─────────────────────────────────────────
|
||||
// Port-scoped gate for the entire Expenses + receipt-upload surface.
|
||||
// Defaults to enabled so existing ports keep the feature on deploy.
|
||||
// Disabling hides both sidebar entries (Expenses + How to upload
|
||||
// receipts) AND swaps the routes for a "Module disabled" placeholder so
|
||||
// bookmarks land on a meaningful page (not a 404) and direct API hits
|
||||
// are rejected at the layout boundary.
|
||||
{
|
||||
key: 'expenses_module_enabled',
|
||||
section: 'operations.expenses',
|
||||
label: 'Expenses module',
|
||||
description:
|
||||
'When enabled, reps can record expenses and upload receipts (mobile scanner + manual entry). Turning this off hides Expenses + receipt-upload from the sidebar and blocks the routes with a "module disabled" page. Disabling does not delete previously-recorded expense rows.',
|
||||
type: 'boolean',
|
||||
scope: 'port',
|
||||
defaultValue: true,
|
||||
},
|
||||
|
||||
// ─── Operations - Invoices module ─────────────────────────────────────────
|
||||
// Port-scoped gate for the standalone `/invoices` flow. Audit conclusion
|
||||
// (2026-05-27, Initiative 1c): the schema is rich (invoices + invoice_line_items
|
||||
// + invoice_expenses + send/payment routes + PDF) but the dev DB has zero
|
||||
// rows. The canonical "money received" path goes via `payments`
|
||||
// (auto-advances pipeline) and the canonical expense-report path goes via
|
||||
// `expenses → invoices` only for the employee-expense use case. The sidebar
|
||||
// nav entry was removed earlier; this toggle hides the route too so
|
||||
// bookmarks land on a clear "module disabled" page instead of an orphaned
|
||||
// form. Default OFF for new ports; existing ports keep the surface visible
|
||||
// until an admin explicitly turns it off.
|
||||
{
|
||||
key: 'invoices_module_enabled',
|
||||
section: 'operations.invoices',
|
||||
label: 'Standalone invoicing module',
|
||||
description:
|
||||
'When enabled, the standalone /invoices flow (create invoice → line items → PDF → send → mark paid) is reachable. The canonical "we received money" path in this CRM goes through the Payments tab on an interest (auto-advances pipeline); the standalone invoicing surface is a separate flow primarily for employee expense reports. Disabling hides /invoices entirely (route renders a "module disabled" page); existing rows are preserved.',
|
||||
type: 'boolean',
|
||||
scope: 'port',
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
// ─── Residential - partner forwarding ──────────────────────────────────────
|
||||
{
|
||||
key: 'residential_partner_recipients',
|
||||
|
||||
@@ -174,7 +174,14 @@ export async function resolveSettings(
|
||||
const out = new Map<string, ResolvedRaw>();
|
||||
await Promise.all(
|
||||
keys.map(async (k) => {
|
||||
out.set(k, await resolveSettingWithSource(k, portId));
|
||||
try {
|
||||
out.set(k, await resolveSettingWithSource(k, portId));
|
||||
} catch {
|
||||
// Unknown registry key — common when a feature stores settings via
|
||||
// its own dedicated route (e.g. branding) and a batch caller asks
|
||||
// for them by key. Skipping keeps the rest of the batch usable;
|
||||
// single-key callers via getSetting() still fail loud.
|
||||
}
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* One breadcrumb hint per pathname. Detail pages push their entity
|
||||
* hierarchy into this store on mount via `useBreadcrumbHint`; the
|
||||
* topbar Breadcrumbs component reads the hint for the current path
|
||||
* and renders Client › Mary Smith › Interest › B17 instead of the
|
||||
* URL-only Clients › Interests trail.
|
||||
* Back-context store, consumed by `useSmartBack` to drive the topbar
|
||||
* back button. Three responsibilities:
|
||||
*
|
||||
* Pathname-keyed (not entity-id-keyed) so concurrent route mounts
|
||||
* don't trample each other when the user navigates between details
|
||||
* via Next's client-side router.
|
||||
* 1. `hints` - per-pathname entity hierarchy registered via
|
||||
* `useBreadcrumbHint` on detail-page mount, used to override the
|
||||
* URL-derived parent (so an interest page's back goes to "Sarah
|
||||
* Doe" instead of "Clients").
|
||||
*
|
||||
* 2. `historyStack` - in-app navigation history pushed by the
|
||||
* `NavigationHistoryTracker`. When non-empty, the smart-back hook
|
||||
* prefers the top of the stack so cross-record drills (Sarah ->
|
||||
* Yacht -> Back) return to the page the rep was actually on rather
|
||||
* than the logical parent. Survives only the SPA session (in-memory),
|
||||
* so deep-link refresh correctly falls through to the logical
|
||||
* parent.
|
||||
*
|
||||
* 3. `labelCache` - per-pathname display label captured at hint-
|
||||
* registration time, persisting past page unmount. Lets the back
|
||||
* button render "Back to Sarah Doe" even after Sarah's detail page
|
||||
* has unmounted (which is the normal case when the rep has drilled
|
||||
* into a Yacht from her record).
|
||||
*
|
||||
* Historical note: this store originally fed a topbar breadcrumb chain
|
||||
* which was removed in favor of a single contextual back button. The
|
||||
* file and hook names kept their `breadcrumb` prefix to avoid touching
|
||||
* the 6 existing detail-page integrations.
|
||||
*
|
||||
* All keys are pathnames (not entity IDs) so concurrent route mounts
|
||||
* don't trample each other under Next's client-side router.
|
||||
*/
|
||||
export interface BreadcrumbHintCrumb {
|
||||
label: string;
|
||||
@@ -21,14 +41,35 @@ export interface BreadcrumbHint {
|
||||
current: string;
|
||||
}
|
||||
|
||||
/** Soft cap on the history stack so a long browsing session doesn't
|
||||
* unboundedly grow the in-memory store. 25 is plenty: the back button
|
||||
* only consumes the top of stack, and stacks rarely exceed 4-5 entries
|
||||
* in real-world flows. */
|
||||
const HISTORY_LIMIT = 25;
|
||||
|
||||
interface BreadcrumbStore {
|
||||
hints: Record<string, BreadcrumbHint>;
|
||||
historyStack: string[];
|
||||
labelCache: Record<string, string>;
|
||||
setHint: (pathname: string, hint: BreadcrumbHint) => void;
|
||||
clearHint: (pathname: string) => void;
|
||||
/** Cache a display label for a pathname. Called from
|
||||
* `useBreadcrumbHint` so labels survive page unmount and the back
|
||||
* button can render "Back to Sarah Doe" after navigating away. */
|
||||
cacheLabel: (pathname: string, label: string) => void;
|
||||
/** Push a pathname onto the history stack. Called by the
|
||||
* NavigationHistoryTracker when the user navigates forward. */
|
||||
pushHistory: (pathname: string) => void;
|
||||
/** Pop the top of the history stack. Called by the
|
||||
* NavigationHistoryTracker when the user navigates back to the page
|
||||
* that was previously on top of the stack. */
|
||||
popHistory: () => void;
|
||||
}
|
||||
|
||||
export const useBreadcrumbStore = create<BreadcrumbStore>((set) => ({
|
||||
hints: {},
|
||||
historyStack: [],
|
||||
labelCache: {},
|
||||
setHint: (pathname, hint) =>
|
||||
set((state) => ({
|
||||
hints: { ...state.hints, [pathname]: hint },
|
||||
@@ -39,4 +80,18 @@ export const useBreadcrumbStore = create<BreadcrumbStore>((set) => ({
|
||||
delete next[pathname];
|
||||
return { hints: next };
|
||||
}),
|
||||
cacheLabel: (pathname, label) =>
|
||||
set((state) => {
|
||||
// Skip the no-op write so subscribers don't re-render when the
|
||||
// hint re-fires with the same label (common during query refetches).
|
||||
if (state.labelCache[pathname] === label) return state;
|
||||
return { labelCache: { ...state.labelCache, [pathname]: label } };
|
||||
}),
|
||||
pushHistory: (pathname) =>
|
||||
set((state) => {
|
||||
const next = [...state.historyStack, pathname];
|
||||
if (next.length > HISTORY_LIMIT) next.shift();
|
||||
return { historyStack: next };
|
||||
}),
|
||||
popHistory: () => set((state) => ({ historyStack: state.historyStack.slice(0, -1) })),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user