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

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