fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish

Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:28:50 +02:00
parent 397dbd1490
commit 4b5f85cb7d
158 changed files with 12255 additions and 1303 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -25,8 +25,25 @@ const loginSchema = z.object({
type LoginFormData = z.infer<typeof loginSchema>;
/**
* H-02: Validate a redirect target before pushing the user to it. The
* middleware appends `?redirect=<path>` when a session check fails on a
* protected route; an unsanitized router.push of that value would let a
* crafted URL bounce the user to an external host or protocol-relative
* `//evil.com` after a successful sign-in. Only same-origin, single-leading-
* slash paths pass.
*/
function safeRedirectTarget(raw: string | null): string {
if (!raw) return '/dashboard';
// Allow only paths starting with a single `/` (rules out `//evil.com`
// protocol-relative URLs and `https://…` absolute ones).
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
return raw;
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
// Fresh-DB bootstrap detection: if no super-admin exists yet, /setup
@@ -76,7 +93,8 @@ export default function LoginPage() {
return;
}
router.push('/dashboard');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(safeRedirectTarget(searchParams.get('redirect')) as any);
} catch {
toast.error('Something went wrong. Please try again.');
} finally {

View File

@@ -1,8 +1,8 @@
'use client';
import { Suspense, useState } from 'react';
import { Suspense, useState, useSyncExternalStore } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -27,10 +27,35 @@ const passwordSchema = z
type SetPasswordFormData = z.infer<typeof passwordSchema>;
/**
* H-03: tokens travel in the URL fragment (`#token=…`) so they never land
* in HTTP access logs or HTTP-Referer headers. Pre-fragment links still
* carry `?token=…` and stay functional until every outstanding invite
* expires — drop the `?token=` fallback after that grace period.
*/
function readTokenFromUrl(): string {
if (typeof window === 'undefined') return '';
const hash = window.location.hash.replace(/^#/, '');
if (hash) {
const params = new URLSearchParams(hash);
const fromFragment = params.get('token');
if (fromFragment) return fromFragment;
}
const search = new URLSearchParams(window.location.search);
return search.get('token') ?? '';
}
const subscribeNoop = () => () => undefined;
function SetPasswordInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
// useSyncExternalStore so the fragment-only token is read post-hydration
// (server snapshot returns null; client returns the actual value).
const token = useSyncExternalStore<string | null>(
subscribeNoop,
() => readTokenFromUrl(),
() => null,
);
const [isLoading, setIsLoading] = useState(false);
const {
@@ -73,6 +98,17 @@ function SetPasswordInner() {
}
}
// Pre-hydration: token is null. Show a loading placeholder so the user
// doesn't see a flash of "Link is missing" while the fragment is being
// read on the client.
if (token === null) {
return (
<BrandedAuthShell>
<div className="text-center text-sm text-gray-500">Loading</div>
</BrandedAuthShell>
);
}
if (!token) {
return (
<BrandedAuthShell>

View File

@@ -1,57 +1,11 @@
import Link from 'next/link';
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
const MASTER_FIELDS: SettingFieldDef[] = [
{
key: 'ai_enabled',
label: 'AI features enabled',
description:
'Master switch. When OFF, every AI surface (receipt OCR fallback, berth-PDF AI parse, future embedding-driven recommendations) is bypassed. Provider keys stay configured but unused.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_monthly_token_cap',
label: 'Monthly token cap (this port)',
description:
'Soft cap on total AI tokens consumed per calendar month across every feature. When exceeded, AI features fall back to non-AI paths and surface a banner. Set 0 for no cap.',
type: 'number',
defaultValue: 0,
},
];
const PROVIDER_FIELDS: SettingFieldDef[] = [
{
key: 'openai_api_key',
label: 'OpenAI API key',
description:
'Used by Receipt OCR fallback and (future) berth-PDF AI parse. Stored AES-encrypted at rest; the field shows blank after save.',
type: 'password',
placeholder: 'sk-…',
defaultValue: '',
},
{
key: 'openai_default_model',
label: 'Default OpenAI model',
description: 'Used when a feature does not specify an explicit model.',
type: 'select',
defaultValue: 'gpt-4o-mini',
options: [
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini — cheap, fast, vision-capable' },
{ value: 'gpt-4o', label: 'gpt-4o — full-strength multimodal' },
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo — legacy text reasoning' },
],
},
];
interface FeatureLink {
href: string;
icon: typeof Bot;
@@ -85,16 +39,16 @@ export default function AiAdminPage() {
eyebrow="ADMIN"
/>
<SettingsFormCard
<RegistryDrivenForm
title="Master controls"
description="Hard kill switch + budget guardrails covering every AI surface in this port."
fields={MASTER_FIELDS}
sections={['ai.master']}
/>
<SettingsFormCard
<RegistryDrivenForm
title="Provider credentials"
description="Shared API keys used by AI-enabled features. Per-feature pages can override the model on a feature-by-feature basis."
fields={PROVIDER_FIELDS}
description="Shared API keys used by AI-enabled features. AES-encrypted at rest. Per-feature pages can override the model on a feature-by-feature basis."
sections={['ai.providers']}
/>
<Card>

View File

@@ -4,148 +4,15 @@ import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const API_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_api_url_override',
label: 'API URL override',
description:
'Optional. Falls back to DOCUMENSO_API_URL env when blank. Bare host only — never include /api/v1; the client appends versioned paths based on the API version below.',
type: 'string',
placeholder: 'https://documenso.example.com',
defaultValue: '',
},
{
key: 'documenso_api_key_override',
label: 'API key override',
description: 'Optional. Falls back to DOCUMENSO_API_KEY env when blank. Stored in plain text.',
type: 'password',
defaultValue: '',
},
{
key: 'documenso_api_version_override',
label: 'API version',
description:
'Which Documenso REST API this port targets. v1 = Documenso 1.13.x stable. v2 = Documenso 2.x with the envelope model and richer per-field metadata. Test the connection after switching. See the v2 benefits card above for what changes when you flip this — and note that template-based EOI generation still uses the v1 formValues shape regardless of this setting (v2 template/use migration is on the roadmap).',
type: 'select',
options: [
{ value: 'v1', label: 'v1 — Documenso 1.13.x (default, stable)' },
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope, recommended for new ports)' },
],
defaultValue: 'v1',
},
];
const SIGNER_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_developer_name',
label: 'Developer signer — name',
description:
'The party who signs after the client (typically the marina developer or owner). Used as the static "developer" recipient in templated documents (EOI). Was hardcoded as "David Mizrahi" in the legacy single-tenant system.',
type: 'string',
placeholder: 'David Mizrahi',
defaultValue: '',
},
{
key: 'documenso_developer_email',
label: 'Developer signer — email',
description: 'Email used to send the developer signing request via Documenso.',
type: 'string',
placeholder: 'dm@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_developer_label',
label: 'Developer signer — display label',
description:
'How the developer slot is referenced in email subjects + signer-progress UI copy. Defaults to "Developer" when blank.',
type: 'string',
placeholder: 'Developer',
defaultValue: '',
},
{
key: 'documenso_developer_user_id',
label: 'Developer signer — linked CRM user (optional)',
description:
"Project Director RBAC binding. When set, the webhook handler fires an in-CRM notification for this user when it's their turn to sign — alongside the branded email. Leave blank if the developer slot doesn't map to a CRM user (e.g. external developer). Use the user's UUID from /admin/users.",
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
{
key: 'documenso_approver_name',
label: 'Approver — name',
description:
'The final approver who signs after the developer (typically a sales/legal lead). Was hardcoded as "Abbie May" in the legacy system.',
type: 'string',
placeholder: 'Abbie May',
defaultValue: '',
},
{
key: 'documenso_approver_email',
label: 'Approver — email',
description: 'Email used to route the final approval signing request.',
type: 'string',
placeholder: 'sales@portnimara.com',
defaultValue: '',
},
{
key: 'documenso_approver_label',
label: 'Approver — display label',
description:
'How the approver slot is referenced in email subjects + signer-progress UI copy. Defaults to "Approver" when blank.',
type: 'string',
placeholder: 'Approver',
defaultValue: '',
},
{
key: 'documenso_approver_user_id',
label: 'Approver — linked CRM user (optional)',
description:
"Same as developer's linked user — when set, fires an in-CRM notification when it's the approver's turn. Use the user's UUID from /admin/users.",
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
const EOI_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_eoi_template_id',
label: 'EOI Documenso template ID',
description: 'Numeric template ID used by the Documenso EOI pathway.',
type: 'string',
placeholder: '12345',
defaultValue: '',
},
{
key: 'eoi_default_pathway',
label: 'Default EOI pathway',
description:
'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.',
type: 'select',
options: [
{ value: 'documenso-template', label: 'Documenso template' },
{ value: 'inapp', label: 'In-app (pdf-lib)' },
],
defaultValue: 'documenso-template',
},
{
key: 'eoi_send_mode',
label: 'Initial signing-invitation email behaviour',
description:
'Auto = the system sends our branded "please sign" email immediately when an EOI/contract/reservation is generated. Manual = the document is generated and the signing URL appears in the UI; a rep clicks "Send invitation" to dispatch. Auto is the lower-friction option for high-volume teams; manual lets reps review before sending. Applies to all document types, not just EOI.',
type: 'select',
options: [
{ value: 'manual', label: 'Manual (rep clicks Send after generation)' },
{ value: 'auto', label: 'Auto (send branded email on generate)' },
],
defaultValue: 'manual',
},
];
// API_FIELDS removed — replaced by <RegistryDrivenForm sections={['documenso.api']} />
// which adds the new webhook-secret field + AES encrypts the API key at rest.
const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
{
@@ -168,30 +35,22 @@ const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
},
];
const EMBED_FIELDS: SettingFieldDef[] = [
{
key: 'embedded_signing_host',
label: 'Embedded signing host',
description:
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
type: 'string',
placeholder: 'https://portnimara.com',
defaultValue: '',
},
];
// Embedded signing field config + Test + Setup help all live inside
// `<EmbeddedSigningCard />` (imported above). Kept out of the field list
// here so the admin page reads as a flat sequence of cards.
const V2_FEATURE_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_signing_order',
label: 'Signing order',
description:
'PARALLEL = recipients can sign in any order (faster, current default). SEQUENTIAL = Documenso refuses to email recipient N+1 until recipient N has signed, enforcing client → developer → approver order on EOIs. Only applies when API version above is v2 v1 instances ignore this and always behave as PARALLEL.',
'Whether all signers receive the invitation at once (PARALLEL — anyone can sign first) or only the next pending signer gets the email once the previous one finishes (SEQUENTIAL). Applied at envelope-create time on both v1 and v2: v1 honours meta.signingOrder on /templates/{id}/generate-document; v2 honours it via /envelope/update right after /template/use.',
type: 'select',
options: [
{ value: '', label: 'PARALLEL (default)' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — enforce signing order (v2 only)' },
{ value: 'PARALLEL', label: 'PARALLEL — all signers invited at once' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — one at a time in order' },
],
defaultValue: '',
defaultValue: 'PARALLEL',
},
{
key: 'documenso_redirect_url',
@@ -369,10 +228,10 @@ export default function DocumensoSettingsPage() {
</CardContent>
</Card>
<SettingsFormCard
<RegistryDrivenForm
title="Documenso API"
description="Per-port API credentials. Leave blank to use the global env defaults."
fields={API_FIELDS}
description="Per-port API credentials. AES-encrypted at rest. Leave blank to inherit from the env fallback (badged below each field)."
sections={['documenso.api']}
extra={<DocumensoTestButton />}
/>
@@ -382,16 +241,17 @@ export default function DocumensoSettingsPage() {
fields={V2_FEATURE_FIELDS}
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['documenso.signers']}
title="Signers (developer + approver)"
description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots."
fields={SIGNER_FIELDS}
description="Identity bound to the developer (signing order 2) and approver (signing order 3) slots in your Documenso templates. Leave name + email blank to fall through to whatever you set on the Documenso template itself; set them here to override the template's stored values at send time. Recipient IDs are populated automatically by 'Sync from Documenso' below. Linking a CRM user is optional — when set, the platform fires an in-CRM notification for that user when it's their turn to sign."
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['documenso.templates']}
title="EOI generation"
description="Default pathway, template, and email behaviour when an interest's EOI is generated."
fields={EOI_FIELDS}
description="Default pathway, template, and email behaviour when an interest's EOI is generated. Recipient + field discovery happens via 'Sync from Documenso' below — that also populates the template ID for you."
extra={<TemplateSyncButton />}
/>
<SettingsFormCard
@@ -400,11 +260,7 @@ export default function DocumensoSettingsPage() {
fields={CONTRACT_RESERVATION_FIELDS}
/>
<SettingsFormCard
title="Embedded signing"
description="Where the public-facing branded signing pages live. The CRM rewrites Documenso signing URLs to point here when sending invitation and reminder emails."
fields={EMBED_FIELDS}
/>
<EmbeddedSigningCard />
</div>
);
}

View File

@@ -1,68 +1,8 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
const FIELDS: SettingFieldDef[] = [
{
key: 'email_from_name',
label: 'From name',
description: 'Display name shown in the From: header on outgoing email.',
type: 'string',
placeholder: 'Port Nimara',
defaultValue: '',
},
{
key: 'email_from_address',
label: 'From address',
description: 'Sender email address. Falls back to SMTP_FROM env when blank.',
type: 'string',
placeholder: 'noreply@example.com',
defaultValue: '',
},
{
key: 'email_reply_to',
label: 'Reply-to address',
description: 'Optional Reply-To: header for replies (e.g. sales@example.com).',
type: 'string',
placeholder: 'sales@example.com',
defaultValue: '',
},
{
key: 'smtp_host_override',
label: 'SMTP host override',
description: 'Optional. Falls back to SMTP_HOST env when blank.',
type: 'string',
placeholder: 'mail.example.com',
defaultValue: '',
},
{
key: 'smtp_port_override',
label: 'SMTP port override',
description: 'Optional. Falls back to SMTP_PORT env when blank.',
type: 'number',
placeholder: '587',
defaultValue: null,
},
{
key: 'smtp_user_override',
label: 'SMTP username override',
description: 'Optional. Falls back to SMTP_USER env when blank.',
type: 'string',
defaultValue: '',
},
{
key: 'smtp_pass_override',
label: 'SMTP password override',
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password',
defaultValue: '',
},
];
export default function EmailSettingsPage() {
return (
<div className="space-y-6">
@@ -70,15 +10,18 @@ export default function EmailSettingsPage() {
title="Email Settings"
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
/>
<SettingsFormCard
{/* Registry-driven so each field shows the "Using env fallback /
port / global / default" badge inline — admins can tell at a
glance which fields are coming from .env vs. UI overrides. */}
<RegistryDrivenForm
sections={['email.from']}
title="From address"
description="Identity headers used by system-generated emails."
fields={FIELDS.slice(0, 3)}
/>
<SettingsFormCard
<RegistryDrivenForm
sections={['email.smtp']}
title="SMTP transport overrides"
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
fields={FIELDS.slice(3)}
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
/>
<SalesEmailConfigCard />
<EmailRoutingCard />

View File

@@ -1,14 +1,23 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { ShieldX } from 'lucide-react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
import { Button } from '@/components/ui/button';
/**
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may
* access any page under /[portSlug]/admin.
*
* H-15: previously this layout silently redirected non-admins to
* `/dashboard`, which left them staring at the dashboard with no
* explanation of why their bookmark / shared admin link "didn't work".
* Render an explicit 403 page instead so the URL stays on the failed
* route and the user can see why their request was denied.
*/
export default async function AdminLayout({
children,
@@ -29,7 +38,23 @@ export default async function AdminLayout({
});
if (!profile?.isSuperAdmin) {
redirect(`/${portSlug}/dashboard`);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<ShieldX className="h-7 w-7 text-destructive" aria-hidden />
</div>
<div className="space-y-1">
<h1 className="text-xl font-semibold">Access denied</h1>
<p className="max-w-md text-sm text-muted-foreground">
This area is for super-administrators only. If you believe you should have access, ask
an administrator to grant the super-admin role on your account.
</p>
</div>
<Button asChild>
<Link href={`/${portSlug}/dashboard`}>Back to dashboard</Link>
</Button>
</div>
);
}
return <>{children}</>;

View File

@@ -0,0 +1,264 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type Mode = 'auto' | 'suggest' | 'off';
const TRIGGERS: Array<{
key: string;
label: string;
description: string;
defaultMode: Mode;
}> = [
{
key: 'eoi_sent',
label: 'EOI sent',
description: 'Rep generates an EOI for signing — moves the deal to "EOI" stage.',
defaultMode: 'auto',
},
{
key: 'eoi_signed',
label: 'EOI signed (all parties)',
description:
'All signatories complete the EOI — moves the deal to "Reservation" stage. Conventional CRM behaviour.',
defaultMode: 'auto',
},
{
key: 'reservation_signed',
label: 'Reservation agreement signed',
description:
'Reservation paperwork signed by all parties — keeps the deal at "Reservation" with sub-status signed.',
defaultMode: 'auto',
},
{
key: 'deposit_received',
label: 'Deposit received in full',
description:
'Deposit total reaches the expected amount — moves the deal to "Deposit Paid" stage.',
defaultMode: 'auto',
},
{
key: 'contract_signed',
label: 'Sales contract signed',
description: 'Final contract signed by all parties — moves the deal to "Contract" stage.',
defaultMode: 'auto',
},
];
const PRESETS = {
aggressive: 'auto',
conservative: 'suggest',
} as const;
type PresetName = keyof typeof PRESETS;
export default function PipelineRulesPage() {
const queryClient = useQueryClient();
const [rules, setRules] = useState<Record<string, Mode>>(() =>
Object.fromEntries(TRIGGERS.map((t) => [t.key, t.defaultMode])),
);
const { data, isLoading } = useQuery<{
data: { values: Record<string, { value?: Record<string, Mode> | null }> };
}>({
queryKey: ['admin', 'settings', 'pipeline.auto_advance'],
queryFn: () =>
apiFetch<{
data: { values: Record<string, { value?: Record<string, Mode> | null }> };
}>('/api/v1/admin/settings/resolved?sections=pipeline.auto_advance'),
});
// Hydrate the local form once the server-side state arrives. We treat
// missing keys as the registered default — the page's persisted JSON
// doesn't have to enumerate every trigger, just the overrides.
useEffect(() => {
const persisted = data?.data?.values?.stage_advance_rules?.value;
if (!persisted || typeof persisted !== 'object') return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setRules((prev) => {
const next = { ...prev };
for (const t of TRIGGERS) {
const v = persisted[t.key];
if (v === 'auto' || v === 'suggest' || v === 'off') next[t.key] = v;
}
return next;
});
}, [data]);
const saveMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/admin/settings/stage_advance_rules', {
method: 'PUT',
body: { value: rules },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
toast.success('Pipeline rules saved.');
},
onError: (err) => toastError(err),
});
const applyPreset = (preset: PresetName) => {
const target = PRESETS[preset];
setRules(Object.fromEntries(TRIGGERS.map((t) => [t.key, target])));
};
const setMode = (key: string, mode: Mode) => {
setRules((prev) => ({ ...prev, [key]: mode }));
};
const allMatch = (mode: Mode) => TRIGGERS.every((t) => rules[t.key] === mode);
const currentPreset: PresetName | 'custom' = allMatch('auto')
? 'aggressive'
: allMatch('suggest')
? 'conservative'
: 'custom';
return (
<div className="space-y-6">
<PageHeader
title="Pipeline auto-advance rules"
description="Control which lifecycle events (signing, payments) automatically advance the deal stage on the kanban. Choose a preset or fine-tune per trigger."
/>
<Card>
<CardHeader>
<CardTitle className="text-base">Preset</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3">
<PresetButton
name="aggressive"
label="Aggressive (default)"
description="Every trigger auto-advances the stage. Matches conventional CRM behaviour and saves rep clicks."
active={currentPreset === 'aggressive'}
onClick={() => applyPreset('aggressive')}
/>
<PresetButton
name="conservative"
label="Conservative"
description="Every trigger sends a notification suggesting the move. Reps click Approve to advance."
active={currentPreset === 'conservative'}
onClick={() => applyPreset('conservative')}
/>
<div
className={`rounded-lg border p-3 ${
currentPreset === 'custom'
? 'border-primary bg-primary/5'
: 'border-muted bg-muted/20'
}`}
>
<p className="text-sm font-semibold">Custom</p>
<p className="text-xs text-muted-foreground">
Mix and match the per-trigger toggles below override the preset.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Per-trigger settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Loading
</div>
) : (
TRIGGERS.map((t) => (
<div
key={t.key}
className="flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex-1">
<p className="text-sm font-medium">{t.label}</p>
<p className="text-xs text-muted-foreground">{t.description}</p>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`mode-${t.key}`} className="sr-only">
Mode
</Label>
<Select
value={rules[t.key] ?? t.defaultMode}
onValueChange={(v) => setMode(t.key, v as Mode)}
>
<SelectTrigger id={`mode-${t.key}`} className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-advance</SelectItem>
<SelectItem value="suggest">Suggest only</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
</div>
))
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="gap-1.5 [&_svg]:size-3.5"
>
{saveMutation.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Save />}
Save rules
</Button>
</div>
</div>
);
}
function PresetButton({
name,
label,
description,
active,
onClick,
}: {
name: PresetName;
label: string;
description: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-lg border p-3 text-left transition-colors ${
active
? 'border-primary bg-primary/5 ring-2 ring-primary/40'
: 'border-muted hover:border-foreground/30 hover:bg-muted/30'
}`}
aria-pressed={active}
>
<p className="text-sm font-semibold">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
<p className="mt-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{name === 'aggressive' ? 'auto for all triggers' : 'suggest for all triggers'}
</p>
</button>
);
}

View File

@@ -6,6 +6,16 @@ import { Plus, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
@@ -163,33 +173,39 @@ export default function InvoicesPage() {
/>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-xs z-50 flex items-center justify-center">
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
<h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground">
{/* M-U09: was a hand-rolled overlay; standardized on AlertDialog so
the focus-trap, Escape-to-close, and a11y semantics match every
other destructive-action surface in the app. */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete invoice?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
<span className="font-mono font-medium">{deleteTarget?.invoiceNumber}</span>. This
action cannot be undone.
</p>
<div className="flex items-center gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={deleteMutation.isPending}
onClick={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive"
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden />
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { AppShell } from '@/components/layout/app-shell';
import { DevModeBanner } from '@/components/shared/dev-mode-banner';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
@@ -48,6 +49,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
<SocketProvider>
<RealtimeToasts />
<WebVitalsReporter />
{/* Sticky banner across the app whenever EMAIL_REDIRECT_TO is
set so reps + admins always know outbound mail is being
rerouted. Production hides itself (env.ts forbids the
flag in prod) so the banner is dev/staging-only. */}
<DevModeBanner />
{/* #26: AppShell mounts ONE responsive tree (desktop OR
* mobile) per render — never both — so pages don't pay the
* double-state, double-fetch, double-Tabs-provider tax. */}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import { FileText } from 'lucide-react';
import { FileText, CheckCircle2, XCircle, Circle } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
@@ -71,29 +71,38 @@ export default async function PortalDocumentsPage() {
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
Signers
</p>
{doc.signers.map((signer, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<span
className={
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500'
}
>
{signer.status === 'signed'
? '✓'
: signer.status === 'declined'
? '✗'
: '○'}
</span>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
))}
{doc.signers.map((signer, idx) => {
const StatusIcon =
signer.status === 'signed'
? CheckCircle2
: signer.status === 'declined'
? XCircle
: Circle;
const statusLabel =
signer.status === 'signed'
? 'Signed'
: signer.status === 'declined'
? 'Declined'
: 'Pending';
const statusColor =
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500';
return (
<div key={idx} className="flex items-center gap-2 text-sm">
<StatusIcon
className={`h-4 w-4 ${statusColor}`}
aria-label={statusLabel}
/>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
);
})}
</div>
)}

View File

@@ -45,9 +45,18 @@ export async function POST(req: NextRequest) {
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
// H-04: RFC 6585 §4 requires Retry-After on 429 so automated clients
// can back off correctly. rateLimitHeaders only emits the X-RateLimit-*
// triplet; checkRateLimit's helper enforcePublicRateLimit adds this
// header, but this route uses checkRateLimit directly so the header
// has to be added explicitly.
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: { message: 'Too many attempts. Try again later.' } },
{ status: 429, headers: rateLimitHeaders(rl) },
{
status: 429,
headers: { ...rateLimitHeaders(rl), 'Retry-After': String(retryAfter) },
},
);
}

View File

@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { findTemplateIdByEnvelopeId } from '@/lib/services/documenso-client';
import { syncDocumensoTemplate } from '@/lib/services/documenso-template-sync.service';
/**
* POST /api/v1/admin/documenso/sync-template/:templateId
*
* Calls Documenso's GET /template/{id} via the configured per-port creds,
* pre-fills the matching documenso_*_recipient_id settings, and caches the
* field name→ID map at documenso_eoi_field_map for v2 prefillFields usage.
*
* Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope
* ID (`envelope_xxxxxxxx`) — the latter is what the Documenso UI URL shows,
* so paste-from-URL works out of the box on v2 instances. Envelope IDs get
* resolved to their numeric template id via `findTemplateIdByEnvelopeId`
* before the sync runs.
*
* Admin-only via `admin.manage_settings`. Audit-logged through the per-field
* writeSetting calls inside the service.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const raw = params.templateId ?? '';
let templateId: number;
if (/^envelope_/.test(raw)) {
const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId);
if (!resolved) {
throw new NotFoundError(`Template "${raw}" — no matching envelopeId found`);
}
templateId = resolved;
} else {
templateId = Number(raw);
if (!Number.isInteger(templateId) || templateId <= 0) {
throw new ValidationError(
'templateId must be a positive integer or a Documenso envelopeId (envelope_…)',
);
}
}
const result = await syncDocumensoTemplate(templateId, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET /api/v1/admin/documenso/sync-template/report
*
* Returns the cached sync result from the most recent successful Sync run,
* so the admin panel's status box survives a page reload without re-hitting
* Documenso. Returns `{ data: null }` when no sync has run for this port.
*
* Admin-only via `admin.manage_settings` — same gate as the sync write
* endpoint, since the report contains template recipient identities and
* AcroForm field names that aren't OK to leak outside the admin surface.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const report = await getEoiTemplateSyncReport(ctx.portId);
return NextResponse.json({ data: report });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { sendEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
const bodySchema = z.object({
to: z.string().email().optional(),
});
/**
* Fire a test email through the per-port sales SMTP credentials. Used by
* the admin "Test SMTP" button on the Sales email config card to verify
* connectivity / auth without waiting for the next real send to fail.
*
* Sends a small text/HTML message to either the body-supplied `to` or
* (default) the admin's own email so they get the verification in their
* inbox. Returns { ok: true } on success or { ok: false, error } on
* failure — the admin UI rates accordingly.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const recipient = body.to ?? ctx.user.email;
if (!recipient) {
return NextResponse.json(
{ data: { ok: false, error: 'No recipient resolved — sign-in email is empty' } },
{ status: 200 },
);
}
try {
const subject = `Port Nimara CRM — SMTP test (${new Date().toLocaleTimeString()})`;
const html = `<p>Hello,</p><p>This is a test message sent from your CRM's <strong>Sales SMTP</strong> configuration. If you received this, your SMTP credentials work.</p><p style="color:#666;font-size:12px;">Timestamp: ${new Date().toISOString()}</p>`;
const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`;
await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
return NextResponse.json({ data: { ok: true, to: recipient } });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn({ portId: ctx.portId, err: message }, 'Sales SMTP test send failed');
return NextResponse.json({ data: { ok: false, error: message } });
}
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { readSetting, SETTING_KEYS } from '@/lib/services/port-config';
import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout';
import { logger } from '@/lib/logger';
/**
* POST /api/v1/admin/embedded-signing/test
*
* Verifies that the configured `embedded_signing_host` (the marketing
* site that hosts the branded embedded-signing wrapper) is reachable
* and returns a 2xx for the test path. Used by the admin "Test
* connection" button on the Documenso settings page so an admin can
* tell whether their marketing-site cutover is ready BEFORE signers
* get sent there from outbound emails.
*
* Two checks:
* 1. Bare host returns 2xx — the site is up.
* 2. `/sign/health` (or `/`) returns 2xx within 5s — soft probe; not
* every marketing site exposes /sign/health, so we degrade to a
* root probe when the dedicated path 404s.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const host = await readSetting<string>(SETTING_KEYS.embeddedSigningHost, ctx.portId);
if (!host) {
return NextResponse.json({
data: {
ok: false,
error: 'No embedded_signing_host configured. Set the URL in Documenso settings first.',
},
});
}
const checked: Array<{ path: string; status?: number; ok: boolean; error?: string }> = [];
const probe = async (path: string) => {
try {
const res = await fetchWithTimeout(`${host.replace(/\/$/, '')}${path}`, {
method: 'GET',
redirect: 'manual',
});
checked.push({
path,
status: res.status,
ok: res.ok || (res.status >= 300 && res.status < 400),
});
return res.status;
} catch (err) {
const msg =
err instanceof FetchTimeoutError
? `timed out after ${err.timeoutMs}ms`
: err instanceof Error
? err.message
: String(err);
checked.push({ path, ok: false, error: msg });
return null;
}
};
// Try root first — it's the most universal signal of "the site is
// up." Then probe /sign/success which the post-signing redirect
// typically points to, so admins can also catch a stale path.
await probe('/');
await probe('/sign/success');
const anyOk = checked.some((c) => c.ok);
if (!anyOk) {
logger.warn({ portId: ctx.portId, host, checked }, 'Embedded signing host probe failed');
}
return NextResponse.json({
data: {
ok: anyOk,
host,
checks: checked,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { copyFromEnv } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/copy-from-env
*
* One-click migration helper used by the admin form's "Copy from env"
* button. Reads the env var named in the registry entry's `envFallback`
* field and writes it as the current scope's row. Returns `{ copied: false }`
* if the env var is unset / empty.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const result = await copyFromEnv(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { registryFor } from '@/lib/settings/registry';
import { getSetting } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/reveal
*
* Returns the decrypted cleartext for an encrypted / sensitive setting.
* Used by the eye-toggle on encrypted fields in the registry-driven admin
* form so the operator can verify what they saved earlier.
*
* Gated on `admin.manage_settings` (the same permission required to write
* the value — so this never widens an existing trust boundary). Every
* reveal is audit-logged with the request id so a super-admin can trace
* who looked at what and when.
*
* Refuses to reveal values resolved from `env` or `default` — those would
* leak server-process secrets via the API.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const key = params.key!;
const entry = registryFor(key);
if (!entry) throw new NotFoundError(`Unknown setting: ${key}`);
if (!entry.encrypted && !entry.sensitive) {
// Non-sensitive values are already returned in the resolved-list
// endpoint, so a dedicated reveal isn't needed (and could be
// misused to bypass observability).
return NextResponse.json({ data: { revealed: false, value: null } }, { status: 200 });
}
// Resolve through the standard chain so the user sees exactly what
// the runtime would. The resolver decrypts on the way out.
const value = await getSetting<string>(key, ctx.portId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'view',
entityType: 'setting',
entityId: key,
metadata: { settingKey: key, op: 'reveal' },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { revealed: true, value: value ?? null } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { writeSetting, deleteSetting } from '@/lib/settings/resolver';
const putSchema = z.object({
value: z.unknown(),
});
/**
* PUT /api/v1/admin/settings/:key
*
* Writes a registry-known setting. The resolver validates against the
* entry's Zod schema, encrypts at rest if registered as such, and writes
* an audit log with secrets masked.
*
* Body: { value: <whatever the entry's type accepts> }
*
* Empty / null `value` on a non-sensitive field DELETEs the row (reverts
* to global → env → default). On a sensitive/encrypted field, empty is a
* no-op so an unchanged save through the ••• placeholder doesn't wipe
* the stored ciphertext. Use the DELETE endpoint to explicitly revert.
*/
export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const { value } = await parseBody(req, putSchema);
await writeSetting(params.key!, value, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* DELETE /api/v1/admin/settings/:key
*
* Removes the row, reverting the resolver to global → env → default.
* 404 if no row exists at the appropriate scope.
*/
export const DELETE = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
await deleteSetting(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { entriesForSections } from '@/lib/settings/registry';
import { resolveForAdminAPI } from '@/lib/settings/resolver';
/**
* GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers
*
* Returns the resolved value + source (port/global/env/default) for every
* registry entry in the requested sections. Drives the registry-driven
* admin form: the `source` field gates the "Using env fallback" badge.
*
* Sensitive fields surface `isSet` only — never the decrypted value.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const url = new URL(req.url);
const sectionsParam = url.searchParams.get('sections');
if (!sectionsParam) {
return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 });
}
const sections = sectionsParam
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const entries = entriesForSections(sections);
const keys = entries.map((e) => e.key);
const resolved = await resolveForAdminAPI(keys, ctx.portId);
// Return the entry metadata so the client can render labels/types
// without bundling the registry into the client JS. Strip the
// `validator` + `transform` function references — they're not
// JSON-serializable.
const entriesForClient = entries.map((e) => ({
key: e.key,
section: e.section,
label: e.label,
description: e.description,
type: e.type,
options: e.options,
encrypted: !!e.encrypted,
sensitive: !!(e.sensitive || e.encrypted),
scope: e.scope,
envFallback: e.envFallback,
placeholder: e.placeholder,
defaultValue: e.defaultValue,
}));
const values: Record<string, unknown> = {};
for (const [k, r] of resolved.entries()) {
values[k] = r;
}
return NextResponse.json({ data: { entries: entriesForClient, values } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { user, userPortRoles, userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/admin/users/picker
*
* Lightweight list of users in the active port, used by admin form
* user-select dropdowns (e.g. linking a CRM user to a Documenso recipient
* slot). Returns only the fields needed to render an option: id, email,
* name. Excludes deactivated users.
*
* Gated on `admin.manage_settings` — anyone editing per-port admin
* settings can already see all the configured Documenso recipient
* email/name values, so revealing the user roster to them doesn't
* widen the trust boundary. Tighter than the full `admin/users` GET
* (which is `admin.manage_users`-gated).
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const rows = await db
.select({
id: user.id,
email: user.email,
name: user.name,
isActive: userProfiles.isActive,
})
.from(user)
.innerJoin(userPortRoles, eq(userPortRoles.userId, user.id))
.leftJoin(userProfiles, eq(userProfiles.userId, user.id))
.where(and(eq(userPortRoles.portId, ctx.portId)));
// Dedupe by id (a user with multiple role rows in this port would
// otherwise repeat) and drop deactivated profiles.
const seen = new Set<string>();
const data = rows
.filter((r) => r.isActive !== false)
.filter((r) => {
if (seen.has(r.id)) return false;
seen.add(r.id);
return true;
})
.map(({ id, email, name }) => ({ id, email, name }));
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -69,6 +69,7 @@ export async function getMatchCandidatesHandler(
id: clients.id,
fullName: clients.fullName,
nationalityIso: clients.nationalityIso,
archivedAt: clients.archivedAt,
})
.from(clients)
.where(and(eq(clients.portId, ctx.portId)));
@@ -142,6 +143,13 @@ export async function getMatchCandidatesHandler(
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
}
// Build a lookup from the original pool for archived flag — the dedup
// candidate type intentionally doesn't carry it, but the suggestion card
// needs to differentiate "use this live client" from "restore this
// archived client". Without this the UX swallows soft-deleted dupes.
const archivedById = new Map<string, Date | null>();
for (const c of liveClients) archivedById.set(c.id, c.archivedAt ?? null);
const data = useful.map((m) => ({
clientId: m.candidate.id,
fullName: m.candidate.fullName,
@@ -151,6 +159,7 @@ export async function getMatchCandidatesHandler(
interestCount: interestsByClient.get(m.candidate.id) ?? 0,
emails: m.candidate.emails,
phonesE164: m.candidate.phonesE164,
archivedAt: archivedById.get(m.candidate.id)?.toISOString() ?? null,
}));
return NextResponse.json({ data });

View File

@@ -26,6 +26,7 @@ export const POST = withAuth(
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{ dimensionUnit: body.dimensionUnit },
);
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {

View File

@@ -1,18 +1,44 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { cancelDocument } from '@/lib/services/documents.service';
const cancelBodySchema = z
.object({
reason: z.string().max(2000).optional().nullable(),
notifyRecipients: z.array(z.string().uuid()).max(20).optional(),
})
.strict()
.optional();
export const POST = withAuth(
withPermission('documents', 'edit', async (_req, ctx, params) => {
withPermission('documents', 'edit', async (req, ctx, params) => {
try {
const doc = await cancelDocument(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
// Body is optional — legacy callers POST with `{}`. parseBody returns
// null when the request has no body; default to empty options.
let body: z.infer<typeof cancelBodySchema> = undefined;
try {
body = await parseBody(req, cancelBodySchema);
} catch {
body = undefined;
}
const doc = await cancelDocument(
params.id!,
ctx.portId,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{
reason: body?.reason ?? null,
notifyRecipients: body?.notifyRecipients ?? [],
},
);
return NextResponse.json({ data: doc });
} catch (error) {
return errorResponse(error);

View File

@@ -54,15 +54,99 @@ export const POST = withAuth(
.where(eq(documentSigners.documentId, documentId))
.orderBy(asc(documentSigners.signingOrder));
const target = body.recipientId
let target = body.recipientId
? signers.find((s) => s.id === body.recipientId)
: signers.find((s) => s.status === 'pending');
if (!target) {
throw new ValidationError('No pending signer found to invite');
}
// Self-heal flow when target.signingUrl is null. Two scenarios:
// 1. Envelope was created before the auto-distribute fix shipped
// — never distributed, so we must call /envelope/distribute
// to mint URLs.
// 2. Envelope WAS auto-distributed at generate time, but the
// response we got didn't carry signingUrls into our DB row
// (transient Documenso bug, or response shape mismatch).
// In that case the envelope is already PENDING and a second
// /distribute call returns 4xx ("already distributed").
//
// Defensive flow: try `getEnvelope` FIRST (cheap, always works).
// If recipients carry signingUrls, persist + skip distribute.
// If not, fall through to distribute, but catch 4xx so we don't
// surface a confusing "Documenso upstream error" to the rep —
// instead we re-fetch via GET one more time and accept whatever
// URLs the envelope has.
if (!target.signingUrl && doc.documensoId) {
const { distributeEnvelopeV2, getDocument } =
await import('@/lib/services/documenso-client');
const persistUrlsForDocument = async (
recipients: Array<{
signingOrder: number;
signingUrl?: string;
embeddedUrl?: string;
token?: string;
}>,
) => {
for (const r of recipients) {
if (!r.signingUrl) continue;
await db
.update(documentSigners)
.set({
signingUrl: r.signingUrl,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
})
.where(
and(
eq(documentSigners.documentId, documentId),
eq(documentSigners.signingOrder, r.signingOrder),
),
);
}
};
// Step 1: cheap GET.
let recovered = false;
try {
const fetched = await getDocument(doc.documensoId, ctx.portId);
if (fetched.recipients.some((r) => r.signingUrl)) {
await persistUrlsForDocument(fetched.recipients);
recovered = true;
}
} catch {
// ignore — fall through to distribute attempt
}
// Step 2: distribute, only if GET didn't recover URLs.
if (!recovered) {
try {
const distributed = await distributeEnvelopeV2(doc.documensoId, ctx.portId);
await persistUrlsForDocument(distributed.recipients);
} catch {
// Probably "already distributed" — last-ditch GET.
try {
const fetched = await getDocument(doc.documensoId, ctx.portId);
await persistUrlsForDocument(fetched.recipients);
} catch {
// give up; the validator below surfaces a clean error
}
}
}
// Re-read target so its signingUrl is now populated.
const refreshed = await db
.select()
.from(documentSigners)
.where(eq(documentSigners.id, target.id))
.limit(1);
target = refreshed[0] ?? target;
}
if (!target.signingUrl) {
throw new ValidationError(
'Signer has no Documenso URL yet — generate or send the document first',
'Signer has no Documenso URL yet — try regenerating the EOI; v2 envelopes require distribution before the signing link exists.',
);
}

View File

@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET `/api/v1/documents/signing-defaults`
@@ -21,6 +22,21 @@ export const GET = withAuth(
withPermission('documents', 'send_for_signing', async (_req, ctx) => {
try {
const cfg = await getPortDocumensoConfig(ctx.portId);
// Signing order resolution chain (highest → lowest priority):
// 1. Cached `documento_eoi_template_sync_report.templateMeta.signingOrder`
// — populated by the admin "Sync from Documenso" button and
// represents the live template's bound order. On v2 this is the
// authoritative value because `/template/use` doesn't accept a
// per-call override.
// 2. Per-port `documenso_signing_order` setting from
// getPortDocumensoConfig (used by v1 + as a UI fallback when the
// admin hasn't run a sync yet).
// 3. Documenso's own default (`PARALLEL` = concurrent signing).
const syncReport = await getEoiTemplateSyncReport(ctx.portId).catch(() => null);
const signingOrder: 'PARALLEL' | 'SEQUENTIAL' =
syncReport?.templateMeta?.signingOrder ?? cfg.signingOrder ?? 'PARALLEL';
return NextResponse.json({
data: {
developer: {
@@ -34,6 +50,16 @@ export const GET = withAuth(
label: cfg.approverLabel ?? 'Approver',
},
sendMode: cfg.sendMode,
signingOrder,
// Surface where the value came from so the UI tooltip can be
// honest about the source. Helps reps debug "I changed it in
// Documenso but the CRM still says X" — they need to re-run
// Sync to pull the change.
signingOrderSource: syncReport?.templateMeta?.signingOrder
? 'template'
: cfg.signingOrder
? 'port-setting'
: 'default',
},
});
} catch (error) {

View File

@@ -10,7 +10,12 @@ export const PATCH = withAuth(
withPermission('email', 'configure_account', async (req, ctx, params) => {
try {
const body = await parseBody(req, toggleAccountSchema);
const account = await toggleAccount(params.accountId!, ctx.userId, body);
const account = await toggleAccount(params.accountId!, ctx.userId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: account });
} catch (error) {
return errorResponse(error);

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { env } from '@/lib/env';
/**
* GET /api/v1/internal/dev-flags
*
* Read-only feed of dev-mode safety flags that the UI surfaces as
* always-visible badges. Authenticated (any signed-in user) — these
* flags affect every outbound email so reps need to see them too,
* not just admins.
*
* Today returns just `emailRedirectTo`. Add more flags here (e.g.
* MOCK_DOCUMENSO, FAKE_PAYMENTS, READ_ONLY_DB) as they appear.
*/
export const GET = withAuth(async () => {
return NextResponse.json({
data: {
emailRedirectTo: env.EMAIL_REDIRECT_TO ?? null,
isDev: env.NODE_ENV !== 'production',
},
});
});

View File

@@ -14,6 +14,7 @@ import {
FileSignature,
FileText,
FileUp,
GitBranch,
Inbox,
ListChecks,
Mail,
@@ -117,6 +118,14 @@ const GROUPS: AdminGroup[] = [
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileSignature,
},
{
href: 'pipeline-rules',
label: 'Pipeline auto-advance',
description:
'Per-trigger control: which lifecycle events (EOI signed, deposit received, contract signed) auto-advance the deal stage.',
icon: GitBranch,
keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'],
},
{
href: 'reminders',
label: 'Reminders',

View File

@@ -1,6 +1,7 @@
'use client';
import { Activity, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react';
import { useState } from 'react';
import { Activity, ChevronDown, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
@@ -72,8 +73,14 @@ interface AuditLogCardProps {
}
export function AuditLogCard({ entry }: AuditLogCardProps) {
const [expanded, setExpanded] = useState(false);
const accentClass = ACTION_ACCENT[entry.action] ?? 'bg-slate-300';
const badgeColor = ACTION_BADGE_COLORS[entry.action] ?? 'bg-gray-500';
const hasDetail =
Boolean(entry.oldValue) ||
Boolean(entry.newValue) ||
Boolean(entry.metadata) ||
Boolean(entry.userAgent);
const entityTitle = `${entry.entityType.charAt(0).toUpperCase()}${entry.entityType.slice(1)}${
entry.entityId ? ` ${entry.entityId.slice(0, 8)}` : ''
@@ -153,7 +160,78 @@ export function AuditLogCard({ entry }: AuditLogCardProps) {
) : null}
</>
) : null}
{hasDetail ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-auto inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
aria-expanded={expanded}
>
<ChevronDown
className={cn(
'h-3 w-3 transition-transform',
expanded ? 'rotate-180' : 'rotate-0',
)}
aria-hidden
/>
{expanded ? 'Hide details' : 'Show details'}
</button>
) : null}
</div>
{expanded && hasDetail ? (
<div className="mt-3 space-y-2 rounded-md border bg-muted/30 p-3 text-xs">
{entry.oldValue ? (
<details>
<summary className="cursor-pointer font-semibold text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.oldValue, null, 2)}
</pre>
</details>
) : null}
{entry.newValue ? (
<details open>
<summary className="cursor-pointer font-semibold text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.newValue, null, 2)}
</pre>
</details>
) : null}
{entry.metadata ? (
<details>
<summary className="cursor-pointer font-semibold text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-background p-2 font-mono text-[11px]">
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</details>
) : null}
{entry.userAgent || entry.ipAddress ? (
<dl className="grid grid-cols-[120px_1fr] gap-x-2 gap-y-0.5">
{entry.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{entry.ipAddress}</dd>
</>
) : null}
{entry.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="truncate font-mono" title={entry.userAgent}>
{entry.userAgent}
</dd>
</>
) : null}
</dl>
) : null}
</div>
) : null}
</div>
</div>
</ListCard>

View File

@@ -3,11 +3,12 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { Search, X } from 'lucide-react';
import { History, Search, X } from 'lucide-react';
import { toast } from 'sonner';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -19,6 +20,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { AuditLogCard } from './audit-log-card';
@@ -85,6 +93,9 @@ const SOURCE_LABEL: Record<string, string> = {
job: 'Job',
};
// L-AU03: entity types that mutations can target but the filter dropdown
// didn't expose. Reps querying the audit log for, e.g., an email-account
// toggle (H-05 fix) couldn't pick it from the dropdown.
const ENTITY_TYPES = [
'client',
'interest',
@@ -99,6 +110,13 @@ const ENTITY_TYPES = [
'setting',
'tag',
'webhook',
'yacht',
'company',
'reservation',
'email_account',
'portal_session',
'portal_user',
'file',
];
function useDebounced<T>(value: T, ms = 300): T {
@@ -129,6 +147,10 @@ export function AuditLogList() {
const [userId, setUserId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
/** Currently-open audit detail row. Drives the side Sheet that
* exposes the full oldValue / newValue / metadata / IP / UA payload
* so reps can inspect a row without leaving the search list. */
const [detailEntry, setDetailEntry] = useState<AuditEntry | null>(null);
const debouncedSearch = useDebounced(search);
const debouncedUserId = useDebounced(userId);
@@ -335,6 +357,27 @@ export function AuditLogList() {
),
size: 130,
},
{
id: 'details',
header: '',
cell: ({ row }) => {
const e = row.original;
const hasDetail =
Boolean(e.oldValue) || Boolean(e.newValue) || Boolean(e.metadata) || Boolean(e.userAgent);
if (!hasDetail) return null;
return (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setDetailEntry(e)}
>
Details
</Button>
);
},
size: 80,
},
];
return (
@@ -359,7 +402,7 @@ export function AuditLogList() {
<Input
id="audit-search"
className="pl-9 h-9"
placeholder="entity id, action, vendor…"
placeholder="entity id, entity type, action, user id…"
value={search}
onChange={(e) => setSearch(e.target.value)}
data-testid="audit-search"
@@ -412,6 +455,22 @@ export function AuditLogList() {
<SelectItem value="webhook_retried">Webhook retried</SelectItem>
<SelectItem value="job_failed">Job failed</SelectItem>
<SelectItem value="cron_run">Cron run</SelectItem>
{/* L-AU02: actions that fire in the code but were missing from
the dropdown — reps couldn't filter on them. */}
<SelectItem value="password_change">Password change</SelectItem>
<SelectItem value="portal_invite">Portal invite</SelectItem>
<SelectItem value="portal_activate">Portal activate</SelectItem>
<SelectItem value="portal_password_reset_request">Portal reset req</SelectItem>
<SelectItem value="portal_password_reset">Portal reset</SelectItem>
<SelectItem value="revoke_invite">Revoke invite</SelectItem>
<SelectItem value="resend_invite">Resend invite</SelectItem>
<SelectItem value="request_gdpr_export">GDPR req</SelectItem>
<SelectItem value="send_gdpr_export">GDPR sent</SelectItem>
<SelectItem value="rule_evaluated">Rule evaluated</SelectItem>
<SelectItem value="outcome_set">Outcome set</SelectItem>
<SelectItem value="outcome_cleared">Outcome cleared</SelectItem>
<SelectItem value="branding.logo.uploaded">Logo uploaded</SelectItem>
<SelectItem value="branding.logo.archived">Logo archived</SelectItem>
</SelectContent>
</Select>
</div>
@@ -522,9 +581,15 @@ export function AuditLogList() {
virtualHeightPx={640}
virtualRowHeightPx={56}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No audit log entries found.</p>
</div>
<EmptyState
icon={History}
title="No audit log entries"
description={
hasActiveFilter
? 'No entries match the current filters. Try clearing them.'
: 'Activity will appear here once users start making changes.'
}
/>
}
/>
</div>
@@ -543,6 +608,73 @@ export function AuditLogList() {
</Button>
</div>
) : null}
<Sheet open={!!detailEntry} onOpenChange={(o) => !o && setDetailEntry(null)}>
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
{detailEntry ? (
<>
<SheetHeader>
<SheetTitle>
{detailEntry.action.replace(/_/g, ' ')} {detailEntry.entityType}
</SheetTitle>
<SheetDescription>
{new Date(detailEntry.createdAt).toLocaleString()}
{detailEntry.actor ? ` · ${detailEntry.actor.name}` : ''}
</SheetDescription>
</SheetHeader>
<div className="space-y-4 pt-4 text-sm">
{detailEntry.oldValue ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.oldValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.newValue ? (
<details open>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.newValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.metadata ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.metadata, null, 2)}
</pre>
</details>
) : null}
{detailEntry.ipAddress || detailEntry.userAgent ? (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1 text-xs">
{detailEntry.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{detailEntry.ipAddress}</dd>
</>
) : null}
{detailEntry.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="font-mono break-all">{detailEntry.userAgent}</dd>
</>
) : null}
</dl>
) : null}
</div>
</>
) : null}
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useState } from 'react';
import { CheckCircle2, HelpCircle, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface TestResult {
ok: boolean;
host?: string;
checks?: Array<{ path: string; status?: number; ok: boolean; error?: string }>;
error?: string;
at: Date;
}
const EMBED_FIELDS: SettingFieldDef[] = [
{
key: 'embedded_signing_host',
label: 'Embedded signing host',
description:
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
type: 'string',
placeholder: 'https://portnimara.com',
defaultValue: '',
},
];
/**
* Admin card for the embedded-signing host setting. Provides:
* - The setting field itself (via SettingsFormCard)
* - A Test connection button that probes the host's `/` and
* `/sign/success` paths to verify the marketing-site cutover is
* ready BEFORE signers get sent there from outbound emails.
* - A Help button that opens a Sheet with the setup instructions —
* what routes the marketing site needs, what URL parameters to
* handle, and the Documenso webhook config that pairs with it.
*/
export function EmbeddedSigningCard() {
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<TestResult | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const handleTest = async () => {
setTesting(true);
setResult(null);
try {
const res = (await apiFetch('/api/v1/admin/embedded-signing/test', {
method: 'POST',
body: {},
})) as {
data: {
ok: boolean;
host?: string;
error?: string;
checks?: Array<{ path: string; status?: number; ok: boolean; error?: string }>;
};
};
setResult({ ...res.data, at: new Date() });
if (res.data.ok) toast.success('Embedded signing host reachable.');
else toast.error('Embedded signing host probe failed — see card.');
} catch (err) {
toastError(err);
setResult({
ok: false,
error: err instanceof Error ? err.message : String(err),
at: new Date(),
});
} finally {
setTesting(false);
}
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle>Embedded signing</CardTitle>
<CardDescription>
Where the public-facing branded signing pages live. The CRM rewrites Documenso
signing URLs to point here when sending invitation and reminder emails.
</CardDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setHelpOpen(true)}
className="gap-1.5 [&_svg]:size-3.5"
>
<HelpCircle />
Setup help
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Renders inside our outer Card with its own micro-header.
Title kept terse (empty string would look broken) so the
user still has a visual anchor for the field. */}
<SettingsFormCard title="Host URL" description="" fields={EMBED_FIELDS} />
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={handleTest}
disabled={testing}
className="gap-1.5 [&_svg]:size-3.5"
>
{testing ? <Loader2 className="animate-spin" aria-hidden /> : null}
Test connection
</Button>
<p className="text-xs text-muted-foreground">
Probes <code>/</code> and <code>/sign/success</code> on the configured host.
</p>
</div>
{result ? (
<div
className={`rounded-md border p-3 text-sm ${
result.ok
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
: 'border-rose-200 bg-rose-50 text-rose-900'
}`}
>
<div className="flex items-start gap-2">
{result.ok ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
) : (
<XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
)}
<div className="flex-1">
<p className="font-medium">{result.ok ? 'Connection ok' : 'Connection failed'}</p>
{result.host ? (
<p className="text-xs">
Host: <code>{result.host}</code>
</p>
) : null}
{result.error ? <p className="text-xs">{result.error}</p> : null}
{result.checks ? (
<ul className="mt-1 space-y-0.5 text-xs">
{result.checks.map((c) => (
<li key={c.path}>
<code>{c.path}</code> {' '}
{c.ok ? (
<span className="text-emerald-800">{c.status ?? 'ok'}</span>
) : (
<span className="text-rose-800">
{c.status ? `${c.status} fail` : (c.error ?? 'fail')}
</span>
)}
</li>
))}
</ul>
) : null}
<p className="mt-1 text-[11px] opacity-70">{result.at.toLocaleTimeString()}</p>
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
<Sheet open={helpOpen} onOpenChange={setHelpOpen}>
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>Set up embedded signing</SheetTitle>
<SheetDescription>
How the marketing site has to be wired up so the branded signing flow works.
</SheetDescription>
</SheetHeader>
<div className="space-y-4 pt-4 text-sm leading-6">
<section>
<h3 className="mb-1 font-semibold">1. Choose the host</h3>
<p className="text-muted-foreground">
Pick a public host (e.g. <code>https://portnimara.com</code>) and enter it in the
Embedded signing host field above. The CRM rewrites raw Documenso signing URLs into{' '}
<code>{'{host}/sign/<role>/<token>'}</code> for every outbound invitation + reminder
email.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">2. Implement the signing route</h3>
<p className="text-muted-foreground">
The marketing site needs to handle <code>/sign/[role]/[token]</code> by forwarding
to the underlying Documenso signing URL (or embedding it in an iframe). Role is one
of <code>client</code> / <code>developer</code> / <code>approver</code> useful for
tracking which slot the signer is in.
</p>
<p className="mt-1 text-muted-foreground">Minimum Next.js example:</p>
<pre className="mt-1 overflow-x-auto rounded bg-muted p-2 font-mono text-[11px]">
{`// app/sign/[role]/[token]/page.tsx
export default function SignPage({ params }) {
const documenseUrl = \`\${env.DOCUMENSO_URL}/sign/\${params.token}\`;
return <iframe src={documenseUrl} className="w-full h-screen" />;
}`}
</pre>
</section>
<section>
<h3 className="mb-1 font-semibold">3. Implement the success route</h3>
<p className="text-muted-foreground">
After signing, Documenso redirects to the URL configured in{' '}
<strong>Post-sign redirect URL</strong>. Default points at{' '}
<code>{'{host}/sign/success'}</code>. Render a confirmation page there (the
signer&apos;s already done; this is just the friendly &ldquo;Thanks!&rdquo; UX).
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">4. Test the connection</h3>
<p className="text-muted-foreground">
Use the Test connection button to verify <code>/</code> and{' '}
<code>/sign/success</code> return 2xx. If either fails, the marketing site
isn&apos;t ready fix the route before flipping live or signers will land on a 404
page from outbound emails.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">5. Pair the Documenso webhook</h3>
<p className="text-muted-foreground">
Make sure the Documenso webhook points at{' '}
<code>{'{appUrl}/api/webhooks/documenso'}</code> with the matching webhook secret
stored under Documenso API Webhook secret. Without this the EOI status never
updates after signing.
</p>
</section>
<section>
<h3 className="mb-1 font-semibold">6. Cutover</h3>
<p className="text-muted-foreground">
Flip the Embedded signing host field to your live URL and save. Existing in-flight
EOIs keep their pre-cutover signing URLs (the rewrite happens at email-dispatch
time, not at envelope creation), so old signers can still complete on the old host
until they sign or the EOI is cancelled.
</p>
</section>
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,437 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Download, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface SyncRecipient {
role: string;
signingOrder: number;
id: number;
name?: string;
email?: string;
mappedSettingKey: string | null;
}
interface AcroFormReport {
envelopeItemId: string;
fields: Array<{ name: string; type: string }>;
matchedFieldNames: string[];
missingFieldNames: string[];
extraFieldNames: string[];
error?: string;
}
interface SyncResult {
syncedAt: string;
templateId: number;
title: string;
recipients: SyncRecipient[];
fieldCount: number;
matchedFields: Array<{ label: string; fieldId: number }>;
unmatchedTemplateFields: Array<{ label: string; fieldId: number }>;
missingFromTemplate: string[];
templateMeta?: {
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
distributionMethod: 'EMAIL' | 'NONE' | null;
redirectUrl: string | null;
};
acroForm: AcroFormReport[];
}
function formatRelative(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms) || ms < 0) return new Date(iso).toLocaleString();
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d ago`;
return new Date(iso).toLocaleDateString();
}
/**
* "Sync from Documenso" admin button — calls GET /template/{id} on the
* configured Documenso instance (via the per-port creds in admin settings),
* pre-fills the recipient slot IDs into the matching documenso_*_recipient_id
* settings, and caches the template's field name→ID map at
* `documenso_eoi_field_map` for v2 prefillFields usage at send time.
*
* Saves the operator from typing 4 numeric IDs by hand and (in v2 mode)
* eliminates the "renaming a field on Documenso silently breaks the EOI"
* class of bug.
*/
export function TemplateSyncButton() {
const queryClient = useQueryClient();
const [templateIdInput, setTemplateIdInput] = useState('');
const [lastResult, setLastResult] = useState<SyncResult | null>(null);
// Seed the result panel from the cached report so the status survives
// page reloads. A subsequent Sync click overwrites both this cache and
// the local state.
const cached = useQuery<{ data: SyncResult | null }>({
queryKey: ['documenso', 'sync-template', 'report'],
queryFn: () =>
apiFetch<{ data: SyncResult | null }>('/api/v1/admin/documenso/sync-template/report'),
staleTime: 60_000,
});
useEffect(() => {
if (!lastResult && cached.data?.data) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastResult(cached.data.data);
setTemplateIdInput(String(cached.data.data.templateId));
}
}, [cached.data, lastResult]);
const sync = useMutation({
mutationFn: async (templateIdOrEnvelopeId: string) => {
const r = await apiFetch<{ data: SyncResult }>(
`/api/v1/admin/documenso/sync-template/${encodeURIComponent(templateIdOrEnvelopeId)}`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (result) => {
setLastResult(result);
toast.success(
`Synced "${result.title}" — ${result.recipients.length} recipients, ${result.fieldCount} fields cached`,
);
void queryClient.invalidateQueries({ queryKey: ['settings', 'resolved'] });
void queryClient.invalidateQueries({
queryKey: ['documenso', 'sync-template', 'report'],
});
},
onError: (err) => toastError(err, 'Template sync failed'),
});
const submit = () => {
const raw = templateIdInput.trim();
if (!raw) {
toast.error('Enter a template ID (number) or envelope ID (envelope_…)');
return;
}
// Accept either a numeric template ID or a Documenso 2.x envelope ID.
// The server resolves envelope_xxx → numeric id via the list endpoint.
const isNumeric = /^\d+$/.test(raw);
const isEnvelopeId = /^envelope_[a-z0-9]+$/i.test(raw);
if (!isNumeric && !isEnvelopeId) {
toast.error('Enter a positive integer or a Documenso envelopeId (envelope_…)');
return;
}
sync.mutate(raw);
};
return (
<div className="rounded-md border bg-card p-4 space-y-4">
<div>
<div className="text-sm font-medium">Sync from Documenso</div>
<p className="mt-1 text-xs text-muted-foreground">
Paste either a numeric template ID (<code>123</code>) or the <code>envelope_</code>{' '}
string from your Documenso template URL (e.g. <code>envelope_nfafbkihzhoaihkb</code>). The
CRM fetches the template via <code>GET /template/&#123;id&#125;</code> on the currently
configured Documenso instance, writes the discovered recipient IDs into the slots above,
and caches the field nameID map for v2 <code>prefillFields</code> at send time.
</p>
</div>
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<Label htmlFor="documenso-sync-template-id" className="text-xs">
Template ID or envelope ID
</Label>
<Input
id="documenso-sync-template-id"
type="text"
placeholder="123 or envelope_xxxxxxxx"
value={templateIdInput}
onChange={(e) => setTemplateIdInput(e.target.value)}
disabled={sync.isPending}
/>
</div>
<Button onClick={submit} disabled={sync.isPending}>
{sync.isPending ? (
<>
<Loader2 className="mr-2 size-3 animate-spin" /> Syncing
</>
) : (
<>
<Download className="mr-2 size-3" /> Sync
</>
)}
</Button>
</div>
{lastResult && (
<div className="rounded-md border border-emerald-200 bg-emerald-50/60 p-3 text-sm dark:border-emerald-900/40 dark:bg-emerald-950/30">
<div className="flex items-center justify-between gap-2 font-medium text-emerald-700 dark:text-emerald-400">
<div className="flex items-center gap-2">
<CheckCircle2 className="size-4" />{' '}
{lastResult.title || `Template #${lastResult.templateId}`}
</div>
<span className="text-[11px] font-normal text-muted-foreground">
Last synced {formatRelative(lastResult.syncedAt)}
</span>
</div>
<div className="mt-2 space-y-1 text-xs">
<div className="font-medium text-muted-foreground">
Recipients ({lastResult.recipients.length})
</div>
<ul className="space-y-0.5">
{lastResult.recipients.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-mono text-xs">#{r.id}</span>
<span className="text-muted-foreground">·</span>
<span>
{r.role} (order {r.signingOrder})
</span>
{r.name && (
<>
<span className="text-muted-foreground">·</span>
<span>{r.name}</span>
</>
)}
{r.mappedSettingKey ? (
<span className="ml-auto rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
{r.mappedSettingKey}
</span>
) : (
<span className="ml-auto rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-950 dark:text-amber-300">
no slot match
</span>
)}
</li>
))}
</ul>
{lastResult.templateMeta && (
<div className="pt-1.5 rounded-md bg-muted/60 px-2 py-1.5">
<div className="font-medium text-muted-foreground">Template-level settings</div>
<p className="text-[11px] text-muted-foreground">
Read from the template itself on Documenso. These values are bound to the
template, so every envelope generated from it inherits them {' '}
<code>/template/use</code> does <strong>not</strong> accept overrides for these.
Change them in Documenso&apos;s template editor.
</p>
<ul className="mt-1 space-y-0.5 text-[11px]">
<li>
<span className="text-muted-foreground">Signing order:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.signingOrder ?? 'unset'}
</span>
</li>
<li>
<span className="text-muted-foreground">Distribution method:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.distributionMethod ?? 'unset'}
</span>
{lastResult.templateMeta.distributionMethod === 'EMAIL' && (
<span className="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-900 dark:bg-amber-950 dark:text-amber-200">
Documenso will email recipients directly the CRM&apos;s branded email
is in addition. Set to NONE on the template to let the CRM be the sole
sender.
</span>
)}
</li>
<li>
<span className="text-muted-foreground">Post-sign redirect:</span>{' '}
<span className="font-mono">
{lastResult.templateMeta.redirectUrl ?? '(none)'}
</span>
</li>
</ul>
</div>
)}
<div className="pt-1 font-medium text-muted-foreground">
Fields: {lastResult.fieldCount} cached for <code>prefillFields</code>
{lastResult.fieldCount === 0 && (
<span className="ml-1 font-normal text-muted-foreground">
that&apos;s fine if your template is a fillable PDF (AcroForm). The CRM will
fill it via <code>formValues</code>-by-name instead, same as on v1.{' '}
<code>prefillFields</code> is only needed if you placed field overlays directly in
the Documenso template editor.
</span>
)}
</div>
{lastResult.matchedFields.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-emerald-700 dark:text-emerald-400">
CRM will fill ({lastResult.matchedFields.length})
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.matchedFields.map((f) => (
<span
key={f.fieldId}
className="rounded bg-emerald-100 px-1.5 py-0.5 font-mono text-[10px] text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"
>
{f.label} #{f.fieldId}
</span>
))}
</div>
</div>
)}
{lastResult.unmatchedTemplateFields.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-amber-700 dark:text-amber-400">
Template fields the CRM doesn&apos;t recognize (
{lastResult.unmatchedTemplateFields.length})
</div>
<p className="text-[11px] text-muted-foreground">
These won&apos;t be filled. Rename them in the Documenso template editor to match
a CRM-expected label (Name, Email, Address, Yacht Name, Length, Width, Draft,
Berth Number, Lease_10, Purchase), or ignore if they&apos;re signature/date fields
the recipient fills in themselves.
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.unmatchedTemplateFields.map((f) => (
<span
key={f.fieldId}
className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
{f.label} #{f.fieldId}
</span>
))}
</div>
</div>
)}
{lastResult.acroForm.length > 0 && (
<div className="pt-2.5 border-t border-emerald-200/60 dark:border-emerald-900/40">
<div className="font-medium text-foreground">
PDF AcroForm fields (the <code>formValues</code> path)
</div>
<p className="pt-0.5 text-[11px] text-muted-foreground">
These are the fillable fields actually in the PDF binary on Documenso. The CRM
fills them by name at send time this is the same mechanism the prod v1 server
uses.
</p>
{lastResult.acroForm.map((report) => (
<div key={report.envelopeItemId} className="mt-1.5 space-y-1">
{report.error ? (
<div className="rounded bg-destructive/10 px-2 py-1 text-[11px] text-destructive">
Couldn&apos;t inspect this PDF: {report.error}
</div>
) : report.fields.length === 0 ? (
<div className="rounded bg-amber-100 px-2 py-1 text-[11px] text-amber-900 dark:bg-amber-950 dark:text-amber-200">
This PDF has no AcroForm fields. The CRM&apos;s <code>formValues</code>{' '}
path will fill nothing. Re-export your PDF with form fields enabled, or
place overlays inside Documenso&apos;s editor and use{' '}
<code>prefillFields</code> instead.
</div>
) : (
<>
{report.matchedFieldNames.length > 0 && (
<div>
<div className="font-medium text-emerald-700 dark:text-emerald-400">
CRM-fillable AcroForm fields ({report.matchedFieldNames.length})
</div>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.matchedFieldNames.map((n) => (
<span
key={n}
className="rounded bg-emerald-100 px-1.5 py-0.5 font-mono text-[10px] text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"
>
{n}
</span>
))}
</div>
</div>
)}
{report.missingFieldNames.length > 0 && (
<div>
<div className="font-medium text-amber-700 dark:text-amber-400">
CRM tokens missing from the PDF ({report.missingFieldNames.length})
</div>
<p className="text-[11px] text-muted-foreground">
These exact names need AcroForm text/checkbox fields in the PDF, or
they&apos;ll be dropped at send time.
</p>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.missingFieldNames.map((n) => (
<span
key={n}
className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
{n}
</span>
))}
</div>
</div>
)}
{report.extraFieldNames.length > 0 && (
<div>
<div className="font-medium text-muted-foreground">
PDF fields the CRM has no token for ({report.extraFieldNames.length})
</div>
<p className="text-[11px] text-muted-foreground">
Usually signature blocks or other fields the recipient fills in
directly. Safe to ignore.
</p>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{report.extraFieldNames.map((n) => (
<span
key={n}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{n}
</span>
))}
</div>
</div>
)}
</>
)}
</div>
))}
</div>
)}
{lastResult.fieldCount > 0 && lastResult.missingFromTemplate.length > 0 && (
<div className="pt-1.5">
<div className="font-medium text-muted-foreground">
CRM data points not in <code>prefillFields</code> (
{lastResult.missingFromTemplate.length})
</div>
<p className="text-[11px] text-muted-foreground">
These would also be available as <code>prefillFields</code> if you added matching
overlays inside Documenso&apos;s template editor.
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{lastResult.missingFromTemplate.map((label) => (
<span
key={label}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{label}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
{sync.isError && !lastResult && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs">
<div className="flex items-center gap-2 font-medium text-destructive">
<XCircle className="size-3" /> Sync failed check the Documenso credentials above and
confirm the template exists on the configured instance.
</div>
</div>
)}
</div>
);
}

View File

@@ -66,7 +66,7 @@ export function EmailRoutingCard() {
mutationFn: (routing: Record<string, Sender>) =>
apiFetch<RoutingResponse>('/api/v1/admin/email/routing', {
method: 'PATCH',
body: JSON.stringify({ routing }),
body: { routing },
}),
onSuccess: (resp) => {
qc.setQueryData(['admin', 'email', 'routing'], resp);

View File

@@ -90,6 +90,7 @@ export function FormTemplateList() {
<Button
variant="ghost"
size="icon"
aria-label="Edit form template"
onClick={() => {
setEditing(t);
setFormOpen(true);
@@ -99,7 +100,12 @@ export function FormTemplateList() {
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Button
variant="ghost"
size="icon"
className="text-destructive"
aria-label="Delete form template"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
}

View File

@@ -13,7 +13,7 @@
* wire means "clear".
*/
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -94,6 +94,8 @@ const EMPTY_FORM: FormState = {
export function SalesEmailConfigCard() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [lastTest, setLastTest] = useState<{ ok: boolean; message: string; at: Date } | null>(null);
const [smtpPassSet, setSmtpPassSet] = useState(false);
const [imapPassSet, setImapPassSet] = useState(false);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
@@ -136,6 +138,38 @@ export function SalesEmailConfigCard() {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleTestSmtp() {
setTesting(true);
setLastTest(null);
try {
const res = (await apiFetch('/api/v1/admin/email/sales-config/test-smtp', {
method: 'POST',
body: {},
})) as { data: { ok: boolean; to?: string; error?: string } };
if (res.data.ok) {
setLastTest({
ok: true,
message: `Test email sent to ${res.data.to ?? 'your inbox'}. Check delivery.`,
at: new Date(),
});
toast.success('Test SMTP send queued.');
} else {
setLastTest({
ok: false,
message: res.data.error ?? 'Unknown error',
at: new Date(),
});
toast.error('SMTP test failed — see card for details.');
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setLastTest({ ok: false, message: msg, at: new Date() });
toastError(err);
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
try {
@@ -385,7 +419,37 @@ export function SalesEmailConfigCard() {
</CardContent>
</Card>
<div className="flex justify-end">
{lastTest ? (
<div
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
lastTest.ok
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
: 'border-rose-200 bg-rose-50 text-rose-900'
}`}
>
{lastTest.ok ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
) : (
<XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
)}
<div className="flex-1">
<p className="font-medium">{lastTest.ok ? 'SMTP test sent' : 'SMTP test failed'}</p>
<p className="text-xs">{lastTest.message}</p>
<p className="mt-0.5 text-[11px] opacity-70">{lastTest.at.toLocaleTimeString()}</p>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={handleTestSmtp}
disabled={testing || saving}
title="Send a test message to your account via the configured SMTP credentials."
>
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
Test SMTP
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />}
Save sales email settings

View File

@@ -0,0 +1,608 @@
'use client';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Download, Eye, EyeOff, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
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 { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type SettingType =
| 'string'
| 'password'
| 'number'
| 'boolean'
| 'select'
| 'url'
| 'email'
| 'textarea'
| 'user-select';
type SettingSource = 'port' | 'global' | 'env' | 'default';
interface RegistryClientEntry {
key: string;
section: string;
label: string;
description: string;
type: SettingType;
options?: Array<{ value: string; label: string }>;
encrypted: boolean;
sensitive: boolean;
scope: 'port' | 'global';
envFallback?: string;
placeholder?: string;
defaultValue?: string | number | boolean | null;
}
interface ResolvedValue {
key: string;
source: SettingSource;
isSet: boolean;
value?: unknown;
}
interface ResolvedResponse {
data: { entries: RegistryClientEntry[]; values: Record<string, ResolvedValue> };
}
interface Props {
/** Section names from the registry to render (e.g. ['documenso.api', 'documenso.signers']). */
sections: string[];
/** Card-level title; omit to render fields without a card wrapper. */
title?: string;
/** Card-level description. */
description?: string;
/** Optional slot below the form (e.g. test-connection button). */
extra?: ReactNode;
}
/**
* Generates an editable settings form from the central registry. Renders the
* "Using env fallback" badge on each field whose resolved source is `env`
* (or `default`), plus a "Copy from env" button when an env value exists to
* one-click migrate the value into the admin DB.
*
* Encrypted / sensitive fields show ••• placeholder text and never receive
* the actual cleartext from the server. Saving an empty value on these
* fields is a no-op (use the explicit DELETE button to revert).
*/
export function RegistryDrivenForm({ sections, title, description, extra }: Props) {
const queryKey = ['settings', 'resolved', ...sections];
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<ResolvedResponse>({
queryKey,
queryFn: () =>
apiFetch<ResolvedResponse>(
`/api/v1/admin/settings/resolved?sections=${sections.map(encodeURIComponent).join(',')}`,
),
});
// Lifted draft state — every field's current input value is held here so
// a card-level "Save N changes" button can write them all in one batch.
// Sensitive fields seed as empty (we never seed cleartext from the server);
// non-sensitive fields seed from the resolved value.
const [drafts, setDrafts] = useState<Record<string, unknown>>({});
// A field is "dirty" only after the operator types into it. Server-driven
// events (eye-toggle reveal, copy-from-env autofill) explicitly clear the
// dirty flag for that key so they don't trigger a phantom save.
const [dirtyKeys, setDirtyKeys] = useState<Set<string>>(() => new Set());
// Re-seed drafts whenever the resolved-values query refreshes (after a
// successful save, revert, or copy-from-env) so values reflect server
// state. Preserves any in-progress edits the user is making.
useEffect(() => {
if (!data) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setDrafts((prev) => {
const next = { ...prev };
for (const entry of data.data.entries) {
if (dirtyKeys.has(entry.key)) continue; // don't trample in-progress edits
if (entry.encrypted || entry.sensitive) {
next[entry.key] = '';
} else {
next[entry.key] = data.data.values[entry.key]?.value ?? '';
}
}
return next;
});
}, [data, dirtyKeys]);
const setDraft = useCallback((key: string, value: unknown, opts?: { dirty?: boolean }) => {
setDrafts((prev) => ({ ...prev, [key]: value }));
if (opts?.dirty !== undefined) {
setDirtyKeys((prev) => {
const next = new Set(prev);
if (opts.dirty) next.add(key);
else next.delete(key);
return next;
});
}
}, []);
// Card-level bulk save. Fires one PUT per dirty field in parallel so the
// common case ("admin tweaks five fields, hits Save") is one round-trip
// worth of latency rather than five. Partial failures are surfaced
// per-field via toast; the resolved-values query gets invalidated once
// even on partial success so the UI reflects what landed.
const saveAll = useMutation({
mutationFn: async () => {
if (!data)
return { succeeded: [] as string[], failed: [] as Array<{ key: string; error: unknown }> };
const dirty = Array.from(dirtyKeys);
const settled = await Promise.allSettled(
dirty.map(async (key) => {
await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
body: { value: drafts[key] },
});
return key;
}),
);
const succeeded: string[] = [];
const failed: Array<{ key: string; error: unknown }> = [];
settled.forEach((r, i) => {
const key = dirty[i]!;
if (r.status === 'fulfilled') succeeded.push(key);
else failed.push({ key, error: r.reason });
});
return { succeeded, failed };
},
onSuccess: ({ succeeded, failed }) => {
// Clear dirty flags for the keys that landed; leave failed ones dirty
// so the operator can fix + retry.
if (succeeded.length > 0) {
setDirtyKeys((prev) => {
const next = new Set(prev);
for (const k of succeeded) next.delete(k);
return next;
});
toast.success(
succeeded.length === 1 ? `Saved 1 setting` : `Saved ${succeeded.length} settings`,
);
}
for (const f of failed) {
const label = data?.data.entries.find((e) => e.key === f.key)?.label ?? f.key;
toastError(f.error, `Failed to save ${label}`);
}
void queryClient.invalidateQueries({ queryKey });
},
onError: (err) => toastError(err, 'Failed to save settings'),
});
const dirtyCount = dirtyKeys.size;
const content = (
<div className="space-y-6">
{isLoading || !data ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : (
<>
{groupBySection(data.data.entries).map(([section, entries]) => (
<SectionGroup
key={section}
entries={entries}
values={data.data.values}
drafts={drafts}
setDraft={setDraft}
onResolvedRefresh={() => queryClient.invalidateQueries({ queryKey })}
/>
))}
<div className="flex items-center justify-between gap-3 border-t pt-4">
<div className="text-xs text-muted-foreground">
{dirtyCount === 0
? 'No unsaved changes.'
: dirtyCount === 1
? '1 unsaved change.'
: `${dirtyCount} unsaved changes.`}
</div>
<Button
onClick={() => saveAll.mutate()}
disabled={saveAll.isPending || dirtyCount === 0}
>
{saveAll.isPending ? (
<>
<Loader2 className="mr-1 size-3 animate-spin" /> Saving
</>
) : dirtyCount > 0 ? (
`Save ${dirtyCount} change${dirtyCount === 1 ? '' : 's'}`
) : (
'Save'
)}
</Button>
</div>
</>
)}
{extra ? <div className="pt-2">{extra}</div> : null}
</div>
);
if (!title) return content;
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent>{content}</CardContent>
</Card>
);
}
function groupBySection(entries: RegistryClientEntry[]): Array<[string, RegistryClientEntry[]]> {
const map = new Map<string, RegistryClientEntry[]>();
for (const e of entries) {
const existing = map.get(e.section);
if (existing) existing.push(e);
else map.set(e.section, [e]);
}
return Array.from(map.entries());
}
function SectionGroup({
entries,
values,
drafts,
setDraft,
onResolvedRefresh,
}: {
entries: RegistryClientEntry[];
values: Record<string, ResolvedValue>;
drafts: Record<string, unknown>;
setDraft: (key: string, value: unknown, opts?: { dirty?: boolean }) => void;
onResolvedRefresh: () => void;
}) {
return (
<div className="space-y-4">
{entries.map((entry) => (
<SettingField
key={entry.key}
entry={entry}
resolved={values[entry.key]}
draft={drafts[entry.key]}
setDraft={(value, opts) => setDraft(entry.key, value, opts)}
onResolvedRefresh={onResolvedRefresh}
/>
))}
</div>
);
}
function SettingField({
entry,
resolved,
draft,
setDraft,
onResolvedRefresh,
}: {
entry: RegistryClientEntry;
resolved: ResolvedValue | undefined;
draft: unknown;
setDraft: (value: unknown, opts?: { dirty?: boolean }) => void;
onResolvedRefresh: () => void;
}) {
const [showSecret, setShowSecret] = useState(false);
// Tracks whether `draft` currently holds a server-revealed value (vs.
// something the operator just typed). Lets the toggle button hide the
// revealed value cleanly without wiping a fresh edit.
const [revealedFromServer, setRevealedFromServer] = useState(false);
const reveal = useMutation({
mutationFn: async () => {
const r = await apiFetch<{ data: { revealed: boolean; value: string | null } }>(
`/api/v1/admin/settings/${encodeURIComponent(entry.key)}/reveal`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (r) => {
if (r.revealed && r.value != null) {
// Server reveal — populate draft but do NOT mark dirty (the value
// already matches what's stored).
setDraft(r.value, { dirty: false });
setRevealedFromServer(true);
setShowSecret(true);
} else {
toast.info(`${entry.label} isn't set — nothing to reveal.`);
}
},
onError: (err) => toastError(err, `Failed to reveal ${entry.label}`),
});
const revert = useMutation({
mutationFn: async () => {
await apiFetch(`/api/v1/admin/settings/${encodeURIComponent(entry.key)}`, {
method: 'DELETE',
});
},
onSuccess: () => {
toast.success(`${entry.label} reverted to default`);
setDraft('', { dirty: false });
onResolvedRefresh();
},
onError: (err) => toastError(err, `Failed to revert ${entry.label}`),
});
const copyFromEnv = useMutation({
mutationFn: async () => {
const r = await apiFetch<{ data: { copied: boolean; envValue?: string } }>(
`/api/v1/admin/settings/${encodeURIComponent(entry.key)}/copy-from-env`,
{ method: 'POST' },
);
return r.data;
},
onSuccess: (r) => {
if (r.copied) {
toast.success(`${entry.label} copied from env`);
if (r.envValue && !entry.sensitive) setDraft(r.envValue, { dirty: false });
} else {
toast.info(`No env value to copy for ${entry.label}`);
}
onResolvedRefresh();
},
onError: (err) => toastError(err, `Failed to copy ${entry.label} from env`),
});
const source = resolved?.source ?? 'default';
const showFallbackBadge = source === 'env' || source === 'default';
const canCopyFromEnv = !!entry.envFallback && source === 'env';
return (
<div className="space-y-1.5 border-l-2 border-l-muted pl-4">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={entry.key} className="text-sm font-medium">
{entry.label}
</Label>
<div className="flex items-center gap-1.5">
{source === 'port' && (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 size-3" />
Port override
</Badge>
)}
{source === 'global' && (
<Badge variant="secondary" className="text-xs">
Global
</Badge>
)}
{showFallbackBadge && resolved?.isSet && (
<Badge variant="outline" className="text-xs">
Using env fallback
</Badge>
)}
{showFallbackBadge && !resolved?.isSet && (
<Badge variant="outline" className="text-xs text-muted-foreground">
Not set
</Badge>
)}
</div>
</div>
{entry.description && <p className="text-xs text-muted-foreground">{entry.description}</p>}
<FieldInput
entry={entry}
value={draft}
onChange={(v) => {
// User typing → mark dirty so the card-level Save button picks it up.
setDraft(v, { dirty: true });
// A fresh keystroke supersedes any prior server-reveal.
if (revealedFromServer) setRevealedFromServer(false);
}}
showSecret={showSecret}
sensitive={entry.sensitive}
placeholder={entry.placeholder}
/>
<div className="flex flex-wrap items-center gap-2 pt-1">
{canCopyFromEnv && (
<Button
variant="outline"
size="sm"
onClick={() => copyFromEnv.mutate()}
disabled={copyFromEnv.isPending}
>
<Download className="mr-1 size-3" />
Copy from env
</Button>
)}
{(source === 'port' || source === 'global') && (
<Button
variant="ghost"
size="sm"
onClick={() => revert.mutate()}
disabled={revert.isPending}
>
Revert to fallback
</Button>
)}
{entry.sensitive && entry.type === 'password' && (
<Button
type="button"
variant="ghost"
size="sm"
disabled={reveal.isPending}
onClick={() => {
if (showSecret) {
// Hide. If this draft came from the server reveal, drop it so
// we don't keep cleartext in component state past the toggle.
if (revealedFromServer) {
setDraft('', { dirty: false });
setRevealedFromServer(false);
}
setShowSecret(false);
return;
}
// Show. If the operator hasn't typed anything yet and the
// setting is saved on the server, ask the API for cleartext.
const hasLocalDraft = typeof draft === 'string' && draft.length > 0;
if (
!hasLocalDraft &&
resolved?.isSet &&
(resolved.source === 'port' || resolved.source === 'global')
) {
reveal.mutate();
} else {
setShowSecret(true);
}
}}
>
{reveal.isPending ? (
<Loader2 className="size-3 animate-spin" />
) : showSecret ? (
<EyeOff className="size-3" />
) : (
<Eye className="size-3" />
)}
</Button>
)}
</div>
</div>
);
}
function FieldInput({
entry,
value,
onChange,
showSecret,
sensitive,
placeholder,
}: {
entry: RegistryClientEntry;
value: unknown;
onChange: (v: unknown) => void;
showSecret: boolean;
sensitive: boolean;
placeholder?: string;
}) {
if (entry.type === 'boolean') {
return (
<Switch id={entry.key} checked={!!value} onCheckedChange={(checked) => onChange(checked)} />
);
}
if (entry.type === 'user-select') {
return (
<UserSelectInput
id={entry.key}
value={typeof value === 'string' ? value : ''}
onChange={onChange}
placeholder={placeholder ?? 'No CRM user linked'}
/>
);
}
if (entry.type === 'select' && entry.options) {
// Radix Select rejects an empty-string `value` because that's its internal
// sentinel for "cleared". Pass `undefined` instead so the placeholder
// renders cleanly when the resolved value is null/blank.
const selectValue = value == null || value === '' ? undefined : String(value);
return (
<Select value={selectValue} onValueChange={(v) => onChange(v)}>
<SelectTrigger id={entry.key}>
<SelectValue placeholder={placeholder ?? 'Choose…'} />
</SelectTrigger>
<SelectContent>
{entry.options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (entry.type === 'textarea') {
return (
<Textarea
id={entry.key}
value={String(value ?? '')}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
rows={4}
/>
);
}
return (
<Input
id={entry.key}
type={
entry.type === 'password' && !showSecret
? 'password'
: entry.type === 'number'
? 'number'
: entry.type === 'email'
? 'email'
: entry.type === 'url'
? 'url'
: 'text'
}
value={sensitive && !showSecret && value === '' ? '' : String(value ?? '')}
placeholder={sensitive ? '••••••••' : placeholder}
onChange={(e) =>
onChange(entry.type === 'number' ? Number(e.target.value || 0) : e.target.value)
}
/>
);
}
interface PickerUser {
id: string;
email: string;
name: string;
}
/**
* Renders a Radix Select of every user in the current port. Stores the
* user's UUID. A "no link" sentinel value lets the operator clear the
* binding (Radix can't store empty string as a value, so we map empty ↔
* `__none__` over the wire).
*/
function UserSelectInput({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const { data, isLoading } = useQuery<{ data: PickerUser[] }>({
queryKey: ['admin', 'users', 'picker'],
queryFn: () => apiFetch<{ data: PickerUser[] }>('/api/v1/admin/users/picker'),
staleTime: 60_000,
});
const NONE = '__none__';
const selectValue = value ? value : NONE;
return (
<Select value={selectValue} onValueChange={(v) => onChange(v === NONE ? '' : v)}>
<SelectTrigger id={id}>
<SelectValue placeholder={isLoading ? 'Loading users…' : placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}> No CRM user linked </SelectItem>
{(data?.data ?? []).map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email} {u.name ? `· ${u.email}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -417,7 +417,14 @@ function ImageUploadField({
<div className="h-20 w-20 shrink-0 rounded-md border bg-muted/30 flex items-center justify-center overflow-hidden">
{}
{value ? (
<img src={value} alt="" className="h-full w-full object-contain" />
<img
src={value}
// M-U11: describe the preview so screen readers don't say
// "image" with no context. Falls back to a generic label
// when no field.label is set.
alt={`${field.label || 'Settings'} preview`}
className="h-full w-full object-contain"
/>
) : (
<span className="text-[10px] text-muted-foreground">No image</span>
)}

View File

@@ -28,6 +28,7 @@ import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -77,13 +78,31 @@ const S3_FIELDS: SettingFieldDef[] = [
defaultValue: '',
},
{
// Reads from the legacy plaintext key for backward compat. New writes
// go through the registry-driven form below (which uses the
// `*_encrypted` key + AES at rest). After running the migration script
// (`pnpm tsx scripts/encrypt-plaintext-credentials.ts`) this field is
// empty and the encrypted form takes over.
key: 'storage_s3_access_key',
label: 'S3 access key',
description: 'IAM access key id (or provider equivalent).',
label: 'S3 access key (legacy plaintext — deprecated)',
description:
'Deprecated. Use the AES-encrypted access key field below instead. After running the migration script, this row is removed and only the encrypted form is used.',
type: 'string',
placeholder: 'AKIA…',
defaultValue: '',
},
{
// M-S01: encrypted at rest like the secret key. The legacy plaintext
// field above is reflected for backward compat but new writes go
// through this AES envelope.
key: 'storage_s3_access_key_encrypted',
label: 'S3 access key (encrypted)',
description:
'Stored AES-encrypted at rest; the field shows blank after save and is replaced only when you type a new value. Run `pnpm tsx scripts/encrypt-plaintext-credentials.ts` to migrate the legacy plaintext value into this field.',
type: 'password',
placeholder: '(unchanged)',
defaultValue: '',
},
{
key: 'storage_s3_secret_key_encrypted',
label: 'S3 secret key',
@@ -133,7 +152,7 @@ export function StorageAdminPanel() {
mutationFn: async (opts: { from: BackendName; to: BackendName }) =>
apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', {
method: 'POST',
body: JSON.stringify({ ...opts, dryRun: true }),
body: { ...opts, dryRun: true },
}),
onSuccess: (result) => {
setDryRun(result.data);
@@ -146,7 +165,7 @@ export function StorageAdminPanel() {
mutationFn: async (opts: { from: BackendName; to: BackendName; skipMigration: boolean }) =>
apiFetch<{ data: MigrationResult }>('/api/v1/admin/storage/migrate', {
method: 'POST',
body: JSON.stringify({ ...opts, dryRun: false }),
body: { ...opts, dryRun: false },
}),
onSuccess: (result) => {
setConfirmOpen(false);
@@ -203,6 +222,16 @@ export function StorageAdminPanel() {
description="Where the CRM stores per-berth PDFs, brochures, GDPR exports, profile photos, and other binary files."
/>
{/* AES-encrypted access key — write path. The legacy plaintext access
key field below is read-only deprecation; new writes should go
through this card. After running the encrypt-plaintext-credentials
migration script, the legacy field becomes empty. */}
<RegistryDrivenForm
title="S3 access key (encrypted)"
description="AES-encrypted at rest. Type your access key here — it replaces the deprecated plaintext field below and fixes audit finding S-23."
sections={['storage.s3']}
/>
{/* STEP 1: configure connection details for the OTHER backend so the
admin can prep + test BEFORE attempting any switch. */}
<SettingsFormCard

View File

@@ -1,9 +1,11 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import { Anchor, Plus } from 'lucide-react';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
@@ -29,6 +31,12 @@ import { mooringLetterTone } from './mooring-letter-tone';
export function BerthList() {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
// M-U14: surface the page title in the mobile topbar.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Berths', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// F13: bulk-add wizard had no UI entry point. Gate the CTA on
// `berths.import` (the existing permission used for adding berths)
// so non-admins don't see a button that 403s on click.

View File

@@ -5,6 +5,7 @@ import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -190,11 +191,25 @@ export function ClientForm({
// is the fall-back if the rep wiped the value after focus.
throw Object.assign(new Error('At least one contact is required.'), { status: 400 });
}
// If none of the remaining contacts is flagged primary, promote
// the first one — guards against a rep removing the originally-
// primary row and leaving an orphan set.
if (!cleanedContacts.some((c) => c.isPrimary)) {
cleanedContacts[0]!.isPrimary = true;
// Primary is per-channel (DB has a partial unique index on
// (client_id, channel) WHERE is_primary). For every channel present
// in the cleaned set, ensure exactly one row is flagged primary —
// promote the first row of that channel if none was explicitly
// marked, and clear duplicates so the API doesn't 409.
const seenPrimaryByChannel = new Set<string>();
for (const c of cleanedContacts) {
if (c.isPrimary && !seenPrimaryByChannel.has(c.channel)) {
seenPrimaryByChannel.add(c.channel);
} else if (c.isPrimary) {
// duplicate primary within the channel — clear
c.isPrimary = false;
}
}
const seenChannels = new Set<string>(cleanedContacts.map((c) => c.channel));
for (const channel of seenChannels) {
if (seenPrimaryByChannel.has(channel)) continue;
const first = cleanedContacts.find((c) => c.channel === channel);
if (first) first.isPrimary = true;
}
const payload: CreateClientInput = { ...data, contacts: cleanedContacts };
@@ -214,6 +229,9 @@ export function ClientForm({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
// M-U10: confirm the write landed. Without this the rep closes
// the sheet not sure whether the create/edit actually saved.
toast.success(isEdit ? 'Client updated' : 'Client created');
onOpenChange(false);
},
});
@@ -389,9 +407,41 @@ export function ClientForm({
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<Checkbox
checked={watch(`contacts.${index}.isPrimary`)}
onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
onCheckedChange={(v) => {
const checked = !!v;
const thisChannel = watch(`contacts.${index}.channel`);
if (checked) {
// Primary is per-channel — flipping this one on
// clears the flag on every other row sharing the
// same channel. (DB enforces uniqueness via a
// partial index, but doing it client-side avoids
// a surprising 409 mid-save.)
const all = getValues('contacts') ?? [];
const next = all.map((c, i) => ({
...c,
isPrimary:
i === index
? true
: c.channel === thisChannel
? false
: !!c.isPrimary,
}));
setValue('contacts', next, { shouldDirty: true });
} else {
setValue(`contacts.${index}.isPrimary`, false, { shouldDirty: true });
}
}}
/>
<span className="font-medium">Primary contact</span>
<span className="font-medium">
Primary{' '}
{watch(`contacts.${index}.channel`) === 'email'
? 'email'
: watch(`contacts.${index}.channel`) === 'phone'
? 'phone'
: watch(`contacts.${index}.channel`) === 'whatsapp'
? 'WhatsApp'
: 'contact'}
</span>
</label>
{fields.length > 1 && (
<Button

View File

@@ -1,11 +1,13 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
@@ -50,6 +52,14 @@ export function ClientList() {
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
// M-U14: surface the page title in the mobile topbar so reps don't
// see a blank chrome row above the list.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Clients', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, ArrowRight, Briefcase, X } from 'lucide-react';
import { AlertCircle, Archive, ArrowRight, Briefcase, X } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
@@ -17,6 +19,10 @@ interface MatchData {
interestCount: number;
emails: string[];
phonesE164: string[];
/** ISO timestamp when the client was archived. When set, the matched
* client is soft-deleted — the suggestion panel surfaces a Restore link
* to the existing restore wizard instead of "Use this client". */
archivedAt: string | null;
}
interface DedupSuggestionPanelProps {
@@ -50,6 +56,8 @@ export function DedupSuggestionPanel({
onUseExisting,
onDismiss,
}: DedupSuggestionPanelProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [dismissed, setDismissed] = useState(false);
// Debounce inputs by 300ms so we don't fire on every keystroke. Keep
@@ -95,36 +103,56 @@ export function DedupSuggestionPanel({
const top = matches[0]!;
const isHigh = top.confidence === 'high';
const isArchived = !!top.archivedAt;
return (
<div
className={cn(
'rounded-lg border p-3 mb-3 transition-colors',
isHigh
? 'border-amber-300 bg-amber-50/60 dark:bg-amber-950/30'
: 'border-border bg-muted/40',
isArchived
? 'border-slate-300 bg-slate-50/60 dark:border-slate-700 dark:bg-slate-900/40'
: isHigh
? 'border-amber-300 bg-amber-50/60 dark:bg-amber-950/30'
: 'border-border bg-muted/40',
)}
data-testid="dedup-suggestion"
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<AlertCircle
className={cn(
'size-5',
isHigh ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground',
)}
aria-hidden
/>
{isArchived ? (
<Archive className="size-5 text-slate-600 dark:text-slate-400" aria-hidden />
) : (
<AlertCircle
className={cn(
'size-5',
isHigh ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground',
)}
aria-hidden
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold leading-tight">
{isHigh
? 'This looks like an existing client'
: 'Possible match - check before creating'}
{isArchived
? 'This contact info belongs to an archived client'
: isHigh
? 'This looks like an existing client'
: 'Possible match — check before creating'}
</p>
{isArchived && (
<p className="mt-0.5 text-xs text-muted-foreground">
Restore the existing record (keeping its history + interests), or create a fresh one
if this is a different person.
</p>
)}
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{top.fullName}</p>
{isArchived && (
<span className="shrink-0 rounded-full bg-slate-200 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-800 dark:bg-slate-700 dark:text-slate-200">
archived
</span>
)}
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
@@ -143,19 +171,36 @@ export function DedupSuggestionPanel({
<Briefcase className="size-3" aria-hidden />
{top.interestCount} {top.interestCount === 1 ? 'interest' : 'interests'}
</span>
{isArchived && top.archivedAt && (
<span className="inline-flex items-center gap-1">
archived {new Date(top.archivedAt).toLocaleDateString()}
</span>
)}
</div>
<p className="mt-1.5 text-[11px] text-muted-foreground">{top.reasons.join(' · ')}</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => onUseExisting(top)}
data-testid="dedup-use-existing"
>
Use this client
<ArrowRight className="ml-1 size-3.5" aria-hidden />
</Button>
{isArchived ? (
<Button asChild type="button" size="sm">
<Link
href={`/${portSlug}/clients/${top.clientId}/restore`}
data-testid="dedup-restore-archived"
>
Restore this client
<ArrowRight className="ml-1 size-3.5" aria-hidden />
</Link>
</Button>
) : (
<Button
type="button"
size="sm"
onClick={() => onUseExisting(top)}
data-testid="dedup-use-existing"
>
Use this client
<ArrowRight className="ml-1 size-3.5" aria-hidden />
</Button>
)}
<Button
type="button"
size="sm"
@@ -167,7 +212,7 @@ export function DedupSuggestionPanel({
data-testid="dedup-dismiss"
>
<X className="mr-1 size-3.5" aria-hidden />
Create new anyway
{isArchived ? 'Create new anyway (different person)' : 'Create new anyway'}
</Button>
{matches.length > 1 ? (
<span className="text-xs text-muted-foreground">

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
@@ -70,7 +71,7 @@ export function CompanyList() {
queryClient.invalidateQueries({ queryKey: ['companies'] });
const s = res.data.summary;
if (s.failed > 0) {
alert(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
toast.warning(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
}
},
});

View File

@@ -225,7 +225,12 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
<PermissionGate resource="memberships" action="manage">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Member actions"
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>

View File

@@ -214,7 +214,13 @@ function ActivityFeedInner() {
{item.label ? (
<>
<span className="font-medium">{item.label}</span>
<span className="ml-1.5 text-muted-foreground text-xs capitalize">
{/* M-NEW-2: explicit middle-dot separator. The
prior `ml-1.5` was getting collapsed under
`truncate` so the label + type rendered as
"Test Person 1interest" with no visible
space between them. */}
<span className="text-muted-foreground/60 mx-1.5">·</span>
<span className="text-muted-foreground text-xs capitalize">
{item.entityType}
</span>
</>

View File

@@ -1,11 +1,11 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useRouter } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Bell, Download, Mail, Trash2, X } from 'lucide-react';
import { ArrowLeft, Bell, Download, Mail, Send, Trash2, UserPlus, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -15,6 +15,22 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cleanSignerName } from '@/components/documents/signing-progress';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/** Capitalize the first letter; rest stays as-is. Used for normalising
* free-text enum values ('signer'/'approver'/'sent'/'pending') for
* display without resorting to full ALL-CAPS that other surfaces use. */
function capFirst(s: string | null | undefined): string {
if (!s) return '';
return s.charAt(0).toUpperCase() + s.slice(1);
}
interface DetailDoc {
id: string;
@@ -39,6 +55,9 @@ interface DetailSigner {
signerRole: string;
signingOrder: number;
status: string;
/** Null = never invited yet → "Send invitation" CTA.
* Set + status pending → "Send reminder" CTA. */
invitedAt: string | null;
signedAt: string | null;
signingUrl: string | null;
}
@@ -158,6 +177,22 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
}
};
// #67: state-aware action button. When a signer has no `invitedAt`
// they've never been mailed — fire the initial invitation (the same
// route the EOI tab uses; handles v2 distribute-or-self-heal).
const handleSendInvitation = async (signerId: string) => {
try {
await apiFetch(`/api/v1/documents/${documentId}/send-invitation`, {
method: 'POST',
body: { signerId },
});
toast.success('Invitation sent');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
};
const handleCancel = async () => {
const ok = await confirm({
title: 'Cancel document',
@@ -213,7 +248,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
kpiLine={
<>
<StatusPill status={STATUS_PILL_MAP[doc.status] ?? 'pending'} withDot>
{doc.status.replace(/_/g, ' ')}
{capFirst(doc.status.replace(/_/g, ' '))}
</StatusPill>
<span>
{signers.filter((s) => s.status === 'signed').length}/{signers.length} signed
@@ -279,28 +314,42 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-foreground">{signer.signerName}</div>
<div className="font-medium text-foreground">
{/* #67 cleanup: strip `(was: …)` / `(placeholder)`
email-redirect leak suffixes that the EOI tab
already scrubs on its own SigningProgress card. */}
{cleanSignerName(signer.signerName) || signer.signerEmail}
</div>
<StatusPill status={SIGNER_PILL_MAP[signer.status] ?? 'pending'}>
{signer.status}
{capFirst(signer.status)}
</StatusPill>
</div>
<div className="text-xs text-muted-foreground">
{signer.signerEmail} · {signer.signerRole}
{signer.signerEmail} · {capFirst(signer.signerRole)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{signer.signedAt
? `Signed ${new Date(signer.signedAt).toLocaleDateString('en-GB')}`
: 'Pending'}
: signer.invitedAt
? `Invited ${new Date(signer.invitedAt).toLocaleDateString('en-GB')}`
: 'Not yet invited'}
</div>
{signer.status === 'pending' && doc.documensoId && isInFlight ? (
<div className="mt-2 flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleRemind(signer.id)}
>
<Bell className="mr-1.5 h-3 w-3" aria-hidden /> Remind
</Button>
{/* #67 state-aware CTA: invited yet? remind. else: send. */}
{signer.invitedAt ? (
<Button
size="sm"
variant="outline"
onClick={() => handleRemind(signer.id)}
>
<Bell className="mr-1.5 h-3 w-3" aria-hidden /> Send reminder
</Button>
) : (
<Button size="sm" onClick={() => handleSendInvitation(signer.id)}>
<Send className="mr-1.5 h-3 w-3" aria-hidden /> Send invitation
</Button>
)}
{signer.signingUrl ? (
<button
type="button"
@@ -339,44 +388,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
{/* Right column */}
<div className="flex flex-col gap-4">
<section className="rounded-md border bg-white p-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Watchers
</h2>
{watchers.length === 0 ? (
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
) : (
<ul className="space-y-1">
{watchers.map((w) => (
<li key={w.userId} className="flex items-center justify-between text-sm">
<span className="truncate font-mono text-xs text-muted-foreground">
{w.userId.slice(0, 8)}
</span>
<button
type="button"
aria-label="Remove watcher"
onClick={async () => {
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers/${w.userId}`, {
method: 'DELETE',
});
toast.success('Watcher removed');
queryClient.invalidateQueries({
queryKey: ['document-detail', documentId],
});
} catch (err) {
toastError(err);
}
}}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
</li>
))}
</ul>
)}
</section>
<WatchersCard documentId={documentId} watchers={watchers} />
<section className="rounded-md border bg-white p-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
@@ -405,3 +417,130 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</div>
);
}
/**
* #67 watcher Add UI. The watchers list previously displayed only
* user-id stubs (truncated UUID) with a delete button and no way to
* add new watchers. This card resolves user IDs to display names
* via the existing `/api/v1/admin/users/picker` endpoint (already
* used by the registry-driven settings form), surfaces a "+ Add"
* select, and keeps the delete affordance unchanged.
*/
interface PickerUser {
id: string;
email: string;
name: string | null;
}
function WatchersCard({ documentId, watchers }: { documentId: string; watchers: DetailWatcher[] }) {
const queryClient = useQueryClient();
const [selected, setSelected] = useState('');
const { data: usersData } = useQuery({
queryKey: ['admin', 'users-picker'],
queryFn: () => apiFetch<{ data: PickerUser[] }>('/api/v1/admin/users/picker'),
});
const users = usersData?.data ?? [];
const userById = useMemo(() => {
const map = new Map<string, PickerUser>();
for (const u of users) map.set(u.id, u);
return map;
}, [users]);
const watcherIds = new Set(watchers.map((w) => w.userId));
const candidates = users.filter((u) => !watcherIds.has(u.id));
async function addWatcher(userId: string) {
if (!userId) return;
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers`, {
method: 'POST',
body: { userId },
});
toast.success('Watcher added');
setSelected('');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
}
async function removeWatcher(userId: string) {
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers/${userId}`, {
method: 'DELETE',
});
toast.success('Watcher removed');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
}
return (
<section className="rounded-md border bg-white p-4">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Watchers
</h2>
<p className="mb-3 text-xs text-muted-foreground">
Watchers receive an in-app notification on every signing event (opened, signed, declined,
completed).
</p>
{watchers.length === 0 ? (
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
) : (
<ul className="mb-3 space-y-1">
{watchers.map((w) => {
const u = userById.get(w.userId);
return (
<li key={w.userId} className="flex items-center justify-between text-sm">
<span className="truncate">
{u?.name ?? u?.email ?? `User ${w.userId.slice(0, 8)}`}
</span>
<button
type="button"
aria-label="Remove watcher"
onClick={() => removeWatcher(w.userId)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
</li>
);
})}
</ul>
)}
<div className="flex items-center gap-2">
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="h-9 flex-1 text-xs">
<SelectValue placeholder="Add a watcher…" />
</SelectTrigger>
<SelectContent>
{candidates.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
All users in this port are already watching.
</div>
) : (
candidates.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name ?? u.email}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={!selected}
onClick={() => addWatcher(selected)}
>
<UserPlus className="mr-1.5 h-3 w-3" aria-hidden /> Add
</Button>
</div>
</section>
);
}

View File

@@ -138,7 +138,10 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
'document:cancelled': [['documents']],
'document:rejected': [['documents']],
'document:signer:signed': [['documents']],
'file:created': [['files']],
// M-D01: server emits `file:uploaded` (see src/lib/services/files.ts);
// every other consumer listens on that name. `file:created` was a
// typo here, so the hub's file list never invalidated on upload.
'file:uploaded': [['files']],
'file:updated': [['files']],
'file:deleted': [['files']],
'folder:created': [['document-folders']],

View File

@@ -0,0 +1,179 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface Signer {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
status: string;
}
interface EoiCancelDialogProps {
documentId: string;
signers: Signer[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Cancel-with-notify modal. Two variants by signedCount:
* - 0 signed: simple confirm with optional reason. Cancel button.
* - 1+ signed: list each signer with a checkbox so the rep picks
* who to email. Pre-checks the signers who have signed (they're
* the most-affected) — rep can opt out.
*
* In both cases the reason textarea is optional and (when present)
* gets inlined into the cancellation email body + the audit log.
*
* On confirm: POST /api/v1/documents/[id]/cancel with
* { reason, notifyRecipients: [signerId, ...] }
* The server voids the envelope, marks status=cancelled, sends the
* branded cancellation email to each picked recipient.
*/
export function EoiCancelDialog({ documentId, signers, open, onOpenChange }: EoiCancelDialogProps) {
const queryClient = useQueryClient();
const [reason, setReason] = useState('');
const [notifyIds, setNotifyIds] = useState<Set<string>>(() => {
// Default: pre-check the signers who have signed — they're the
// recipients most likely to want to know. Pending signers can be
// notified too but the rep needs to opt them in.
return new Set(signers.filter((s) => s.status === 'signed').map((s) => s.id));
});
const signedCount = useMemo(() => signers.filter((s) => s.status === 'signed').length, [signers]);
const cancelMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/documents/${documentId}/cancel`, {
method: 'POST',
body: {
reason: reason.trim() || null,
notifyRecipients: Array.from(notifyIds),
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success(
notifyIds.size > 0
? `EOI cancelled. ${notifyIds.size} signer${notifyIds.size === 1 ? '' : 's'} notified.`
: 'EOI cancelled.',
);
onOpenChange(false);
// Reset internal state so a second open of the dialog starts clean.
setReason('');
setNotifyIds(new Set());
},
onError: (err) => toastError(err),
});
const toggle = (id: string) => {
setNotifyIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-amber-600" aria-hidden /> Cancel this EOI?
</DialogTitle>
<DialogDescription>
{signedCount === 0
? 'No signatures have been collected yet. The signing service will be told to void this envelope.'
: `${signedCount} signer${signedCount === 1 ? ' has' : 's have'} already signed. The envelope will be voided and pick the signers you want to notify by email below.`}
</DialogDescription>
</DialogHeader>
{signedCount > 0 && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Notify
</p>
<ul className="space-y-1.5">
{signers.map((s) => (
<li key={s.id} className="flex items-center gap-2 text-sm">
<Checkbox
id={`notify-${s.id}`}
checked={notifyIds.has(s.id)}
onCheckedChange={() => toggle(s.id)}
/>
<Label htmlFor={`notify-${s.id}`} className="flex-1 cursor-pointer font-normal">
<span className="font-medium">{s.signerName || s.signerEmail}</span>{' '}
<span className="text-xs text-muted-foreground">
· {s.signerRole}
{s.status === 'signed' ? ' · already signed' : ' · pending'}
</span>
</Label>
</li>
))}
</ul>
<p className="text-xs italic text-muted-foreground">
Leave all unchecked to cancel silently no emails will be sent.
</p>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="cancel-reason" className="text-xs font-semibold uppercase tracking-wide">
Reason (optional)
</Label>
<Textarea
id="cancel-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Yacht owner changed terms; will resend a fresh EOI."
className="min-h-[80px] resize-y"
maxLength={2000}
/>
<p className="text-xs text-muted-foreground">
Appears in the cancellation email (if you notify anyone) and the audit log.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep EOI
</Button>
<Button
variant="destructive"
onClick={() => cancelMutation.mutate()}
disabled={cancelMutation.isPending}
className="gap-1.5 [&_svg]:size-3.5"
>
{cancelMutation.isPending ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<XCircle />
)}
Cancel EOI
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -6,13 +6,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ExternalLink, FileSignature, Pencil } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import {
Select,
@@ -47,7 +47,14 @@ interface EoiContextResponse {
nationality: string | null;
primaryEmail: string | null;
primaryPhone: string | null;
address: { street: string; city: string; country: string } | null;
address: {
street: string;
city: string;
subdivision: string;
postalCode: string;
country: string;
countryIso: string;
} | null;
};
yacht: {
id: string;
@@ -55,6 +62,16 @@ interface EoiContextResponse {
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
/** Which unit the rep originally entered the dimensions in — drives
* the toggle's default position. The trio of *Unit columns usually
* share a value in practice; we read `lengthUnit` as the
* representative. */
lengthUnit: 'ft' | 'm';
widthUnit: 'ft' | 'm';
draftUnit: 'ft' | 'm';
hullNumber: string | null;
flag: string | null;
} | null;
@@ -100,18 +117,46 @@ export function EoiGenerateDialog({
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
// Unit picker for the Length/Width/Draft preview row + the values that
// ship to Documenso. Defaults to whichever side the rep originally typed
// (drives off the yacht's `lengthUnit` column). Stored as state so the
// rep can flip ft↔m before generating without losing the underlying data.
const [dimensionUnit, setDimensionUnit] = useState<'ft' | 'm' | null>(null);
// Resolved EOI context — the actual values the document will be
// auto-filled with. Loaded only while the dialog is open so we don't
// pay for the join tree on every interest detail page render.
const { data: ctxRes, isLoading: ctxLoading } = useQuery<EoiContextResponse>({
const {
data: ctxRes,
isLoading: ctxLoading,
error: ctxError,
} = useQuery<EoiContextResponse>({
queryKey: ['interests', interestId, 'eoi-context'],
queryFn: () => apiFetch<EoiContextResponse>(`/api/v1/interests/${interestId}/eoi-context`),
enabled: open,
staleTime: 30_000,
retry: false,
});
const ctx = ctxRes?.data;
// Server-side EOI validators throw `Cannot generate EOI - missing
// required client details: client name, client email, client address`.
// Parse that list so the dialog can render an inline fix-it form
// (no need to bounce out to the client detail page).
const ctxErrorMessage = ctxError instanceof Error && ctxError.message ? ctxError.message : null;
const missingFields = useMemo(() => {
if (!ctxErrorMessage) return new Set<'name' | 'email' | 'address'>();
const m = ctxErrorMessage.match(/missing required client details:\s*([^.]+)/i);
if (!m) return new Set<'name' | 'email' | 'address'>();
const tokens = m[1]!.split(',').map((s) => s.trim().toLowerCase());
const out = new Set<'name' | 'email' | 'address'>();
for (const t of tokens) {
if (t.includes('name')) out.add('name');
if (t.includes('email')) out.add('email');
if (t.includes('address')) out.add('address');
}
return out;
}, [ctxErrorMessage]);
const { data: templatesRes } = useQuery<{ data: InAppTemplate[] }>({
queryKey: ['document-templates', { templateType: 'eoi', isActive: true }],
@@ -123,6 +168,86 @@ export function EoiGenerateDialog({
});
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
// Only show the template picker when there's a real choice — the
// Documenso path is always present, so we show the dropdown once at
// least one in-app pdf-lib template is configured. Otherwise it's a
// 1-item select which adds noise.
const showTemplatePicker = inAppTemplates.length > 0;
// ─── Inline fix-it form for missing client fields ──────────────────────────
// Drafted as one piece of local state so a partial save (e.g. address
// succeeds but email fails) leaves the rest of the inputs untouched.
const [fixDraft, setFixDraft] = useState<{
name: string;
email: string;
street: string;
city: string;
postalCode: string;
subdivisionIso: string;
countryIso: string | null;
}>({
name: '',
email: '',
street: '',
city: '',
postalCode: '',
subdivisionIso: '',
countryIso: null,
});
const [fixSaving, setFixSaving] = useState(false);
const persistMissingFields = async (): Promise<void> => {
if (!clientId) {
toastError(new Error('Client ID missing — refresh the page.'));
return;
}
setFixSaving(true);
try {
// Issue one PATCH/POST per missing field. Sequential rather than
// parallel so a downstream failure surfaces a coherent error rather
// than partial-and-confused state.
if (missingFields.has('name')) {
if (!fixDraft.name.trim()) throw new Error('Client name is required.');
await apiFetch(`/api/v1/clients/${clientId}`, {
method: 'PATCH',
body: { fullName: fixDraft.name.trim() },
});
}
if (missingFields.has('email')) {
if (!fixDraft.email.trim()) throw new Error('Client email is required.');
await apiFetch(`/api/v1/clients/${clientId}/contacts`, {
method: 'POST',
body: { channel: 'email', value: fixDraft.email.trim(), isPrimary: true },
});
}
if (missingFields.has('address')) {
if (!fixDraft.street.trim()) throw new Error('Street address is required.');
await apiFetch(`/api/v1/clients/${clientId}/addresses`, {
method: 'POST',
body: {
streetAddress: fixDraft.street.trim(),
city: fixDraft.city.trim() || null,
postalCode: fixDraft.postalCode.trim() || null,
subdivisionIso: fixDraft.subdivisionIso.trim() || null,
countryIso: fixDraft.countryIso,
isPrimary: true,
},
});
}
// Refetch the EOI context so the dialog flips into preview-ready mode.
// Also bounce caches that downstream surfaces watch (client detail,
// interest detail) so the rep sees the edits everywhere immediately.
await queryClient.invalidateQueries({
queryKey: ['interests', interestId, 'eoi-context'],
});
await queryClient.invalidateQueries({ queryKey: ['clients', clientId] });
await queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
} catch (err) {
toastError(err);
} finally {
setFixSaving(false);
}
};
async function patchClient(body: Record<string, unknown>) {
if (!ctx) return;
@@ -149,22 +274,6 @@ export function EoiGenerateDialog({
placeholder: 'Full legal name',
},
},
{
key: 'nationality',
label: 'Nationality',
value: ctx.client.nationality,
present: !!ctx.client.nationality,
edit: {
variant: 'country' as const,
onSave: async (next: string | null) => {
// Country combobox emits the ISO code; the read-only string is the
// localised country name (resolved server-side). Coerce here so we
// store the canonical ISO.
const iso = next ? (next as string).toUpperCase() : null;
await patchClient({ nationalityIso: iso });
},
},
},
{
key: 'email',
label: 'Email address',
@@ -173,9 +282,17 @@ export function EoiGenerateDialog({
},
{
key: 'address',
// Mirrors the rendered EOI Address field exactly so the rep sees
// what's going to appear on the document.
label: 'Address',
value: ctx.client.address
? [ctx.client.address.street, ctx.client.address.city, ctx.client.address.country]
? [
ctx.client.address.street,
ctx.client.address.city,
ctx.client.address.subdivision,
ctx.client.address.postalCode,
ctx.client.address.countryIso,
]
.filter(Boolean)
.join(', ')
: null,
@@ -184,6 +301,17 @@ export function EoiGenerateDialog({
]
: [];
// Default the dimension toggle to the unit the rep originally typed in
// (yacht.lengthUnit). We fall back to 'ft' for legacy rows where the
// unit column was never set.
const defaultDimensionUnit: 'ft' | 'm' = ctx?.yacht?.lengthUnit ?? 'ft';
const effectiveDimensionUnit: 'ft' | 'm' = dimensionUnit ?? defaultDimensionUnit;
const dimensionsForRender = ctx?.yacht
? effectiveDimensionUnit === 'ft'
? [ctx.yacht.lengthFt, ctx.yacht.widthFt, ctx.yacht.draftFt]
: [ctx.yacht.lengthM, ctx.yacht.widthM, ctx.yacht.draftM]
: [];
// Optional — Section 3 of the EOI. Generation proceeds without them.
const optional = ctx
? [
@@ -200,12 +328,8 @@ export function EoiGenerateDialog({
},
{
key: 'dimensions',
label: 'Dimensions (L × W × D, ft)',
value: ctx.yacht
? [ctx.yacht.lengthFt, ctx.yacht.widthFt, ctx.yacht.draftFt]
.map((v) => v ?? '—')
.join(' × ')
: null,
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
value: ctx.yacht ? dimensionsForRender.map((v) => v ?? '—').join(' × ') : null,
},
{
key: 'berth',
@@ -241,11 +365,25 @@ export function EoiGenerateDialog({
pathway: isDocumenso ? 'documenso-template' : 'inapp',
// Signers derived server-side from EOI context for both pathways.
signers: [],
// Dimension unit chosen in the drawer's toggle — drives which
// side (ft|m) of the yacht's stored dimensions flows into the
// EOI's Length/Width/Draft formValues. Defaults server-side to
// the yacht's own `lengthUnit` column when unspecified.
dimensionUnit: effectiveDimensionUnit,
},
});
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
});
// Bounce every cache that surfaces the interest's EOI state so the
// Overview tab flips immediately from "Generate EOI" prompt to
// "EOI sent / awaiting signatures", the EOI tab picks up the new
// signers row, and the timeline reflects the just-stamped milestone.
await Promise.all([
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
}),
queryClient.invalidateQueries({ queryKey: ['interests', interestId] }),
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] }),
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
]);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
@@ -255,38 +393,41 @@ export function EoiGenerateDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<FileSignature className="size-4" aria-hidden />
Generate Expression of Interest
</DialogTitle>
<DialogDescription>
Review the values that will be auto-filled into the EOI. Anything wrong? Edit it on the
client&apos;s record before generating.
</DialogDescription>
</DialogHeader>
</SheetTitle>
<SheetDescription>
Review the values that will be auto-filled. Edit anything inline changes save back to
the client / interest record automatically. The EOI is generated once everything looks
right.
</SheetDescription>
</SheetHeader>
<div className="space-y-4 py-1">
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Standard EOI sent for e-signature (recommended)
</SelectItem>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
<div className="space-y-4 py-4">
{showTemplatePicker && (
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Standard EOI sent for e-signature (recommended)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{ctxLoading ? (
<div className="space-y-2">
@@ -305,9 +446,6 @@ export function EoiGenerateDialog({
<PreviewRow
key={row.key}
label={row.label}
// Nationality stores the localised country name in the preview
// but commits the ISO. Pass the underlying ISO via a closure
// so the CountryCombobox can highlight it correctly.
value={row.value}
missing={!row.present}
edit={row.edit}
@@ -316,9 +454,41 @@ export function EoiGenerateDialog({
</dl>
</div>
<div className="space-y-1 border-t pt-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Optional (Section 3 left blank if absent)
</p>
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Optional (Section 3 left blank if absent)
</p>
{ctx.yacht ? (
<div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
<button
type="button"
onClick={() => setDimensionUnit('ft')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'ft'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'ft'}
>
ft
</button>
<button
type="button"
onClick={() => setDimensionUnit('m')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'm'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'm'}
>
m
</button>
</div>
) : null}
</div>
<dl className="space-y-1.5">
{optional.map((row) => (
<PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
@@ -328,9 +498,8 @@ export function EoiGenerateDialog({
{portSlug && clientId && (
<div className="border-t pt-2 space-y-1">
<p className="text-[11px] text-muted-foreground">
Editing name / nationality / yacht name above patches the underlying records
directly. For phone, address, or to manage linked berths, jump to the canonical
page:
Editing name / yacht name above patches the underlying records directly. For
phone, address, or to manage linked berths, jump to the canonical page:
</p>
<div className="flex flex-wrap gap-3">
<Link
@@ -357,10 +526,132 @@ export function EoiGenerateDialog({
</div>
)}
</div>
) : missingFields.size > 0 && clientId ? (
<div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 space-y-3">
<div className="space-y-0.5">
<p className="text-xs font-medium text-amber-900">
Missing required client details
</p>
<p className="text-[11px] text-amber-800/80">
Fill the fields below they&apos;ll be saved to the client&apos;s record before
the EOI renders.
</p>
</div>
<div className="space-y-3">
{missingFields.has('name') && (
<div className="space-y-1">
<Label htmlFor="fix-name" className="text-xs">
Client full name
</Label>
<Input
id="fix-name"
value={fixDraft.name}
onChange={(e) => setFixDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="Jane Smith"
/>
</div>
)}
{missingFields.has('email') && (
<div className="space-y-1">
<Label htmlFor="fix-email" className="text-xs">
Client email
</Label>
<Input
id="fix-email"
type="email"
value={fixDraft.email}
onChange={(e) => setFixDraft((d) => ({ ...d, email: e.target.value }))}
placeholder="jane@example.com"
/>
</div>
)}
{missingFields.has('address') && (
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="fix-street" className="text-xs">
Street address
</Label>
<Input
id="fix-street"
value={fixDraft.street}
onChange={(e) => setFixDraft((d) => ({ ...d, street: e.target.value }))}
placeholder="123 Marina Way"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="fix-city" className="text-xs">
City
</Label>
<Input
id="fix-city"
value={fixDraft.city}
onChange={(e) => setFixDraft((d) => ({ ...d, city: e.target.value }))}
placeholder="Athens"
/>
</div>
<div className="space-y-1">
<Label htmlFor="fix-postal" className="text-xs">
Postal code
</Label>
<Input
id="fix-postal"
value={fixDraft.postalCode}
onChange={(e) =>
setFixDraft((d) => ({ ...d, postalCode: e.target.value }))
}
placeholder="98000"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="fix-region" className="text-xs">
Region / State <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="fix-region"
value={fixDraft.subdivisionIso}
onChange={(e) =>
setFixDraft((d) => ({ ...d, subdivisionIso: e.target.value }))
}
placeholder="ISO-3166-2 e.g. US-CA"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Country</Label>
<CountryCombobox
value={fixDraft.countryIso}
onChange={(iso) =>
setFixDraft((d) => ({ ...d, countryIso: iso ?? null }))
}
/>
</div>
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
size="sm"
onClick={() => void persistMissingFields()}
disabled={fixSaving}
>
{fixSaving ? 'Saving…' : 'Save & preview EOI'}
</Button>
</div>
</div>
) : (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Couldn&apos;t load the EOI preview data. Try closing and reopening the dialog.
</p>
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 space-y-1">
{ctxErrorMessage ? (
<p className="font-medium">{ctxErrorMessage}</p>
) : (
<p>
Couldn&apos;t load the EOI preview data. Try closing and reopening the dialog.
</p>
)}
</div>
)}
{!ctxLoading && ctx && !requiredMet && (
@@ -374,16 +665,16 @@ export function EoiGenerateDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<SheetFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isGenerating}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating || ctxLoading}>
{isGenerating ? 'Generating…' : 'Generate EOI'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -2,9 +2,13 @@
import { apiFetch } from '@/lib/api/client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Check, Clock, X, Mail, Eye, Bell, Send } from 'lucide-react';
import { toastError } from '@/lib/api/toast-error';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Signer {
id: string;
@@ -14,7 +18,6 @@ interface Signer {
signingOrder: number;
status: string;
signedAt?: string | null;
/** Phase 1+2 lifecycle columns surfaced on the API row. */
invitedAt?: string | null;
openedAt?: string | null;
lastReminderSentAt?: string | null;
@@ -25,28 +28,78 @@ interface SigningProgressProps {
signers: Signer[];
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-muted border-muted-foreground/30 text-muted-foreground',
signed: 'bg-green-100 border-green-500 text-green-800',
declined: 'bg-red-100 border-red-500 text-red-800',
};
const STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
signed: 'Signed',
declined: 'Declined',
};
const ROLE_LABELS: Record<string, string> = {
client: 'Client',
signer: 'Signer',
developer: 'Developer',
approver: 'Sales/Approver',
approver: 'Approver',
sales: 'Sales / Approver',
cc: 'CC',
viewer: 'Viewer',
other: 'Other',
};
type Tone = 'pending' | 'opened' | 'signed' | 'declined';
const STATUS_META: Record<string, { label: string; tone: Tone; icon: typeof Check }> = {
pending: { label: 'Pending', tone: 'pending', icon: Clock },
signed: { label: 'Signed', tone: 'signed', icon: Check },
declined: { label: 'Declined', tone: 'declined', icon: X },
};
// Card styling per status — colour-tinted background + left accent stripe.
// `opened` is a runtime-derived tone (pending status + openedAt set) so a
// signer who's actually looked at the doc reads visually distinct from one
// who hasn't yet — the rep can tell at a glance who's stalling vs who
// hasn't engaged at all.
const TONE_STYLES: Record<
Tone,
{
card: string;
accentBar: string;
circle: string;
statusChipBg: string;
statusChipText: string;
iconBubble: string;
}
> = {
pending: {
card: 'bg-card hover:shadow-sm',
accentBar: 'before:bg-amber-300/70',
circle: 'bg-muted text-foreground/70 border-border',
statusChipBg: 'bg-amber-50 border-amber-200',
statusChipText: 'text-amber-800',
iconBubble: 'bg-amber-100 text-amber-700 border-card',
},
opened: {
card: 'bg-sky-50/40 hover:bg-sky-50/60',
accentBar: 'before:bg-sky-400',
circle: 'bg-sky-100 text-sky-800 border-sky-200',
statusChipBg: 'bg-sky-50 border-sky-200',
statusChipText: 'text-sky-800',
iconBubble: 'bg-sky-500 text-white border-card',
},
signed: {
card: 'bg-emerald-50/50 hover:bg-emerald-50/70',
accentBar: 'before:bg-emerald-500',
circle: 'bg-emerald-500 text-white border-emerald-500',
statusChipBg: 'bg-emerald-100 border-emerald-300',
statusChipText: 'text-emerald-800',
iconBubble: 'bg-emerald-500 text-white border-card',
},
declined: {
card: 'bg-rose-50/40 hover:bg-rose-50/60',
accentBar: 'before:bg-rose-500',
circle: 'bg-rose-500 text-white border-rose-500',
statusChipBg: 'bg-rose-100 border-rose-300',
statusChipText: 'text-rose-800',
iconBubble: 'bg-rose-500 text-white border-card',
},
};
/**
* Phase 6 polish: human-readable "X minutes/hours/days ago" for the
* activity badges (invited / opened / last reminded). Uses
* Intl.RelativeTimeFormat so it follows the user's locale.
* "X minutes/hours/days ago" using Intl.RelativeTimeFormat. Returns null
* when the input is null/invalid so callers can skip rendering.
*/
function humanRelative(isoOrNull: string | null | undefined): string | null {
if (!isoOrNull) return null;
@@ -64,14 +117,94 @@ function humanRelative(isoOrNull: string | null | undefined): string | null {
return rtf.format(-days, 'day');
}
/** Compact absolute timestamp for inline display next to relative time.
* Always renders date + time so a signer who signed weeks ago still
* reads as a real moment in the timeline (not just "Signed 12 days
* ago"). Year is omitted for the current calendar year to keep the
* string short; long-running EOIs that span year boundaries see the
* year so "Dec 3, 23:14" doesn't ambiguously mean last year or this. */
function compactAbsolute(isoOrNull: string | null | undefined): string | null {
if (!isoOrNull) return null;
const d = new Date(isoOrNull);
if (Number.isNaN(d.getTime())) return null;
const sameYear = d.getFullYear() === new Date().getFullYear();
const dateOpts: Intl.DateTimeFormatOptions = sameYear
? { month: 'short', day: 'numeric' }
: { year: 'numeric', month: 'short', day: 'numeric' };
const date = d.toLocaleDateString(undefined, dateOpts);
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return `${date}, ${time}`;
}
/** Tick state every minute so relative-time strings ("Signed 3 min ago")
* re-render without a manual refresh. Returns a number that increments
* every 60s — components read it to invalidate memoization. */
function useMinuteTick(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 60_000);
return () => clearInterval(id);
}, []);
return tick;
}
/**
* Initials shown in the avatar circle.
*
* Cleans the signer name before deriving initials:
* - Strips the `(was: <orig-email>)` suffix that `applyRecipientRedirect`
* bakes into Documenso recipients when EMAIL_REDIRECT_TO is on.
* - Strips Documenso template placeholder markers like `(placeholder)`.
*
* Then derives the bubble label:
* - Real CRM-source-of-truth name (e.g. "David Mizrahi") → "DM".
* - Single-word role placeholder ("Developer" / "Approver" / "Client")
* → first letter only ("D" / "A" / "C"). Reads as a typed role
* marker rather than a truncated name.
* - Empty string → "?".
*/
function getInitials(name: string): string {
const clean = name
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) {
const word = parts[0]!;
return word.slice(0, 1).toUpperCase();
}
return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase();
}
/**
* Cleaned signer display name (matches the initials derivation above).
* The raw `signerName` may carry redirect/placeholder suffixes; this is
* what the card surfaces as the headline. Exported so the document
* detail page can apply the same scrub (#67).
*/
export function cleanSignerName(name: string): string {
return name
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
}
export function SigningProgress({ documentId, signers }: SigningProgressProps) {
const queryClient = useQueryClient();
// Force a re-render every 60s so the "X minutes ago" labels update
// even when the user leaves the tab open without a webhook arriving.
// Reading `tick` below is enough to wire the dependency.
const tick = useMinuteTick();
void tick;
const sorted = [...signers].sort((a, b) => a.signingOrder - b.signingOrder);
// Phase 6 — surface reminder cooldown / success / error in a toast
// rather than the silent catch the old handler used. Reps need to
// know whether the manual "Resend" actually fired.
// Reminder = follow-up nudge to someone who's already been invited.
// Documenso enforces per-signer rate-limiting (default once / 7 days)
// so this only fires when the cooldown has elapsed.
const remindMutation = useMutation({
mutationFn: (signerId: string) =>
apiFetch<{ data: { sent: boolean; reason?: string } }>(
@@ -89,75 +222,201 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) {
onError: (err) => toastError(err, 'Failed to send reminder'),
});
// Initial invitation = the first branded email containing the signing
// link. In `manual` send mode (per-port admin setting) the EOI is
// generated without auto-sending, so this is the rep's first chance
// to dispatch. In `auto` mode the initial email goes out at generate
// time and the button is hidden because invitedAt is already stamped.
const inviteMutation = useMutation({
mutationFn: (signerId: string) =>
apiFetch<{ data: { recipientId: string; sent: boolean } }>(
`/api/v1/documents/${documentId}/send-invitation`,
{ method: 'POST', body: { recipientId: signerId } },
),
onSuccess: () => {
toast.success('Invitation sent.');
queryClient.invalidateQueries({ queryKey: ['documents', documentId, 'signers'] });
},
onError: (err) => toastError(err, 'Failed to send invitation'),
});
return (
<div className="flex items-start gap-2">
{sorted.map((signer, idx) => {
<div className="space-y-2.5">
{sorted.map((signer) => {
const baseStatus = STATUS_META[signer.status] ?? STATUS_META.pending!;
// Promote `pending + has been opened` to the `opened` tone so the
// card reads visually distinct from "invited but never clicked".
const tone: Tone =
baseStatus.tone === 'pending' && signer.openedAt ? 'opened' : baseStatus.tone;
const styles = TONE_STYLES[tone];
const StatusIcon =
tone === 'opened' ? Eye : tone === 'signed' ? Check : tone === 'declined' ? X : Clock;
const statusLabel =
tone === 'opened'
? 'Opened'
: tone === 'signed'
? 'Signed'
: tone === 'declined'
? 'Declined'
: 'Pending';
const invitedAgo = humanRelative(signer.invitedAt);
const openedAgo = humanRelative(signer.openedAt);
const remindedAgo = humanRelative(signer.lastReminderSentAt);
return (
<div key={signer.id} className="flex items-center gap-2">
<div className="flex flex-col items-center gap-1">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 text-xs font-bold ${STATUS_COLORS[signer.status] ?? STATUS_COLORS.pending}`}
>
{signer.signingOrder}
<div key={signer.id} className="relative">
<div
className={cn(
// Left accent stripe via a `::before` so the colour reads
// immediately at the line of the card without competing
// with the avatar circle.
'relative flex items-start gap-3 rounded-lg border p-3 pl-4 transition-colors',
'before:absolute before:left-0 before:top-2 before:bottom-2 before:w-1 before:rounded-r',
styles.card,
styles.accentBar,
)}
>
{/* Avatar circle (initials) with status icon overlay so the
state reads from the avatar itself even before the
status pill is parsed. */}
<div className="relative shrink-0">
<div
className={cn(
'flex h-11 w-11 items-center justify-center rounded-full border-2 text-sm font-bold shadow-sm',
styles.circle,
)}
>
{getInitials(signer.signerName)}
</div>
<div
className={cn(
'absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full border-2 shadow-sm',
styles.iconBubble,
)}
>
<StatusIcon className="size-3" aria-hidden />
</div>
</div>
<div className="max-w-28 text-center">
<p className="truncate text-xs font-medium">{signer.signerName}</p>
<p className="truncate text-xs text-muted-foreground">
{ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
</p>
<p className="text-xs text-muted-foreground">
{STATUS_LABELS[signer.status] ?? signer.status}
</p>
{signer.signedAt && (
<p className="text-xs text-muted-foreground">
{new Date(signer.signedAt).toLocaleDateString('en-GB')}
</p>
)}
{/* Phase 6 polish — activity badges so reps can see at a
glance when each signer was last touched. */}
{signer.status === 'pending' && (invitedAgo || openedAgo || remindedAgo) && (
<div className="mt-1 space-y-0.5">
{invitedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.invitedAt ?? ''}
>
Invited {invitedAgo}
</p>
{/* Name + role + email + status pill + activity */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">
{cleanSignerName(signer.signerName) || signer.signerEmail}
</span>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
styles.statusChipBg,
styles.statusChipText,
)}
{openedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.openedAt ?? ''}
>
Opened {openedAgo}
</p>
)}
{remindedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.lastReminderSentAt ?? ''}
>
Reminded {remindedAgo}
</p>
)}
</div>
)}
{signer.status === 'pending' && (
<button
onClick={() => remindMutation.mutate(signer.id)}
disabled={remindMutation.isPending}
className="mt-1 text-xs text-primary underline hover:no-underline disabled:opacity-50"
>
{remindMutation.isPending ? 'Sending…' : 'Resend'}
</button>
)}
<StatusIcon className="size-2.5" aria-hidden />
{statusLabel}
</span>
<span className="text-[11px] text-muted-foreground">
· {ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
{' · '}
<span className="font-medium">#{signer.signingOrder}</span>
</span>
</div>
<p className="truncate text-xs text-muted-foreground">{signer.signerEmail}</p>
{/* Activity timeline — explicit "Not yet invited" state so
reps in manual-send mode know an action is required.
Once invited, each event surfaces with a precise
timestamp tooltip (the relative-time is the headline). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-0.5 text-[11px] text-muted-foreground">
{!signer.invitedAt && signer.status === 'pending' ? (
<span className="inline-flex items-center gap-1 italic text-amber-700">
<Mail className="size-3" aria-hidden />
Not yet invited
</span>
) : null}
{invitedAgo && (
<span
className="inline-flex items-center gap-1"
title={signer.invitedAt ? new Date(signer.invitedAt).toLocaleString() : ''}
>
<Mail className="size-3" aria-hidden />
Invited {invitedAgo}
</span>
)}
{openedAgo && (
<span
className="inline-flex items-center gap-1"
title={signer.openedAt ? new Date(signer.openedAt).toLocaleString() : ''}
>
<Eye className="size-3" aria-hidden />
Opened {openedAgo}
</span>
)}
{remindedAgo && (
<span
className="inline-flex items-center gap-1"
title={
signer.lastReminderSentAt
? new Date(signer.lastReminderSentAt).toLocaleString()
: ''
}
>
<Bell className="size-3" aria-hidden />
Reminded {remindedAgo}
</span>
)}
{signer.signedAt && (
<span
className="inline-flex items-center gap-1 font-medium text-emerald-700"
title={new Date(signer.signedAt).toLocaleString()}
>
<Check className="size-3" aria-hidden />
Signed {humanRelative(signer.signedAt)}
<span className="font-normal text-emerald-700/70">
· {compactAbsolute(signer.signedAt)}
</span>
</span>
)}
</div>
</div>
{/* Per-signer action button — semantics depend on send state:
• `invitedAt === null` → "Send invitation" (the rep is the
one dispatching the first email; this fires the branded
invite + stamps invitedAt).
• `invitedAt !== null` → "Send reminder" (Documenso-side
nudge, rate-limited per cooldown).
• Signed/declined → no button. */}
{signer.status === 'pending' &&
(signer.invitedAt ? (
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs [&_svg]:size-3"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(signer.id)}
title="Send a follow-up reminder. Rate-limited by Documenso."
>
<Bell />
{remindMutation.isPending && remindMutation.variables === signer.id
? 'Sending…'
: 'Send reminder'}
</Button>
) : (
<Button
variant="default"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs [&_svg]:size-3"
disabled={inviteMutation.isPending}
onClick={() => inviteMutation.mutate(signer.id)}
title="Send the initial signing invitation to this recipient."
>
<Send />
{inviteMutation.isPending && inviteMutation.variables === signer.id
? 'Sending…'
: 'Send invitation'}
</Button>
))}
</div>
{idx < sorted.length - 1 && <div className="mb-6 h-0.5 w-8 shrink-0 bg-border" />}
</div>
);
})}

View File

@@ -156,7 +156,12 @@ export function EmailAccountsList() {
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Button
variant="ghost"
size="icon"
className="text-destructive"
aria-label="Remove account"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
}

View File

@@ -118,8 +118,8 @@ export function FileGrid({
<div className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5" />
<Button variant="ghost" size="icon" className="h-6 w-6" aria-label="File actions">
<MoreHorizontal className="h-3.5 w-3.5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">

View File

@@ -75,6 +75,12 @@ interface BerthRecommenderPanelProps {
desiredLengthFt: number | null;
desiredWidthFt: number | null;
desiredDraftFt: number | null;
/**
* Unit the rep originally entered the dimensions in. Drives header
* display so a metric-entered deal doesn't render its dims as ft.
* Falls back to 'ft' when missing.
*/
desiredUnit?: 'ft' | 'm' | null;
}
const TIER_LABELS: Record<Tier, { label: string; tone: string }> = {
@@ -115,11 +121,23 @@ function formatDimensions(
return parts.join(' · ');
}
function formatDesired(length: number | null, width: number | null, draft: number | null): string {
function formatDesired(
length: number | null,
width: number | null,
draft: number | null,
unit: 'ft' | 'm' = 'ft',
): string {
// Storage is canonical-ft (the recommender's SQL ranks against
// berths.length_ft etc.). For display we convert back to whatever the rep
// entered. 0.3048 m/ft exactly.
const toDisplay = (ft: number): string => {
const v = unit === 'm' ? ft * 0.3048 : ft;
return v.toFixed(2).replace(/\.?0+$/, '');
};
const parts: string[] = [];
if (length !== null) parts.push(`${length}ft L`);
if (width !== null) parts.push(`${width}ft W`);
if (draft !== null) parts.push(`${draft}ft D`);
if (length !== null) parts.push(`${toDisplay(length)}${unit} L`);
if (width !== null) parts.push(`${toDisplay(width)}${unit} W`);
if (draft !== null) parts.push(`${toDisplay(draft)}${unit} D`);
return parts.length > 0 ? parts.join(' · ') : 'no dimensions set';
}
@@ -332,11 +350,14 @@ function AmenityFilterForm({ filters, onChange }: AmenityFilterFormProps) {
);
}
// destructure includes `desiredUnit` so the header formatter pivots on the
// rep's entered unit. Falls back to 'ft' (the legacy default) when missing.
export function BerthRecommenderPanel({
interestId,
desiredLengthFt,
desiredWidthFt,
desiredDraftFt,
desiredUnit,
}: BerthRecommenderPanelProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
@@ -364,7 +385,12 @@ export function BerthRecommenderPanel({
apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, {
method: 'POST',
body: {
...(showAll ? { topN: 999 } : {}),
// `showAll` opens the floodgates: bumps `topN` AND raises the
// oversize-cap so berths well beyond the strict feasibility window
// surface. Without that second bump the user could end up staring
// at "no berths match" when the test data only had oversized rows
// — exactly the case in our seeded demo port.
...(showAll ? { topN: 999, maxOversizePct: 1000 } : {}),
...(Object.keys(amenityFilters).length > 0 ? { amenityFilters } : {}),
},
}).then((r) => r.data),
@@ -400,7 +426,13 @@ export function BerthRecommenderPanel({
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="size-4 text-brand-600" aria-hidden />
Recommendations for {formatDesired(desiredLengthFt, desiredWidthFt, desiredDraftFt)}
Recommendations for{' '}
{formatDesired(
desiredLengthFt,
desiredWidthFt,
desiredDraftFt,
desiredUnit === 'm' ? 'm' : 'ft',
)}
</CardTitle>
{!hasDimensions ? (
<p className="text-xs text-muted-foreground">
@@ -489,9 +521,18 @@ export function BerthRecommenderPanel({
))}
</div>
) : recommendations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No berths match the current dimensions and filters.
</p>
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
<p>
{showAll
? 'No berths in the port match these dimensions and filters.'
: 'No berths fit inside the strict oversize tolerance.'}
</p>
{!showAll && (
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
Show oversized matches too
</Button>
)}
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => (
@@ -507,7 +548,7 @@ export function BerthRecommenderPanel({
{hasDimensions && recommendations.length > 0 ? (
<div className="flex justify-center pt-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show top recommendations' : 'Show all feasible'}
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
</Button>
</div>
) : null}

View File

@@ -1,15 +1,17 @@
'use client';
import { Activity } from 'lucide-react';
import { useState } from 'react';
import { Activity, ExternalLink } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
import { cn } from '@/lib/utils';
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800',
warm: 'border-amber-200 bg-amber-50 text-amber-800',
cold: 'border-rose-200 bg-rose-50 text-rose-800',
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800 hover:bg-emerald-100',
warm: 'border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100',
cold: 'border-rose-200 bg-rose-50 text-rose-800 hover:bg-rose-100',
};
const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
@@ -19,12 +21,17 @@ const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
};
/**
* Header chip surfacing the rule-based deal-health score. The tooltip
* exposes every signal that contributed to the score so the calculation is
* transparent — stakeholders averse to AI black boxes can read exactly
* which dates / stages drove the verdict.
* Header chip surfacing the rule-based deal-health score.
*
* Click opens a popover with the full per-signal breakdown + plain-language
* explanation of how the score is computed, plus a link to the docs page
* for users who want the deep-dive. Replaces the prior hover-tooltip so
* the content is keyboard-accessible, doesn't time out, and reads on
* touch devices.
*/
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const [open, setOpen] = useState(false);
// Closed / archived deals don't get a pulse — UX would be confusing.
if (interest.archivedAt || interest.outcome) return null;
@@ -33,46 +40,84 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const label = PULSE_LABEL[health.pulse];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium cursor-help',
tint,
)}
aria-label={`Deal pulse: ${label}, score ${health.score}/100`}
>
<Activity className="size-3" aria-hidden />
{label} · {health.score}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p className="font-semibold mb-1.5">
Deal pulse {label} ({health.score}/100)
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors cursor-pointer',
tint,
)}
aria-label={`Deal pulse: ${label}, score ${health.score}/100. Click for breakdown.`}
>
<Activity className="size-3" aria-hidden />
{label} · {health.score}
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-80 p-4 space-y-3">
<div>
<p className="text-sm font-semibold">
Deal pulse {label} ({health.score} / 100)
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
How likely this deal is to keep moving forward, scored from 0 to 100.
</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
What pushed the score
</p>
{health.signals.length === 0 ? (
<p className="text-xs">
Baseline score (50) nothing notable yet. Log contact or progress the stage to move
the dial.
<p className="mt-1 text-xs text-muted-foreground">
Nothing notable yet the score is sitting at the baseline (50). Log a contact,
progress the stage, or send a signing request and you&apos;ll see the dial move.
</p>
) : (
<ul className="space-y-1 text-xs">
<ul className="mt-1.5 space-y-1.5 text-xs">
{health.signals.map((s) => (
<li key={s.id} className="flex gap-2">
<span className={s.delta > 0 ? 'text-emerald-300' : 'text-rose-300'}>
<li key={s.id} className="flex items-start gap-2">
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums',
s.delta > 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-rose-100 text-rose-800',
)}
>
{s.delta > 0 ? `+${s.delta}` : s.delta}
</span>
<span>{s.detail}</span>
<span className="text-foreground/90">{s.detail}</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-[10px] opacity-70">
Rule-based. Every signal traces to a date or stage you can see no AI.
</div>
<div className="rounded-md bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground/80">How this is calculated</p>
<p className="mt-0.5">
Every signal above traces to a specific date or pipeline stage on this deal. Recent
contact + recent stage movement push the score up; long silences and outdated documents
pull it down.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Close
</Button>
<Button asChild variant="link" size="sm" className="text-xs">
<a
href="/docs/deal-pulse"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1"
>
Full guide
<ExternalLink className="size-3" aria-hidden />
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -211,7 +211,7 @@ export function InlineStagePicker({
const isOverride = !canTransitionStage(stage, target);
mutation.mutate({
next: target,
reason: isOverride ? 'Reverted to Open and unlinked all berths' : null,
reason: isOverride ? 'Reverted to New Enquiry and unlinked all berths' : null,
});
setOpenConfirmTarget(null);
} catch (err) {
@@ -226,7 +226,7 @@ export function InlineStagePicker({
setPendingStage(target);
mutation.mutate({
next: target,
reason: isOverride ? 'Reverted to Open (kept linked berths)' : null,
reason: isOverride ? 'Reverted to New Enquiry (kept linked berths)' : null,
});
setOpenConfirmTarget(null);
}
@@ -463,12 +463,13 @@ export function InlineStagePicker({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset this deal to Open?</AlertDialogTitle>
<AlertDialogTitle>Reset this deal to New Enquiry?</AlertDialogTitle>
<AlertDialogDescription>
This interest has {linkedBerthCount} linked{' '}
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
usually means restarting the lead keeping the berth links would leave them showing
as under offer on the public map for a deal that&apos;s no longer in progress.
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to{' '}
<strong>New Enquiry</strong> usually means restarting the lead keeping the berth
links would leave them showing as under offer on the public map for a deal that&apos;s
no longer in progress.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">

View File

@@ -121,8 +121,13 @@ export function getInterestColumns({
const notesCount = row.original.notesCount ?? 0;
return (
<div className="flex items-center gap-1.5 min-w-0">
{/* Client cell on the Interests list links to the INTEREST detail
— not the client page. Users browsing the interest list want
the deal context, not the underlying client. The interest
detail header has its own "Client page" deep-link if the rep
actually wants the client surface. */}
<Link
href={`/${portSlug}/clients/${row.original.clientId}`}
href={`/${portSlug}/interests/${row.original.id}`}
className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>

View File

@@ -13,6 +13,7 @@ import {
Mail,
Phone,
AlarmClock,
User,
} from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link';
@@ -316,8 +317,28 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
{interest.clientPrimaryEmail ||
interest.clientPrimaryPhone ||
whatsappNumber ||
interest.clientId ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
) : null}
{interest.clientPrimaryEmail ? (
<Button
asChild

View File

@@ -39,6 +39,7 @@ interface InterestData {
id: string;
content: string;
authorId: string;
authorName: string | null;
createdAt: string;
} | null;
berthId: string | null;

View File

@@ -5,9 +5,13 @@ import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
ArrowDown,
CheckCircle2,
Download,
Eye,
ExternalLink,
FileSignature,
GitBranch,
Loader2,
RefreshCw,
Upload,
@@ -18,12 +22,14 @@ import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { EoiCancelDialog } from '@/components/documents/eoi-cancel-dialog';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import {
DOCUMENT_STATUS_ACTIVE,
DOCUMENT_STATUS_LABELS,
@@ -45,6 +51,10 @@ interface DocumentRow {
status: DocumentStatus;
createdAt: string;
signers?: Array<{ status: string }>;
/** Null while the EOI is in flight; populated by the completion webhook
* once the fully-signed PDF has been downloaded from Documenso and
* stored in MinIO/filesystem. Drives the "Download signed PDF" CTA. */
signedFileId?: string | null;
}
interface DocumentSigner {
@@ -141,6 +151,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{d.signedFileId ? <SignedPdfActions fileId={d.signedFileId} /> : null}
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -186,25 +197,56 @@ function ActiveEoiCard({
}) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const [cancelOpen, setCancelOpen] = useState(false);
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
queryFn: () => apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${doc.id}/signers`),
refetchInterval: 30_000,
// Polling backstop in case a webhook event misses the open browser
// (transient socket drop, user in a different tab when the event
// fires, cloudflared tunnel hiccup). Primary update path is
// socket-driven via `useRealtimeInvalidation` below — this just
// bounds the worst-case staleness to ~5s.
refetchInterval: 5_000,
});
// Surface the per-port signing-order preference (Sequential vs Concurrent
// = Parallel in Documenso parlance) so the team knows what order recipients
// will receive the signing chain in.
const { data: signingDefaultsRes } = useQuery<{
data: { signingOrder: 'PARALLEL' | 'SEQUENTIAL' };
}>({
queryKey: ['documents', 'signing-defaults'],
queryFn: () =>
apiFetch<{ data: { signingOrder: 'PARALLEL' | 'SEQUENTIAL' } }>(
'/api/v1/documents/signing-defaults',
),
staleTime: 60_000,
});
const signingOrder = signingDefaultsRes?.data?.signingOrder ?? 'PARALLEL';
const signers = signersRes?.data ?? [];
const signedCount = signers.filter((s) => s.status === 'signed').length;
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('EOI cancelled.');
},
onError: (err) => toastError(err),
// Treat "all signers complete" as the finalised UX even when the
// DOCUMENT_COMPLETED webhook hasn't landed yet — defends against the
// gap between the last per-recipient sign event and the document-level
// completion event. The badge below flips to "Finalising" so the rep
// sees the in-flight state rather than a stale PARTIALLY_SIGNED chip.
const effectivelyCompleted = doc.status === 'completed' || allSigned;
const isAwaitingFinalisation = allSigned && doc.status !== 'completed';
// Real-time push: invalidate the signers query the moment a webhook
// fires `document:signer:*` so the card flips state without waiting
// for the 30s refetch interval. Same for `document:completed` so the
// "all signed" footer chip appears as soon as the last signer finishes.
useRealtimeInvalidation({
'document:signer:signed': [['documents', doc.id, 'signers'], ['documents']],
'document:signer:opened': [['documents', doc.id, 'signers']],
'document:completed': [['documents', doc.id, 'signers'], ['documents']],
'document:signer:rejected': [['documents', doc.id, 'signers'], ['documents']],
});
const remindAllMutation = useMutation({
@@ -223,12 +265,45 @@ function ActiveEoiCard({
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" aria-hidden />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
{isAwaitingFinalisation ? (
<Badge variant="outline" className="border-sky-300 bg-sky-50 text-sky-800">
<Loader2 className="mr-1 size-3 animate-spin" aria-hidden /> Finalising
</Badge>
) : (
<StatusBadge status={doc.status} />
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</span>
{/* Signing-order badge — tells the team whether recipients
must sign in order or can sign concurrently. Drives off
the per-port setting; for v2 templates the template's
stored order wins server-side and we still surface our
local preference here so the UI matches what was sent. */}
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
signingOrder === 'SEQUENTIAL'
? 'border-indigo-200 bg-indigo-50 text-indigo-800'
: 'border-sky-200 bg-sky-50 text-sky-800',
)}
title={
signingOrder === 'SEQUENTIAL'
? 'Signers receive the invite chain one at a time — each must sign before the next is emailed.'
: 'All signers receive the invite at once and can sign in any order.'
}
>
{signingOrder === 'SEQUENTIAL' ? (
<ArrowDown className="size-2.5" aria-hidden />
) : (
<GitBranch className="size-2.5" aria-hidden />
)}
{signingOrder === 'SEQUENTIAL' ? 'Sequential' : 'Concurrent'}
</span>
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{portSlug && (
@@ -242,7 +317,8 @@ function ActiveEoiCard({
</Link>
</Button>
)}
{!allSigned && (
{/* Remind all hides once every signer is signed — no-one to nudge. */}
{!effectivelyCompleted && (
<Button
variant="outline"
size="sm"
@@ -278,47 +354,147 @@ function ActiveEoiCard({
)}
</div>
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={async () => {
const ok = await confirm({
title: 'Cancel EOI',
description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel EOI',
});
if (ok) cancelMutation.mutate();
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel EOI
</Button>
{/* Signed-PDF inline preview, shown once the completion webhook has
downloaded + stored the final signed file. Defends in two ways:
(a) status === 'completed' (the ideal path), (b) doc reports a
signedFileId even when status hasn't flipped yet. */}
{doc.signedFileId ? (
<div className="mt-3 rounded-lg border bg-background p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signed document
</h3>
<SignedPdfActions fileId={doc.signedFileId} />
</div>
<SignedPdfPreview fileId={doc.signedFileId} />
</div>
</footer>
) : null}
{/* Footer hides once every signer is signed: Cancel + Remind reminder
stop making sense, and the rep's natural next action is to view
the signed PDF (rendered above) or open the linked document
detail page. Upload-paper-signed-copy stays available — useful
for in-person sign-out workflows even after the digital flow. */}
{!effectivelyCompleted ? (
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
{/* Regenerate is only safe when no one has signed yet — once
signatures are on the doc, the rep must go through the
cancel-with-notify path so collaborators learn about the
discard. */}
{signedCount === 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={async () => {
const ok = await confirm({
title: 'Regenerate this EOI?',
description:
'The current envelope will be voided silently — no recipients will be notified — and the generate dialog will re-open so you can rebuild.',
confirmLabel: 'Regenerate',
});
if (ok) {
try {
await apiFetch(`/api/v1/documents/${doc.id}/cancel`, {
method: 'POST',
body: { reason: 'regenerated', notifyRecipients: [] },
});
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
});
toast.success('EOI voided. Regenerate now.');
} catch (err) {
toastError(err);
}
}
}}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
title="Void the current envelope (no notifications) and rebuild from scratch."
>
<RefreshCw />
Regenerate
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setCancelOpen(true)}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel EOI
</Button>
</div>
</footer>
) : null}
{confirmDialog}
<EoiCancelDialog
documentId={doc.id}
signers={signers}
open={cancelOpen}
onOpenChange={setCancelOpen}
/>
</section>
);
}
/**
* Inline iframe preview of a signed PDF. Fetches a short-lived presigned
* URL from `/api/v1/files/[id]/download` and renders the browser's native
* PDF viewer inside the EOI card. Constrained to a fixed max-height so a
* tall multi-page document doesn't blow out the page; the rep can open
* the file in a new tab via the alongside View button for full-screen.
*/
function SignedPdfPreview({ fileId }: { fileId: string }) {
const { data, isLoading, isError } = useQuery<{ data: { url: string; filename: string } }>({
queryKey: ['files', fileId, 'download-url'],
queryFn: () =>
apiFetch<{ data: { url: string; filename: string } }>(`/api/v1/files/${fileId}/download`),
// Presigned URL TTLs vary per backend — refresh well before they
// expire so a long-open card doesn't suddenly 403. 4 minutes is
// comfortably below the 5-minute MinIO default.
staleTime: 4 * 60_000,
});
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center text-xs text-muted-foreground">
<Loader2 className="mr-2 size-3 animate-spin" aria-hidden /> Loading preview
</div>
);
}
if (isError || !data?.data.url) {
return (
<p className="text-xs italic text-muted-foreground">
Preview unavailable use the Download button to grab the signed PDF.
</p>
);
}
return (
<iframe
src={data.data.url}
title="Signed EOI preview"
className="h-[560px] w-full rounded border bg-white"
/>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyEoiState({
@@ -368,3 +544,47 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
</Badge>
);
}
/**
* View + Download buttons for a signed PDF. `/api/v1/files/[id]/download`
* returns a presigned URL in JSON (rather than streaming the file), so
* we fetch the URL via `apiFetch` and then either open it in a new tab
* (View) or trigger a programmatic download (Download).
*/
function SignedPdfActions({ fileId }: { fileId: string }) {
const open = async (mode: 'view' | 'download') => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${fileId}/download`,
);
if (mode === 'view') {
window.open(res.data.url, '_blank', 'noopener,noreferrer');
} else {
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
}
} catch (err) {
toastError(err, 'Failed to fetch signed PDF');
}
};
return (
<>
<button
type="button"
onClick={() => open('view')}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Eye className="size-3" aria-hidden /> View
</button>
<button
type="button"
onClick={() => open('download')}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Download className="size-3" aria-hidden /> Download
</button>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
@@ -120,6 +121,26 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const [createYachtOpen, setCreateYachtOpen] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
// Auto-fill pipelineStage + leadCategory based on whether a berth was
// picked. Once the rep manually edits either field we stop touching it,
// so we don't fight the user. Edit mode skips the auto-fill entirely —
// changing the berth on an in-flight interest shouldn't silently demote
// it back to "enquiry".
const userTouchedStage = useRef(false);
const userTouchedCategory = useRef(false);
useEffect(() => {
if (isEdit) return;
const hasBerth = !!selectedBerthId;
if (!userTouchedStage.current) {
setValue('pipelineStage', hasBerth ? 'qualified' : 'enquiry');
}
if (!userTouchedCategory.current) {
setValue('leadCategory', hasBerth ? 'specific_qualified' : 'general_interest');
}
// setValue is stable from RHF; isEdit doesn't change after mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBerthId]);
function requestClose() {
if (isDirty && !isSubmitting && !mutation.isPending) {
setDiscardConfirmOpen(true);
@@ -146,6 +167,39 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
]
: undefined;
// Probe whether the selected client (or their member companies) owns any
// yachts. When zero, the form swaps the picker for an "Add yacht" CTA so
// reps don't get stuck on an empty dropdown wondering what to do. We hit
// the same autocomplete endpoint the picker uses but with an empty query
// to get the full unfiltered list scoped to the owner filter.
// Tags-availability probe — drives whether the whole Tags section
// (label + picker) renders. The picker itself returns null when empty,
// but the wrapping label/separator needed the same gate.
const { data: tagsList } = useQuery<{ data: Array<{ id: string }> }>({
queryKey: ['tag-availability-for-interest-form'],
queryFn: () => apiFetch('/api/v1/tags/options'),
staleTime: 60_000,
});
const tagsAvailable = (tagsList?.data?.length ?? 0) > 0;
const { data: yachtCount } = useQuery<{ data: Array<{ id: string }> }>({
queryKey: [
'yacht-count-for-interest-form',
selectedClientId,
memberCompanyIds.sort().join(','),
],
queryFn: () => {
const params = new URLSearchParams({ q: '' });
if (selectedClientId) params.set('ownerClientId', selectedClientId);
if (memberCompanyIds.length > 0) {
params.set('ownerCompanyIds', memberCompanyIds.join(','));
}
return apiFetch(`/api/v1/yachts/autocomplete?${params.toString()}`);
},
enabled: !!selectedClientId,
});
const hasAnyYachts = (yachtCount?.data?.length ?? 0) > 0;
const {
options: clientOptions,
isLoading: clientsLoading,
@@ -230,10 +284,27 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
method: 'POST',
body: enriched,
});
// Materialise any additional berths the rep picked in the multi-
// select. The first (primary) berth is already linked via the create
// payload's berthId; everything else gets a follow-up POST to the
// junction endpoint. We fire them in parallel — failure on one is
// surfaced as a toast but doesn't roll back the interest creation.
if (additionalBerthIds.length > 0) {
await Promise.allSettled(
additionalBerthIds.map((berthId) =>
apiFetch(`/api/v1/interests/${res.data.id}/berths`, {
method: 'POST',
body: { berthId, isSpecificInterest: false },
}),
),
);
}
return { id: res.data.id, created: true };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['interests'] });
// M-U10: confirm the write landed.
toast.success(result.created ? 'Interest created' : 'Interest updated');
onOpenChange(false);
// F20: navigate to the new interest's detail page so the rep can
// start the workflow immediately. Edits stay in place — no point
@@ -254,6 +325,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedClient = clientOptions.find((c) => c.value === selectedClientId);
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
// Additional berths (beyond the primary `berthId`) accumulated by the
// multi-select. On create, after the interest row exists, each id here
// gets a follow-up POST /interests/{id}/berths so they show up in the
// linked-berths list with isPrimary=false. The primary berth (the form's
// `berthId`) is materialised by the standard create path. Edit mode
// doesn't surface this — managing extra berths post-create happens on
// the interest detail page's linked-berths section.
const [additionalBerthIds, setAdditionalBerthIds] = useState<string[]>([]);
return (
<Sheet
open={open}
@@ -337,7 +417,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div>
<div className="space-y-1">
<Label>Berth (optional)</Label>
<Label>Berths (optional)</Label>
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
<PopoverTrigger asChild>
<Button
@@ -346,10 +426,20 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
aria-expanded={berthOpen}
className={cn(
'w-full justify-between',
!selectedBerthId && 'text-muted-foreground',
!selectedBerthId &&
additionalBerthIds.length === 0 &&
'text-muted-foreground',
)}
>
{selectedBerth?.label ?? interest?.berthMooringNumber ?? 'Select berth...'}
<span className="truncate">
{selectedBerthId
? `${selectedBerth?.label ?? interest?.berthMooringNumber ?? selectedBerthId}${
additionalBerthIds.length > 0
? ` + ${additionalBerthIds.length} more`
: ''
}`
: 'Select berths…'}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
@@ -362,43 +452,80 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
value="__clear__"
onSelect={() => {
setValue('berthId', undefined);
setBerthOpen(false);
setAdditionalBerthIds([]);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
!selectedBerthId ? 'opacity-100' : 'opacity-0',
!selectedBerthId && additionalBerthIds.length === 0
? 'opacity-100'
: 'opacity-0',
)}
/>
None
</CommandItem>
{berthOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
setValue('berthId', val);
setBerthOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedBerthId === option.value ? 'opacity-100' : 'opacity-0',
{berthOptions.map((option) => {
const isPrimary = selectedBerthId === option.value;
const isAdditional = additionalBerthIds.includes(option.value);
const isSelected = isPrimary || isAdditional;
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
// Multi-select toggle. First pick becomes
// the primary berthId (the one the API uses
// for templates / list views). Subsequent
// picks go into additionalBerthIds and are
// materialised via POST /berths after the
// interest is created.
if (isPrimary) {
// Demote primary; promote first additional
// (if any) to primary so the deal still
// has one primary berth.
const promote = additionalBerthIds[0];
setValue('berthId', promote ?? undefined);
setAdditionalBerthIds(additionalBerthIds.slice(1));
} else if (isAdditional) {
setAdditionalBerthIds(
additionalBerthIds.filter((id) => id !== val),
);
} else if (!selectedBerthId) {
setValue('berthId', val);
} else {
setAdditionalBerthIds([...additionalBerthIds, val]);
}
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
<span className="flex-1">{option.label}</span>
{isPrimary && (
<span className="ml-2 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
primary
</span>
)}
/>
{option.label}
</CommandItem>
))}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
Pick one or more berths. The first becomes the primary berth (used in templates and
list views); the rest get linked as alternates and can be promoted later from the
interest detail page.
</p>
</div>
<div className="space-y-2">
@@ -406,7 +533,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Label>
Yacht <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
{selectedClientId && (
{selectedClientId && hasAnyYachts && (
<Button
type="button"
variant="ghost"
@@ -419,15 +546,34 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Button>
)}
</div>
<YachtPicker
value={selectedYachtId ?? null}
onChange={(id) => setValue('yachtId', id ?? undefined)}
ownerFilter={yachtOwnerFilter}
disabled={!selectedClientId}
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
/>
{/* Hide the picker entirely when the selected client has no
yachts on file (and isn't linked to a company with yachts).
An empty dropdown is a dead-end UX — the only useful action
in that state is "create a yacht for this client". */}
{selectedClientId && !hasAnyYachts ? (
<div className="rounded-md border border-dashed bg-muted/40 p-3 text-sm">
<p className="text-muted-foreground">This client has no yachts on file yet.</p>
<Button
type="button"
size="sm"
className="mt-2"
onClick={() => setCreateYachtOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden />
Add a yacht for this client
</Button>
</div>
) : (
<YachtPicker
value={selectedYachtId ?? null}
onChange={(id) => setValue('yachtId', id ?? undefined)}
ownerFilter={yachtOwnerFilter}
disabled={!selectedClientId}
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
/>
)}
<p className="text-xs text-muted-foreground">
Required before the interest can leave the &quot;Open&quot; stage.
Required before the interest can leave the New Enquiry stage.
{memberCompanyIds.length > 0 && (
<>
{' '}
@@ -450,10 +596,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1">
<Label>Stage</Label>
<Select
value={watch('pipelineStage') ?? 'open'}
onValueChange={(v) =>
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number])
}
value={watch('pipelineStage') ?? 'enquiry'}
onValueChange={(v) => {
userTouchedStage.current = true;
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number]);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select stage" />
@@ -472,12 +619,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Label>Lead Category</Label>
<Select
value={watch('leadCategory') ?? ''}
onValueChange={(v) =>
onValueChange={(v) => {
userTouchedCategory.current = true;
setValue(
'leadCategory',
v ? (v as (typeof LEAD_CATEGORIES)[number]) : undefined,
)
}
);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
@@ -583,13 +731,19 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
)}
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
{/* Tags — TagPicker itself returns null when the port has no tags
configured AND the form has nothing selected. We hide the
wrapping label + separator in that same case so an empty
"Tags" header doesn't sit in the form. */}
{(tagIds.length > 0 || tagsAvailable) && (
<>
<Separator />
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
</>
)}
<SheetFooter>
<Button type="button" variant="outline" onClick={requestClose}>

View File

@@ -12,6 +12,9 @@ import {
TagsIcon,
} from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
@@ -67,6 +70,13 @@ export function InterestList() {
const { confirm, dialog: confirmDialog } = useConfirmation();
const { viewMode, setViewMode } = usePipelineStore();
// M-U14: surface the page title in the mobile topbar.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Interests', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// Force the list view at mobile widths even when the user previously
// toggled the kanban from desktop — the board is desktop-only.
useEffect(() => {
@@ -143,7 +153,7 @@ export function InterestList() {
queryClient.invalidateQueries({ queryKey: ['interests'] });
const s = res.data.summary;
if (s.failed > 0) {
alert(
toast.warning(
`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed — check the activity log.`,
);
}
@@ -230,26 +240,30 @@ export function InterestList() {
placeholder="Filter by tag / event…"
/>
</div>
{/* Columns + saved views are table-only concepts; the kanban
* always shows the same compact card across every stage so
* hiding both controls in board mode keeps the toolbar honest. */}
{viewMode === 'table' ? (
<>
<SavedViewsDropdown
entityType="interests"
onApplyView={(savedFilters) => {
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
<StageLegend />
{/* Right-aligned toolbar group: saved views + column picker + stage
legend. `ml-auto` pushes the group to the right edge so it sits
flush with where the table extends to on desktop. Wraps to a new
line on narrow viewports because the outer container is
`flex-wrap`. Kanban view hides the table-only controls. */}
<div className="ml-auto flex flex-wrap items-center gap-2">
{viewMode === 'table' ? (
<>
<SavedViewsDropdown
entityType="interests"
onApplyView={(savedFilters) => {
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
<StageLegend />
</div>
</div>
<SaveViewDialog

View File

@@ -7,6 +7,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
import { parsePhone } from '@/lib/i18n/phone';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -14,9 +16,24 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RecommendationList } from '@/components/interests/recommendation-list';
// Legacy `RecommendationList` removed 2026-05-15 — replaced by the same
// rule-based `BerthRecommenderPanel` (already imported above) used on the
// Overview tab so the scoring + UI stay consistent. The old component
// pulled stale "AI"-style rows that all scored 50% because the underlying
// generate endpoint was orphaned.
import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel';
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
// Shared parser for the interest's stringly-typed numeric columns (Drizzle
// returns Postgres numeric as string). Used by both the Overview milestone
// classifier and the Recommendations tab so the conversion stays
// consistent regardless of entry point.
function toNum(v: string | null | undefined): number | null {
if (v === null || v === undefined) return null;
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
}
import { InterestTimeline } from '@/components/interests/interest-timeline';
import { WonStatusPanel } from '@/components/interests/won-status-panel';
import { SupplementalInfoRequestButton } from '@/components/interests/supplemental-info-request-button';
@@ -65,6 +82,10 @@ interface InterestTabsOptions {
desiredLengthFt?: string | null;
desiredWidthFt?: string | null;
desiredDraftFt?: string | null;
/** Unit the rep originally entered the dims in — drives the
* recommender header's display so a metric-entered deal doesn't
* render as ft. The three columns share an entry unit in practice. */
desiredLengthUnit?: string | null;
leadCategory: string | null;
source: string | null;
eoiStatus: string | null;
@@ -83,6 +104,23 @@ interface InterestTabsOptions {
contractDocStatus?: string | null;
/** Final outcome — 'won' surfaces the wrap-up checklist panel. */
outcome?: string | null;
/** Interest id — needed for the queryClient.invalidateQueries calls
* that fire after an inline contact edit. The parent passes this
* through `interestId` already, but the inline-edit handlers below
* use the structured object form. */
id: string;
/** Linked client id — required for the PATCH /api/v1/clients/[id]/
* contacts/[contactId] flow that the inline Email + Phone editors
* use. Null on an unlinked interest (rare but possible). */
clientId: string | null;
/** Primary contact channels resolved from the linked client record by
* getInterestById — both editable inline. The contact row's id is
* exposed alongside so the inline editor can PATCH the right row
* without an extra fetch. */
clientPrimaryEmail?: string | null;
clientPrimaryEmailContactId?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneContactId?: string | null;
dateFirstContact: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
@@ -105,6 +143,7 @@ interface InterestTabsOptions {
id: string;
content: string;
authorId: string;
authorName: string | null;
createdAt: string;
} | null;
tags?: Array<{ id: string; name: string; color: string }>;
@@ -476,12 +515,21 @@ function FutureMilestones({
function OverviewTab({
interestId,
interest,
clientId,
}: {
interestId: string;
interest: InterestTabsOptions['interest'];
clientId: string | null;
}) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
// QueryClient lifted to the top of the tab so the inline-edit email +
// phone handlers below can invalidate ['interest', id] on success.
const queryClient = useQueryClient();
// Lift the EOI generate dialog into the Overview so the milestone card
// can launch it inline — same dialog the dedicated EOI tab uses, so the
// editing/confirmation flow is identical regardless of entry point.
const [eoiGenerateOpen, setEoiGenerateOpen] = useState(false);
const mutation = useInterestPatch(interestId);
const stageMutation = useStageMutation(interestId);
const { confirm, dialog: confirmDialog } = useConfirmation();
@@ -530,10 +578,8 @@ function OverviewTab({
// genuinely skips stages — the click then routes through the same
// override-confirm flow as the inline stage picker.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
const contractIdx = PIPELINE_STAGES.indexOf('contract');
// Sub-status carries the "is this milestone's doc actually signed?" bit
// for the doc-bearing stages (eoi / reservation / contract). A milestone
@@ -543,55 +589,41 @@ function OverviewTab({
const reservationSigned = interest.reservationDocStatus === 'signed';
const contractSigned = interest.contractDocStatus === 'signed';
// Berth Interest milestone — first thing the rep needs to capture
// (especially for general_interest leads). Completes the moment ANY
// berth is linked to the interest via the junction. While unset, it
// sits as the "current" milestone unless the deal has already moved
// past EOI sent (in which case the rep clearly didn't need a berth
// pinned first, so we mark it 'past' implicitly).
// 2026-05-15: rewrote phase classification so the Overview always
// surfaces a CURRENT milestone for the rep, regardless of where the
// pipeline-stage column happens to sit. The previous "phase === current
// only when stageIdx exactly matches" rule produced an empty Overview
// for the qualified + nurturing stages (no milestone marked current, EOI
// hidden under "show upcoming") — exactly the gap the rep complained
// about. New model: the FIRST not-yet-complete milestone in the fixed
// berth_interest → eoi → reservation → deposit → contract order is
// 'current'. Everything before is 'past'; everything after is 'future'.
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
const berthInterestPhase: Phase = hasLinkedBerth
? 'past'
: stageIdx === -1 || stageIdx >= eoiIdx
? 'past'
: 'current';
const eoiPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > eoiIdx || (stageIdx === eoiIdx && eoiSigned)
? 'past'
: stageIdx === eoiIdx
? 'current'
: 'future';
const reservationPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > reservationIdx || (stageIdx === reservationIdx && reservationSigned)
? 'past'
: stageIdx === reservationIdx
? 'current'
: 'future';
// Deposit becomes 'current' once the reservation is signed; auto-advance
// moves it to 'past' the moment the running deposit total catches up.
const depositPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > depositIdx
? 'past'
: stageIdx === depositIdx
? 'past'
: stageIdx === reservationIdx && reservationSigned
? 'current'
: 'future';
const contractPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx === contractIdx && contractSigned
? 'past'
: stageIdx === contractIdx
? 'current'
: 'future';
const reservationStageReached = stageIdx >= reservationIdx;
const depositComplete = stageIdx > depositIdx;
const milestoneCompletion = {
berth_interest: hasLinkedBerth,
eoi: eoiSigned,
reservation: reservationSigned,
deposit: depositComplete,
contract: contractSigned,
} as const;
const order = ['berth_interest', 'eoi', 'reservation', 'deposit', 'contract'] as const;
const firstIncompleteKey = order.find((k) => !milestoneCompletion[k]) ?? null;
const phaseFor = (k: (typeof order)[number]): Phase => {
if (milestoneCompletion[k]) return 'past';
if (k === firstIncompleteKey) return 'current';
return 'future';
};
const berthInterestPhase: Phase = phaseFor('berth_interest');
const eoiPhase: Phase = phaseFor('eoi');
const reservationPhase: Phase = phaseFor('reservation');
const depositPhase: Phase = phaseFor('deposit');
const contractPhase: Phase = phaseFor('contract');
// Payments-section visibility: useless real estate until a deposit is
// actually expected (reservation stage onwards). Reps on enquiry /
// qualified / nurturing should see stage-guidance instead.
const showPaymentsSection = reservationStageReached;
const activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null =
berthInterestPhase === 'current'
@@ -606,11 +638,8 @@ function OverviewTab({
? 'contract'
: null;
const toNum = (v: string | null | undefined): number | null => {
if (v === null || v === undefined) return null;
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
};
// toNum extracted to module scope so the Recommendations tab can use it
// alongside the Overview tab. See top of file.
const milestones: Array<{
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
@@ -659,7 +688,11 @@ function OverviewTab({
label: 'EOI sent',
date: interest.dateEoiSent,
advanceStage: 'eoi',
actionLabel: 'Mark EOI as sent',
// 99% of the time the EOI is sent through Documenso and this
// stamps automatically via the webhook. Label as "manually" so
// reps reach for it only when Documenso fails to deliver or the
// EOI was sent outside the integrated flow.
actionLabel: 'Mark EOI as sent manually',
},
{
label: 'EOI signed',
@@ -667,9 +700,30 @@ function OverviewTab({
// Stage stays at 'eoi'; the sub-status badge flips via a separate
// PATCH (see MilestoneAdvanceButton.onConfirm fallback below).
advanceStage: 'eoi',
actionLabel: 'Mark EOI as signed',
actionLabel: 'Mark EOI as signed manually',
},
],
// When the EOI milestone is the active next step but nothing's been
// sent yet, surface the actual generation entry points instead of
// making the rep navigate to the EOI tab first. Mirrors the EOI
// tab's Generate flow exactly — same dialog component, same
// confirmation step — so behaviour stays consistent.
footer:
eoiPhase === 'current' && !interest.dateEoiSent ? (
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button type="button" size="sm" onClick={() => setEoiGenerateOpen(true)}>
Generate EOI
</Button>
<Button asChild type="button" size="sm" variant="outline">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/interests/${interestId}?tab=eoi` as any}
>
Open EOI tab
</Link>
</Button>
</div>
) : null,
pastSummary: interest.dateEoiSigned
? `Signed ${formatDate(interest.dateEoiSigned)}`
: 'Completed',
@@ -778,12 +832,17 @@ function OverviewTab({
{/* Payments — bank-issued invoices live elsewhere; this is the
internal audit record of money received against the deal. The
running deposit total here drives the auto-advance into the
deposit_paid stage server-side. */}
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
deposit_paid stage server-side. Hidden before the reservation
stage: no deposit is expected yet, so the empty card is just
noise — the next-milestone card carries the actionable copy
instead. */}
{showPaymentsSection && (
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
)}
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
@@ -865,12 +924,73 @@ function OverviewTab({
</dl>
</div>
{/* Contact dates (read-only - kept compact next to Lead) */}
{/* Contact — client's primary email + phone (from the linked client
record) AND the first/last-contact activity dates from the
contact log. Phone is rendered via libphonenumber-js's
international formatter so `+33633219796` reads as
`+33 6 33 21 97 96` (matches the canonical client-page display).
Both email + phone are click-to-edit: the PATCH flows to the
underlying client_contacts row (resolved via the
`*ContactId` fields surfaced by the interest read). */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
<EditableRow label="Email">
{interest.clientPrimaryEmailContactId ? (
<InlineEditableField
variant="text"
value={interest.clientPrimaryEmail ?? ''}
onSave={async (next) => {
if (!interest.clientId || !interest.clientPrimaryEmailContactId) return;
await apiFetch(
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryEmailContactId}`,
{ method: 'PATCH', body: { value: next } },
);
await queryClient.invalidateQueries({
queryKey: ['interest', interest.id],
});
}}
/>
) : (
<span className="text-muted-foreground"></span>
)}
</EditableRow>
<EditableRow label="Phone">
{interest.clientPrimaryPhoneContactId ? (
<InlineEditableField
variant="text"
value={
interest.clientPrimaryPhone
? (parsePhone(interest.clientPrimaryPhone).international ??
interest.clientPrimaryPhone)
: ''
}
onSave={async (next) => {
if (!interest.clientId || !interest.clientPrimaryPhoneContactId) return;
await apiFetch(
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryPhoneContactId}`,
{ method: 'PATCH', body: { value: next } },
);
await queryClient.invalidateQueries({
queryKey: ['interest', interest.id],
});
}}
/>
) : (
<span className="text-muted-foreground"></span>
)}
</EditableRow>
{interest.dateFirstContact || interest.dateLastContact ? (
<>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
</>
) : (
<p className="mt-1 text-xs text-muted-foreground italic">
No contact activity logged yet log a call, email, or meeting from the Contact log
tab to start tracking.
</p>
)}
{interest.reservationStatus ? (
<InfoRow label="Reservation" value={interest.reservationStatus} />
) : null}
@@ -918,7 +1038,11 @@ function OverviewTab({
addSuffix: true,
})}
{interest.recentNote.authorId
? ` · ${interest.recentNote.authorId === 'system' ? 'system' : interest.recentNote.authorId}`
? ` · ${
interest.recentNote.authorId === 'system'
? 'system'
: (interest.recentNote.authorName ?? 'Unknown')
}`
: ''}
</p>
</div>
@@ -963,8 +1087,19 @@ function OverviewTab({
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
/>
{confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
footer button can launch the dialog without leaving the tab. Same
dialog component the dedicated EOI tab uses — single source of
truth for the editing/confirmation flow. */}
<EoiGenerateDialog
interestId={interestId}
clientId={clientId}
open={eoiGenerateOpen}
onOpenChange={setEoiGenerateOpen}
/>
</div>
);
}
@@ -1000,7 +1135,7 @@ export function getInterestTabs({
{
id: 'overview',
label: 'Overview',
content: <OverviewTab interestId={interestId} interest={interest} />,
content: <OverviewTab interestId={interestId} interest={interest} clientId={clientId} />,
},
{
id: 'contact-log',
@@ -1049,7 +1184,15 @@ export function getInterestTabs({
{
id: 'recommendations',
label: 'Recommendations',
content: <RecommendationList interestId={interestId} />,
content: (
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
/>
),
},
{
id: 'activity',

View File

@@ -274,7 +274,9 @@ function LinkedBerthRowItem({
>
{row.mooringNumber ?? row.berthId}
</Link>
{row.area ? <span className="text-xs text-muted-foreground">{row.area}</span> : null}
{/* `row.area` is the area letter (A, B, C…) which is already the
leading character of the mooring number rendered above, so
surfacing it again is pure noise. Hidden 2026-05-15. */}
<StatusPill status={statusToPill(row.status)}>{formatStatus(row.status)}</StatusPill>
{row.isPrimary ? (
<span className="inline-flex items-center gap-1 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-800">
@@ -386,8 +388,8 @@ function LinkedBerthRowItem({
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Include this berth in the EOI&apos;s signed berth range. When on, the berth is
covered by the same signature and shows up in the EOI&apos;s
<strong> Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
covered by the same signature and shows up in the EOI&apos;s{' '}
<strong>Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
to keep the link without legal coverage.
</TooltipContent>
</Tooltip>
@@ -546,7 +548,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
{dealBerth ? renderRow(dealBerth, { highlight: true }) : null}
</BerthSection>
{bundleRows.length > 0 || dealBerth ? (
{bundleRows.length > 0 ? (
<BerthSection
title="In EOI bundle"
hint="Additional berths covered by the same EOI signature. Won't drive templates, but the client's signature applies to all of them."

View File

@@ -30,8 +30,14 @@ export function MultiEoiChip({ interestId }: { interestId: string }) {
staleTime: 60_000,
});
// "In-flight" = the deal actually has more than one ACTIVE EOI the rep
// could be confused by. Excludes terminal statuses (cancelled / voided /
// declined / deleted / completed) and archived rows. Without this filter
// a deal with one active EOI + N cancelled / deleted ones from prior
// attempts surfaces a misleading "N EOIs" warning.
const TERMINAL_STATUSES = new Set(['cancelled', 'voided', 'declined', 'deleted', 'completed']);
const inflight = (data?.data ?? []).filter(
(d) => !d.archivedAt && d.status !== 'voided' && d.status !== 'declined',
(d) => !d.archivedAt && !TERMINAL_STATUSES.has(d.status),
);
if (inflight.length < 2) return null;

View File

@@ -20,6 +20,7 @@ interface QualificationRow {
confirmedAt: string | null;
confirmedBy: string | null;
notes: string | null;
autoSatisfied: boolean;
}
interface QualificationResponse {
@@ -109,7 +110,11 @@ export function QualificationChecklist({
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
disabled={toggleMutation.isPending}
// Auto-satisfied rows can't be unchecked from the UI — the
// underlying data signal would just re-tick the box on the next
// refetch. The rep clears the dimensions tick by removing the
// yacht dims or desired-berth dims from the interest.
disabled={toggleMutation.isPending || c.autoSatisfied}
onCheckedChange={(v) =>
toggleMutation.mutate({ criterionKey: c.key, confirmed: v === true })
}
@@ -118,14 +123,25 @@ export function QualificationChecklist({
<label
htmlFor={`qual-${c.key}`}
className={cn(
'flex-1 text-sm cursor-pointer',
'flex-1 text-sm',
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
c.confirmed ? 'text-foreground' : 'text-foreground/90',
)}
>
<span
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
>
{c.label}
<span className="flex flex-wrap items-center gap-1.5">
<span
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
>
{c.label}
</span>
{c.autoSatisfied && (
<span
className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
title="System-derived from data on this interest"
>
Auto
</span>
)}
</span>
{c.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>

View File

@@ -2,9 +2,14 @@
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 { MobileLayout } from '@/components/layout/mobile/mobile-layout';
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';
import { MoreSheet } from '@/components/layout/mobile/more-sheet';
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
type SidebarProps = ComponentProps<typeof Sidebar>;
type TopbarProps = ComponentProps<typeof Topbar>;
@@ -27,11 +32,23 @@ interface AppShellProps {
const MOBILE_QUERY = '(max-width: 1023.98px)';
/**
* #26: single-tree responsive shell. Pre-fix the layout mounted BOTH
* desktop and mobile shells in the DOM and CSS-hid one — doubling React
* state, fetches, Tabs providers, and a11y landmarks. AppShell decides
* once per render which tree to mount, so a page only ever runs the
* effects + queries it actually displays.
* #26 + H-09: single-tree responsive shell with stable children subtree.
*
* The shell renders ONE `<main>` and ONE `<MobileLayoutProvider>` at all
* viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs)
* conditionally renders. Two payoffs:
*
* - #26 / first ship: no double-mount of chrome subtrees (Sidebar +
* MobileTopbar both running fetches / providers in parallel like the
* old layout did).
* - H-09: `{children}` stays mounted across viewport flips. A rep
* editing an inline field on desktop who resizes through the mobile
* breakpoint no longer loses the draft mid-edit — the children tree's
* position in the DOM is invariant, so React preserves its state.
*
* The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only
* mount in the mobile branch — they have no desktop counterpart and would
* be wasteful to keep mounted otherwise.
*
* SSR safety: the server passes its UA-classified hint via `initialFormFactor`;
* the first client render uses the same value so hydration matches. After
@@ -46,6 +63,8 @@ export function AppShell({
children,
}: AppShellProps) {
const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile');
const [moreOpen, setMoreOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
const mq = window.matchMedia(MOBILE_QUERY);
@@ -55,17 +74,54 @@ export function AppShell({
return () => mq.removeEventListener('change', update);
}, []);
if (isMobile) {
return <MobileLayout>{children}</MobileLayout>;
}
// Build the chrome subtree based on form factor; the children's parent
// chain (MobileLayoutProvider > div > main) is invariant across both
// branches, so React reconciliation keeps the children subtree mounted
// when isMobile flips.
const chrome = isMobile ? (
<>
<MobileTopbar />
</>
) : (
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
);
const footer = isMobile ? (
<>
<MobileBottomTabs
onMoreClick={() => setMoreOpen(true)}
onSearchClick={() => setSearchOpen(true)}
/>
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
</>
) : null;
const desktopTopbar = !isMobile ? <Topbar ports={ports} user={user} /> : null;
return (
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar ports={ports} user={user} />
<main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">{children}</main>
<MobileLayoutProvider>
<div
className={cn(
'bg-background',
isMobile ? 'min-h-[100dvh]' : 'flex h-screen overflow-hidden',
)}
>
{chrome}
<div className={cn(isMobile ? 'contents' : 'flex-1 flex flex-col overflow-hidden min-w-0')}>
{desktopTopbar}
<main
className={cn(
isMobile
? 'px-4 min-h-[100dvh] pt-[calc(56px+env(safe-area-inset-top)+1rem)] pb-[calc(56px+env(safe-area-inset-bottom)+2rem)]'
: 'flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6',
)}
>
{children}
</main>
</div>
{footer}
</div>
</div>
</MobileLayoutProvider>
);
}

View File

@@ -8,6 +8,7 @@ import {
Bookmark,
Building2,
FileSignature,
FileText,
Globe,
Home,
Inbox,
@@ -66,6 +67,9 @@ const MORE_GROUPS: MoreGroup[] = [
label: 'Operations',
items: [
{ label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' },
// M-U15: invoices was missing from the mobile nav — reps doing
// mobile follow-ups had to type the URL by hand.
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
{ label: 'Reports', icon: BarChart3, segment: 'reports' },

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Bell } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -59,11 +59,24 @@ export function NotificationBell() {
const notifications = data?.data ?? [];
// Auto-mark-as-read on display: when the dropdown opens and lists land,
// POST /read-all so the badge clears once the user has actually seen the
// items. Individual rows still link out — the auto-clear here is the
// "I've seen these" gesture; the per-row mark-read action stays
// available for selective dismissal in the inbox page.
useEffect(() => {
if (!open || isLoading) return;
if (notifications.some((n) => !n.isRead)) {
markAllReadMutation.mutate();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isLoading, notifications.length]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
<Bell className="h-5 w-5" aria-hidden />
{unreadCount > 0 && (
<span
key={unreadCount}

View File

@@ -101,7 +101,10 @@ export function CountryCombobox({
disabled={disabled}
className={cn(
'justify-between',
compact ? 'w-20 px-2' : 'w-full',
// `shrink-0` keeps the country trigger from collapsing below its
// natural width when the parent flex row gets squeezed, which
// was causing "🇺🇸 US +1" to wrap vertically inside PhoneInput.
compact ? 'w-24 shrink-0 px-2' : 'w-full',
!selected && 'text-muted-foreground',
className,
)}

View File

@@ -0,0 +1,47 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface DevFlags {
emailRedirectTo: string | null;
isDev: boolean;
}
/**
* Single-line warning banner shown across the app whenever a dev-mode
* safety net is active (today: `EMAIL_REDIRECT_TO`). Sticky at the top
* of every authenticated surface so reps and admins can't miss that
* every outbound email is being rerouted to a single inbox.
*
* Production hides the banner entirely because env.ts refuses to boot
* with EMAIL_REDIRECT_TO set when NODE_ENV=production — the flag is
* only ever non-null in dev / staging.
*/
export function DevModeBanner() {
const { data } = useQuery<{ data: DevFlags }>({
queryKey: ['internal', 'dev-flags'],
queryFn: () => apiFetch<{ data: DevFlags }>('/api/v1/internal/dev-flags'),
staleTime: 5 * 60_000,
// Don't refetch on focus; the flag changes only on a restart.
refetchOnWindowFocus: false,
});
const redirect = data?.data?.emailRedirectTo;
if (!redirect) return null;
return (
<div
role="alert"
className="flex items-center justify-center gap-2 border-b border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-900"
title={`Every outbound email is rewritten so the recipient is ${redirect}. The original address is preserved in the recipient name as "(was: original@...)". Unset EMAIL_REDIRECT_TO in your env to disable.`}
>
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
<span>
Dev mode: outbound emails redirected to <code className="font-mono">{redirect}</code>
</span>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { apiFetch } from '@/lib/api/client';
import { stageLabel } from '@/lib/constants';
type NoteSource =
| 'client'
@@ -31,6 +32,9 @@ interface Note {
source?: NoteSource;
sourceId?: string;
sourceLabel?: string;
/** Pipeline stage the linked interest was at when the note was authored.
* Only populated for interest notes — drives the small stage chip. */
pipelineStageAtCreation?: string | null;
}
type NotesEntityType =
@@ -280,6 +284,19 @@ export function NotesList({ entityType, entityId, currentUserId, aggregate }: No
{SOURCE_LABEL[note.source]} · {note.sourceLabel}
</span>
)}
{/* Pipeline-stage stamp: shows what stage the linked
interest was at when the note was authored. Lets a
rep trace how the deal's notes evolved (concerns
raised at qualified vs after reservation). Only
populated for interest notes from 2026-05-15+. */}
{note.pipelineStageAtCreation && (
<span
className="inline-flex items-center rounded-full bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-900"
title="Pipeline stage when note was authored"
>
@ {stageLabel(note.pipelineStageAtCreation)}
</span>
)}
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" aria-hidden />}
{canEdit(note) && (
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>

View File

@@ -108,7 +108,10 @@ export function PhoneInput({
return (
<div
className={cn(
'flex items-stretch gap-1.5',
// `w-full` keeps the row matching the field width below it instead
// of collapsing to its content's intrinsic width when nested inside
// a flex/grid cell.
'flex w-full items-stretch gap-1.5',
invalid && '[&_input]:border-destructive [&_button[role=combobox]]:border-destructive',
)}
>

View File

@@ -234,47 +234,61 @@ export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtForm
<Separator />
{/* Dimensions (ft) */}
{/* Dimensions — auto-convert ft ↔ m. Whichever unit the operator
types into, the other unit gets recomputed in place. We round
the converted value to keep the input clean (2 decimal places),
and skip the recompute when the user is mid-edit on the same
field so the cursor doesn't jump. */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Dimensions (ft)
Dimensions
</h3>
<p className="text-xs text-muted-foreground -mt-2">
Type a value in either ft or m the other unit auto-fills.
</p>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Length (ft)</Label>
<Input {...register('lengthFt')} placeholder="120" />
</div>
<div className="space-y-1">
<Label>Width (ft)</Label>
<Input {...register('widthFt')} placeholder="25" />
</div>
<div className="space-y-1">
<Label>Draft (ft)</Label>
<Input {...register('draftFt')} placeholder="8" />
</div>
</div>
</div>
<Separator />
{/* Dimensions (m) */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Dimensions (m)
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Length (m)</Label>
<Input {...register('lengthM')} placeholder="36.5" />
</div>
<div className="space-y-1">
<Label>Width (m)</Label>
<Input {...register('widthM')} placeholder="7.6" />
</div>
<div className="space-y-1">
<Label>Draft (m)</Label>
<Input {...register('draftM')} placeholder="2.4" />
</div>
<DimensionPair
label="Length"
ftValue={watch('lengthFt')}
mValue={watch('lengthM')}
onFtChange={(v) => {
setValue('lengthFt', v, { shouldDirty: true });
setValue('lengthM', ftToM(v), { shouldDirty: true });
}}
onMChange={(v) => {
setValue('lengthM', v, { shouldDirty: true });
setValue('lengthFt', mToFt(v), { shouldDirty: true });
}}
placeholders={{ ft: '120', m: '36.58' }}
/>
<DimensionPair
label="Width"
ftValue={watch('widthFt')}
mValue={watch('widthM')}
onFtChange={(v) => {
setValue('widthFt', v, { shouldDirty: true });
setValue('widthM', ftToM(v), { shouldDirty: true });
}}
onMChange={(v) => {
setValue('widthM', v, { shouldDirty: true });
setValue('widthFt', mToFt(v), { shouldDirty: true });
}}
placeholders={{ ft: '25', m: '7.62' }}
/>
<DimensionPair
label="Draft"
ftValue={watch('draftFt')}
mValue={watch('draftM')}
onFtChange={(v) => {
setValue('draftFt', v, { shouldDirty: true });
setValue('draftM', ftToM(v), { shouldDirty: true });
}}
onMChange={(v) => {
setValue('draftM', v, { shouldDirty: true });
setValue('draftFt', mToFt(v), { shouldDirty: true });
}}
placeholders={{ ft: '8', m: '2.44' }}
/>
</div>
</div>
@@ -369,3 +383,69 @@ export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtForm
</Sheet>
);
}
// 1 ft = 0.3048 m exactly. Round to 2 decimals so the cross-filled value is
// readable but stable; `trimZero` strips trailing `.0` so a whole-number
// conversion like `5 ft → 1.52 m → 1.52` doesn't render as `1.520000`.
const FT_PER_M = 3.28084;
function trimZero(s: string): string {
if (!s.includes('.')) return s;
return s.replace(/\.?0+$/, '');
}
function ftToM(value: string | null | undefined): string {
if (value == null || value === '') return '';
const n = Number(value);
if (!Number.isFinite(n)) return '';
return trimZero((n * 0.3048).toFixed(2));
}
function mToFt(value: string | null | undefined): string {
if (value == null || value === '') return '';
const n = Number(value);
if (!Number.isFinite(n)) return '';
return trimZero((n * FT_PER_M).toFixed(2));
}
function DimensionPair({
label,
ftValue,
mValue,
onFtChange,
onMChange,
placeholders,
}: {
label: string;
ftValue: string | null | undefined;
mValue: string | null | undefined;
onFtChange: (value: string) => void;
onMChange: (value: string) => void;
placeholders: { ft: string; m: string };
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium">{label}</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">ft</span>
<Input
inputMode="decimal"
value={ftValue ?? ''}
onChange={(e) => onFtChange(e.target.value)}
placeholder={placeholders.ft}
/>
</div>
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">m</span>
<Input
inputMode="decimal"
value={mValue ?? ''}
onChange={(e) => onMChange(e.target.value)}
placeholder={placeholders.m}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,12 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
@@ -39,6 +42,13 @@ export function YachtList() {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
// M-U14: surface the page title in the mobile topbar.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Yachts', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
@@ -63,7 +73,7 @@ export function YachtList() {
queryClient.invalidateQueries({ queryKey: ['yachts'] });
const s = res.data.summary;
if (s.failed > 0) {
alert(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
toast.warning(`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed.`);
}
},
});

View File

@@ -27,7 +27,11 @@ export async function processDocumensoPoll(): Promise<void> {
if (!doc.documensoId) continue;
try {
const remoteDoc = await getDocumensoDoc(doc.documensoId);
// Pass the doc's portId so the client uses per-port credentials
// (admin-set Documenso URL/key/version), not the global env fallback.
// Without portId a multi-port deployment hits the wrong instance and
// 401s every poll.
const remoteDoc = await getDocumensoDoc(doc.documensoId, doc.portId);
// Reconcile signer statuses
for (const remoteRecipient of remoteDoc.recipients) {
@@ -59,10 +63,16 @@ export async function processDocumensoPoll(): Promise<void> {
// Reconcile document status
if (remoteDoc.status === 'COMPLETED' && doc.status !== 'completed') {
logger.info({ documentId: doc.id, portId: doc.portId }, 'Reconciling completed document from poll');
logger.info(
{ documentId: doc.id, portId: doc.portId },
'Reconciling completed document from poll',
);
await handleDocumentCompleted({ documentId: doc.documensoId, portId: doc.portId });
} else if (remoteDoc.status === 'EXPIRED' && doc.status !== 'expired') {
logger.info({ documentId: doc.id, portId: doc.portId }, 'Reconciling expired document from poll');
logger.info(
{ documentId: doc.id, portId: doc.portId },
'Reconciling expired document from poll',
);
await handleDocumentExpired({ documentId: doc.documensoId, portId: doc.portId });
}
} catch (err) {

View File

@@ -20,13 +20,19 @@ export function parseQuery<T extends ZodSchema>(req: NextRequest, schema: T): z.
/**
* Parses the JSON request body against a Zod schema.
* Throws a ZodError on validation failure (caught by `errorResponse`).
*
* H-14: tolerates empty request bodies (content-length 0 or req.json()
* throwing on an empty stream) by substituting `{}` so DELETE/PATCH
* routes whose schemas have all-optional fields don't crash with a
* 500 — the schema's own optionality decides whether the empty object
* is a valid input.
*/
export async function parseBody<T extends ZodSchema>(
req: NextRequest,
schema: T,
): Promise<z.infer<T>> {
const body = await req.json();
return schema.parse(body);
const body = await req.json().catch(() => ({}));
return schema.parse(body ?? {});
}
/**

View File

@@ -51,7 +51,13 @@ export type AuditAction =
// evaluateRule() call so admins can debug "why did this fire / not fire"
// without reading server logs. Distinct from the actual `update` audit
// row the auto-applied path emits when it mutates berth status.
| 'rule_evaluated';
| 'rule_evaluated'
// M-AU04: distinct verbs for outcome-set / outcome-cleared. The pre-fix
// path used a generic `update` row with `metadata.type = 'outcome_set'`,
// which the audit filter dropdown couldn't surface as its own bucket
// and the FTS GENERATED index missed entirely.
| 'outcome_set'
| 'outcome_cleared';
/**
* Common shape passed to service functions so they can stamp audit logs and
@@ -220,6 +226,24 @@ export function diffFields(
const DEFAULT_SEVERITY_BY_ACTION: Partial<Record<AuditAction, AuditSeverity>> = {
permission_denied: 'warning',
hard_delete: 'critical',
// L-AU01: explicit severities so the row badge in /admin/audit lights
// up correctly. Without these, security-relevant verbs landed as
// generic 'info' grey rows next to read events.
password_change: 'warning',
portal_invite: 'info',
portal_activate: 'info',
portal_password_reset_request: 'warning',
portal_password_reset: 'warning',
revoke_invite: 'warning',
request_gdpr_export: 'info',
send_gdpr_export: 'info',
request_hard_delete_code: 'warning',
outcome_set: 'info',
outcome_cleared: 'info',
// Webhook lifecycle defaults to warning when a delivery fails.
webhook_failed: 'warning',
webhook_dead_letter: 'error',
job_failed: 'error',
};
const AUTH_ACTIONS = new Set<AuditAction>(['login', 'logout', 'password_change']);

View File

@@ -0,0 +1,10 @@
-- The `dimensions` qualification criterion is auto-satisfied when EITHER
-- the linked yacht has length/width/draft OR the interest itself has
-- desired berth dimensions set. The original description ("We know the
-- vessel's length, width, and draft") implied the yacht-only path, which
-- confused reps after the auto-satisfy rule shipped. Updated to reflect
-- both paths.
UPDATE qualification_criteria
SET description = 'Vessel dimensions OR desired berth dimensions are recorded (length, width, draft).'
WHERE key = 'dimensions'
AND description = 'We know the vessel''s length, width, and draft.';

View File

@@ -0,0 +1,11 @@
-- Documenso v2 webhooks send only the numeric internal ID (`payload.id = 19`),
-- but the rest of the v2 API expects the public `envelope_xxx` string that we
-- already store in `documents.documenso_id`. To resolve incoming webhooks
-- against our documents, capture the numeric id alongside the envelope id at
-- create time and let the resolver try either column.
--
-- v1 documents only have a single numeric id; existing rows leave this column
-- null and continue resolving by `documenso_id` as before.
ALTER TABLE documents ADD COLUMN IF NOT EXISTS documenso_numeric_id text;
CREATE INDEX IF NOT EXISTS idx_docs_documenso_numeric_id ON documents(documenso_numeric_id);

View File

@@ -0,0 +1,10 @@
-- Snapshot the linked interest's pipeline_stage at note-creation time so
-- the timeline of notes carries the stage they were made at. Read by the
-- NotesList UI to render a per-note stage chip.
--
-- Pre-2026-05-15 rows stay null — backfill from audit_logs would be
-- inaccurate (the audit row only captures the AFTER-stage on stage moves,
-- not the at-rest state when a note was inserted). New notes carry the
-- stamp going forward.
ALTER TABLE interest_notes ADD COLUMN IF NOT EXISTS pipeline_stage_at_creation text;

View File

@@ -0,0 +1,94 @@
-- H-01: explicit ON DELETE actions for previously-implicit NO ACTION FKs.
--
-- Without an explicit action Postgres defaults to NO ACTION, so a hard-
-- delete of a parent (client, port, berth, file, document signer) is
-- blocked at FK check time — sometimes intentional, often surprising.
-- Each FK below now declares whether parent deletion is RESTRICT (block,
-- force the operator to archive the parent or unlink the children first)
-- or SET NULL (allow the deletion, null the FK so child rows stay around
-- as historical records).
--
-- All ALTER COLUMNs are idempotent because we drop the constraint first
-- (if it exists) and re-add it under the same name; if the constraint is
-- already in the desired shape this is a no-op against Postgres.
-- interests: required parent links; archive-first is the supported path,
-- so RESTRICT a hard-delete to surface the misuse loudly.
ALTER TABLE interests DROP CONSTRAINT IF EXISTS interests_port_id_ports_id_fk;
ALTER TABLE interests
ADD CONSTRAINT interests_port_id_ports_id_fk
FOREIGN KEY (port_id) REFERENCES ports(id) ON DELETE RESTRICT;
ALTER TABLE interests DROP CONSTRAINT IF EXISTS interests_client_id_clients_id_fk;
ALTER TABLE interests
ADD CONSTRAINT interests_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT;
-- documents: client/file links are nullable already; tolerate parent
-- deletion via SET NULL so the document row stays for audit purposes.
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_client_id_clients_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_file_id_files_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_file_id_files_id_fk
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_signed_file_id_files_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_signed_file_id_files_id_fk
FOREIGN KEY (signed_file_id) REFERENCES files(id) ON DELETE SET NULL;
-- document_events: outlive their signer row so the audit trail stays
-- intact when a recipient is removed.
ALTER TABLE document_events DROP CONSTRAINT IF EXISTS document_events_signer_id_document_signers_id_fk;
ALTER TABLE document_events
ADD CONSTRAINT document_events_signer_id_document_signers_id_fk
FOREIGN KEY (signer_id) REFERENCES document_signers(id) ON DELETE SET NULL;
-- berth_reservations: every parent FK gets RESTRICT (canonical occupancy
-- record; never silently orphaned). interestId is nullable and SET NULL
-- so a reservation legitimately outlives the originating deal.
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_berth_id_berths_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_berth_id_berths_id_fk
FOREIGN KEY (berth_id) REFERENCES berths(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_port_id_ports_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_port_id_ports_id_fk
FOREIGN KEY (port_id) REFERENCES ports(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_client_id_clients_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_yacht_id_yachts_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_yacht_id_yachts_id_fk
FOREIGN KEY (yacht_id) REFERENCES yachts(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_interest_id_interests_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_interest_id_interests_id_fk
FOREIGN KEY (interest_id) REFERENCES interests(id) ON DELETE SET NULL;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_contract_file_id_files_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_contract_file_id_files_id_fk
FOREIGN KEY (contract_file_id) REFERENCES files(id) ON DELETE SET NULL;
-- reminders.client_id: nullable, tolerate parent delete with SET NULL.
ALTER TABLE reminders DROP CONSTRAINT IF EXISTS reminders_client_id_clients_id_fk;
ALTER TABLE reminders
ADD CONSTRAINT reminders_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL;
-- invoices.pdf_file_id: nullable, tolerate parent delete with SET NULL.
ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_pdf_file_id_files_id_fk;
ALTER TABLE invoices
ADD CONSTRAINT invoices_pdf_file_id_files_id_fk
FOREIGN KEY (pdf_file_id) REFERENCES files(id) ON DELETE SET NULL;

View File

@@ -42,6 +42,11 @@ export const companies = pgTable(
index('idx_companies_taxid')
.on(table.portId, table.taxId)
.where(sql`${table.taxId} IS NOT NULL`),
// M-SC02: partial index covering the hot "non-archived companies"
// path. Matches the pattern already used on clients/yachts/interests.
index('idx_companies_archived')
.on(table.portId)
.where(sql`${table.archivedAt} IS NULL`),
],
);

View File

@@ -69,7 +69,10 @@ export const documents = pgTable(
.notNull()
.references(() => ports.id),
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
clientId: text('client_id').references(() => clients.id),
// H-01: nullable; tolerate the owning client being hard-deleted (rare —
// archive is the normal path — but if it happens the document row
// should outlive it so the audit trail stays intact).
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }),
reservationId: text('reservation_id').references(() => berthReservations.id, {
@@ -82,8 +85,16 @@ export const documents = pgTable(
title: text('title').notNull(),
status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled
documensoId: text('documenso_id'),
fileId: text('file_id').references(() => files.id),
signedFileId: text('signed_file_id').references(() => files.id),
/** Documenso v2 webhooks send only the numeric internal id (e.g. "19"),
* while every other v2 API path expects the public envelope_xxx string
* stored in `documensoId`. Captured at create-time so the webhook
* resolver can match incoming events by either id. Null for v1
* documents (where `documensoId` already holds the only id). */
documensoNumericId: text('documenso_numeric_id'),
// H-01: nullable file references; both pre- and post-signing blobs
// can be soft-archived independently of the document row.
fileId: text('file_id').references(() => files.id, { onDelete: 'set null' }),
signedFileId: text('signed_file_id').references(() => files.id, { onDelete: 'set null' }),
isManualUpload: boolean('is_manual_upload').notNull().default(false),
/** Email addresses CC'd on the completion notification (the
* passive Documenso CC concept — see plan Q4). Per-document set
@@ -119,6 +130,7 @@ export const documents = pgTable(
// the documents table fully.
index('idx_docs_file_id').on(table.fileId),
index('idx_docs_signed_file_id').on(table.signedFileId),
index('idx_docs_documenso_numeric_id').on(table.documensoNumericId),
index('idx_docs_folder').on(table.folderId),
// Composite indexes for the aggregated-projection queries
// (`listInflightWorkflowsAggregatedByEntity`) — every join carries a
@@ -173,7 +185,9 @@ export const documentEvents = pgTable(
.notNull()
.references(() => documents.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(), // created, sent, viewed, signed, completed, expired, reminder_sent
signerId: text('signer_id').references(() => documentSigners.id),
// H-01: events outlive their signer row so the audit trail stays
// intact if a recipient is removed.
signerId: text('signer_id').references(() => documentSigners.id, { onDelete: 'set null' }),
eventData: jsonb('event_data').default({}),
signatureHash: text('signature_hash'), // deduplication
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -117,7 +117,9 @@ export const invoices = pgTable(
paymentDate: date('payment_date'),
paymentMethod: text('payment_method'),
paymentReference: text('payment_reference'),
pdfFileId: text('pdf_file_id').references(() => files.id),
// H-01: nullable — losing the rendered invoice PDF shouldn't bring
// down the invoice row (totals + payments are the source of truth).
pdfFileId: text('pdf_file_id').references(() => files.id, { onDelete: 'set null' }),
/** Optional link to a sales interest. When the invoice is paid and `kind`
* is 'deposit', recordPayment auto-advances the interest's pipelineStage
* to deposit_paid (no-op if already further along). */

View File

@@ -24,12 +24,19 @@ export const interests = pgTable(
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
// H-01: deleting a port is a manual super-admin operation; interests
// shouldn't outlive their port. RESTRICT forces the operator to
// explicitly archive/transfer interests first.
portId: text('port_id')
.notNull()
.references(() => ports.id),
.references(() => ports.id, { onDelete: 'restrict' }),
// H-01: client is required and design intent is archive-first — the
// service-layer hard-delete path nullifies FKs explicitly. RESTRICT
// is a defensive backstop against an ad-hoc DB hard-delete that
// would otherwise leave the interest pointing at a missing client.
clientId: text('client_id')
.notNull()
.references(() => clients.id),
.references(() => clients.id, { onDelete: 'restrict' }),
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
/** Who owns this deal. Auto-assigned on create from system_settings
* `default_new_interest_owner`; reassignable via the interest header. */
@@ -175,6 +182,11 @@ export const interestNotes = pgTable(
content: text('content').notNull(),
mentions: text('mentions').array(), // array of mentioned user IDs
isLocked: boolean('is_locked').notNull().default(false),
/** Snapshot of the linked interest's pipeline_stage at note creation.
* Lets a rep see how the deal's notes evolved across the lifecycle
* (e.g. concerns raised at qualified vs after reservation). Backfill
* not attempted for pre-2026-05-15 rows — they stay null. */
pipelineStageAtCreation: text('pipeline_stage_at_creation'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},

View File

@@ -22,7 +22,9 @@ export const reminders = pgTable(
status: text('status').notNull().default('pending'), // pending, snoozed, completed, dismissed
assignedTo: text('assigned_to'), // user ID
createdBy: text('created_by').notNull(),
clientId: text('client_id').references(() => clients.id),
// H-01: nullable — reminder rows stay around as historical follow-up
// records even if the linked client/interest/berth is hard-deleted.
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
berthId: text('berth_id').references(() => berths.id, { onDelete: 'set null' }),
autoGenerated: boolean('auto_generated').notNull().default(false),

View File

@@ -13,24 +13,35 @@ export const berthReservations = pgTable(
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
// H-01: reservations are the canonical "who occupies a berth right
// now" record; RESTRICT on every parent FK keeps an ad-hoc DB-side
// hard-delete from leaving a reservation pointing at a missing
// berth/client/yacht. Interest is nullable + SET NULL because a
// reservation legitimately outlives the originating deal.
berthId: text('berth_id')
.notNull()
.references(() => berths.id),
.references(() => berths.id, { onDelete: 'restrict' }),
portId: text('port_id')
.notNull()
.references(() => ports.id),
.references(() => ports.id, { onDelete: 'restrict' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id),
.references(() => clients.id, { onDelete: 'restrict' }),
yachtId: text('yacht_id')
.notNull()
.references(() => yachts.id),
interestId: text('interest_id').references(() => interests.id),
.references(() => yachts.id, { onDelete: 'restrict' }),
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
status: text('status').notNull(), // 'pending' | 'active' | 'ended' | 'cancelled'
startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(),
endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }),
tenureType: text('tenure_type').notNull().default('permanent'), // 'permanent' | 'fixed_term' | 'seasonal'
contractFileId: text('contract_file_id').references(() => files.id),
// M-L01: canonical tenure_type union is
// `permanent | fixed_term | fee_simple | strata_lot | seasonal`
// (kept in sync with berths.tenure_type). 'seasonal' is reservation-
// specific (winter haul-out etc.); the others mirror the berth's
// own tenure shape. Configurable via the per-port vocabulary at
// /admin/vocabularies (key: berth_tenure_types).
tenureType: text('tenure_type').notNull().default('permanent'),
contractFileId: text('contract_file_id').references(() => files.id, { onDelete: 'set null' }),
notes: text('notes'),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -48,9 +48,13 @@ export const auditLogs = pgTable(
/** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' — lets the
* UI filter by event origin without grepping action names. */
source: text('source').notNull().default('user'),
/** Full-text search column. Stored generated; updated by the migration's
* GENERATED ALWAYS expression covering action + entityType + entityId
* + actor email lookup. */
/** Full-text search column. **Read-only / DB-managed**: the column is
* declared `GENERATED ALWAYS AS (...) STORED` in migration
* 0014_black_banshee.sql (covers action + entity_type + entity_id +
* user_id). Drizzle has no first-class marker for generated columns,
* so writes through this schema property would be rejected by
* Postgres at SQL level — never set this from application code.
* M-SC04: documented to prevent accidental write attempts. */
searchText: tsvector('search_text'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},

View File

@@ -804,13 +804,14 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
}
// ── 6b. Standard EOI Template (in-app PDF path) ────────────────────────
// One row per port. Used by the in-app pdfme renderer when the port opts
// for in-app PDF generation over the Documenso template flow.
// One row per port. Used by the in-app pdf-lib AcroForm renderer when
// the port opts for in-app PDF generation over the Documenso template
// flow. (Renamed from pdfme in the 2026-05-12 PDF stack overhaul.)
await tx.insert(documentTemplates).values({
portId,
name: 'Standard EOI (in-app)',
description:
'Default Expression of Interest / Letter of Intent template, rendered in-app via pdfme. Use for ports that prefer in-app PDF generation over the Documenso template path.',
'Default Expression of Interest / Letter of Intent template, rendered in-app via pdf-lib AcroForm. Use for ports that prefer in-app PDF generation over the Documenso template path.',
templateType: 'eoi',
bodyHtml: STANDARD_EOI_BODY_HTML,
mergeFields: STANDARD_EOI_MERGE_FIELDS,

View File

@@ -17,7 +17,7 @@ import { getPortEmailConfig, type PortEmailConfig } from '@/lib/services/port-co
// worker concurrency slot for up to 2 min × 5 retry attempts = 10 min
// per job. With concurrency 5, all slots can be starved by a single
// flaky upstream. Explicit timeouts cap the worst case under a minute.
const SMTP_TIMEOUTS = {
export const SMTP_TIMEOUTS = {
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 30_000,
@@ -123,6 +123,11 @@ export async function sendEmail(
text?: string,
portId?: string,
attachments?: EmailAttachmentRef[],
// M-EM02: optional CC / BCC. Mirror the same EMAIL_REDIRECT_TO scrub
// as `to` so dev-mode redirects don't accidentally leak a CC outside
// the safety net.
cc?: string | string[],
bcc?: string | string[],
): Promise<nodemailer.SentMessageInfo> {
const cfg = portId ? await getPortEmailConfig(portId) : null;
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
@@ -132,6 +137,11 @@ export async function sendEmail(
const effectiveSubject = env.EMAIL_REDIRECT_TO
? `[redirected from ${requestedTo}] ${subject}`
: subject;
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO — the redirect target
// already gets the message; CCing additional recipients would defeat
// the dev safety net.
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : cc;
const effectiveBcc = env.EMAIL_REDIRECT_TO ? undefined : bcc;
const fromHeader =
from ??
@@ -148,6 +158,8 @@ export async function sendEmail(
html,
...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}),
...(text ? { text } : {}),
...(effectiveCc ? { cc: effectiveCc } : {}),
...(effectiveBcc ? { bcc: effectiveBcc } : {}),
...(resolvedAttachments.length > 0 ? { attachments: resolvedAttachments } : {}),
});

View File

@@ -22,6 +22,12 @@ export const TEMPLATE_KEYS = [
'inquiry_sales_notification',
'residential_inquiry_client_confirmation',
'residential_inquiry_sales_alert',
// M-EM04: daily notification digest. The digest service previously
// resolved its subject via `'crm_invite' as any` because no entry
// existed; making it a first-class key removes the cast and lets
// admins override the subject from /admin/email like every other
// template.
'notification_digest',
] as const;
export type TemplateKey = (typeof TEMPLATE_KEYS)[number];
@@ -95,6 +101,14 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
defaultSubject: 'New residential inquiry — {{clientName}}',
},
notification_digest: {
key: 'notification_digest',
label: 'Notification digest',
description:
"Daily roll-up of a rep's pending notifications. Fires from the digest worker; respects per-user opt-out.",
mergeTokens: ['portName', 'recipientName', 'unreadCount'],
defaultSubject: 'Your {{portName}} CRM digest — {{unreadCount}} updates',
},
};
/** system_settings key for a template's subject override. */

View File

@@ -325,3 +325,76 @@ export async function signingReminderEmail(
text,
};
}
// ─── 4. Cancelled ─────────────────────────────────────────────────────────────
interface CancelledData {
recipientName: string;
documentLabel: string;
portName: string;
/** Optional rep-authored reason. When null, the body explains the
* cancellation without speculation; when set, the reason renders in
* the same callout style as the invitation `customMessage`. */
reason?: string | null;
}
function CancelledBody({ data, accent }: { data: CancelledData; accent: string }) {
const greeting = `Dear ${data.recipientName},`;
return (
<>
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
{data.documentLabel} cancelled
</Text>
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
The {data.documentLabel} you were signing for {data.portName} has been cancelled. No further
action is required from you any signing link previously sent is no longer valid.
</Text>
{data.reason ? (
<Text
style={{
margin: '20px 0',
fontSize: '15px',
lineHeight: '1.6',
color: '#444',
padding: '14px 18px',
background: '#f8f9fb',
borderLeft: `3px solid ${accent}`,
borderRadius: '4px',
whiteSpace: 'pre-wrap',
}}
>
{data.reason}
</Text>
) : null}
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
If you have any questions, please reach out to your representative at {data.portName}.
</Text>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
<Text style={{ fontSize: '16px', marginTop: '24px' }}>
Thank you,
<br />
<strong>The {data.portName} team</strong>
</Text>
</>
);
}
export async function signingCancelledEmail(
data: CancelledData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const accent = brandingPrimaryColor(overrides?.branding);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
: `${data.documentLabel} cancelled — ${data.portName}`;
const body = await render(<CancelledBody data={data} accent={accent} />, { pretty: false });
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} you were signing for ${data.portName} has been cancelled. No further action is required.${data.reason ? '\n\nReason: ' + data.reason : ''}\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -13,27 +13,38 @@ const envSchema = z
BETTER_AUTH_URL: z.string().url(),
CSRF_SECRET: z.string().min(32),
// MinIO
MINIO_ENDPOINT: z.string().min(1),
MINIO_PORT: z.coerce.number().int().positive(),
MINIO_ACCESS_KEY: z.string().min(1),
MINIO_SECRET_KEY: z.string().min(1),
MINIO_BUCKET: z.string().min(1),
MINIO_USE_SSL: z.enum(['true', 'false']).transform((v) => v === 'true'),
// ─── Tenant-configurable (admin UI is canonical; env is fallback) ─────
// The settings registry at src/lib/settings/registry.ts wires each of
// these into the per-port admin UI with port → global → env → default
// precedence. They're optional here so a fresh deploy without an env
// file can still boot — the operator configures everything via
// /admin/<integration> after first super-admin login. See
// docs/superpowers/specs/2026-05-15-env-to-admin-migration-design.md.
// Documenso
DOCUMENSO_API_URL: z.string().url(),
DOCUMENSO_API_KEY: z.string().min(1),
// MinIO / S3 (storage backend) — admin: /admin/storage
MINIO_ENDPOINT: z.string().min(1).optional(),
MINIO_PORT: z.coerce.number().int().positive().optional(),
MINIO_ACCESS_KEY: z.string().min(1).optional(),
MINIO_SECRET_KEY: z.string().min(1).optional(),
MINIO_BUCKET: z.string().min(1).optional(),
MINIO_USE_SSL: z
.enum(['true', 'false'])
.optional()
.transform((v) => (v == null ? undefined : v === 'true')),
// Documenso — admin: /admin/documenso
DOCUMENSO_API_URL: z.string().url().optional(),
DOCUMENSO_API_KEY: z.string().min(1).optional(),
DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'),
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8),
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192),
DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().default(193),
DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().default(194),
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16).optional(),
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().optional(),
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().optional(),
DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().optional(),
DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().optional(),
// Email
SMTP_HOST: z.string().min(1),
SMTP_PORT: z.coerce.number().int().positive(),
// Email / SMTP — admin: /admin/email
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().int().positive().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().optional(),
@@ -66,9 +77,10 @@ const envSchema = z
SENTRY_ENVIRONMENT: z.string().optional(),
SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(0.1),
// App
// App URLs — admin: /admin/general (TODO once general admin page exists;
// for now write via the API: PUT /api/v1/admin/settings/app_url)
APP_URL: z.string().url(),
PUBLIC_SITE_URL: z.string().url(),
PUBLIC_SITE_URL: z.string().url().optional(),
/**
* Client-side bundle baseline URL. Inlined at build time by Next, so
* a missing value at build leaks into the browser as the empty

View File

@@ -231,6 +231,12 @@ export const ERROR_CODES = {
status: 504,
userMessage: 'The signing service is taking too long to respond. Please try again in a moment.',
},
DOCUMENSO_V1_NOT_SUPPORTED: {
status: 400,
userMessage:
'This action requires Documenso 2.x — the connected instance is on the legacy v1 API. Ask an admin to upgrade Documenso, then retry.',
hint: 'updateEnvelope and other v2-native endpoints require the envelope API introduced in Documenso 2.0.',
},
OCR_UPSTREAM_ERROR: {
status: 502,
userMessage:

View File

@@ -67,7 +67,14 @@ export async function loadEoiTemplatePdf(): Promise<Uint8Array> {
function formatAddress(address: EoiContext['client']['address']): string {
if (!address) return '';
return [address.street, address.city, address.country].filter(Boolean).join(', ');
// EOI's Address field renders as: "street, city, REGION, postal, COUNTRY"
// with REGION as the ISO-3166-2 suffix (e.g. NY) and COUNTRY as the
// alpha-2 code (e.g. US) so the line fits in the PDF box. The separate
// `Nationality` PDF field has been retired — the resident's country code
// here is the canonical replacement.
return [address.street, address.city, address.subdivision, address.postalCode, address.countryIso]
.filter(Boolean)
.join(', ');
}
function setText(form: ReturnType<PDFDocument['getForm']>, name: string, value: string): void {
@@ -120,6 +127,7 @@ function setCheckbox(
export async function fillEoiFormFields(
pdfBytes: Uint8Array,
context: EoiContext,
options?: { dimensionUnit?: 'ft' | 'm' },
): Promise<Uint8Array> {
const doc = await PDFDocument.load(pdfBytes);
const form = doc.getForm();
@@ -128,11 +136,20 @@ export async function fillEoiFormFields(
setText(form, 'Email', context.client.primaryEmail ?? '');
setText(form, 'Address', formatAddress(context.client.address));
// Yacht + berth (EOI Section 3) are optional - leave the AcroForm fields
// blank when the interest hasn't been linked to either.
// blank when the interest hasn't been linked to either. Dimension side
// (ft|m) honours the drawer's toggle; legacy callers omit and get ft.
setText(form, 'Yacht Name', context.yacht?.name ?? '');
setText(form, 'Length', context.yacht?.lengthFt ?? '');
setText(form, 'Width', context.yacht?.widthFt ?? '');
setText(form, 'Draft', context.yacht?.draftFt ?? '');
const dimUnit: 'ft' | 'm' = options?.dimensionUnit ?? context.yacht?.lengthUnit ?? 'ft';
const yLen = dimUnit === 'ft' ? context.yacht?.lengthFt : context.yacht?.lengthM;
const yWid = dimUnit === 'ft' ? context.yacht?.widthFt : context.yacht?.widthM;
const yDra = dimUnit === 'ft' ? context.yacht?.draftFt : context.yacht?.draftM;
// Append the unit suffix so the rendered EOI reads "45 ft" / "13.7 m"
// rather than the bare number — matches the Documenso pathway.
const withDimUnit = (v: string | null | undefined): string =>
v && String(v).trim() ? `${String(v).trim()} ${dimUnit}` : '';
setText(form, 'Length', withDimUnit(yLen));
setText(form, 'Width', withDimUnit(yWid));
setText(form, 'Draft', withDimUnit(yDra));
// Berth Number = compact range for multi-berth, primary mooring for
// single-berth (formatBerthRange(['A1']) === 'A1' so single-berth is
// byte-identical to the legacy primary-only path). The dedicated
@@ -160,7 +177,10 @@ export async function fillEoiFormFields(
/**
* Convenience: loads the source PDF from disk and returns the filled bytes.
*/
export async function generateEoiPdfFromTemplate(context: EoiContext): Promise<Uint8Array> {
export async function generateEoiPdfFromTemplate(
context: EoiContext,
options?: { dimensionUnit?: 'ft' | 'm' },
): Promise<Uint8Array> {
const bytes = await loadEoiTemplatePdf();
return fillEoiFormFields(bytes, context);
return fillEoiFormFields(bytes, context, options);
}

View File

@@ -0,0 +1,35 @@
import { PDFDocument } from 'pdf-lib';
/**
* Result of inspecting a PDF's AcroForm. Used by the Documenso template
* sync flow to surface what AcroForm fields the operator's uploaded PDF
* actually has — so the admin can verify their fillable PDF matches the
* CRM's expected field-label set before any EOI is sent in anger.
*/
export interface AcroFormField {
name: string;
/**
* `getType()` from pdf-lib's PDFField subclasses — usually one of
* `PDFTextField`, `PDFCheckBox`, `PDFDropdown`, `PDFRadioGroup`,
* `PDFSignature`, `PDFButton`. Exposed verbatim so the UI can show
* the admin what each field expects at the AcroForm layer.
*/
type: string;
}
/**
* Parses the AcroForm in `pdfBytes` and returns one descriptor per form
* field. Returns an empty array when the PDF has no AcroForm at all
* (i.e. a flat / non-fillable PDF). Never throws on a missing form —
* the caller treats "empty list" as a signal to nudge the operator
* that their PDF isn't actually fillable.
*/
export async function inspectPdfAcroForm(pdfBytes: Buffer | Uint8Array): Promise<AcroFormField[]> {
const doc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
const form = doc.getForm();
const fields = form.getFields();
return fields.map((f) => ({
name: f.getName(),
type: f.constructor.name,
}));
}

View File

@@ -13,8 +13,12 @@ interface RecurringJobDef {
*/
export async function registerRecurringJobs(): Promise<void> {
const recurring: RecurringJobDef[] = [
// Documenso signature fallback poll - primary is webhooks, this is safety net
{ queue: 'documents', name: 'signature-poll', pattern: '0 */6 * * *' },
// Documenso signature fallback poll - primary is webhooks, this is the
// safety net for any missed delivery (cloudflared tunnel hiccup, transient
// 5xx on our receiver, Documenso quirk). Tightened from 6h to 5m so the
// user-facing "stuck on partially_signed" symptom only persists for the
// 5-min window between polls. Cheap query: ~1 GET per in-flight doc.
{ queue: 'documents', name: 'signature-poll', pattern: '*/5 * * * *' },
// Reminder checks
{ queue: 'notifications', name: 'reminder-check', pattern: '0 * * * *' },

View File

@@ -1,57 +1,8 @@
import { and, eq, desc, sql, gte, lte } from 'drizzle-orm';
import { db } from '@/lib/db';
import { auditLogs } from '@/lib/db/schema';
interface AuditListQuery {
page: number;
limit: number;
entityType?: string;
action?: string;
userId?: string;
entityId?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
}
export async function listAuditLogs(portId: string, query: AuditListQuery) {
const conditions = [eq(auditLogs.portId, portId)];
if (query.entityType) conditions.push(eq(auditLogs.entityType, query.entityType));
if (query.action) conditions.push(eq(auditLogs.action, query.action));
if (query.userId) conditions.push(eq(auditLogs.userId, query.userId));
if (query.entityId) conditions.push(eq(auditLogs.entityId, query.entityId));
if (query.dateFrom) conditions.push(gte(auditLogs.createdAt, new Date(query.dateFrom)));
if (query.dateTo) conditions.push(lte(auditLogs.createdAt, new Date(query.dateTo)));
if (query.search) {
conditions.push(
sql`(${auditLogs.entityType} ILIKE ${'%' + query.search + '%'} OR ${auditLogs.action} ILIKE ${'%' + query.search + '%'})`,
);
}
const offset = (query.page - 1) * query.limit;
const [data, countResult] = await Promise.all([
db
.select()
.from(auditLogs)
.where(and(...conditions))
.orderBy(desc(auditLogs.createdAt))
.limit(query.limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(auditLogs)
.where(and(...conditions)),
]);
return {
data,
pagination: {
page: query.page,
limit: query.limit,
total: Number(countResult[0]?.count ?? 0),
},
};
}
// L-AU04: the legacy ILIKE-based `listAuditLogs` was superseded by the
// FTS-backed `searchAuditLogs` (audit-search.service.ts) in 2026-05-08.
// Nothing imports the old function anymore — keeping a stub file rather
// than deleting outright in case the module path resolves elsewhere in
// build tooling. Remove the file in a follow-up sweep.
//
// All audit reads should go through `searchAuditLogs(options)` instead.
export {};

View File

@@ -617,6 +617,22 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
userAgent: meta.userAgent,
});
// H-07: emit per-interest archive rows so an auditor searching for a
// specific archived interest finds it directly — the client-level row's
// `cascadedInterestIds` array doesn't participate in audit-log FTS.
for (const interestId of archivedInterestIds) {
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'interest',
entityId: interestId,
metadata: { cascadeSource: 'client_archive', clientId: id },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
for (const interestId of archivedInterestIds) {
emitToRoom(`port:${portId}`, 'interest:archived', { interestId });
@@ -737,7 +753,8 @@ export async function updateContact(
const [updated] = await db
.update(clientContacts)
.set({ ...data, updatedAt: new Date() })
.where(eq(clientContacts.id, contactId))
// M-MT03: pin the WHERE to (id, clientId) for defense-in-depth.
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
.returning();
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
@@ -761,7 +778,10 @@ export async function removeContact(
});
if (!contact) throw new NotFoundError('Contact');
await db.delete(clientContacts).where(eq(clientContacts.id, contactId));
// M-MT03: pin (id, clientId) for defense-in-depth.
await db
.delete(clientContacts)
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)));
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
}

View File

@@ -68,7 +68,12 @@ export async function createCrmInvite(args: {
internalMessage: 'Failed to create CRM invite',
});
const link = `${env.APP_URL}/set-password?token=${raw}`;
// H-03: token moves to the URL fragment so it never lands in nginx/Caddy
// access logs (or any HTTP-Referer-leaking middlebox). The fragment is
// browser-only; the server only sees the path. set-password/page.tsx
// reads either `#token=` or `?token=` (back-compat for outstanding
// links). encodeURIComponent guards against `#`/`&` in the token.
const link = `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`;
const result = await crmInviteEmail({
link,
ttlHours: INVITE_TTL_HOURS,
@@ -230,7 +235,12 @@ export async function resendCrmInvite(
.set({ tokenHash: hash, expiresAt })
.where(eq(crmUserInvites.id, inviteId));
const link = `${env.APP_URL}/set-password?token=${raw}`;
// H-03: token moves to the URL fragment so it never lands in nginx/Caddy
// access logs (or any HTTP-Referer-leaking middlebox). The fragment is
// browser-only; the server only sees the path. set-password/page.tsx
// reads either `#token=` or `?token=` (back-compat for outstanding
// links). encodeURIComponent guards against `#`/`&` in the token.
const link = `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`;
const branding = await getBrandingShell(meta.portId);
const result = await crmInviteEmail(
{

Some files were not shown because too many files have changed in this diff Show More