chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -4,8 +4,10 @@ import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
Activity,
BarChart3,
AlertCircle,
Anchor,
BellRing,
BookMarked,
BookOpen,
ClipboardList,
CopyCheck,
@@ -15,6 +17,7 @@ import {
FileText,
FileUp,
GitBranch,
Home,
Inbox,
ListChecks,
Mail,
@@ -64,62 +67,60 @@ interface AdminGroup {
// Catalog lives inside the client component so React Server Components don't
// need to serialize the lucide icon factories (functions / $$typeof refs)
// across the RSC boundary that crash was the error the rep hit when the
// across the RSC boundary - that crash was the error the rep hit when the
// catalog lived in the server-component `page.tsx`. Adding a new admin
// surface? Append it to the matching group below.
// surface? Append it to the matching domain below.
//
// 7-domain IA regrouped 2026-05-22 per docs/admin-ia-proposal.md:
// 1. Brand & Communication - how outbound looks
// 2. Sales workflow - pipeline behaviour + templates
// 3. Catalog - tenant-defined data shapes
// 4. Identity & access - who can use the system
// 5. Inbox & data quality - admin queues + cleanup
// 6. Integrations - external systems + providers
// 7. System & observability - infra + diagnostics + escape hatches
//
// Pages deleted in the regroup (redirect-shimmed): /admin/ocr (dup of /ai),
// /admin/invitations (merged into /users 2026-05-21), /admin/reports
// (duplicated dashboard widgets). See docs/admin-ia-proposal.md §8.
const GROUPS: AdminGroup[] = [
{
title: 'Access',
description: 'Who can sign in and what they can do once they do.',
title: 'Brand & Communication',
description: 'How outbound looks and which channels it ships on.',
sections: [
{
href: 'users',
label: 'Users',
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
icon: Users,
keywords: [
'accounts',
'staff',
'team',
'disable user',
'reset password',
'residential access',
],
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Paintbrush,
keywords: ['logo', 'colors', 'email shell', 'preview', 'test email'],
},
// /admin/invitations merged into /admin/users (Active users +
// Invitations tabs) on 2026-05-21. The standalone tile was
// removed; reps still find the invitation flow via the Users
// tile's "Invitations" tab.
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
href: 'email',
label: 'Email Settings',
description:
'SMTP credentials (noreply + sales), per-template tester, routing rules, and the connectivity probe.',
icon: Mail,
keywords: ['smtp', 'noreply', 'sales mailbox', 'test send', 'routing'],
},
{
href: 'email-templates',
label: 'Email Templates',
description:
'Subject-line + body overrides for transactional emails (portal, inquiry, invite).',
icon: FilePen,
},
],
},
{
title: 'Configuration',
description: 'Branding, integrations, and per-port settings.',
title: 'Sales workflow',
description: 'How the pipeline behaves - triggers, scoring, document + form templates.',
sections: [
{
href: 'email',
label: 'Email Settings',
description: 'From address, signatures, and per-port SMTP overrides.',
icon: Mail,
},
{
href: 'documenso',
label: 'EOI signing service',
description:
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileSignature,
},
{
href: 'pipeline-rules',
label: 'Pipeline auto-advance',
label: 'Pipeline rules',
description:
'Per-trigger control: which lifecycle events (EOI signed, deposit received, contract signed) auto-advance the deal stage.',
'Berth-rules engine triggers + per-event auto-advance (EOI signed, deposit received, contract signed).',
icon: GitBranch,
keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'],
},
@@ -127,7 +128,7 @@ const GROUPS: AdminGroup[] = [
href: 'pulse',
label: 'Deal Pulse',
description:
'Configure the chip on every interest header master toggle, per-signal toggles, and tier-label overrides.',
'Configure the chip on every interest header - master toggle, per-signal toggles, and tier-label overrides.',
icon: Activity,
keywords: ['pulse', 'deal-health', 'health chip', 'hot warm cold'],
},
@@ -138,15 +139,232 @@ const GROUPS: AdminGroup[] = [
icon: BellRing,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Paintbrush,
href: 'qualification-criteria',
label: 'Qualification criteria',
description:
'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the "ready to qualify" hint on the interest detail.',
icon: ListChecks,
keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'],
},
{
href: 'residential-stages',
label: 'Residential pipeline stages',
description:
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
icon: Home,
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
},
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: ClipboardList,
},
{
href: 'templates',
label: 'Document Templates',
description:
'PDF + email templates with merge-field placeholders - EOI, reservation, contract.',
icon: FileText,
},
],
},
{
title: 'Catalog',
description: 'Tenant-defined data shapes that attach to records.',
sections: [
{
href: 'vocabularies',
label: 'Vocabularies',
description:
'Per-port pick lists used across the CRM: interest temperatures, status reasons, tenure types, expense categories, document types.',
icon: BookOpen,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: SlidersHorizontal,
},
{
href: 'brochures',
label: 'Brochures',
description: 'Per-port versioned brochure PDFs distributed through the public site + reps.',
icon: BookMarked,
},
],
},
{
title: 'Identity & access',
description: 'Who can sign in and what they can do once they do.',
sections: [
{
href: 'users',
label: 'Users',
description:
'Active CRM accounts + pending invitations (merged tabs), role assignments, residential access toggles.',
icon: Users,
keywords: [
'accounts',
'staff',
'team',
'invitations',
'invite',
'disable user',
'reset password',
'residential access',
],
},
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
},
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves (super-admin only).',
icon: Ship,
},
],
},
{
title: 'Inbox & data quality',
description: 'Admin queues for inbound submissions + cleanup tooling.',
sections: [
{
href: 'inquiries',
label: 'Inquiry Inbox',
description:
'Submissions captured from the public marketing site (berth, residence, contact).',
icon: Inbox,
},
{
href: 'sends',
label: 'Send Log',
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
icon: Send,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: CopyCheck,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: FileUp,
},
{
href: 'berths',
label: 'Berths admin',
description:
'Bulk-add new berths in one wizard + reconcile berths missing required fields.',
icon: Anchor,
keywords: ['bulk add', 'reconcile', 'berth pdf', 'mooring', 'bulk berths'],
},
],
},
{
title: 'Integrations',
description: 'External systems + provider configuration.',
sections: [
{
href: 'documenso',
label: 'Signing service (Documenso)',
description:
'API credentials, signer identities, templates, and signing-behaviour for every document the CRM puts out for signature.',
icon: FileSignature,
keywords: ['documenso', 'eoi', 'signing', 'envelope', 'signer', 'embedded'],
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: TrendingUp,
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
},
{
href: 'ai',
label: 'AI configuration',
description:
'One panel for every AI feature: master switch, provider credentials, OCR settings, interest scoring, email-drafts, recommender controls.',
icon: Sparkles,
keywords: [
'openai',
'anthropic',
'gpt',
'claude',
'llm',
'api key',
'embeddings',
'receipt',
'scan',
'tesseract',
'ocr',
'expense scanner',
'interest scoring',
'email drafts',
],
},
],
},
{
title: 'System & observability',
description: 'Infra, observability, escape hatches.',
sections: [
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Activity,
},
{
href: 'errors',
label: 'Errors',
description:
'Recent server-side error events with request-id drill-down, plus the error-code catalog.',
icon: AlertCircle,
keywords: ['exceptions', 'request id', 'stack trace', 'error code'],
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: DatabaseBackup,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: Server,
},
{
href: 'settings',
label: 'System Settings',
description: 'Generic key/value configuration store for advanced flags.',
description: 'Generic key/value configuration store for advanced flags (escape hatch).',
icon: Settings,
// Mirrors KNOWN_SETTINGS in settings-manager.tsx so a search for any
// individual flag jumps straight to this card. Keep in sync when
@@ -188,194 +406,15 @@ const GROUPS: AdminGroup[] = [
'default currency',
],
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
],
},
{
title: 'Content',
description: 'Forms, templates, and labels that users see.',
sections: [
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: ClipboardList,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'email-templates',
label: 'Email Templates',
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
icon: FilePen,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'vocabularies',
label: 'Vocabularies',
description:
'Per-port pick lists used across the CRM: interest temperatures, status reasons, tenure types, expense categories, document types.',
icon: BookOpen,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: SlidersHorizontal,
},
],
},
{
title: 'Data Quality',
description: 'Cleanup, imports, and the audit trail.',
sections: [
{
href: 'inquiries',
label: 'Inquiry Inbox',
description:
'Submissions captured from the public marketing site (berth, residence, contact).',
icon: Inbox,
},
{
href: 'sends',
label: 'Send Log',
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
icon: Send,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: CopyCheck,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: FileUp,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
],
},
{
title: 'Operations',
description: 'Health checks and disaster recovery.',
sections: [
{
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: BarChart3,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Activity,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: DatabaseBackup,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: Server,
},
],
},
{
title: 'Tenancy',
description: 'Multi-port and multi-install scaffolding.',
sections: [
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Ship,
},
{
href: 'onboarding',
label: 'Onboarding checklist',
description:
'Step-by-step setup checklist for fresh ports auto-detects what youve configured and lets you mark manual steps complete.',
'Step-by-step setup checklist for fresh ports - auto-detects what youve configured and lets you mark manual steps complete.',
icon: ListChecks,
},
],
},
{
title: 'Integrations',
description: 'Third-party providers wired into the app.',
sections: [
{
href: 'ai',
label: 'AI configuration',
description:
'Master switch, provider credentials, and the Receipt OCR settings in one place. Per-feature pages (berth-PDF parser, recommender) link out from here.',
icon: Sparkles,
keywords: [
'openai',
'anthropic',
'gpt',
'claude',
'llm',
'api key',
'embeddings',
'receipt',
'scan',
'tesseract',
'ocr',
'expense scanner',
],
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: TrendingUp,
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
},
{
href: 'residential-stages',
label: 'Residential pipeline stages',
description:
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
icon: ListChecks,
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
},
{
href: 'qualification-criteria',
label: 'Qualification criteria',
description:
'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the soft "ready to qualify" hint on the interest detail.',
icon: ListChecks,
keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'],
},
],
},
];
interface AdminSectionsBrowserProps {

View File

@@ -165,7 +165,7 @@ export function AuditLogList() {
if (source !== 'all') params.set('source', source);
if (debouncedSearch) params.set('search', debouncedSearch);
if (debouncedUserId) params.set('userId', debouncedUserId);
// Skip the date filters when From > To the inline warning below
// Skip the date filters when From > To - the inline warning below
// tells the user to fix it; we don't want to fire a request with a
// useless empty range either.
const datesValid = !(dateFrom && dateTo && dateFrom > dateTo);
@@ -213,7 +213,7 @@ export function AuditLogList() {
useEffect(() => {
// Refetch on filter change. Migrating this list to useInfiniteQuery
// would be the proper fix but is deferred the fetch-on-effect
// would be the proper fix but is deferred - the fetch-on-effect
// pattern here is functionally correct and gated by the queryString
// memo so it only fires when filters actually change.
// eslint-disable-next-line react-hooks/set-state-in-effect

View File

@@ -38,7 +38,7 @@ const STATUS_TONE: Record<string, string> = {
};
function formatBytes(n: number | null): string {
if (n === null) return '';
if (n === null) return '-';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;

View File

@@ -1,7 +1,10 @@
'use client';
import { useState } from 'react';
import { Eye, Send } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import type { Route } from 'next';
import { ArrowRight, Eye, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -21,6 +24,8 @@ interface PreviewResponse {
* without firing one of the real flows.
*/
export function EmailPreviewCard() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [html, setHtml] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
@@ -116,6 +121,19 @@ export function EmailPreviewCard() {
Sends the same sample email to the address you enter. Useful for checking how it lands
in Gmail, Outlook, Apple Mail, etc.
</p>
{/* The branded shell only fires the generic sample copy. To
test the actual subject/body of a specific transactional
template (password reset, EOI invite, signing reminder
etc.), use the per-template tester on Email Settings. */}
{portSlug ? (
<Link
href={`/${portSlug}/admin/email` as Route}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Test individual templates (password reset, EOI invite, signing reminder, )
<ArrowRight className="h-3 w-3" aria-hidden />
</Link>
) : null}
</div>
</CardContent>
</Card>

View File

@@ -64,8 +64,8 @@ async function brandingHeaders(): Promise<Headers> {
const WARNING_LABELS: Record<string, string> = {
trimmed: 'Auto-trimmed whitespace borders',
resized: 'Downscaled for size',
'no-alpha': 'No transparent background will show a white box on dark headers',
'jpeg-source': 'JPEG source prefer PNG with alpha for crisp rendering',
'no-alpha': 'No transparent background - will show a white box on dark headers',
'jpeg-source': 'JPEG source - prefer PNG with alpha for crisp rendering',
'svg-rasterized': 'SVG rasterized to PNG at 300 DPI',
'heic-converted': 'HEIC/HEIF converted to PNG',
'webp-converted': 'WebP converted to PNG',

View File

@@ -11,7 +11,7 @@
* dimensions / pontoon / pricing. "Apply to all" inputs at the top
* of each column copy a value down. Validation is inline.
*
* Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch left as a
* Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch - left as a
* follow-up; keyboard-friendly "Apply to all" covers most of the
* speed win without the complexity.
*/
@@ -39,7 +39,7 @@ import { useVocabulary } from '@/hooks/use-vocabulary';
import { CurrencySelect } from '@/components/shared/currency-select';
// Common dock-letter shorthand. Wizard accepts any uppercase letter
// sequence matching the canonical mooring regex (`^[A-Z]+$`) these
// sequence matching the canonical mooring regex (`^[A-Z]+$`) - these
// five are the most-frequently-used; reps add new ones via the
// "Custom" input below.
const COMMON_DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
@@ -117,7 +117,7 @@ export function BulkAddBerthsWizard() {
const [checkingDups, setCheckingDups] = useState(false);
async function handleGenerate() {
// Validate the dock letter must be one or more uppercase letters per
// Validate the dock letter - must be one or more uppercase letters per
// the canonical mooring regex. Custom-input path normalises to upper
// already, but guard against an empty input.
if (!letter || !/^[A-Z]+$/.test(letter)) {
@@ -321,7 +321,7 @@ export function BulkAddBerthsWizard() {
{/* Dimension-unit toggle. The wizard stores values as-entered;
conversion to canonical feet (1 m = 3.28084 ft) happens once
at submit. Switching mid-edit leaves existing inputs as
numeric strings the rep is responsible for re-entering if
numeric strings - the rep is responsible for re-entering if
the unit interpretation just changed under them. */}
<Button
type="button"
@@ -329,7 +329,7 @@ export function BulkAddBerthsWizard() {
variant="outline"
onClick={() => setDimUnit(dimUnit === 'ft' ? 'm' : 'ft')}
aria-label={`Switch dimension entry to ${dimUnit === 'ft' ? 'metres' : 'feet'}`}
title={`Entering dimensions in ${dimUnit === 'ft' ? 'feet' : 'metres'} click to switch`}
title={`Entering dimensions in ${dimUnit === 'ft' ? 'feet' : 'metres'} - click to switch`}
className="font-mono text-xs"
>
{dimUnit}

View File

@@ -46,7 +46,7 @@ const EMBED_FIELDS: SettingFieldDef[] = [
* - 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
* - 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.
*/

View File

@@ -61,7 +61,7 @@ function formatRelative(iso: string): string {
}
/**
* "Sync from Documenso" admin button calls GET /template/{id} on the
* "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

View File

@@ -13,8 +13,8 @@ import { apiFetch } from '@/lib/api/client';
/**
* SMTP connectivity test card. Distinct from the branding-page "Send a
* test" affordance:
* - This one isolates SMTP plaintext + minimal HTML, no logo, no
* branded shell so the failure mode is pure transport.
* - This one isolates SMTP - plaintext + minimal HTML, no logo, no
* branded shell - so the failure mode is pure transport.
* - The branding-preview send exercises the full rendering pipeline.
*
* Surfaces the SMTP error inline (under the input) instead of toasting,

View File

@@ -0,0 +1,177 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Send, CheckCircle2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface TemplateMeta {
id: string;
label: string;
description: string;
}
/**
* Per-template test-send card. Lists every transactional template the
* system can emit, lets the admin pick one + a recipient address, and
* fires a realistic preview (subject line tagged `[TEST · …]`) through
* the configured SMTP transport.
*
* Sits next to `<SmtpTestSendCard>` on the Email admin page - the
* SMTP-test verifies transport-layer auth + connectivity, this card
* verifies that each template renders + deliverability of the real
* branded payload.
*/
export function TestTemplateCard() {
const [templateId, setTemplateId] = useState<string>('');
const [recipient, setRecipient] = useState('');
const [lastResult, setLastResult] = useState<
| { kind: 'ok'; label: string; recipient: string; messageId: string | null }
| { kind: 'err'; message: string }
| null
>(null);
// Registry is fetched at runtime so adding a template on the backend
// surfaces in the dropdown without a client-side build.
const { data, isLoading } = useQuery<{ data: TemplateMeta[] }>({
queryKey: ['admin', 'email', 'test-template', 'list'],
queryFn: () => apiFetch('/api/v1/admin/email/test-template'),
staleTime: 5 * 60_000,
});
const templates = data?.data ?? [];
const selected = templates.find((t) => t.id === templateId);
const mutation = useMutation({
mutationFn: () =>
apiFetch<{
data: { templateId: string; recipient: string; subject: string; messageId: string | null };
}>('/api/v1/admin/email/test-template', {
method: 'POST',
body: { templateId, recipient },
}),
onSuccess: (res) => {
const label = selected?.label ?? res.data.templateId;
setLastResult({
kind: 'ok',
label,
recipient: res.data.recipient,
messageId: res.data.messageId,
});
toast.success(`${label} sent to ${res.data.recipient}`);
},
onError: (err) => {
const message = err instanceof Error ? err.message : 'Send failed';
setLastResult({ kind: 'err', message });
toastError(err);
},
});
const canSend = !!templateId && /.+@.+\..+/.test(recipient) && !mutation.isPending;
return (
<Card>
<CardHeader>
<CardTitle>Test each transactional email</CardTitle>
<CardDescription>
Pick a template and fire a realistic preview to a designated address. The subject is
prefixed <span className="font-mono">[TEST · &lt;template&gt;]</span> so it&apos;s
unambiguous in the recipient&apos;s inbox. Uses the port&apos;s real From / SMTP
configuration - the same path the live flow takes.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-template-select">Template</Label>
{isLoading ? (
<Skeleton className="h-9 w-full" />
) : (
<Select value={templateId} onValueChange={setTemplateId}>
<SelectTrigger id="test-template-select" className="w-full">
<SelectValue placeholder="Select a template…" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{selected ? (
<p className="text-xs text-muted-foreground">{selected.description}</p>
) : (
<p className="text-xs text-muted-foreground">
Tip: pick a template above to see what production flow fires it.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="test-template-recipient">Recipient</Label>
<div className="flex flex-wrap items-center gap-2">
<Input
id="test-template-recipient"
type="email"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="you@example.com"
className="flex-1 min-w-[240px]"
/>
<Button onClick={() => mutation.mutate()} disabled={!canSend}>
<Send className="mr-1.5 h-4 w-4" aria-hidden />
{mutation.isPending ? 'Sending…' : 'Send test'}
</Button>
</div>
</div>
{lastResult?.kind === 'ok' && (
<div
role="status"
className="flex items-start gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-900"
>
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<div>
<div className="font-medium">
{lastResult.label} sent to {lastResult.recipient}.
</div>
{lastResult.messageId ? (
<div className="text-xs text-emerald-800/80">
Message-ID: <span className="font-mono">{lastResult.messageId}</span>
</div>
) : null}
<div className="text-xs text-emerald-800/80">
Check the recipient&apos;s mailbox (and spam folder) to confirm delivery.
</div>
</div>
</div>
)}
{lastResult?.kind === 'err' && (
<div
role="alert"
className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<div>
<div className="font-medium">Send failed</div>
<div className="font-mono text-xs break-all">{lastResult.message}</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -115,7 +115,7 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
function changeBinding(idx: number, raw: string) {
if (raw === BIND_TO_NONE) {
// Clear the binding but leave the rest of the field untouched
// Clear the binding but leave the rest of the field untouched -
// admins may want to keep a custom field that no longer autofills.
setFields((prev) =>
prev.map((f, i) => {
@@ -324,7 +324,7 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
* Map a bindable column's natural input type onto the form-field types we
* actually render. When binding to a `number` column we still let the
* admin keep `select` if they'd already chosen it (e.g. they want to
* constrain to specific values) same for `textarea`.
* constrain to specific values) - same for `textarea`.
*/
function coerceFieldType(
bindableType: BindableType,

View File

@@ -21,7 +21,7 @@ interface OnboardingStep {
autoCheckSettingKey?: string;
/** Multi-key gate: all listed setting keys must be present (non-empty)
* for the step to auto-complete. Useful for compound checks where a
* single key would falsely mark "done" e.g. Documenso needs a URL
* single key would falsely mark "done" - e.g. Documenso needs a URL
* plus signer identity plus a template id, not just the URL. */
autoCheckSettingKeysAll?: readonly string[];
/** Override: read this many users / tags / roles from a list endpoint
@@ -133,7 +133,7 @@ export function OnboardingChecklist() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [autoChecks, setAutoChecks] = useState<Record<string, boolean>>({});
// Per-step source flags populated for steps whose auto-check resolved
// Per-step source flags - populated for steps whose auto-check resolved
// via the env / default fallback rather than a port / global override.
// Surfaces "Resolving from env" copy so super admins see what's
// backing each green tick without digging into the settings page.
@@ -157,7 +157,7 @@ export function OnboardingChecklist() {
if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k);
}
// Manual-checkbox state still lives in the raw system_settings
// row (it's a JSON blob, not a per-key registry entry) keep
// row (it's a JSON blob, not a per-key registry entry) - keep
// fetching it the old way.
const [resolved, settings] = await Promise.all([
keys.size > 0

View File

@@ -21,17 +21,17 @@ import { apiFetch } from '@/lib/api/client';
// Marina deals price in a small set; an admin who needs an exotic
// currency can add it here without a schema change.
const CURRENCY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'USD', label: 'USD US Dollar' },
{ value: 'EUR', label: 'EUR Euro' },
{ value: 'GBP', label: 'GBP British Pound' },
{ value: 'CHF', label: 'CHF Swiss Franc' },
{ value: 'AED', label: 'AED UAE Dirham' },
{ value: 'SAR', label: 'SAR Saudi Riyal' },
{ value: 'PLN', label: 'PLN Polish Złoty' },
{ value: 'AUD', label: 'AUD Australian Dollar' },
{ value: 'CAD', label: 'CAD Canadian Dollar' },
{ value: 'NZD', label: 'NZD New Zealand Dollar' },
{ value: 'JPY', label: 'JPY Japanese Yen' },
{ value: 'USD', label: 'USD - US Dollar' },
{ value: 'EUR', label: 'EUR - Euro' },
{ value: 'GBP', label: 'GBP - British Pound' },
{ value: 'CHF', label: 'CHF - Swiss Franc' },
{ value: 'AED', label: 'AED - UAE Dirham' },
{ value: 'SAR', label: 'SAR - Saudi Riyal' },
{ value: 'PLN', label: 'PLN - Polish Złoty' },
{ value: 'AUD', label: 'AUD - Australian Dollar' },
{ value: 'CAD', label: 'CAD - Canadian Dollar' },
{ value: 'NZD', label: 'NZD - New Zealand Dollar' },
{ value: 'JPY', label: 'JPY - Japanese Yen' },
];
interface PortFormProps {

View File

@@ -54,7 +54,7 @@ interface ListResponse {
* Per-port qualification-criteria admin. Lists current criteria, add via
* the dialog, toggle enabled inline, drag-to-reorder via dnd-kit (the
* whole list ships in one PATCH so partial failure can't scramble the
* order see qualification.service.reorderCriteria).
* order - see qualification.service.reorderCriteria).
*/
export function QualificationCriteriaAdmin() {
const queryClient = useQueryClient();

View File

@@ -34,7 +34,7 @@ const STATUS_PILL: Record<string, StatusPillStatus> = {
};
function relativeAge(iso: string | null): string {
if (!iso) return '';
if (!iso) return '-';
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';

View File

@@ -8,7 +8,7 @@
* that the document-sends flow uses.
*
* §14.10 enforcement: passwords are write-only. The GET endpoint never
* returns the decrypted value only a `*PassIsSet` boolean. Empty
* returns the decrypted value - only a `*PassIsSet` boolean. Empty
* password input means "leave unchanged"; explicit `null` sent over the
* wire means "clear".
*/
@@ -129,7 +129,7 @@ export function SalesEmailConfigCard() {
}
useEffect(() => {
// Initial load on mount canonical fetch-once pattern.
// Initial load on mount - canonical fetch-once pattern.
// eslint-disable-next-line react-hooks/set-state-in-effect
void refresh();
}, []);

View File

@@ -26,7 +26,7 @@ interface SendRow {
brochureId: string | null;
clientId: string | null;
interestId: string | null;
/** Phase 6 populated by the IMAP bounce poller when a delivery
/** Phase 6 - populated by the IMAP bounce poller when a delivery
* failure for this send was matched in the configured mailbox. */
bounceStatus: 'hard' | 'soft' | 'ooo' | null;
bounceReason: string | null;

View File

@@ -92,7 +92,7 @@ export function RegistryDrivenForm({ sections, title, description, extra }: Prop
),
});
// Lifted draft state every field's current input value is held here so
// 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.
@@ -313,13 +313,13 @@ function SettingField({
},
onSuccess: (r) => {
if (r.revealed && r.value != null) {
// Server reveal populate draft but do NOT mark dirty (the value
// 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.`);
toast.info(`${entry.label} isn't set - nothing to reveal.`);
}
},
onError: (err) => toastError(err, `Failed to reveal ${entry.label}`),
@@ -439,7 +439,7 @@ function SettingField({
disabled={
reveal.isPending ||
// Disable when the value is resolved from env/default and the
// rep hasn't typed anything yet there's no in-app cleartext
// rep hasn't typed anything yet - there's no in-app cleartext
// path for those, and silently no-op'ing was indistinguishable
// from a broken toggle.
(!showSecret &&

View File

@@ -44,14 +44,14 @@ export interface SettingFieldDef {
options?: Array<{ value: string; label: string }>;
/** For 'image-upload' fields: aspect ratio for the cropper (default 1).
* For 'html' fields: when set, renders an "Insert default" button that
* pastes this text into the textarea used for email-template defaults
* pastes this text into the textarea - used for email-template defaults
* so admins can see the baseline before editing. */
defaultTemplate?: string;
/** For 'image-upload' fields: cropper aspect ratio. */
imageAspect?: number;
/** For 'image-upload' fields: output format. Default 'jpeg' (smaller
* files, good for photos / backgrounds). Use 'png' for logos with
* transparency JPEG has no alpha channel, so transparent pixels
* transparency - JPEG has no alpha channel, so transparent pixels
* composite against black on export. */
imageFormat?: 'jpeg' | 'png';
}
@@ -87,7 +87,7 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings
// Parent components often pass `FIELDS.slice(0, 5)` directly, so the prop
// reference changes on every render. Capture it in a ref so the fetch
// callback can read the current list without being re-created and looping
// through useEffect forever. Update inside an effect writing to ref
// through useEffect forever. Update inside an effect - writing to ref
// .current during render trips the React Compiler purity rules.
const fieldsRef = useRef(fields);
useEffect(() => {
@@ -112,7 +112,7 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings
}, []);
useEffect(() => {
// Initial load fetchValues internally setStates loading + values.
// Initial load - fetchValues internally setStates loading + values.
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchValues();
}, [fetchValues]);
@@ -404,13 +404,13 @@ function ImageUploadField({
async function uploadCropped(blob: Blob) {
const fd = new FormData();
// Trust the blob's own MIME the cropper auto-picks PNG when the
// Trust the blob's own MIME - the cropper auto-picks PNG when the
// source had alpha, JPEG otherwise. Hardcoding to JPEG here threw
// away the alpha channel on transparent logos.
const mime = blob.type || 'image/jpeg';
const ext = mime === 'image/png' ? 'png' : 'jpg';
fd.append('file', new File([blob], `image.${ext}`, { type: mime }));
// Raw fetch (not apiFetch FormData body) → manually attach the
// Raw fetch (not apiFetch - FormData body) → manually attach the
// X-Port-Id header that the admin settings route requires.
const headers = new Headers();
if (typeof window !== 'undefined') {

View File

@@ -41,12 +41,12 @@ function useCustomFieldTokens() {
* Renders the canonical `MERGE_FIELDS` catalog grouped by entity scope.
* Below the static catalog, lazily fetches per-port custom field
* definitions and renders any whose entityType is resolvable at
* send-time (client / interest / berth see `mergeCustomFieldValues`
* send-time (client / interest / berth - see `mergeCustomFieldValues`
* in `document-sends.service.ts`) as a "Custom" group.
*
* The validator accepts any `{{custom.<fieldName>}}` shape, but only
* the three entity types above resolve to real values, so we only show
* those surfacing client-portal-only fields would mis-set expectations.
* those - surfacing client-portal-only fields would mis-set expectations.
*/
export function TemplateTokenPicker() {
const customQuery = useCustomFieldTokens();

View File

@@ -209,7 +209,7 @@ export function StorageAdminPanel() {
// Dry run first so the dialog shows the exact rows + bytes.
dryRunMutation.mutate({ from: s.backend, to: otherBackend });
} else {
// Switch-only no dry run, just show the warning.
// Switch-only - no dry run, just show the warning.
setDryRun(null);
setConfirmOpen(true);
}

View File

@@ -111,7 +111,7 @@ export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
))}
</div>
<div className="flex items-center gap-2 mt-2">
{/* Native colour picker clicking the swatch opens the
{/* Native colour picker - clicking the swatch opens the
* OS picker, and the chosen colour writes back as a
* hex string. Keeps a manual hex input next to it for
* pasting brand colours from spec sheets. */}

View File

@@ -46,7 +46,7 @@ interface TemplateData {
templateFormat: string;
sourceFileId: string | null;
overlayPositions: FieldMap | null;
/** Tokens marked as required for the EOI flow see
/** Tokens marked as required for the EOI flow - see
* STANDARD_EOI_MERGE_FIELDS in lib/templates/merge-fields. The editor
* surfaces a checklist of which required tokens are still unplaced. */
mergeFields?: string[] | null;
@@ -79,7 +79,7 @@ const DEFAULT_MARKER_H = 0.04;
const MIN_MARKER_DIM = 0.02;
/**
* Phase 7.1 + 7.2 PDF marker editor.
* Phase 7.1 + 7.2 - PDF marker editor.
*
* - Click anywhere to drop a marker (page-aware).
* - Drag markers to move; corner handles to resize.
@@ -158,7 +158,7 @@ function TemplateEditorBody({
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Modern browsers ignore the returnValue string and show their own
// generic "you have unsaved changes" prompt setting it still
// generic "you have unsaved changes" prompt - setting it still
// triggers the prompt, just without our wording.
e.returnValue = '';
};
@@ -385,7 +385,7 @@ function TemplateEditorBody({
// Map detected type → best-guess merge token. Falls back to first
// sorted token when the detector finds a Documenso-only field
// (SIGNATURE / INITIALS) that has no direct merge-token equivalent
// in the in-app fill pipeline the user retags from the dropdown.
// in the in-app fill pipeline - the user retags from the dropdown.
const fallbackToken = TOKEN_OPTIONS[0] ?? '';
const pick = (candidates: string[]): string => {
for (const c of candidates) {

View File

@@ -100,7 +100,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Admin email change for an existing user goes through a confirmation
// dialog because it locks the original sign-in identity out the
// dialog because it locks the original sign-in identity out - the
// submit path runs after the admin acknowledges. New-user creation
// and same-email saves go straight through.
if (isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase()) {

View File

@@ -52,7 +52,7 @@ const GROUP_LABELS: Record<string, string> = {
residential_interests: 'Residential Interests',
};
// Mirrors RolePermissions in src/lib/db/schema/users.ts used as the
// Mirrors RolePermissions in src/lib/db/schema/users.ts - used as the
// canonical leaf list so the matrix shows every action even when the
// baseline JSON omits a key (older roles, partial overrides).
const PERMISSION_LEAVES: Record<string, string[]> = {

View File

@@ -20,7 +20,7 @@ export function AlertRail() {
const overflow = Math.max(alerts.length - visible.length, 0);
// Smooth enter/leave for alerts as new ones arrive via socket realtime
// and stale ones get dismissed replaces the jarring "card just
// and stale ones get dismissed - replaces the jarring "card just
// appears/disappears" with a subtle fade+slide.
const [animateRef] = useAutoAnimate<HTMLDivElement>();

View File

@@ -14,7 +14,7 @@ import type { AlertStatus } from './types';
* `embedded` mode drops the PageHeader and outer spacing so the shell
* can render as a section inside the merged Inbox page without
* duplicating chrome. Standalone /alerts route still uses the default
* (non-embedded) mode via the redirect actually, /alerts now redirects
* (non-embedded) mode via the redirect - actually, /alerts now redirects
* to /inbox#alerts, so non-embedded mode is currently unused but kept
* for flexibility.
*/

View File

@@ -31,7 +31,7 @@ interface Props {
*/
export function ActiveInterestsPopover({ berthId, portSlug, count }: Props) {
// Lazy-load: only fetch when the popover opens. Pattern from the
// detail-label fallback queries elsewhere in the codebase the
// detail-label fallback queries elsewhere in the codebase - the
// `enabled` flag flips on first open.
const { data, isLoading, isError } = useQuery<{ data: ActiveInterestRow[] }>({
queryKey: ['berth', berthId, 'active-interests'],

View File

@@ -44,7 +44,7 @@ export function BerthCard({ berth }: BerthCardProps) {
// already conveyed by the pill below, so the stripe is dock-keyed.
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
// Dimensions string Length × Width × Draft (each segment is optional).
// Dimensions string - Length × Width × Draft (each segment is optional).
// The avatar already conveys the mooring number, so this becomes the
// primary "what is this berth" line.
const dimParts: string[] = [];
@@ -53,7 +53,7 @@ export function BerthCard({ berth }: BerthCardProps) {
if (berth.draftM) dimParts.push(`${berth.draftM}m draft`);
const dimText = dimParts.length > 0 ? dimParts.join(' × ') : null;
// Recommended boat size the most rep-actionable signal in a glance
// Recommended boat size - the most rep-actionable signal in a glance
// ("can my client's yacht park here?"). Tenure was previously here but
// dropped: tenure is set per EOI/contract, not per berth, so showing
// it as a berth property was misleading.
@@ -64,7 +64,7 @@ export function BerthCard({ berth }: BerthCardProps) {
boatCapacityText = `Fits up to ${berth.nominalBoatSize}ft`;
}
// Water depth operational; matters for deep-keel yachts.
// Water depth - operational; matters for deep-keel yachts.
let waterDepthText: string | null = null;
if (berth.waterDepthM) {
const prefix = berth.waterDepthIsMinimum ? '≥ ' : '';
@@ -134,7 +134,7 @@ export function BerthCard({ berth }: BerthCardProps) {
}
>
<div className="flex items-center gap-3">
{/* The mooring number IS the avatar recognisable at a glance
{/* The mooring number IS the avatar - recognisable at a glance
(A1, B12, …) and eliminates the duplicate berth-number heading
that previously sat to the right of an anchor icon. */}
<ListCardAvatar

View File

@@ -82,7 +82,7 @@ export type BerthRow = {
/**
* Toggleable columns for the berth list ColumnPicker. Heavy NocoDB
* fields default to hidden; reps can switch them on per-table-view.
* `mooringNumber` is intentionally omitted from this list it's the
* `mooringNumber` is intentionally omitted from this list - it's the
* primary identifier and always visible.
*/
export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
@@ -108,7 +108,7 @@ export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'tags', label: 'Tags' },
];
/** Hidden by default power-users turn them on via the picker. */
/** Hidden by default - power-users turn them on via the picker. */
export const BERTH_DEFAULT_HIDDEN: string[] = [
'tenure',
'sidePontoon',
@@ -148,14 +148,14 @@ function StatusBadge({ status }: { status: string }) {
/**
* #67 Phase 2: small amber chip beside the status pill flagging rows
* whose status was set manually and has no backing interest. These are
* the candidates for the catch-up wizard the rep flipped a berth to
* the candidates for the catch-up wizard - the rep flipped a berth to
* "Under Offer" or "Sold" without ever creating the matching deal.
*/
function ManualBadge() {
return (
<span
className="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-800"
title="Status set manually with no backing interest needs catch-up"
title="Status set manually with no backing interest - needs catch-up"
>
Manual
</span>
@@ -470,7 +470,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
* cell renderer reading a context.
*
* Imperial columns assume the canonical `*Ft` columns are populated
* (true by default the import pipeline + bulk-add wizard write both,
* (true by default - the import pipeline + bulk-add wizard write both,
* and the inline editor in yacht-tabs.tsx auto-fills the counterpart).
* Rows with only the metric counterpart fall through to `?` for that
* dimension; the cell still renders so the rep sees what's set.

View File

@@ -105,7 +105,7 @@ interface InterestOption {
id: string;
clientName: string;
pipelineStage: string;
/** Used to sort the picker most recently interacted with floats to the top. */
/** Used to sort the picker - most recently interacted with floats to the top. */
updatedAt?: string;
}
@@ -138,7 +138,7 @@ function StatusChangeDialog({
const interestId = watch('interestId');
const showInterestPicker = status === 'under_offer' || status === 'sold';
// Active interests for this port used to populate the prospect
// Active interests for this port - used to populate the prospect
// selector when status moves to under_offer / sold. Only fetched when
// the picker is actually visible to avoid an unnecessary round-trip
// for available-status changes.
@@ -317,7 +317,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
/**
* Searchable combobox for picking a linked prospect when changing berth
* status. Replaces the bare Select which had no filter, no stage colours,
* and no recency sort for ports with 200+ active interests that became
* and no recency sort - for ports with 200+ active interests that became
* a scroll-fest. Stage labels render with the same coloured pill the rest
* of the CRM uses for stage badges so the rep can scan the list visually.
*/
@@ -332,7 +332,7 @@ function InterestLinkPicker({
}) {
const [open, setOpen] = useState(false);
// Sort with the most recently updated interest first so reps see the
// active deals at the top of the list older / dormant ones drop
// active deals at the top of the list - older / dormant ones drop
// beneath. `updatedAt` is set on every patch + every stage advance.
const sorted = [...options].sort((a, b) => {
if (!a.updatedAt && !b.updatedAt) return 0;

View File

@@ -51,7 +51,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
useEffect(() => {
if (searchParams.get('edit') === 'true') {
// setState in effect is the right shape here the URL is an
// setState in effect is the right shape here - the URL is an
// external store and the trigger is a query-param change, not a
// prop in the React tree.
// eslint-disable-next-line react-hooks/set-state-in-effect

View File

@@ -1,9 +1,9 @@
/**
* Documents tab on the berth detail page (Phase 6b see plan §5.6).
* Documents tab on the berth detail page (Phase 6b - see plan §5.6).
*
* Sections:
* - Current PDF panel (download link, "Replace PDF" button, parse-engine chip).
* - Version history list newest first, with rollback affordance on every
* - Version history list - newest first, with rollback affordance on every
* non-current row.
* - Reconcile-diff dialog (PdfReconcileDialog), opened after a successful
* upload + parse. Shows auto-applied vs conflicted fields and lets the

View File

@@ -47,7 +47,7 @@ export function BerthInterestPulse({ berthId }: { berthId: string }) {
// Stay in sync with the linked-berths list + add-to-interest dialog.
// Each of those flows emits a realtime socket event but does NOT
// invalidate this exact query key (it's berth-scoped, theirs are
// interest-scoped) bridge via the invalidation hook.
// interest-scoped) - bridge via the invalidation hook.
useRealtimeInvalidation({
'interest:berthLinked': [queryKey],
'interest:berthUnlinked': [queryKey],

View File

@@ -90,7 +90,7 @@ export function BerthList() {
'berth:statusChanged': [['berths']],
});
// Persisted column visibility + row density + dimension unit same
// Persisted column visibility + row density + dimension unit - same
// pattern as ClientList / InterestList; density falls back to
// 'comfortable' and dimensionUnit to 'ft' for users who haven't picked.
const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } =
@@ -98,7 +98,7 @@ export function BerthList() {
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
const berthColumns = getBerthColumns(dimensionUnit);
// Bulk-action state one dialog per action (status / tenure type /
// Bulk-action state - one dialog per action (status / tenure type /
// tag add+remove). Mirrors the InterestList pattern so reps already
// know the idiom from there.
const qc = useQueryClient();
@@ -143,16 +143,16 @@ export function BerthList() {
// No "New" button - berths are import-only
/>
{/* Toolbar two halves separated by `justify-between` so the
{/* Toolbar - two halves separated by `justify-between` so the
Columns + Saved-views actions stay pinned to the right edge of
the row at every width. The previous `ml-auto` trick didn't
survive flex-wrap on intermediate widths the actions ended
survive flex-wrap on intermediate widths - the actions ended
up centered. */}
<div className="flex items-center gap-2 flex-wrap justify-between">
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
<FilterBar
// Search is hoisted out of the popover into the inline input
// below keeps the daily "find by mooring/area" lookup one
// below - keeps the daily "find by mooring/area" lookup one
// tap away instead of buried behind the Filters dropdown.
filters={berthFilterDefinitions.filter((d) => d.key !== 'search')}
values={filters}
@@ -302,7 +302,7 @@ export function BerthList() {
: undefined
}
cardRender={(row) => <BerthCard berth={row.original} />}
// Group adjacent cards by dock letter (area) on mobile adds a
// Group adjacent cards by dock letter (area) on mobile - adds a
// dim divider + uppercased label above the first card of each
// group. Data is already sorted by mooringNumber (A1, A2, …, B1,
// B2, …) so consecutive rows naturally share dock letters.
@@ -436,7 +436,7 @@ export function BerthList() {
toast.error('Pick at least one tag.');
return;
}
// Per-tag bulk call the endpoint takes one tagId at a
// Per-tag bulk call - the endpoint takes one tagId at a
// time. For the typical 1-2 tag case the round-trips are
// cheap; multi-tag UX can come later.
const action = tagDialog?.mode === 'add' ? 'add_tag' : 'remove_tag';

View File

@@ -81,7 +81,7 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
/**
* Tags Card for the berth overview. Wraps the InlineTagEditor in a Card so
* the section header uses CardTitle styling; mirrors the visibility rule
* the editor itself uses hides entirely when the port has no tags
* the editor itself uses - hides entirely when the port has no tags
* defined AND this berth has none applied.
*/
function BerthTagsCard({ berth }: { berth: BerthData }) {
@@ -215,7 +215,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
const patch = useBerthPatch(berth.id);
// User-selected display unit for dimensions. Persisted in localStorage
// so reps' preferred unit sticks across navigations + sessions.
// Lazy initializer reads localStorage on first render avoids the
// Lazy initializer reads localStorage on first render - avoids the
// mount-effect-setState shape the compiler flags.
const [units, setUnits] = useState<'ft' | 'm'>(() => {
if (typeof window === 'undefined') return 'ft';

View File

@@ -61,7 +61,7 @@ const STATUS_TO_STAGES: Record<string, readonly string[]> = {
* under_offer → enquiry...reservation, available → any)
*
* Doc upload + payment recording (Phases 4.4 / 4.5 of the spec) are
* out of scope for the initial cut once the interest exists, the rep
* out of scope for the initial cut - once the interest exists, the rep
* has the standard interest detail page to upload contracts and record
* payments. The wizard's job is to get them from "manual berth, no
* interest" to "interest exists, override cleared" in one round-trip.
@@ -94,7 +94,7 @@ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProp
});
const allowedStages = berth ? (STATUS_TO_STAGES[berth.data.status] ?? PIPELINE_STAGES) : [];
// Default the stage picker to the "right" default for each status
// Default the stage picker to the "right" default for each status -
// sold defaults to contract (and we auto-set outcome=won server-side),
// under_offer defaults to eoi since that's the most common pre-deal
// status that reps mark manually.
@@ -124,7 +124,7 @@ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProp
);
},
onSuccess: (res) => {
toast.success('Berth reconciled new interest created');
toast.success('Berth reconciled - new interest created');
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berths', 'reconcile-queue'] });
queryClient.invalidateQueries({ queryKey: ['interests'] });

View File

@@ -1,14 +1,14 @@
/**
* Maps a berth's mooring-letter prefix (A, B, C…) to a subtle visual
* accent. Pontoons cluster physically A row is one dock, B another
* so the berth grid reads at a glance when each pontoon's rows
* accent. Pontoons cluster physically - A row is one dock, B another
* - so the berth grid reads at a glance when each pontoon's rows
* share a colour cue. Earlier iteration tinted the entire row
* background; that proved visually noisy. This version keeps rows
* white and surfaces the colour as a coloured left border, plus a
* matching dot the column factory uses inside the Mooring # cell.
*
* Cycle wraps at the 8th letter; ports with more pontoons get
* repeats (fine in practice they don't sit adjacent on the page).
* repeats (fine in practice - they don't sit adjacent on the page).
*/
const BORDER_CYCLE = [
'border-l-4 border-l-rose-400',

View File

@@ -1,13 +1,13 @@
/**
* Reconcile-diff dialog (Phase 6b see plan §4.7b, §14.6).
* Reconcile-diff dialog (Phase 6b - see plan §4.7b, §14.6).
*
* Shown after a successful per-berth PDF upload + parse. Surfaces three
* sections:
* - Warnings (mooring-number mismatch, imperial-vs-metric drift, etc.)
* so the rep can abort before applying.
* - Auto-applied fields fields the parser found that the CRM had as null;
* - Auto-applied fields - fields the parser found that the CRM had as null;
* these are pre-checked and applied on confirm.
* - Conflicts fields where CRM and PDF disagree on a non-null value.
* - Conflicts - fields where CRM and PDF disagree on a non-null value.
* The rep picks "Keep CRM" or "Use PDF" per row before confirming.
*
* On confirm, the dialog POSTs to /pdf-versions/parse-results/apply with the

View File

@@ -34,7 +34,7 @@ interface SkippedRow {
}
/**
* Key-based remount of the body when the dialog opens fresh state per
* Key-based remount of the body when the dialog opens - fresh state per
* open without an open→reset useEffect (React Compiler-safe).
*/
export function BulkHardDeleteDialog(props: Props) {

View File

@@ -1,5 +1,6 @@
'use client';
import type { ReactNode } from 'react';
import { Archive, MoreHorizontal, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -9,6 +10,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CountryFlag } from '@/components/shared/country-flag';
import { TagBadge } from '@/components/shared/tag-badge';
import {
ListCard,
@@ -34,7 +36,21 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
const sourceLabel = formatSource(client.source);
const tags = client.tags ?? [];
const meta = [nationality, sourceLabel].filter(Boolean) as string[];
const metaItems: { key: string; node: ReactNode }[] = [];
if (nationality) {
metaItems.push({
key: 'nationality',
node: (
<span className="inline-flex items-center gap-1">
<CountryFlag code={client.nationalityIso} className="h-2.5 w-3.5" decorative />
<ListCardMeta>{nationality}</ListCardMeta>
</span>
),
});
}
if (sourceLabel) {
metaItems.push({ key: 'source', node: <ListCardMeta>{sourceLabel}</ListCardMeta> });
}
const interest = client.latestInterest ?? null;
const interestCount = client.interestCount ?? 0;
@@ -91,12 +107,12 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
<p className="truncate text-sm text-muted-foreground">{primaryContactValue}</p>
) : null}
{meta.length > 0 ? (
{metaItems.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{meta.map((m, i) => (
<span key={m} className="inline-flex items-center gap-1">
{metaItems.map((m, i) => (
<span key={m.key} className="inline-flex items-center gap-1">
{i > 0 ? <span aria-hidden>·</span> : null}
<ListCardMeta>{m}</ListCardMeta>
{m.node}
</span>
))}
</div>

View File

@@ -30,7 +30,7 @@ export interface ContactRow {
interface Props {
clientId: string;
/**
* Channel filter picker shows only `email` (or `phone` + `whatsapp` for
* Channel filter - picker shows only `email` (or `phone` + `whatsapp` for
* phone-style channels). Edits / promotions stay scoped to the chosen
* channel.
*/
@@ -39,11 +39,11 @@ interface Props {
* value rendering when the picker isn't open). */
primaryContactId: string | null;
primaryValue: string | null;
/** Phone channel only E.164 form + ISO-3166-1 alpha-2 country code so the
/** Phone channel only - E.164 form + ISO-3166-1 alpha-2 country code so the
* inline phone editor can preserve the national-format roundtrip. */
primaryValueE164?: string | null;
primaryValueCountry?: string | null;
/** Query keys to invalidate after any mutation succeeds the parent
/** Query keys to invalidate after any mutation succeeds - the parent
* detail view is usually keyed on `['interest', interestId]` or
* `['clients', clientId]` so the picker can't hard-code which to bump. */
invalidateKeys?: ReadonlyArray<readonly unknown[]>;

View File

@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { CountryFlag } from '@/components/shared/country-flag';
import { getCountryName } from '@/lib/i18n/countries';
import { stageDotClass, stageLabel, formatSource, formatOutcome } from '@/lib/constants';
import { cn } from '@/lib/utils';
@@ -29,7 +30,7 @@ export interface ClientRow {
createdAt: string;
primaryEmail?: string | null;
primaryPhone?: string | null;
/** E.164 (digits + leading +) used to build wa.me / tel: links. */
/** E.164 (digits + leading +) - used to build wa.me / tel: links. */
primaryPhoneE164?: string | null;
yachtCount?: number;
companyCount?: number;
@@ -39,7 +40,7 @@ export interface ClientRow {
* Berths the client has interests in (active only) with the most-active
* interest's stage attached. Sorted server-side: open deals first, most
* progressed stage first, then mooring alphabetical. Each chip in the
* list view links to the interest, not the berth that's the action
* list view links to the interest, not the berth - that's the action
* sales reps want.
*/
linkedBerths?: Array<{
@@ -53,7 +54,7 @@ export interface ClientRow {
}
/**
* Picker manifest drives the `<ColumnPicker>` dropdown next to the
* Picker manifest - drives the `<ColumnPicker>` dropdown next to the
* filter bar. Order here is the order shown in the menu. `alwaysVisible`
* marks columns the user can't hide (otherwise the table is unusable).
*
@@ -76,7 +77,7 @@ export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [
/**
* Default-hidden columns for a fresh user. The hook merges this with
* the user's saved overrides once they explicitly toggle a column,
* the user's saved overrides - once they explicitly toggle a column,
* their choice wins. New columns surface for existing users by default
* (they're absent from the user's stored hidden list).
*/
@@ -174,8 +175,12 @@ export function getClientColumns({
header: 'Country',
cell: ({ getValue }) => {
const iso = getValue() as string | null;
if (!iso) return <span className="text-muted-foreground">-</span>;
return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<CountryFlag code={iso} className="h-3 w-4" decorative />
<span>{getCountryName(iso, 'en')}</span>
</span>
);
},
},
@@ -264,7 +269,7 @@ export function getClientColumns({
},
},
{
// Hidden by default the per-berth stage is now carried by each
// Hidden by default - the per-berth stage is now carried by each
// chip in the Berths column, so this standalone column is only
// useful when a user has explicitly toggled it on.
id: 'latestStage',
@@ -327,10 +332,10 @@ export function getClientColumns({
/**
* Single berth-with-stage chip used in the inline (top-2) chip row of
* the Berths column. Shows mooring + full stage label, with a colored
* dot for stage reinforcement (decorative the label carries the
* dot for stage reinforcement (decorative - the label carries the
* meaning so color-blind / no-hover users don't lose anything).
*
* Click target is the *interest*, not the berth the user almost
* Click target is the *interest*, not the berth - the user almost
* always wants to act on the deal, not look at the berth's static
* specs. Outcome-set rows (won/lost/cancelled) get a muted dot so they
* read as historical context rather than active work.

View File

@@ -16,6 +16,7 @@ import { HardDeleteDialog } from '@/components/clients/hard-delete-dialog';
import { ReminderForm } from '@/components/reminders/reminder-form';
import { useQueryClient } from '@tanstack/react-query';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { CountryFlag } from '@/components/shared/country-flag';
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
import { cn } from '@/lib/utils';
@@ -69,7 +70,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const addedLabel = client.createdAt
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
: null;
const meta = [country, addedLabel].filter(Boolean) as string[];
return (
<>
@@ -87,8 +87,21 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)}
</div>
{meta.length > 0 ? (
<p className="text-xs text-muted-foreground sm:text-sm">{meta.join(' · ')}</p>
{country || addedLabel ? (
<p className="flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground sm:text-sm">
{country ? (
<span className="inline-flex items-center gap-1.5">
<CountryFlag
code={client.nationalityIso}
className="h-3 w-4 sm:h-3.5 sm:w-5"
decorative
/>
<span>{country}</span>
</span>
) : null}
{country && addedLabel ? <span aria-hidden>·</span> : null}
{addedLabel ? <span>{addedLabel}</span> : null}
</p>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">

View File

@@ -42,7 +42,7 @@ interface ClientFormProps {
* or opening the create-interest dialog pre-filled with that
* clientId. Skipped in edit mode. */
onUseExistingClient?: (clientId: string) => void;
/** Optional initial values for the create flow used by the
/** Optional initial values for the create flow - used by the
* inquiry-inbox "Convert to client" triage step (P-4.5) so the rep
* doesn't retype values they just read in the inbox. The
* `sourceInquiryId` is persisted to `clients.source_inquiry_id` on
@@ -110,7 +110,7 @@ export function ClientForm({
// Primary-address fields. Live outside RHF because the API splits
// client creation (`POST /api/v1/clients`) from address creation
// (`POST /api/v1/clients/{id}/addresses`) the address gets chained
// (`POST /api/v1/clients/{id}/addresses`) - the address gets chained
// after the client POST returns the new id. Edit mode uses the
// dedicated Addresses tab; the form here is create-only.
const [addressOpen, setAddressOpen] = useState(false);
@@ -217,7 +217,7 @@ export function ClientForm({
}
// 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
// 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>();
@@ -225,7 +225,7 @@ export function ClientForm({
if (c.isPrimary && !seenPrimaryByChannel.has(c.channel)) {
seenPrimaryByChannel.add(c.channel);
} else if (c.isPrimary) {
// duplicate primary within the channel clear
// duplicate primary within the channel - clear
c.isPrimary = false;
}
}
@@ -253,7 +253,7 @@ export function ClientForm({
body: payload,
});
// Chain the address POST when any field is filled. Address errors
// don't unwind the client create surface a toast warning and
// don't unwind the client create - surface a toast warning and
// leave the client in place so the rep can finish in the
// Addresses tab.
const hasAddress =
@@ -467,7 +467,7 @@ export function ClientForm({
const checked = !!v;
const thisChannel = watch(`contacts.${index}.channel`);
if (checked) {
// Primary is per-channel flipping this one on
// 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
@@ -589,7 +589,7 @@ export function ClientForm({
<Separator />
{/* Primary Address create-only. Editing happens in the
{/* Primary Address - create-only. Editing happens in the
client detail page's Addresses tab. */}
{!isEdit ? (
<div className="space-y-3">
@@ -657,7 +657,7 @@ export function ClientForm({
value={addrCountryIso}
onChange={(iso) => {
setAddrCountryIso(iso ?? null);
// Clear region if country changes keeps the
// Clear region if country changes - keeps the
// subdivision picker consistent with its country.
setAddrSubdivisionIso(null);
}}

View File

@@ -170,7 +170,7 @@ export function ClientList() {
});
// Per-user column visibility, persisted into user_profiles.preferences
// via /api/v1/me. Hidden IDs are the source of truth `actions` and
// via /api/v1/me. Hidden IDs are the source of truth - `actions` and
// `select` columns aren't user-toggleable so they're never in the
// hidden set. New columns surface for existing users by default.
const { hidden, setHidden } = useTablePreferences('clients', CLIENT_DEFAULT_HIDDEN);
@@ -190,7 +190,7 @@ export function ClientList() {
<SavedViewsDropdown
entityType="clients"
onApplyView={(savedFilters, _savedSort) => {
// Atomic replace sequential setFilter() calls dropped all
// Atomic replace - sequential setFilter() calls dropped all
// but the last value (each one read stale `filters` from
// closure and overwrote). setAllFilters writes the whole
// saved view in one setState.

View File

@@ -28,7 +28,7 @@ export interface ClientInterestRow {
dateLastContact: string | null;
berthMooringNumber?: string | null;
yachtName?: string | null;
/** Requirements surfaced on the Client Overview panel "Wants L × W × D
/** Requirements surfaced on the Client Overview panel - "Wants L × W × D
* · Source" lets reps see what the deal is looking for without drilling
* into the Interest detail. Fields are nullable when the rep hasn't
* captured constraints yet. */
@@ -88,7 +88,7 @@ export function StageStepper({
);
})}
</div>
{/* Stage-name row below the bar surfaces all reached stage names
{/* Stage-name row below the bar - surfaces all reached stage names
inline (compact short-labels) so the bar isn't a mystery without
hovering. Future stages render in muted text so the rep can still
see the ladder ahead. The `xs` size variant hides this row to
@@ -323,7 +323,7 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
</span>
</div>
{/* Requirements one-liner: "Wants 50ft × 18ft × 8ft · Referral".
Hidden when the rep hasn't captured any constraints yet
Hidden when the rep hasn't captured any constraints yet -
noise reduction over empty placeholders. */}
{(() => {
const dims = [i.desiredLengthFt, i.desiredWidthFt, i.desiredDraftFt]

View File

@@ -169,7 +169,7 @@ function OverviewTab({
value={client.nationalityIso ?? null}
onSave={async (iso) => {
// Auto-default the timezone to the country's primary
// zone when none is set yet saves the rep a click
// zone when none is set yet - saves the rep a click
// and matches what a marina actually wants for first
// contact (London for GB, NYC for US, etc.). Only
// fires when timezone is empty so we never clobber a

View File

@@ -32,7 +32,7 @@ interface Contact {
valueCountry?: string | null;
label?: string | null;
isPrimary: boolean;
/** Phase 3d origin tag surfaced as an [EOI] badge when an EOI
/** Phase 3d - origin tag surfaced as an [EOI] badge when an EOI
* spawned this contact. */
source?: string | null;
sourceDocumentId?: string | null;
@@ -230,7 +230,7 @@ function ContactRow({
</div>
{/* Override history is only meaningful for the canonical "primary
email" / "primary phone" entries the supplemental form
overwrites secondary contacts don't have a matching
overwrites - secondary contacts don't have a matching
bindable path. The icon renders nothing when no rows exist. */}
{contact.isPrimary && contact.channel === 'email' ? (
<FieldHistoryIcon fieldPath="client.primaryEmail" />
@@ -276,7 +276,7 @@ function ContactRow({
className="inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800"
title={
contact.sourceDocumentId
? 'Spawned from an EOI open the source document for details.'
? 'Spawned from an EOI - open the source document for details.'
: 'Spawned from an EOI override.'
}
>

View File

@@ -20,7 +20,7 @@ interface MatchData {
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
* client is soft-deleted - the suggestion panel surfaces a Restore link
* to the existing restore wizard instead of "Use this client". */
archivedAt: string | null;
}
@@ -137,7 +137,7 @@ export function DedupSuggestionPanel({
? 'This contact info belongs to an archived client'
: isHigh
? 'This looks like an existing client'
: 'Possible match check before creating'}
: 'Possible match - check before creating'}
</p>
{isArchived && (
<p className="mt-0.5 text-xs text-muted-foreground">

View File

@@ -35,7 +35,7 @@ type Stage = 'intent' | 'confirm';
* Outer wrapper keeps the Dialog mounted (so its close animation runs);
* the body only mounts when `open` is true and remounts on each
* open via the `clientId` key. This avoids the open→reset-state
* useEffect that React Compiler flags fresh state per open is just
* useEffect that React Compiler flags - fresh state per open is just
* the natural mount.
*/
export function HardDeleteDialog(props: Props) {

View File

@@ -58,7 +58,7 @@ export function SendDocumentsDialog({
| null
>(null);
// Lightweight brochures fetch only fires once dialog is opened.
// Lightweight brochures fetch - only fires once dialog is opened.
const brochuresQuery = useQuery<BrochuresResponse>({
queryKey: ['brochures', 'list'],
queryFn: () => apiFetch('/api/v1/admin/brochures'),

View File

@@ -206,7 +206,7 @@ function SmartArchiveDialogBody({
if (!dossier) throw new Error('No dossier');
// Pick the first linked interest for this berth from the
// authoritative dossier join. Berths with no linked interest for
// this client are skipped sending an empty interestId would
// this client are skipped - sending an empty interestId would
// make the server-side delete silently match zero rows.
const berthDec = dossier.berths
.map((b) => {

View File

@@ -52,7 +52,7 @@ export const COMPANY_COLUMN_OPTIONS = [
{ id: 'actions', label: 'Actions', alwaysVisible: true },
];
/** Hidden by default keep the table dense; opt-in to longer columns. */
/** Hidden by default - keep the table dense; opt-in to longer columns. */
export const COMPANY_DEFAULT_HIDDEN: string[] = ['legalName', 'taxId'];
interface GetCompanyColumnsOptions {

View File

@@ -77,7 +77,7 @@ interface CompanyFormProps {
notes: string | null;
};
/**
* Optional initial values for the create flow used by the global
* Optional initial values for the create flow - used by the global
* command-search quick-create ("New company 'matthew'" → lands on
* `/companies?create=1&prefill_name=matthew`). Ignored in edit mode.
*/
@@ -91,7 +91,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
const router = useRouter();
const isEdit = !!company;
const [formError, setFormError] = useState<string | null>(null);
// Connection state only used in create mode. Editing companies is done
// Connection state - only used in create mode. Editing companies is done
// from the detail page where members + yachts have their own tabs that
// know how to handle removal / reassignment cleanly.
const [attachedClientIds, setAttachedClientIds] = useState<string[]>([]);
@@ -107,7 +107,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
{ yachtId: string; yachtName: string }[] | null
>(null);
// Reserved for the inverse pull-in (attached yacht → owner client). Wired
// through but the inferring query is deferred owner history isn't yet
// through but the inferring query is deferred - owner history isn't yet
// surfaced cheaply via the yacht endpoint.
// const [pendingOwnerPullIn, setPendingOwnerPullIn] = useState<...>(null);
const [createInterestFor, setCreateInterestFor] = useState<string | null>(null);
@@ -174,7 +174,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
});
const newCompanyId = res.data.id;
// Connect each attached client as a company member. Failures collected
// here surface as a toast but don't roll back the company create the
// here surface as a toast but don't roll back the company create - the
// rep can fix individual mismatches from the company detail page.
for (const clientId of attachedClientIds) {
try {
@@ -232,10 +232,10 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
return;
}
} catch {
// Yacht lookup failure is non-fatal fall through to interest prompt.
// Yacht lookup failure is non-fatal - fall through to interest prompt.
}
// (Step 2b yacht-owner pull-in deferred. Adding it cleanly needs
// (Step 2b - yacht-owner pull-in - deferred. Adding it cleanly needs
// the yachts API to surface prior owners post-transfer, which currently
// only lives in the activity log. Tracked for follow-up.)
@@ -396,7 +396,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
<Separator />
{/* Connections only on create. Editing membership / yacht ownership
{/* Connections - only on create. Editing membership / yacht ownership
from this form would race with the same actions on the detail
tabs (and the audit trail of a "create + attach 5 clients in one
flow" is much more readable than 6 separate create rows). */}
@@ -498,7 +498,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
</SheetContent>
{/* Stacked "+ New client" / "+ New yacht" forms. On successful create
the picker we open them from doesn't know the new id yet the
the picker we open them from doesn't know the new id yet - the
ClientList / YachtList query refetches via react-query invalidation
and the rep can pick the new entity from the dropdown immediately. */}
<ClientForm open={clientFormOpen} onOpenChange={setClientFormOpen} />
@@ -506,7 +506,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
<YachtForm
open={yachtFormOpen}
onOpenChange={setYachtFormOpen}
// No initialOwner the new yacht starts unowned-by-rules-engine; the
// No initialOwner - the new yacht starts unowned-by-rules-engine; the
// company-form will optionally transfer it on save.
/>
)}

View File

@@ -125,7 +125,7 @@ export function CompanyList() {
onArchive: (company) => setArchiveCompany(company),
});
// Persisted column visibility same pattern as ClientList / BerthList.
// Persisted column visibility - same pattern as ClientList / BerthList.
const { hidden, setHidden } = useTablePreferences('companies', COMPANY_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));

View File

@@ -19,7 +19,7 @@ interface KpiResponse {
}
/**
* Compact rail-sized KPI tile single number, label, and a click-
* Compact rail-sized KPI tile - single number, label, and a click-
* through to the interests pipeline. Reuses the existing dashboard KPIs
* endpoint so we don't pay an extra round-trip.
*/
@@ -36,7 +36,7 @@ export function ActiveDealsTile() {
return (
<Card>
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` (it assumes a
CardHeader sits above). The `sm:` variants are required without
CardHeader sits above). The `sm:` variants are required - without
them `sm:pt-0` wins at the sm breakpoint and the content snaps to
the top edge. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5 sm:pt-5 sm:pb-5">
@@ -57,7 +57,7 @@ export function ActiveDealsTile() {
</div>
<Link
// Next typedRoutes can't infer dynamic-segment routes from a template
// literal cast through unknown rather than `any` so the lint rule
// literal - cast through unknown rather than `any` so the lint rule
// is satisfied while the runtime href is still correct.
href={`/${portSlug}/interests` as unknown as Route}
className="text-xs font-medium text-primary hover:underline"

View File

@@ -29,7 +29,7 @@ interface ActivityItem {
label: string | null;
userId: string | null;
/** Server-resolved actor display name (from user_profiles). When null,
* the actor row no longer exists render falls back to a "Unknown
* the actor row no longer exists - render falls back to a "Unknown
* user" sentinel rather than the raw UUID prefix. */
actorName: string | null;
fieldChanged: string | null;
@@ -52,6 +52,55 @@ function humanizeFieldName(name: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Entity type alias map for the feed labels. Most types humanize fine
* via `humanizeFieldName`, but a few read awkwardly ("Residential
* Client" is clearer than the raw enum, notes flatten to their parent). */
const ENTITY_TYPE_LABELS: Record<string, string> = {
residential_client: 'Residential client',
residential_interest: 'Residential interest',
berth_reservation: 'Berth reservation',
berth_maintenance_log: 'Berth maintenance',
berth_recommendation: 'Berth recommendation',
client_note: 'Client note',
yacht_note: 'Yacht note',
company_note: 'Company note',
interest_note: 'Interest note',
interest_qualification: 'Interest qualification',
document_send: 'Document send',
document_folder: 'Document folder',
document_template: 'Document template',
documentTemplate: 'Document template',
form_template: 'Form template',
report_template: 'Report template',
email_account: 'Email account',
email_message: 'Email message',
user_email_change: 'Email change',
custom_field_definition: 'Custom field',
custom_field_values: 'Custom field',
expense_export: 'Expense export',
gdpr_export: 'GDPR export',
qualification_criterion: 'Qualification criterion',
website_submission: 'Website submission',
webhook_inbound: 'Inbound webhook',
webhook_delivery: 'Webhook delivery',
audit_log: 'Audit log',
portal_user: 'Portal user',
portal_session: 'Portal session',
portal_auth_token: 'Portal token',
client_contact: 'Client contact',
clientContact: 'Client contact',
clientAddress: 'Client address',
companyAddress: 'Company address',
clientRelationship: 'Client relationship',
company_membership: 'Company membership',
crm_invite: 'CRM invite',
queue_job: 'Queue job',
super_admin: 'Super admin',
};
function humanizeEntityType(type: string): string {
return ENTITY_TYPE_LABELS[type] ?? humanizeFieldName(type);
}
/** Map enum-typed field values to their canonical human labels. The audit
* log stores raw enum strings (`deposit_10pct`, `lost_other_marina`); the
* feed should read like `10% Deposit`, not the wire value. */
@@ -85,13 +134,13 @@ function normalizeEnumValue(field: string, value: unknown): unknown {
* count; nulls / empty render as em-dash. */
function shortValue(value: unknown, fieldContext?: string): string {
if (fieldContext) value = normalizeEnumValue(fieldContext, value);
if (value === null || value === undefined || value === '') return '';
if (value === null || value === undefined || value === '') return '-';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`;
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return '';
if (entries.length === 0) return '-';
return entries
.slice(0, 3)
.map(
@@ -199,7 +248,7 @@ function ActivityFeedInner() {
// A1: permission_denied rows on the activity feed render as a bare
// action badge with no entity name (they target `admin.X` with empty
// entityId). They're noise for the rep keep them in the audit log
// entityId). They're noise for the rep - keep them in the audit log
// page but hide them from the dashboard feed.
const items = (data ?? []).filter((i) => i.action !== 'permission_denied');
@@ -245,18 +294,23 @@ function ActivityFeedInner() {
space between them. */}
<span className="text-muted-foreground/60 mx-1.5">·</span>
<span className="text-muted-foreground text-xs capitalize">
{item.entityType}
{humanizeEntityType(item.entityType)}
</span>
</>
) : (
<>
<span className="font-medium capitalize">{item.entityType}</span>
{item.entityId && (
<span className="ml-1 text-muted-foreground font-mono text-xs">
{item.entityId.slice(0, 8)}
// No resolvable label - either the entity was
// deleted or the type isn't in the server-side
// resolver yet. Either way we never expose a
// UUID fragment: it reads as noise to the rep
// and leaks an internal identifier.
<span className="font-medium capitalize">
{humanizeEntityType(item.entityType)}
{item.entityId ? (
<span className="ml-1 text-muted-foreground text-xs font-normal">
(removed)
</span>
)}
</>
) : null}
</span>
)}
</p>
{diffLine ? (

View File

@@ -1,7 +1,7 @@
'use client';
/**
* Berth-demand widget ranks berths by active interest count, with a
* Berth-demand widget - ranks berths by active interest count, with a
* horizontal bar per row encoding magnitude relative to the leader.
* Matches the standard CardHeader / CardContent layout of its dashboard
* siblings; the bars (not chrome) do the visual work.

View File

@@ -9,6 +9,7 @@ import { Globe } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { CountryFlag } from '@/components/shared/country-flag';
import { getCountryName } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
@@ -32,7 +33,7 @@ interface ClientsByCountryResponse {
* into a specific market. Country names render via the existing
* locale-aware helper; unknown ISO codes fall back to the raw code.
*
* Variant (b) of the master-doc design a true choropleth would need
* Variant (b) of the master-doc design - a true choropleth would need
* a heavier viz lib (react-simple-maps + topojson) and pushes us to
* the chart-library migration agenda. Variant (a) ships now; the
* world-map variant can land alongside the recharts→ECharts pass.
@@ -100,13 +101,11 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
className="group flex items-center justify-between gap-3 rounded-md px-2 py-1.5 -mx-2 hover:bg-foreground/5"
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="w-8 shrink-0 text-xs font-mono uppercase text-muted-foreground">
{row.country}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<CountryFlag code={row.country} className="h-3.5 w-5" decorative />
<span className="truncate text-sm">{name}</span>
</div>
{/* Mini bar same `BerthHeatWidget` idiom: a thin
{/* Mini bar - same `BerthHeatWidget` idiom: a thin
background track with a coloured fill. The count
sits on the right so the eye can read both the
bar shape and the precise number. */}

View File

@@ -33,23 +33,35 @@ import {
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
import type { DashboardWidget } from './widget-registry';
import type { DashboardWidget, WidgetGroup } from './widget-registry';
// The dashboard renders widgets in three independent visual regions at
// xl (1280+): charts (main column), rails (right aside), feed (full-
// width). Below xl, all three regions stack into one visual column -
// from the rep's eye it reads as a single ordered list, so the modal
// flattens its sortable in that tier. At xl it splits into three
// region-scoped sortables to match the actual side-by-side layout.
const GROUP_LABELS: Record<WidgetGroup, string> = {
chart: 'Charts',
rail: 'Side rail',
feed: 'Activity',
};
const GROUP_ORDER: readonly WidgetGroup[] = ['chart', 'rail', 'feed'];
/**
* Combined visibility + reorder picker for the dashboard header. Two
* sections in one modal:
* Combined visibility + reorder picker for the dashboard header.
*
* 1. "On dashboard" — visible widgets, each row with a drag handle
* (reorder via dnd-kit single SortableContext, no buckets); flipping
* a switch off moves the row to section 2.
* 2. "Hidden" — widgets currently off; flipping a switch on appends to
* the bottom of section 1.
* The dashboard renders widgets in three independent visual regions -
* Charts (main column), Side rail (right aside), Activity (full-width
* feed). A drag across regions can't change the visual outcome, so the
* modal exposes one sortable list per region instead of a single flat
* list that silently fails on cross-region moves. Toggling a widget off
* moves it to the "Hidden" section; toggling on appends it to the
* bottom of its native region.
*
* Both visibility toggles and order changes commit optimistically via
* `useDashboardWidgets` so the dashboard reflows in the background and
* the rep can keep editing. The "Rearrange" button on the header is
* gone — order lives here too now, keeping all dashboard layout
* controls in one place.
* the rep can keep editing.
*/
export function CustomizeWidgetsMenu() {
const [open, setOpen] = useState(false);
@@ -57,6 +69,7 @@ export function CustomizeWidgetsMenu() {
allWidgets,
visibleWidgets,
visibility,
isXlLayout,
setVisible,
setAll,
setOrder,
@@ -79,7 +92,53 @@ export function CustomizeWidgetsMenu() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function onDragEnd(event: DragEndEvent) {
// Visible widgets split per region. Empty regions render nothing so
// we don't show an "On dashboard / Side rail (0)" tease.
const visibleByGroup: Record<WidgetGroup, DashboardWidget[]> = {
chart: visibleWidgets.filter((w) => w.group === 'chart'),
rail: visibleWidgets.filter((w) => w.group === 'rail'),
feed: visibleWidgets.filter((w) => w.group === 'feed'),
};
// A drag inside group X only moves widgets within that group. Rebuild
// the flat order by walking `visibleWidgets` in its current sequence
// and replacing each group-X slot with the next id from the reordered
// group list. This preserves the relative position of every other
// widget - only the dragged group's internal order changes.
function reorderGroup(group: WidgetGroup, oldIndex: number, newIndex: number) {
const groupIds = visibleByGroup[group].map((w) => w.id);
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= groupIds.length ||
newIndex >= groupIds.length
) {
return;
}
const reordered = arrayMove(groupIds, oldIndex, newIndex);
let cursor = 0;
const nextOrder = visibleWidgets.map((w) =>
w.group === group ? (reordered[cursor++] ?? w.id) : w.id,
);
setOrder(nextOrder);
}
function makeDragEndHandler(group: WidgetGroup) {
return (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = visibleByGroup[group].map((w) => w.id);
const oldIndex = ids.indexOf(String(active.id));
const newIndex = ids.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
reorderGroup(group, oldIndex, newIndex);
};
}
// Flat reorder used by the stacked layout (< xl). One SortableContext
// over every visible widget; drops persist via setOrder, which the
// hook routes to the mobile order field.
function onFlatDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = visibleWidgets.map((w) => w.id);
@@ -97,24 +156,64 @@ export function CustomizeWidgetsMenu() {
Customize
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Customize dashboard</DialogTitle>
<DialogDescription>
Drag a visible widget to change its position. Toggle the switch to show or hide. Hidden
widgets leave no empty space - the layout reflows to fill the available width.
{isXlLayout
? 'Editing the desktop layout - drag a widget to reorder it within its region.'
: 'Editing the stacked layout for this device - drag a widget to reorder. Your desktop arrangement is saved separately.'}{' '}
Toggle the switch to show or hide. Hidden widgets leave no empty space - the layout
reflows to fill the available width.
</DialogDescription>
</DialogHeader>
{/* Toggle + reorder list. Capped at ~60vh with internal scroll so
the modal doesn't push the action footer off-screen. */}
the modal doesn't push the action footer off-screen. The
layout matches what the rep is actually seeing: at xl the
dashboard renders charts | rails | feed as three independent
slots, so the picker exposes three region-scoped sortables.
Below xl everything stacks into one column visually, so the
picker collapses to a single flat sortable that reorders
across the whole list. */}
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
{visibleWidgets.length > 0 ? (
{isXlLayout ? (
GROUP_ORDER.map((group) => {
const widgets = visibleByGroup[group];
if (widgets.length === 0) return null;
return (
<Section key={group} title={`${GROUP_LABELS[group]} (${widgets.length})`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={makeDragEndHandler(group)}
>
<SortableContext
items={widgets.map((w) => w.id)}
strategy={verticalListSortingStrategy}
>
<ul className="space-y-1">
{widgets.map((w, idx) => (
<SortableVisibleRow
key={w.id}
widget={w}
position={idx + 1}
disabled={isSaving}
onToggle={(checked) => setVisible(w.id, checked)}
/>
))}
</ul>
</SortableContext>
</DndContext>
</Section>
);
})
) : visibleWidgets.length > 0 ? (
<Section title={`On dashboard (${visibleWidgets.length})`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
onDragEnd={onFlatDragEnd}
>
<SortableContext
items={visibleWidgets.map((w) => w.id)}

View File

@@ -90,7 +90,7 @@ export function DashboardShell({
const feed = visibleWidgets.filter((w) => w.group === 'feed');
// Reuses the existing ['me'] cache (5-minute staleTime) populated by
// useTablePreferences elsewhere usually a cache hit, so no extra
// useTablePreferences elsewhere - usually a cache hit, so no extra
// request. When the page server-prefetches the first name we seed it
// here via `initialData` so the cache is warm before the post-mount
// fetch resolves, eliminating the "Welcome back → Hello, Matt" flash.
@@ -107,12 +107,12 @@ export function DashboardShell({
// Greeting word is computed in a useEffect so the rendered HTML can't lock
// to the server's clock during hydration. Until the effect fires, the
// header reads "Welcome" a neutral phrase that's correct at every hour
// header reads "Welcome" - a neutral phrase that's correct at every hour
// and never produces a hydration warning. `clientGreeting` flips to the
// local-time-aware phrasing once the component has mounted.
const [clientGreeting, setClientGreeting] = useState<string | null>(null);
useEffect(() => {
// setState here is intentional we delay the time-aware greeting
// setState here is intentional - we delay the time-aware greeting
// until after hydration to avoid SSR/client clock mismatch.
// eslint-disable-next-line react-hooks/set-state-in-effect
setClientGreeting(timeOfDayGreeting());
@@ -149,7 +149,7 @@ export function DashboardShell({
<div className="space-y-6">
{/* Mobile-only greeting strip. The shared PageHeader is hidden
below `sm` (its title is normally duplicated by the topbar),
so we render the welcome message inline here for mobile
so we render the welcome message inline here for mobile -
keeps the personalized touch from desktop without polluting
the topbar (which stays "Dashboard" for wayfinding). */}
<div className="sm:hidden">
@@ -170,8 +170,8 @@ export function DashboardShell({
actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={setRange} />
<ExportDashboardPdfButton />
<CustomizeWidgetsMenu />
<ExportDashboardPdfButton />
</div>
}
/>
@@ -232,7 +232,7 @@ export function DashboardShell({
/**
* Placeholder shown when the rep has hidden every widget. Without this,
* the dashboard collapses to just the gradient header strip and looks
* like a broken page this hints at the "Customize" button to bring
* like a broken page - this hints at the "Customize" button to bring
* widgets back.
*/
function EmptyDashboardHint() {

View File

@@ -25,7 +25,7 @@ interface HotDealsResponse {
// Local label map intentionally narrowed to the stages this widget
// surfaces. Keys MUST match the canonical DB values for the 7-stage
// pipeline (post-2026-05 refactor) the reporting audit caught typos
// pipeline (post-2026-05 refactor) - the reporting audit caught typos
// that broke the rank ladder server-side AND rendered raw enum to the user.
const STAGE_LABELS: Record<string, string> = {
contract: 'Contract',

View File

@@ -59,7 +59,7 @@ const STAGE_BAR_CLASS: Record<string, string> = {
export function PipelineValueTile({ range }: { range?: DateRange } = {}) {
// Range query-string is keyed on the slug ('7d' / 'custom-2026-01-01...').
// When range is undefined, the tile falls back to the "all active deals"
// snapshot preserves the old behaviour for callers that don't yet
// snapshot - preserves the old behaviour for callers that don't yet
// thread range through.
const slug = range ? rangeToSlug(range) : null;
const qs = slug ? `?range=${encodeURIComponent(slug)}` : '';

View File

@@ -70,7 +70,7 @@ export function SourceConversionChart() {
</span>
</span>
</div>
{/* Inline bar keeps the widget compact and lets eight
{/* Inline bar - keeps the widget compact and lets eight
rows share the same vertical space a Recharts plot
would use for two. */}
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">

View File

@@ -86,7 +86,7 @@ export function TimezoneDriftBanner() {
try {
window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true');
} catch {
// Non-fatal we just don't persist the dismissal.
// Non-fatal - we just don't persist the dismissal.
}
}

View File

@@ -4,11 +4,11 @@
* Compact "Website at a glance" tile for the main sales dashboard. Shows
* pageviews for the dashboard's current range + active visitors right
* now + a deep-link to the full /website-analytics page. Soft-fails
* (renders nothing) when Umami isn't configured for this port the
* (renders nothing) when Umami isn't configured for this port - the
* configure-prompt lives on the dedicated page, not the dashboard.
*
* When an Umami call fails (auth, network, shape) the tile renders a
* dash "" instead of "0" so the rep can tell error from no-data.
* dash "-" instead of "0" so the rep can tell error from no-data.
*/
import Link from 'next/link';
@@ -49,7 +49,7 @@ export function WebsiteGlanceTile({ range = '30d' }: Props) {
return null;
}
// Umami v3 returns flat numbers `data?.data?.pageviews` is a number,
// Umami v3 returns flat numbers - `data?.data?.pageviews` is a number,
// not `{value, prev}`. The previous nested shape was Umami v1; v3 moved
// comparison values into a sibling `comparison` block.
const pageviews = stats.data?.data?.pageviews;

View File

@@ -1,5 +1,5 @@
/**
* Dashboard widget registry the single source of truth for which
* Dashboard widget registry - the single source of truth for which
* widgets exist, what they're called, where they live, and what they
* default to. The DashboardShell loops over this; the settings UI also
* loops over this. Adding a new widget = adding one entry here.
@@ -76,7 +76,7 @@ export type WidgetGroup = 'chart' | 'rail' | 'feed';
export type WidgetIntegration = 'umami' | 'documenso';
export interface DashboardWidget {
/** Stable persistence key. Don't rename old preferences would break. */
/** Stable persistence key. Don't rename - old preferences would break. */
id: string;
label: string;
description: string;
@@ -92,7 +92,7 @@ export interface DashboardWidget {
/**
* Some widgets self-gate (e.g. WebsiteGlanceTile renders null when
* Umami isn't configured). When `true`, the settings UI still shows
* the toggle so admins can enable it once the integration is wired
* the toggle so admins can enable it once the integration is wired -
* but the widget itself decides whether to render content.
*/
selfGates?: boolean;
@@ -106,7 +106,7 @@ export interface DashboardWidget {
export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
// ── KPI tiles (rail) ────────────────────────────────────────────────
// Off by default keep the existing dashboard layout unchanged for
// Off by default - keep the existing dashboard layout unchanged for
// users on first paint after the upgrade; reps can flip them on from
// the Customize menu.
{
@@ -166,10 +166,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
{
id: 'source_conversion',
label: 'Source Conversion',
description: 'Win rate per lead source which channels deliver buyers, not just leads.',
description: 'Win rate per lead source - which channels deliver buyers, not just leads.',
render: () => <SourceConversionChart />,
group: 'chart',
// Flipped on 2026-05-14 investor-facing conversion-funnel-by-source
// Flipped on 2026-05-14 - investor-facing conversion-funnel-by-source
// surface (PRE-DEPLOY-PLAN § 1.6.23). Reads inquiry → client linkage
// (clients.source_inquiry_id) added in migration 0065.
defaultVisible: true,
@@ -189,7 +189,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
description:
'Per-country distribution of the active client book. Click a row to filter the clients list by country.',
render: () => <ClientsByCountryWidget />,
// Same rail-tile idiom as BerthHeatWidget + HotDealsCard compact
// Same rail-tile idiom as BerthHeatWidget + HotDealsCard - compact
// ranked list with mini-bars. Variant (a) per the master-doc design;
// the world-map variant lands alongside the recharts→ECharts pass.
group: 'rail',

View File

@@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import { Loader2, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
export type CancelMode = 'delete' | 'keep_remote';
interface CancelDocumentDialogProps {
open: boolean;
onOpenChange: (next: boolean) => void;
/** Label used in the dialog ("Cancel reservation", "Cancel contract", "Cancel EOI"). */
documentLabel: string;
/** Fires when the rep confirms. Caller invokes the mutation with the
* chosen `cancelMode` (and optional reason). The dialog stays open
* until `onOpenChange(false)` is called by the parent - typically on
* mutation success/failure. */
onConfirm: (params: { cancelMode: CancelMode; reason: string }) => void;
/** When true, disables the confirm action + shows a spinner. */
isSubmitting?: boolean;
}
/**
* Cancel-confirm dialog with an explicit "what to do with Documenso?"
* choice. Default `'delete'` mirrors the prior behaviour - DELETE the
* upstream envelope to keep the Documenso log uncluttered. `keep_remote`
* leaves the envelope intact so admins can later inspect it for audit /
* forensics; only the local CRM row flips to `cancelled`.
*
* Used by the Reservation / Contract / EOI tabs (any signing-doc
* surface that exposes a Cancel CTA). Replaces the previous
* `useConfirmation()` flow which had no way to surface this choice.
*/
export function CancelDocumentDialog({
open,
onOpenChange,
documentLabel,
onConfirm,
isSubmitting = false,
}: CancelDocumentDialogProps) {
const [cancelMode, setCancelMode] = useState<CancelMode>('delete');
const [reason, setReason] = useState('');
function reset() {
setCancelMode('delete');
setReason('');
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) reset();
onOpenChange(next);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Cancel {documentLabel.toLowerCase()}</DialogTitle>
<DialogDescription>
Signers will no longer be able to sign. Choose how to handle the document on Documenso.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<RadioGroup
value={cancelMode}
onValueChange={(value) => setCancelMode(value as CancelMode)}
className="gap-3"
>
<label
htmlFor="cancel-mode-delete"
className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/40"
>
<RadioGroupItem id="cancel-mode-delete" value="delete" className="mt-0.5" />
<span className="space-y-0.5">
<span className="block text-sm font-medium">Delete from Documenso</span>
<span className="block text-xs text-muted-foreground">
Frees the envelope slot upstream. Use this when the draft was abandoned and the
upstream record is no longer useful.
</span>
</span>
</label>
<label
htmlFor="cancel-mode-keep"
className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/40"
>
<RadioGroupItem id="cancel-mode-keep" value="keep_remote" className="mt-0.5" />
<span className="space-y-0.5">
<span className="block text-sm font-medium">Keep on Documenso for audit</span>
<span className="block text-xs text-muted-foreground">
Marks the local copy cancelled but leaves the envelope visible on Documenso so an
admin can review it later.
</span>
</span>
</label>
</RadioGroup>
<div className="space-y-1.5">
<Label htmlFor="cancel-reason" className="text-xs font-medium text-muted-foreground">
Reason (optional)
</Label>
<Textarea
id="cancel-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={2}
placeholder="What changed? Inlined into the cancellation audit log."
className="text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Keep open
</Button>
<Button
variant="destructive"
onClick={() => onConfirm({ cancelMode, reason: reason.trim() })}
disabled={isSubmitting}
>
{isSubmitting ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<XCircle className="mr-1.5 h-4 w-4" aria-hidden />
)}
Cancel {documentLabel.toLowerCase()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -27,7 +27,7 @@ import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { DOCUMENT_TYPES } from '@/lib/constants';
// Display labels for SIGNER_ROLES internal values stay lowercase, UI shows
// Display labels for SIGNER_ROLES - internal values stay lowercase, UI shows
// capitalized. Falls back to capitalize-first-letter for any value not in the
// explicit map.
const SIGNER_ROLE_LABELS: Record<string, string> = {
@@ -330,7 +330,7 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
value={subjectType}
onValueChange={(v) => {
setSubjectType(v as typeof subjectType);
// Reset subject id when the type changes pickers are
// Reset subject id when the type changes - pickers are
// type-specific and old ids belong to the wrong table.
setSubjectId('');
}}

View File

@@ -227,7 +227,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
const isComplete = ['completed', 'signed'].includes(doc.status);
// #67: linked-entity rows now show the entity TYPE + NAME (resolved
// server-side in getDocumentDetail) so the card reads "Interest
// server-side in getDocumentDetail) so the card reads "Interest -
// Matt Ciaccio" instead of "Interest →". Multiple linked entities
// render as a chip row; nothing renders when there's nothing to
// link.
@@ -245,7 +245,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
label: 'Interest',
// Show the berth label (e.g. "A1-A3, B5-B7" or "A12") so the
// Interest link carries distinct information from the Client
// link rendered just below otherwise both rows show the same
// link rendered just below - otherwise both rows show the same
// client name and the Interest row reads as duplicate.
sub: linked.interest.berthLabel ?? linked.interest.clientName ?? 'No berths linked',
});
@@ -585,7 +585,7 @@ function WatchersCard({ documentId, watchers }: { documentId: string; watchers:
{watchers.length === 0 ? (
// Larger bottom spacing (pb-1 + mb-4) gives the empty-state row enough
// breathing room above the "Add a watcher…" select the prior `mb-3`
// breathing room above the "Add a watcher…" select - the prior `mb-3`
// alone left the two lines stacked tight against each other.
<p className="mb-4 pb-1 text-xs text-muted-foreground">
No one is watching this document yet.

View File

@@ -71,7 +71,7 @@ async function downloadSignedFile(fileId: string, fallbackName: string) {
);
triggerUrlDownload(res.data.url, res.data.filename || fallbackName);
} catch {
// silent toast handled by the presign route on its own
// silent - toast handled by the presign route on its own
}
}

View File

@@ -244,7 +244,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
return (
// Escape the AppShell's desktop main padding (px-6 pt-3 pb-6) so the
// folder column sits flush against the global app sidebar reads
// folder column sits flush against the global app sidebar - reads
// as an extension of navigation rather than a card-inside-a-page.
// Inner content keeps its own padding so the right pane doesn't
// run flush with the viewport edge.
@@ -290,7 +290,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
}
// ---------------------------------------------------------------------------
// FlatFolderListing the original search + type-chip + document rows panel,
// FlatFolderListing - the original search + type-chip + document rows panel,
// now scoped to a specific folder (or null for root-only).
// ---------------------------------------------------------------------------
@@ -475,7 +475,7 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
)}
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Upload file</DialogTitle>
<DialogDescription>
@@ -501,7 +501,7 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
}
// ---------------------------------------------------------------------------
// FolderDropZone wraps the main content panel and accepts file drops onto
// FolderDropZone - wraps the main content panel and accepts file drops onto
// the currently-viewed folder. Files dropped here upload with folder_id +
// entity FKs set so they land where the rep expects.
//
@@ -521,7 +521,7 @@ interface FolderDropZoneProps {
function FolderDropZone({ folderId, entityType, entityId, children }: FolderDropZoneProps) {
const [dragActive, setDragActive] = useState(false);
const [uploading, setUploading] = useState(false);
// useRef for mutable per-drag counters useMemo's value is supposed
// useRef for mutable per-drag counters - useMemo's value is supposed
// to be immutable; React Compiler flags writes as a bug class.
const dragCounter = useRef(0);
const queryClient = useQueryClient();

View File

@@ -16,6 +16,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -40,7 +41,7 @@ interface EoiCancelDialogProps {
* - 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.
* 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.
@@ -53,8 +54,9 @@ interface EoiCancelDialogProps {
export function EoiCancelDialog({ documentId, signers, open, onOpenChange }: EoiCancelDialogProps) {
const queryClient = useQueryClient();
const [reason, setReason] = useState('');
const [cancelMode, setCancelMode] = useState<'delete' | 'keep_remote'>('delete');
const [notifyIds, setNotifyIds] = useState<Set<string>>(() => {
// Default: pre-check the signers who have signed they're the
// 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));
@@ -69,19 +71,23 @@ export function EoiCancelDialog({ documentId, signers, open, onOpenChange }: Eoi
body: {
reason: reason.trim() || null,
notifyRecipients: Array.from(notifyIds),
cancelMode,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success(
const base =
notifyIds.size > 0
? `EOI cancelled. ${notifyIds.size} signer${notifyIds.size === 1 ? '' : 's'} notified.`
: 'EOI cancelled.',
: 'EOI cancelled.';
toast.success(
cancelMode === 'keep_remote' ? `${base} Envelope kept on Documenso for audit.` : base,
);
onOpenChange(false);
// Reset internal state so a second open of the dialog starts clean.
setReason('');
setNotifyIds(new Set());
setCancelMode('delete');
},
onError: (err) => toastError(err),
});
@@ -138,6 +144,42 @@ export function EoiCancelDialog({ documentId, signers, open, onOpenChange }: Eoi
</div>
)}
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Documenso envelope
</p>
<RadioGroup
value={cancelMode}
onValueChange={(value) => setCancelMode(value as 'delete' | 'keep_remote')}
className="gap-2"
>
<label
htmlFor="eoi-cancel-mode-delete"
className="flex cursor-pointer items-start gap-3 rounded-md border p-2.5 hover:bg-accent/40"
>
<RadioGroupItem id="eoi-cancel-mode-delete" value="delete" className="mt-0.5" />
<span className="space-y-0.5">
<span className="block text-sm font-medium">Delete from Documenso</span>
<span className="block text-xs text-muted-foreground">
Frees the upstream envelope slot. Default - keeps the Documenso log clean.
</span>
</span>
</label>
<label
htmlFor="eoi-cancel-mode-keep"
className="flex cursor-pointer items-start gap-3 rounded-md border p-2.5 hover:bg-accent/40"
>
<RadioGroupItem id="eoi-cancel-mode-keep" value="keep_remote" className="mt-0.5" />
<span className="space-y-0.5">
<span className="block text-sm font-medium">Keep on Documenso for audit</span>
<span className="block text-xs text-muted-foreground">
Leaves the envelope intact. Local copy still flips to Cancelled.
</span>
</span>
</label>
</RadioGroup>
</div>
<div className="space-y-1.5">
<Label htmlFor="cancel-reason" className="text-xs font-semibold uppercase tracking-wide">
Reason (optional)

View File

@@ -68,7 +68,7 @@ interface EoiContextResponse {
lengthM: string | null;
widthM: string | null;
draftM: string | null;
/** Which unit the rep originally entered the dimensions in drives
/** 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. */
@@ -85,7 +85,7 @@ interface EoiContextResponse {
} | null;
eoiBerthRange: string;
port: { name: string };
/** Phase 3b every contact row the dialog renders in its
/** Phase 3b - every contact row the dialog renders in its
* override comboboxes. Populated by the eoi-context route. */
available: {
emails: Array<{ id: string; value: string; isPrimary: boolean; source: string }>;
@@ -111,7 +111,7 @@ interface EoiContextResponse {
}
/**
* Phase 3b per-field override state captured by the dialog. Sent
* Phase 3b - per-field override state captured by the dialog. Sent
* verbatim on the generate-and-sign POST and translated server-side
* into the documents.override_* columns + (optionally) client_contacts
* mutations.
@@ -127,7 +127,7 @@ interface FieldOverrideState {
}
/**
* Phase 3 follow-up address override state. Treated as one logical
* Phase 3 follow-up - address override state. Treated as one logical
* field with one pair of checkboxes (intent flags apply to the whole
* address rather than per-component).
*/
@@ -181,15 +181,15 @@ export function EoiGenerateDialog({
// (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);
// Phase 3b per-field override state. null entries = no override.
// Phase 3b - per-field override state. null entries = no override.
const [emailOverride, setEmailOverride] = useState<FieldOverrideState | null>(null);
const [phoneOverride, setPhoneOverride] = useState<FieldOverrideState | null>(null);
const [yachtNameOverride, setYachtNameOverride] = useState<FieldOverrideState | null>(null);
const [addressOverride, setAddressOverride] = useState<AddressOverrideState | null>(null);
// Phase 3c yacht spawn flow.
// Phase 3c - yacht spawn flow.
const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false);
// Resolved EOI context the actual values the document will be
// 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 {
@@ -233,11 +233,11 @@ export function EoiGenerateDialog({
enabled: open,
});
// U66 (c) EOI berth-scope picker. Pulls every linked berth so the
// U66 (c) - EOI berth-scope picker. Pulls every linked berth so the
// rep can confirm signature scope (`isInEoiBundle`) and public-map
// visibility (`isSpecificInterest`) at the moment of EOI generation
// the moment the "which berths does this EOI cover?" question is
// actually live in their head instead of relying on them having
// - the moment the "which berths does this EOI cover?" question is
// actually live in their head - instead of relying on them having
// visited the LinkedBerthsList toggles upstream. Post-(a) defaults
// (in_bundle=true; specific=primary) mean the picker is mostly
// already correct; this surface lets them carve exceptions.
@@ -313,7 +313,7 @@ export function EoiGenerateDialog({
}
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
// Only show the template picker when there's a real choice the
// 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.
@@ -405,7 +405,7 @@ export function EoiGenerateDialog({
//
// Email is rendered separately below with the Phase 3b override
// controls (combobox + 2 checkboxes), so it's omitted from the row
// array here but its required-met status still gates `requiredMet`
// array here - but its required-met status still gates `requiredMet`
// via `emailPresent` below.
const required = ctx
? [
@@ -436,7 +436,7 @@ export function EoiGenerateDialog({
: [ctx.yacht.lengthM, ctx.yacht.widthM, ctx.yacht.draftM]
: [];
// Optional Section 3 of the EOI. Generation proceeds without them.
// Optional - Section 3 of the EOI. Generation proceeds without them.
// Yacht-name + phone are rendered separately below with Phase 3b
// override controls; the remainder show as straight previews.
const optional = ctx
@@ -478,7 +478,7 @@ export function EoiGenerateDialog({
setIsGenerating(true);
setError(null);
try {
// U66 (c) persist any berth-scope edits BEFORE kicking off the
// U66 (c) - persist any berth-scope edits BEFORE kicking off the
// envelope so the EOI/public-map state is consistent with what the
// rep just confirmed. Diff against the server snapshot so an
// unchanged scope is a no-op (avoids spurious audit-log rows).
@@ -510,7 +510,7 @@ export function EoiGenerateDialog({
const isDocumenso = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
// Phase 3b pack the per-field overrides the rep selected. Each
// Phase 3b - pack the per-field overrides the rep selected. Each
// is null when untouched; the server validator accepts an absent
// entry and falls back to the canonical record.
const overridePayload = (s: FieldOverrideState | null) =>
@@ -555,7 +555,7 @@ 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
// 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.
@@ -586,7 +586,7 @@ export function EoiGenerateDialog({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<FileSignature className="size-4" aria-hidden />
@@ -760,7 +760,7 @@ export function EoiGenerateDialog({
className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-3 py-2"
>
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono text-sm">{link.mooringNumber ?? ''}</span>
<span className="font-mono text-sm">{link.mooringNumber ?? '-'}</span>
{link.isPrimary ? (
<span className="text-[10px] uppercase tracking-wide text-primary">
Primary
@@ -1113,7 +1113,7 @@ function PreviewRow({
}
/**
* Phase 3b overridable row for a contact channel (email/phone) or a
* Phase 3b - overridable row for a contact channel (email/phone) or a
* single-value field (yacht name). Renders as a plain text row showing
* the canonical value, with a small "Override" affordance that expands
* into a Select (over `options`) + Input (for fresh values) + the two
@@ -1137,7 +1137,7 @@ function OverridableContactField({
* pre-select the matching Select item when the user opens override
* mode without changing anything. */
canonicalContactId: string | null;
/** Picker options. For yacht-name pass [] only the manual text path
/** Picker options. For yacht-name pass [] - only the manual text path
* is available. */
options: Array<{ id: string; value: string; isPrimary: boolean }>;
override: FieldOverrideState | null;
@@ -1284,7 +1284,7 @@ function OverridableContactField({
onChange({
...override,
useOnlyForThisEoi: e.target.checked,
// Mutually exclusive intent both true at once doesn't
// Mutually exclusive intent - both true at once doesn't
// make sense (per-doc vs. promote-to-canonical).
setAsDefault: e.target.checked ? false : override.setAsDefault,
})
@@ -1327,7 +1327,7 @@ function OverridableContactField({
}
/**
* Phase 3 follow-up address override row. Treats the address as one
* Phase 3 follow-up - address override row. Treats the address as one
* logical field with one pair of checkboxes (master-plan decision:
* reps think about addresses all-or-nothing). The per-component input
* UX mirrors the canonical address form (separate fields per

View File

@@ -49,7 +49,7 @@ interface SignatoryRow {
interface InitialState {
title: string;
/** YYYY-MM-DD slice the DatePicker treats it as ISO date. */
/** YYYY-MM-DD slice - the DatePicker treats it as ISO date. */
signedAt: string;
notes: string;
signatories: SignatoryRow[];
@@ -65,7 +65,7 @@ interface Props {
/**
* Edits an existing external-EOI document's metadata (title, signed
* date, notes, signatories). Backed by `PATCH /api/v1/documents/[id]/metadata`,
* which refuses on Documenso-managed docs so the caller (detail page)
* which refuses on Documenso-managed docs - so the caller (detail page)
* already gates rendering on `isManualUpload`.
*
* Mirrors the upload-side dialog's signatory shape so the on-screen
@@ -75,7 +75,7 @@ export function ExternalEoiEditDialog({ open, onOpenChange, documentId, initial
// State is initialised once per mount; the parent guarantees a fresh
// mount on every open by only rendering this component when the
// dialog is open. That avoids a setState-in-effect re-hydration
// pattern (banned by lint) the dialog's open lifecycle IS the
// pattern (banned by lint) - the dialog's open lifecycle IS the
// initialisation trigger.
const qc = useQueryClient();
const [title, setTitle] = useState(initial.title);
@@ -119,7 +119,7 @@ export function ExternalEoiEditDialog({ open, onOpenChange, documentId, initial
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Edit document metadata</DialogTitle>
<DialogDescription>

View File

@@ -92,7 +92,7 @@ export function NewDocumentMenu({
</DropdownMenu>
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Upload file</DialogTitle>
<DialogDescription>
@@ -112,7 +112,7 @@ export function NewDocumentMenu({
yachtId={entityType === 'yacht' ? entityId : undefined}
onUploadComplete={(file) => {
if (!file) {
// Trailing "batch done" call invalidate hub caches so the
// Trailing "batch done" call - invalidate hub caches so the
// newly-uploaded file appears in the Recent files / folder
// listings without a manual reload.
queryClient.invalidateQueries({ queryKey: ['files'] });

View File

@@ -55,7 +55,7 @@ export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Signing details</DialogTitle>
<DialogDescription>

View File

@@ -4,7 +4,7 @@ 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 { Check, Clock, X, Mail, Eye, Bell, Send, Link2 } from 'lucide-react';
import { toastError } from '@/lib/api/toast-error';
import { Button } from '@/components/ui/button';
@@ -21,6 +21,11 @@ interface Signer {
invitedAt?: string | null;
openedAt?: string | null;
lastReminderSentAt?: string | null;
/** Documenso-issued URL the recipient hits to sign. Available as soon
* as the doc has been created+sent - independent of whether the
* invitation email has actually been dispatched, so reps can copy it
* for manual delivery / QA before triggering the auto-send. */
signingUrl?: string | null;
}
interface SigningProgressProps {
@@ -47,10 +52,10 @@ const STATUS_META: Record<string, { label: string; tone: Tone; icon: typeof Chec
declined: { label: 'Declined', tone: 'declined', icon: X },
};
// Card styling per status colour-tinted background + left accent stripe.
// 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
// 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,
@@ -138,7 +143,7 @@ function compactAbsolute(isoOrNull: string | null | undefined): string | null {
/** 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. */
* every 60s - components read it to invalidate memoization. */
function useMinuteTick(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
@@ -322,7 +327,7 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) {
</div>
<p className="truncate text-xs text-muted-foreground">{signer.signerEmail}</p>
{/* Activity timeline explicit "Not yet invited" state so
{/* 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). */}
@@ -379,13 +384,38 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) {
</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. */}
{/* Per-signer actions. Order: Copy link (when available)
then the primary action button.
• Copy: surfaces the Documenso signing URL for QA /
manual delivery. Available the moment Documenso has
issued the URL - independent of whether the
invitation email has gone out - so reps can preview
the page before triggering auto-send.
• `invitedAt === null` → "Send invitation" (rep
dispatches the first email; fires branded invite +
stamps invitedAt).
• `invitedAt !== null` → "Send reminder"
(Documenso-side nudge, rate-limited per cooldown).
• Signed/declined → no action buttons. */}
{signer.status === 'pending' && signer.signingUrl ? (
<Button
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground [&_svg]:size-3"
onClick={async () => {
try {
await navigator.clipboard.writeText(signer.signingUrl!);
toast.success(`Signing link for ${signer.signerName} copied`);
} catch {
toast.error('Could not copy to clipboard');
}
}}
title="Copy this signer's Documenso URL to the clipboard - for QA or manual delivery."
>
<Link2 />
Copy link
</Button>
) : null}
{signer.status === 'pending' &&
(signer.invitedAt ? (
<Button

View File

@@ -46,17 +46,17 @@ import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
/**
* Phase 4 Upload-for-Documenso-signing dialog.
* Phase 4 - Upload-for-Documenso-signing dialog.
*
* Four-step flow inside one dialog:
* 1. select-file drag/drop or click to upload a PDF
* 2. configure-recipients name/email/role per signer, with
* 1. select-file - drag/drop or click to upload a PDF
* 2. configure-recipients - name/email/role per signer, with
* client + developer + approver prefilled from port + interest
* 3. place-fields render the PDF page-by-page, run
* 3. place-fields - render the PDF page-by-page, run
* auto-detect, let the rep drag/place/delete fields per signer
* 4. sending POST to /upload-for-signing, show spinner
* 4. sending - POST to /upload-for-signing, show spinner
*
* The implementation is intentionally compact the field-overlay
* The implementation is intentionally compact - the field-overlay
* uses native DOM drag rather than dnd-kit so the coordinate math
* stays obvious. Auto-detect lives on the server (uses pdfjs-dist) so
* the same parser ships once.
@@ -83,7 +83,7 @@ type FieldType =
| 'RADIO';
interface PlacedField {
/** Client-side id only server doesn't see this. */
/** Client-side id only - server doesn't see this. */
id: string;
type: FieldType;
recipientIndex: number;
@@ -143,9 +143,10 @@ interface UploadForSigningDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interestId: string;
/** Pre-set the document type the parent (Contract/Reservation tab)
* decides which to upload. */
documentType: 'contract' | 'reservation_agreement';
/** Pre-set the document type - the parent (EOI/Contract/Reservation
* tab) decides which to upload. EOI here is the upload-draft path;
* the template-driven generate flow lives on EoiGenerateDialog. */
documentType: 'eoi' | 'contract' | 'reservation_agreement';
/** Optional: client name/email to prefill the first recipient.
* When omitted the dialog fetches from the interest. */
clientPrefill?: { name: string; email: string };
@@ -163,7 +164,7 @@ export function UploadForSigningDialog({
if (!open) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
<DialogContent className="sm:max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
<DialogBody
key={`${interestId}:${documentType}`}
interestId={interestId}
@@ -195,7 +196,7 @@ interface PersistedDraft {
recipients: Recipient[];
fields: PlacedField[];
invitationMessage: string;
/** Saved at timestamp surfaces in the UI as "Draft saved <relative>". */
/** Saved at timestamp - surfaces in the UI as "Draft saved <relative>". */
savedAt: string;
}
@@ -205,7 +206,7 @@ function loadDraft(interestId: string, documentType: string): PersistedDraft | n
const raw = window.localStorage.getItem(draftStorageKey(interestId, documentType));
if (!raw) return null;
const parsed = JSON.parse(raw) as PersistedDraft;
// Defensive shape check drop drafts that look malformed rather
// Defensive shape check - drop drafts that look malformed rather
// than crashing the dialog.
if (
typeof parsed.title !== 'string' ||
@@ -225,7 +226,7 @@ function saveDraft(interestId: string, documentType: string, draft: PersistedDra
try {
window.localStorage.setItem(draftStorageKey(interestId, documentType), JSON.stringify(draft));
} catch {
// localStorage may throw on private mode or quota swallow.
// localStorage may throw on private mode or quota - swallow.
}
}
@@ -245,7 +246,7 @@ function DialogBody({
onClose,
}: {
interestId: string;
documentType: 'contract' | 'reservation_agreement';
documentType: 'eoi' | 'contract' | 'reservation_agreement';
clientPrefill?: { name: string; email: string };
onClose: () => void;
}) {
@@ -263,21 +264,26 @@ function DialogBody({
const [recipients, setRecipients] = useState<Recipient[]>(initialDraft?.recipients ?? []);
const [fields, setFields] = useState<PlacedField[]>(initialDraft?.fields ?? []);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
// Phase 6 polish optional rep-authored note that appears above the
// Phase 6 polish - optional rep-authored note that appears above the
// CTA in every invitation email for this doc. Empty string means
// "no custom note use the template default copy".
// "no custom note - use the template default copy".
const [invitationMessage, setInvitationMessage] = useState(initialDraft?.invitationMessage ?? '');
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(initialDraft?.savedAt ?? null);
const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement';
const docLabel =
documentType === 'contract'
? 'Sales Contract'
: documentType === 'eoi'
? 'Expression of Interest'
: 'Reservation Agreement';
// Defaults endpoint drives the developer/approver prefill.
// Defaults endpoint - drives the developer/approver prefill.
const { data: defaults } = useQuery<{ data: SigningDefaults }>({
queryKey: ['documents', 'signing-defaults'],
queryFn: () => apiFetch<{ data: SigningDefaults }>('/api/v1/documents/signing-defaults'),
});
// Interest endpoint used to prefill the client recipient when the
// Interest endpoint - used to prefill the client recipient when the
// caller didn't supply one. Cached so the same dialog open/reopen
// hits the cache.
const { data: interestData } = useQuery<{
@@ -294,7 +300,7 @@ function DialogBody({
/**
* Build the prefill recipient list from the async query data. The
* dialog reads this on the "Next" button click in the file-picker
* step to seed `recipients` keeping the seeding as a user-event
* step to seed `recipients` - keeping the seeding as a user-event
* handler rather than an effect avoids the cascading-render lint
* (react-hooks/set-state-in-effect, Wave 3) that earlier versions
* tripped. Returns an empty array until the defaults query resolves;
@@ -331,17 +337,34 @@ function DialogBody({
return next;
}, [defaults, interestData, clientPrefill]);
const fileObjectUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
// We previously passed an object URL into react-pdf, but PDF.js runs
// its parser in a Web Worker loaded from unpkg.com (a different
// origin from localhost). Cross-origin workers can't fetch blob URLs
// minted on the main page - the worker XHR returns response (0) and
// the preview surfaces "Unexpected server response (0)". Reading the
// file into an ArrayBuffer once and handing PDF.js the raw bytes via
// `{ data: ... }` sidesteps the fetch entirely, so the cross-origin
// worker has nothing to retrieve.
const [fileBytes, setFileBytes] = useState<Uint8Array | null>(null);
useEffect(() => {
if (!file) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear preview bytes when caller drops the file
setFileBytes(null);
return;
}
let cancelled = false;
void file.arrayBuffer().then((buf) => {
if (!cancelled) setFileBytes(new Uint8Array(buf));
});
return () => {
if (fileObjectUrl) URL.revokeObjectURL(fileObjectUrl);
cancelled = true;
};
}, [fileObjectUrl]);
}, [file]);
// Persist the rep's progress to localStorage as they work. Debounced
// at 500ms so a flurry of state updates (typing a long invitation
// message, dragging a field across the page) doesn't hammer storage.
// We DO NOT persist the File object itself the rep has to re-pick
// We DO NOT persist the File object itself - the rep has to re-pick
// the PDF after a refresh. Everything else (title, signers,
// placements, custom note) round-trips. The `step` is restored too
// so the dialog reopens on the same screen the rep left.
@@ -420,11 +443,11 @@ function DialogBody({
`Auto-detect placed ${placed.length} field${placed.length === 1 ? '' : 's'}.`,
);
} else {
toast.info('No fields auto-detected place them manually.');
toast.info('No fields auto-detected - place them manually.');
}
},
onError: () => {
toast.info('Auto-detect skipped place fields manually.');
toast.info('Auto-detect skipped - place fields manually.');
},
});
@@ -440,7 +463,7 @@ function DialogBody({
if (invitationMessage.trim()) {
form.append('invitationMessage', invitationMessage.trim());
}
// Strip the client-side `id` from each placed field the server
// Strip the client-side `id` from each placed field - the server
// assigns its own ids on the documenso side.
form.append(
'fields',
@@ -478,13 +501,13 @@ function DialogBody({
onSuccess: (res) => {
toast.success(
defaults?.data?.sendMode === 'auto'
? 'Document sent for signing first signer has been invited.'
? 'Document sent for signing - first signer has been invited.'
: 'Document uploaded and ready to send. Use the Send button on the doc to email the first signer.',
);
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
void res;
// Clear the draft on successful submission the in-flight upload
// Clear the draft on successful submission - the in-flight upload
// is now an actual document; the localStorage shouldn't keep its
// shadow around.
clearDraft(interestId, documentType);
@@ -512,7 +535,7 @@ function DialogBody({
dialog open / close cycles. Discard wipes the draft and
resets to the file-picker step. The file itself isn't
persisted (large blobs + browser quota), so on reopen the
rep needs to re-pick the PDF the rest of the state
rep needs to re-pick the PDF - the rest of the state
(title, signers, placements, custom note) survives. */}
{draftSavedAt ? (
<div className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
@@ -540,7 +563,7 @@ function DialogBody({
setFile(f);
setTitle(f.name.replace(/\.pdf$/i, ''));
// Seed recipients from the prefill snapshot when the rep
// first lands a file only if they haven't already
// first lands a file - only if they haven't already
// edited the list. This pattern keeps the prefill
// synchronization in user-event handlers (no setState-
// in-effect lint trip).
@@ -564,9 +587,9 @@ function DialogBody({
onInvitationMessageChange={setInvitationMessage}
/>
)}
{step === 'place-fields' && fileObjectUrl && (
{step === 'place-fields' && fileBytes && (
<FieldPlacementStep
fileUrl={fileObjectUrl}
fileBytes={fileBytes}
fields={fields}
onFieldsChange={setFields}
recipients={recipients}
@@ -688,7 +711,7 @@ function FilePickerStep({
id="doc-title"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="e.g. Berth A-12 Sales Contract John Smith"
placeholder="e.g. Berth A-12 Sales Contract - John Smith"
/>
</div>
<div
@@ -856,7 +879,7 @@ function RecipientsStep({
// ─── Step 3: field placement overlay ──────────────────────────────
function FieldPlacementStep({
fileUrl,
fileBytes,
fields,
onFieldsChange,
recipients,
@@ -864,7 +887,7 @@ function FieldPlacementStep({
onSelectField,
isDetecting,
}: {
fileUrl: string;
fileBytes: Uint8Array;
fields: PlacedField[];
onFieldsChange: (next: PlacedField[]) => void;
recipients: Recipient[];
@@ -875,7 +898,7 @@ function FieldPlacementStep({
const [numPages, setNumPages] = useState(1);
const [pageNumber, setPageNumber] = useState(1);
const [placingType, setPlacingType] = useState<FieldType | null>(null);
// PDF render zoom defaults to 1 (the historical fixed scale). Buttons
// PDF render zoom - defaults to 1 (the historical fixed scale). Buttons
// below the page-nav let reps zoom out for an overview or zoom in for
// tight placement work. Field coordinates stay in % of page dimensions
// so the placed-field overlay scales automatically with the PDF.
@@ -886,6 +909,12 @@ function FieldPlacementStep({
const [pdfLoadError, setPdfLoadError] = useState<string | null>(null);
const pageContainerRef = useRef<HTMLDivElement>(null);
// react-pdf re-creates its internal PDF document whenever the `file`
// prop's reference identity changes, so the `{ data }` object MUST
// be memoized - otherwise every render restarts parsing from scratch
// and flickers the placeholder.
const pdfFileSource = useMemo(() => ({ data: fileBytes }), [fileBytes]);
const pageFields = useMemo(
() => fields.filter((f) => f.pageNumber === pageNumber),
[fields, pageNumber],
@@ -920,7 +949,7 @@ function FieldPlacementStep({
if (selectedFieldId === id) onSelectField(null);
}
// Keyboard shortcuts on the placement canvas Delete / Backspace
// Keyboard shortcuts on the placement canvas - Delete / Backspace
// removes the selected field; arrow keys nudge it by 0.5% (Shift = 5%
// for coarser moves). Listens at document level so the handler still
// fires when the rep's focus is on the PDF canvas (which doesn't take
@@ -1042,7 +1071,7 @@ function FieldPlacementStep({
>
<ChevronRight className="size-4" />
</Button>
{/* Zoom controls render zoom only, field coordinates stay
{/* Zoom controls - render zoom only, field coordinates stay
in % so placements scale automatically with the canvas. */}
<div className="ml-3 flex items-center gap-1 border-l pl-3">
<Button
@@ -1103,7 +1132,11 @@ function FieldPlacementStep({
</div>
) : (
<Document
file={fileUrl}
// Passing { data } gives PDF.js the raw bytes directly,
// so its (cross-origin) Web Worker doesn't have to fetch
// anything - this is the only way to make react-pdf work
// when the worker is loaded from a CDN.
file={pdfFileSource}
onLoadSuccess={({ numPages: n }) => {
setNumPages(n);
setPdfLoadError(null);
@@ -1176,7 +1209,7 @@ function FieldOverlay({
const color = RECIPIENT_COLORS[field.recipientIndex % RECIPIENT_COLORS.length];
const recipient = recipients[field.recipientIndex];
// Drag handler translate mouse-move pixels into percent deltas
// Drag handler - translate mouse-move pixels into percent deltas
// against the parent container's bounding rect.
function startDrag(e: React.MouseEvent) {
e.preventDefault();

View File

@@ -31,7 +31,7 @@ interface Props {
/** Called with the minted public URL so the parent compose surface can
* paste it into the email body / textarea. */
onInsert?: (url: string) => void;
/** Display variant `inline` is a small text button suitable for a
/** Display variant - `inline` is a small text button suitable for a
* toolbar; `default` is a sized button suitable for a form footer. */
variant?: 'inline' | 'default';
}
@@ -43,7 +43,7 @@ interface Props {
* message" action that calls back to the parent.
*
* Permission-gated on `email.send` so reps without send rights don't
* see the affordance same as the server-side check on the POST route.
* see the affordance - same as the server-side check on the POST route.
*/
export function TrackedLinkComposerButton({ sendId, onInsert, variant = 'inline' }: Props) {
const [open, setOpen] = useState(false);
@@ -80,7 +80,7 @@ export function TrackedLinkComposerButton({ sendId, onInsert, variant = 'inline'
setOpen(true);
}}
className={variant === 'inline' ? 'h-7 px-2 text-xs' : undefined}
title="Mint a tracked link the recipient can click clicks count back to this send."
title="Mint a tracked link the recipient can click - clicks count back to this send."
>
<LinkIcon
className={variant === 'inline' ? 'mr-1 size-3' : 'mr-1.5 size-3.5'}

View File

@@ -21,7 +21,7 @@ import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
* Renders an image thumbnail for previewable receipts (jpeg/png/webp/heic
* via the existing /files/[id]/preview presign), falling back to a "Download"
* link for PDFs and other non-previewable types. Replaces the prior
* impossible-to-use UUID-badge list reps can finally see the receipt
* impossible-to-use UUID-badge list - reps can finally see the receipt
* they uploaded against the expense.
*/
function ReceiptThumbnail({ fileId }: { fileId: string }) {
@@ -38,7 +38,7 @@ function ReceiptThumbnail({ fileId }: { fileId: string }) {
return res;
} catch (e) {
// Non-image files raise ValidationError ("This file type cannot be
// previewed") fall through to the Download link.
// previewed") - fall through to the Download link.
return { data: null, error: e instanceof Error ? e.message : 'preview unavailable' };
}
},

View File

@@ -49,7 +49,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
// Per-port vocabulary override for expense categories. Falls back to
// the shipped EXPENSE_CATEGORIES constant when /api/v1/vocabularies
// hasn't loaded yet or returns malformed data keeps the picker
// hasn't loaded yet or returns malformed data - keeps the picker
// populated during the first render.
const { data: vocab } = useQuery<{ data: Record<string, readonly string[]> }>({
queryKey: ['vocabularies'],
@@ -128,7 +128,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
};
}, [previewUrl]);
// Reset upload state whenever the sheet closes re-opening on the same
// Reset upload state whenever the sheet closes - re-opening on the same
// expense was carrying stale state from the prior session.
useEffect(() => {
if (!open) {
@@ -440,6 +440,17 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
onChange={handleFileChange}
/>
{uploadError && <p className="text-xs text-destructive">{uploadError}</p>}
{/* `receiptFileIds` is set via `setValue`, not `register`,
so it has no DOM input the auto-scroll-to-error helper
can focus. Without this surface the schema's refine
(receiptFileIds.length > 0 || noReceiptAcknowledged)
would fail invisibly and Submit would silently do
nothing. Bucket-4 #7 fix. */}
{errors.receiptFileIds?.message ? (
<p role="alert" className="text-xs text-destructive">
{String(errors.receiptFileIds.message)}
</p>
) : null}
<div className="flex items-start gap-2 pt-1">
<Checkbox

View File

@@ -19,7 +19,7 @@ import { apiFetch } from '@/lib/api/client';
const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false });
import 'yet-another-react-lightbox/styles.css';
// pdfjs-dist is ~150kb gzip lazy-load so routes that never preview
// pdfjs-dist is ~150kb gzip - lazy-load so routes that never preview
// PDFs don't ship it. ssr:false because the worker setup needs window.
const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m.PdfViewer })), {
ssr: false,
@@ -40,7 +40,7 @@ interface FilePreviewDialogProps {
/**
* Routes a file's mime type to one of seven preview surfaces. Order
* matters `application/pdf` is matched before the generic
* matters - `application/pdf` is matched before the generic
* "application/*" bucket so PDFs stay on the rich pdfjs viewer.
*/
type PreviewKind =
@@ -187,12 +187,12 @@ export function FilePreviewDialog({
{!loading && !error && previewUrl && kind === 'office' && (
// Office documents render via Microsoft's hosted Office viewer
// public URL only; presigned download URLs include a token
// - public URL only; presigned download URLs include a token
// in the query string so they work here even though the file
// isn't world-public. The viewer streams the document and
// renders a high-fidelity preview without us shipping a
// headless LibreOffice. Falls back to "download to view" if
// the embed loads but renders nothing (e.g. CORS rejected)
// the embed loads but renders nothing (e.g. CORS rejected) -
// detection is hard so we just keep the download CTA below.
<iframe
title={fileName ?? 'Office document preview'}
@@ -240,7 +240,7 @@ export function FilePreviewDialog({
}
/**
* Plain-text preview pane fetches the file body via the presigned
* Plain-text preview pane - fetches the file body via the presigned
* URL (no auth needed; the URL itself carries the access token) and
* renders it as monospaced text. Caps the body at 1 MB so a huge log
* file doesn't lock the browser; surfaces a "first 1 MB shown" notice

View File

@@ -53,7 +53,7 @@ export function FileUploadZone({
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState<UploadingFile[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
// Scope toggle only meaningful when an interest is being uploaded
// Scope toggle - only meaningful when an interest is being uploaded
// FROM (i.e. the rep is on the InterestDocumentsTab). Default to the
// narrower "deal" scope so a rep uploading a contract specific to this
// deal doesn't accidentally surface it under every interest the client
@@ -162,7 +162,7 @@ export function FileUploadZone({
return (
<div className="space-y-3">
{/* Scope radio only renders when an interest FK is present. The
{/* Scope radio - only renders when an interest FK is present. The
rep picks whether the upload files at the deal level (nested
under Clients/<Name>/<Interest>) or the client level
(Clients/<Name>). Default = deal, since that's the narrower

View File

@@ -51,7 +51,7 @@ function PdfViewerBody({ url, fileName }: PdfViewerProps) {
const [error, setError] = useState<string | null>(null);
// Keep options stable across renders so react-pdf doesn't refetch
// every render useMemo wins because react-pdf compares by identity.
// every render - useMemo wins because react-pdf compares by identity.
const options = useMemo(
() => ({
cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
@@ -70,7 +70,7 @@ function PdfViewerBody({ url, fileName }: PdfViewerProps) {
{
scaleBounds: { min: 0.5, max: 3 },
from: () => [scale, 0],
// Don't hijack wheel events desktop users zoom via buttons,
// Don't hijack wheel events - desktop users zoom via buttons,
// wheel still scrolls the page.
eventOptions: { passive: false },
},
@@ -91,7 +91,7 @@ function PdfViewerBody({ url, fileName }: PdfViewerProps) {
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-[80px] text-center tabular-nums">
{numPages ? `${pageNumber} / ${numPages}` : ''}
{numPages ? `${pageNumber} / ${numPages}` : '-'}
</span>
<Button
type="button"

View File

@@ -10,7 +10,7 @@ import { ReminderList } from '@/components/reminders/reminder-list';
import { useAlertCount } from '@/components/alerts/use-alerts';
/**
* Merged "Inbox" surface replaces the previously-separate /alerts and
* Merged "Inbox" surface - replaces the previously-separate /alerts and
* /reminders pages. Two stacked sections (Reminders first, Alerts second)
* preserve the source distinction (system-flagged vs user-set) while
* giving reps a single "things demanding my attention" surface.
@@ -30,7 +30,7 @@ export function InboxPageShell() {
const [remindersOpen, setRemindersOpen] = useState(true);
const { data: alertCount } = useAlertCount();
// localStorage hydration on mount canonical "read from external
// localStorage hydration on mount - canonical "read from external
// store" pattern. setState in effect is intentional.
useEffect(() => {
const a = localStorage.getItem('inbox.alerts.open');

View File

@@ -88,7 +88,7 @@ export function AddBerthToInterestDialog({
checked={choice === 'exploring'}
title="Just exploring"
description="The berth is being considered or covered by the EOI bundle, but not pitched specifically."
consequence="This berth stays marked “Available” on the public map the link is internal only."
consequence="This berth stays marked “Available” on the public map - the link is internal only."
icon={<EyeOff className="size-4" aria-hidden />}
/>
</RadioGroup>

View File

@@ -413,11 +413,11 @@ export function BerthRecommenderPanel({
const [amenityFilters, setAmenityFilters] = useState<AmenityFilters>({});
const [showAll, setShowAll] = useState(false);
const [pendingBerth, setPendingBerth] = useState<Recommendation | null>(null);
// Area-letter filter chips above the list let reps narrow to a
// Area-letter filter - chips above the list let reps narrow to a
// single pier (e.g. "show me only A-row matches"). Client-side over
// the already-fetched result set; no service change required.
const [selectedAreas, setSelectedAreas] = useState<string[]>([]);
// Collapse state defaults to collapsed when the deal already has at
// Collapse state - defaults to collapsed when the deal already has at
// least one linked berth (recommender becomes a "browse more options"
// tool rather than the primary surface). Reps can manually expand any
// time. Header click toggles.
@@ -432,7 +432,7 @@ export function BerthRecommenderPanel({
const { data, isFetching, refetch } = useQuery({
queryKey,
// Skip the network call when collapsed no point fetching options
// Skip the network call when collapsed - no point fetching options
// the rep won't see. Re-fires automatically on expand.
enabled: hasDimensions && !collapsed,
queryFn: () =>
@@ -443,7 +443,7 @@ export function BerthRecommenderPanel({
// 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.
// - exactly the case in our seeded demo port.
...(showAll ? { topN: 999, maxOversizePct: 1000 } : {}),
...(Object.keys(amenityFilters).length > 0 ? { amenityFilters } : {}),
},

View File

@@ -32,7 +32,7 @@ const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const [open, setOpen] = useState(false);
// Closed / archived deals don't get a pulse UX would be confusing.
// Closed / archived deals don't get a pulse - UX would be confusing.
if (interest.archivedAt || interest.outcome) return null;
const health = computeDealHealth(interest);

View File

@@ -58,7 +58,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10));
// `null` means "rep hasn't touched the list yet show the
// `null` means "rep hasn't touched the list yet - show the
// derived-from-interest seed". Once edited (add/remove/change),
// the explicit array takes over. Avoids a setState-in-effect that
// the React Compiler bans.
@@ -66,7 +66,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
const [notes, setNotes] = useState('');
// Fetched on open to power the default title:
// "External EOI <Client> <berth range> YYYY-MM-DD". Without
// "External EOI - <Client> - <berth range> - YYYY-MM-DD". Without
// this the file lands as just "External EOI - <date>" which is
// unscannable in any list when a port has multiple deals closing on
// the same day. Also drives auto-fill on signatory rows tagged
@@ -83,7 +83,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
staleTime: 60_000,
});
// Compute the effective signatory list when the rep hasn't touched
// Compute the effective signatory list - when the rep hasn't touched
// anything, seed from the interest's client. Once they edit, the
// explicit override takes over.
const signatories: SignatoryRow[] = useMemo(() => {
@@ -118,7 +118,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
if (clientName) parts.push(clientName);
if (berthLabel) parts.push(berthLabel);
parts.push(date);
return parts.join(' ');
return parts.join(' - ');
}, [interestData, berthsData, signedAt]);
const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({
@@ -175,7 +175,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Upload externally-signed EOI</DialogTitle>
<DialogDescription>

View File

@@ -46,7 +46,7 @@ interface InlineStagePickerProps {
* inline prereq view that lets them link a yacht and proceed in one
* flow instead of bouncing them out to the form. */
currentYachtId?: string | null;
/** Client owning the interest scopes the inline yacht-picker so the
/** Client owning the interest - scopes the inline yacht-picker so the
* rep only sees yachts that actually belong to this lead. */
clientId?: string;
}
@@ -80,7 +80,7 @@ export function InlineStagePicker({
// When a user picks a stage that isn't a legal next step (and has the
// override permission), the popover transitions into a confirm view
// that asks for a reason before committing. Reasons are not exposed
// for legal transitions they're stored as audit-log notes on the
// for legal transitions - they're stored as audit-log notes on the
// interest's history, accessible via the activity timeline.
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
const [overrideReason, setOverrideReason] = useState('');
@@ -160,7 +160,7 @@ export function InlineStagePicker({
const isOverride = !canTransitionStage(stage, next);
if (isOverride && canOverride) {
// Switch into the confirm view rather than firing the mutation
// immediately overrides bypass the transition guard so a reason
// immediately - overrides bypass the transition guard so a reason
// is genuinely useful for the audit trail.
setOverrideTarget(next);
setOverrideReason('');
@@ -207,7 +207,7 @@ export function InlineStagePicker({
),
);
// After unlinking, the canTransition table might no longer flag this
// as an override re-evaluate just in case.
// as an override - re-evaluate just in case.
const isOverride = !canTransitionStage(stage, target);
mutation.mutate({
next: target,
@@ -286,7 +286,7 @@ export function InlineStagePicker({
onClick={(e) => stopPropagation && e.stopPropagation()}
>
{yachtPrereqTarget ? (
// F23: inline yacht-prereq view only reached when the rep
// F23: inline yacht-prereq view - only reached when the rep
// picked a non-Enquiry stage without a yacht linked. Surfaces
// a yacht-picker right inside the popover so they can fix
// the prereq and move the stage in one flow.
@@ -394,7 +394,7 @@ export function InlineStagePicker({
</div>
</div>
) : (
// Default view: just the stage list. No upfront textarea
// Default view: just the stage list. No upfront textarea -
// earlier UX put a "Reason (optional)…" field at the top
// which read as visually noisy for the >90% of changes that
// are normal transitions and never get a reason attached.
@@ -416,7 +416,7 @@ export function InlineStagePicker({
blockedByPermission
? `Override required (you don't have permission)`
: isOverride
? 'Non-standard transition confirm step required'
? 'Non-standard transition - confirm step required'
: undefined
}
className={cn(
@@ -425,7 +425,7 @@ export function InlineStagePicker({
isCurrent && 'font-medium',
)}
>
{/* Colored chip (mirrors the inline stage badge) turns
{/* Colored chip (mirrors the inline stage badge) - turns
the picker into a visual scan rather than just a list. */}
<span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
@@ -440,7 +440,7 @@ export function InlineStagePicker({
) : isCurrent ? (
<Check className="size-3.5 text-muted-foreground" aria-hidden />
) : isOverride && canOverride ? (
// F22: was ⚑ unicode glyph replaced with a Lucide
// F22: was ⚑ unicode glyph - replaced with a Lucide
// icon to match the rest of the visual system.
<AlertTriangle
className="size-3.5 text-amber-600"

View File

@@ -28,12 +28,12 @@ interface CompetingInterest {
/**
* Surfaces when one of the interest's linked berths is sold or under offer
* to a different deal. We don't block the rep from proceeding (the user
* explicitly wanted v1 to still let the deal advance the assumption is
* explicitly wanted v1 to still let the deal advance - the assumption is
* that the rep is aware and treating the current deal as a fallback if
* the other one falls through), but the banner makes the conflict visible
* so they aren't surprised when the rules engine flags it.
*
* Fires only for active (non-archived, non-closed) interests banners on
* Fires only for active (non-archived, non-closed) interests - banners on
* lost deals are noise.
*/
export function InterestBerthStatusBanner({
@@ -74,7 +74,7 @@ export function InterestBerthStatusBanner({
});
if (archivedAt || interestOutcome) return null;
// The banner is most useful before the rep is committed to the deal
// The banner is most useful before the rep is committed to the deal -
// once contract is in motion, the conflict is moot.
if (interestPipelineStage === 'contract') return null;

View File

@@ -74,7 +74,7 @@ const SOURCE_LABELS: Record<string, string> = {
/**
* Toggleable columns for the InterestList ColumnPicker. `actions` and
* `clientName` are intentionally omitted from this list actions is a
* `clientName` are intentionally omitted from this list - actions is a
* row-control column that should never be hidden, and clientName is the
* primary entity identifier (a row with no name has no useful purpose).
*/
@@ -90,7 +90,7 @@ export const INTEREST_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
/**
* Columns hidden by default for users who haven't customised their view.
* Keep the busy `desiredSize` and `eoiStatus` collapsed by default
* Keep the busy `desiredSize` and `eoiStatus` collapsed by default -
* power-users can turn them back on via the column picker.
*/
export const INTEREST_DEFAULT_HIDDEN: string[] = ['desiredSize', 'eoiStatus'];
@@ -122,7 +122,7 @@ export function getInterestColumns({
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
- 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. */}

View File

@@ -83,7 +83,7 @@ interface ContactLogEntry {
updatedAt: string;
}
/** Quick-template seeds drop a starting structure into the summary so reps
/** Quick-template seeds - drop a starting structure into the summary so reps
* spend their typing on the substance, not the scaffolding. */
const TEMPLATE_SEEDS: Record<
Template,
@@ -125,7 +125,7 @@ const CHANNEL_META: Record<Channel, { label: string; icon: ChannelIcon; tone: st
/**
* Per-interaction contact log. Sales reps log every email / call /
* WhatsApp / meeting touch with the client here so the team has a
* structured history of "what was the last conversation about" not
* structured history of "what was the last conversation about" - not
* just the bare "last contact 8d ago" timestamp on the interest.
*
* Each entry can optionally schedule a follow-up that auto-creates a
@@ -305,7 +305,7 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
// ─── Compose / edit sheet ───────────────────────────────────────────────────
// Exported for §1.4 interest-detail-header.tsx mounts this sheet
// Exported for §1.4 - interest-detail-header.tsx mounts this sheet
// directly via a "Log contact" quick-action button (sibling to the
// Email / Call / WhatsApp pills) so the rep doesn't have to navigate
// to the Contact log tab first.
@@ -362,7 +362,7 @@ function ComposeDialogBody({
const voice = useVoiceTranscription();
// Append committed transcript chunks into the summary as the rep speaks.
// We diff against the previous final transcript so we only append the new
// tail otherwise the entire transcript gets re-pasted on every event.
// tail - otherwise the entire transcript gets re-pasted on every event.
const previousFinalRef = useRef<string>('');
useEffect(() => {
const prev = previousFinalRef.current;
@@ -385,7 +385,7 @@ function ComposeDialogBody({
const seed = TEMPLATE_SEEDS[t];
setChannel(seed.channel);
setDirection(seed.direction);
// Don't clobber if the rep already typed something append a divider
// Don't clobber if the rep already typed something - append a divider
// so the template scaffolds the *next* block.
setSummary((cur) => (cur.trim().length === 0 ? seed.summary : `${cur}\n\n${seed.summary}`));
setTemplateUsed(t);

View File

@@ -22,6 +22,10 @@ import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upl
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import {
CancelDocumentDialog,
type CancelMode,
} from '@/components/documents/cancel-document-dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -55,6 +59,8 @@ interface DocumentSigner {
signingOrder: number;
status: string;
signedAt?: string | null;
invitedAt?: string | null;
signingUrl?: string | null;
}
const STATUS_LABELS = DOCUMENT_STATUS_LABELS;
@@ -75,13 +81,13 @@ const ACTIVE_STATUSES = DOCUMENT_STATUS_ACTIVE;
/**
* Dedicated Contract workspace tab. Mirrors the EOI tab pattern but
* for sales contracts. Contracts differ from EOIs in that there's no
* standard Documenso template each contract is drafted custom per
* standard Documenso template - each contract is drafted custom per
* deal. So the active flows are:
*
* 1. **Upload paper-signed copy** the signed contract was handled
* 1. **Upload paper-signed copy** - the signed contract was handled
* outside the system; rep uploads the PDF for the record.
*
* 2. **Upload draft for Documenso signing** rep uploads the PDF
* 2. **Upload draft for Documenso signing** - rep uploads the PDF
* draft, configures signers + signing order + signature field
* placement, then sends via Documenso. (Recipient configurator
* and field-placement UI are the bigger pieces; for v1 a default
@@ -213,7 +219,7 @@ function ActiveContractCard({
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const { dialog: confirmDialog } = useConfirmation();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
@@ -226,11 +232,24 @@ function ActiveContractCard({
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
mutationFn: (params: { cancelMode: CancelMode; reason: string }) =>
apiFetch(`/api/v1/documents/${doc.id}/cancel`, {
method: 'POST',
body: {
cancelMode: params.cancelMode,
...(params.reason ? { reason: params.reason } : {}),
},
}),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('Contract cancelled.');
toast.success(
vars.cancelMode === 'keep_remote'
? 'Contract cancelled. Envelope kept on Documenso for audit.'
: 'Contract cancelled.',
);
setCancelDialogOpen(false);
},
onError: (err) => toastError(err),
});
@@ -309,7 +328,8 @@ function ActiveContractCard({
<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).
Manual reminders are rate-limited by Documenso (max once per 7 days per signer). Automatic
follow-ups run on the configured cadence and are not throttled by us.
</p>
<div className="flex items-center gap-1">
<Button
@@ -327,14 +347,7 @@ function ActiveContractCard({
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={async () => {
const ok = await confirm({
title: 'Cancel contract',
description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel contract',
});
if (ok) cancelMutation.mutate();
}}
onClick={() => setCancelDialogOpen(true)}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
@@ -342,6 +355,13 @@ function ActiveContractCard({
</Button>
</div>
</footer>
<CancelDocumentDialog
open={cancelDialogOpen}
onOpenChange={setCancelDialogOpen}
documentLabel="Contract"
isSubmitting={cancelMutation.isPending}
onConfirm={(params) => cancelMutation.mutate(params)}
/>
{confirmDialog}
</section>
);

View File

@@ -106,13 +106,13 @@ interface InterestDetailHeaderProps {
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
/** Activity-log entries in the last 7 days drives deal-pulse +5 signal. */
/** Activity-log entries in the last 7 days - drives deal-pulse +5 signal. */
recentActivityCount?: number | null;
/** Phase 2 risk-signal dates fed into DealPulseChip. */
dateDocumentDeclined?: string | Date | null;
dateReservationCancelled?: string | Date | null;
dateBerthSoldToOther?: string | Date | null;
/** Sales rep who owns this deal populated by the AssignedToChip. */
/** Sales rep who owns this deal - populated by the AssignedToChip. */
assignedTo?: string | null;
assignedToName?: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
@@ -160,9 +160,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
// F26: confirm to the user that the action ran pre-fix the
// F26: confirm to the user that the action ran - pre-fix the
// button gave no feedback and reps weren't sure if it took.
toast.success('Outcome cleared interest is open again.');
toast.success('Outcome cleared - interest is open again.');
},
});

View File

@@ -44,7 +44,7 @@ interface InterestData {
} | null;
berthId: string | null;
berthMooringNumber: string | null;
/** Linked yacht null until the rep ties one to the deal. Required to
/** Linked yacht - null until the rep ties one to the deal. Required to
* leave Enquiry; surfaced inline in the stage picker as a prereq. */
yachtId: string | null;
/** Yacht-fit dimensions (numeric strings from postgres). Drive the
@@ -97,7 +97,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
// F17: don't retry 404s they're intentional (wrong port, archived,
// F17: don't retry 404s - they're intentional (wrong port, archived,
// deleted). Let the error state render the EmptyState below.
retry: (failureCount, err) => {
const status = (err as { status?: number } | null | undefined)?.status;
@@ -124,7 +124,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
// Topbar breadcrumb: Clients Mary Smith Interest B17.
// Parent client links straight back to the client detail; the
// current crumb is the primary berth's mooring (or "Interest" if
// no berth linked yet same trick the page H1 uses).
// no berth linked yet - same trick the page H1 uses).
useBreadcrumbHint(
data
? {

View File

@@ -27,9 +27,9 @@ interface InterestData {
}
/**
* Documents tab legal instruments (EOI / contract / reservation) with
* Documents tab - legal instruments (EOI / contract / reservation) with
* full signing status, plus an Attachments section for any other file the
* rep wants on the deal. Replaces the standalone Files tab at the
* rep wants on the deal. Replaces the standalone Files tab - at the
* interest level virtually everything is either a legal doc or rare
* one-off, and a separate tab was dead weight 95% of the time.
*/
@@ -47,7 +47,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
// Files attach at the client level (the schema has no interest_id
// FK on `files`). For an interest, surface every file that belongs
// to its parent client covers the realistic case where a rep
// to its parent client - covers the realistic case where a rep
// uploaded a passport / scan / photo while working a deal.
// Until the interest record loads we pass a sentinel clientId so the
// server returns empty rather than the unscoped port-wide file list.

View File

@@ -27,6 +27,7 @@ import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -105,6 +106,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [generateOpen, setGenerateOpen] = useState(false);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const [markSignedOpen, setMarkSignedOpen] = useState(false);
// Lifted preview state so the View button on every signed-PDF row opens
// the in-app preview dialog rather than navigating to a presigned URL
@@ -138,6 +140,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
) : (
<EmptyEoiState
onGenerate={() => setGenerateOpen(true)}
onUploadForSigning={() => setUploadForSigningOpen(true)}
onUploadSigned={() => setUploadSignedOpen(true)}
onMarkSigned={() => setMarkSignedOpen(true)}
/>
@@ -200,6 +203,19 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
interestId={interestId}
/>
{/* Phase 4 parity - same upload-PDF + place-fields wizard as
Contract/Reservation, scoped to documentType="eoi". The
template-driven generate flow lives on `EoiGenerateDialog`
above; this branch handles the custom-draft path. */}
{uploadForSigningOpen && (
<UploadForSigningDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
interestId={interestId}
documentType="eoi"
/>
)}
<MarkExternallySignedDialog
open={markSignedOpen}
onOpenChange={setMarkSignedOpen}
@@ -242,7 +258,7 @@ function ActiveEoiCard({
// 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
// socket-driven via `useRealtimeInvalidation` below - this just
// bounds the worst-case staleness to ~5s.
refetchInterval: 5_000,
});
@@ -268,7 +284,7 @@ function ActiveEoiCard({
const allSigned = totalCount > 0 && signedCount === totalCount;
// Treat "all signers complete" as the finalised UX even when the
// DOCUMENT_COMPLETED webhook hasn't landed yet defends against 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.
@@ -287,7 +303,7 @@ function ActiveEoiCard({
'document:rejected': [['documents', doc.id, 'signers'], ['documents']],
});
// §4.13: surface the rejection callout in a high-visibility banner
// §4.13: surface the rejection callout in a high-visibility banner -
// status pill alone doesn't communicate that the doc is dead and the
// rep must take action.
const isRejected = doc.status === 'rejected' || doc.status === 'declined';
@@ -448,7 +464,8 @@ function ActiveEoiCard({
<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).
Manual reminders are rate-limited by Documenso (max once per 7 days per signer).
Automatic follow-ups run on the configured cadence and are not throttled by us.
</p>
<div className="flex items-center gap-1">
<Button
@@ -535,7 +552,7 @@ function SignedPdfPreview({ fileId }: { fileId: 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
// 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,
@@ -568,10 +585,12 @@ function SignedPdfPreview({ fileId }: { fileId: string }) {
function EmptyEoiState({
onGenerate,
onUploadForSigning,
onUploadSigned,
onMarkSigned,
}: {
onGenerate: () => void;
onUploadForSigning: () => void;
onUploadSigned: () => void;
onMarkSigned: () => void;
}) {
@@ -584,14 +603,18 @@ function EmptyEoiState({
No EOI in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Generate the EOI to send it for signing. The signing service handles the signing chain. You
can also upload a paper-signed copy if it was signed outside the system.
Generate the EOI from the template, upload a custom draft and place signing fields, or
upload a paper-signed copy if it was signed outside the system.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onGenerate} size="sm" className="gap-1.5">
<FileSignature className="size-4" aria-hidden />
Generate EOI
</Button>
<Button onClick={onUploadForSigning} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" aria-hidden />
Upload draft for signing
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" aria-hidden />
Upload paper-signed copy

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