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:
2026-05-27 22:42:37 +02:00
parent 3bdf59e917
commit cb8292464c
62 changed files with 2944 additions and 662 deletions

View File

@@ -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

View File

@@ -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">

View 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`}
/>
);
}

View 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`}
/>
);
}

View File

@@ -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} />;
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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) {

View File

@@ -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

View File

@@ -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&apos;ve pre-filled what we have on file. Please review, correct anything that&apos;s
wrong, and add what&apos;s missing.
wrong, and add what&apos;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>

View File

@@ -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',

View File

@@ -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&apos;s branding. Save
changes first, then refresh the preview to see them.
Renders a sample transactional email with the current port&apos;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">

View File

@@ -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',

View File

@@ -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)}
/>
)}
</>

View File

@@ -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

View File

@@ -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' ? (

View File

@@ -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.

View File

@@ -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}

View File

@@ -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',

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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'}

View 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;
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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">

View 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>
);
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
View 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;
}

View File

@@ -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;
}

View 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);

View File

@@ -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),
],
);

View File

@@ -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

View File

@@ -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&apos;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>
</>
);
}

View File

@@ -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&apos;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&apos;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>
</>
);
}

View File

@@ -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&apos;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>
</>
);
}

View File

@@ -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&apos;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&apos;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&apos;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&apos;t request this, you may safely ignore this message - your existing password
will continue to work.
</Text>
</>
);
}

View File

@@ -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 reps 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
View 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())
);
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;
}
}

View 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;
}

View 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;
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
};
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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) })),
}));