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:
@@ -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 you’ve configured and lets you mark manual steps complete.',
|
||||
'Step-by-step setup checklist for fresh ports - auto-detects what you’ve 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
177
src/components/admin/email/test-template-card.tsx
Normal file
177
src/components/admin/email/test-template-card.tsx
Normal 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 · <template>]</span> so it's
|
||||
unambiguous in the recipient's inbox. Uses the port'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}, []);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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[]> = {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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]));
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}` : '';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
146
src/components/documents/cancel-document-dialog.tsx
Normal file
146
src/components/documents/cancel-document-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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('');
|
||||
}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user