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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
260
src/components/admin/documenso/embedded-signing-card.tsx
Normal file
260
src/components/admin/documenso/embedded-signing-card.tsx
Normal 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's already done; this is just the friendly “Thanks!” 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
437
src/components/admin/documenso/template-sync-button.tsx
Normal file
437
src/components/admin/documenso/template-sync-button.tsx
Normal 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/{id}</code> on the currently
|
||||
configured Documenso instance, writes the discovered recipient IDs into the slots above,
|
||||
and caches the field name→ID 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'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'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'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't recognize (
|
||||
{lastResult.unmatchedTemplateFields.length})
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
These won'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'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'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's <code>formValues</code>{' '}
|
||||
path will fill nothing. Re-export your PDF with form fields enabled, or
|
||||
place overlays inside Documenso'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
608
src/components/admin/shared/registry-driven-form.tsx
Normal file
608
src/components/admin/shared/registry-driven-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']],
|
||||
|
||||
179
src/components/documents/eoi-cancel-dialog.tsx
Normal file
179
src/components/documents/eoi-cancel-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'll be saved to the client'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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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's
|
||||
no longer in progress.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ interface InterestData {
|
||||
id: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string | null;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
berthId: string | null;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "Open" 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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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's signed berth range. When on, the berth is
|
||||
covered by the same signature and shows up in the EOI's
|
||||
<strong> Berth Range</strong> form field (e.g. "A1-A3, B5-B7"). Turn off
|
||||
covered by the same signature and shows up in the EOI's{' '}
|
||||
<strong>Berth Range</strong> form field (e.g. "A1-A3, B5-B7"). 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."
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
47
src/components/shared/dev-mode-banner.tsx
Normal file
47
src/components/shared/dev-mode-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user