Files
pn-new-crm/src/components/interests/interest-reservation-tab.tsx
Matt 3ffee79f3f feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00

420 lines
15 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
FileSignature,
Loader2,
RefreshCw,
Upload,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
interface InterestReservationTabProps {
interestId: string;
clientId: string | null;
}
interface DocumentRow {
id: string;
documentType: string;
title: string;
status: 'draft' | 'sent' | 'partially_signed' | 'completed' | 'expired' | 'cancelled';
createdAt: string;
signers?: Array<{ status: string }>;
}
interface DocumentSigner {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
}
const STATUS_LABELS: Record<DocumentRow['status'], string> = {
draft: 'Draft',
sent: 'Awaiting signatures',
partially_signed: 'Partially signed',
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
};
const STATUS_TONES: Record<DocumentRow['status'], string> = {
draft: 'bg-slate-100 text-slate-700',
sent: 'bg-blue-100 text-blue-700',
partially_signed: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-700',
expired: 'bg-rose-100 text-rose-700',
cancelled: 'bg-slate-200 text-slate-600',
};
const ACTIVE_STATUSES = new Set<DocumentRow['status']>(['draft', 'sent', 'partially_signed']);
/**
* Dedicated Reservation workspace tab. Mirrors the EOI tab pattern but
* for reservation agreements. Contracts differ from EOIs in that there's no
* standard Documenso template — each reservation is drafted custom per
* deal. So the active flows are:
*
* 1. **Upload paper-signed copy** — the signed reservation was handled
* outside the system; rep uploads the PDF for the record.
*
* 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
* footer-anchored signature layout is used.)
*
* The Documents tab still shows every reservation document (signed or
* drafted) as a permanent history.
*/
export function InterestReservationTab({
interestId,
clientId: _clientId,
}: InterestReservationTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'reservation_agreement' }],
queryFn: () =>
apiFetch<{ data: DocumentRow[] }>(
`/api/v1/documents?interestId=${interestId}&documentType=reservation_agreement`,
),
});
const docs = docsRes?.data ?? [];
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
return (
<div className="space-y-5">
{docsLoading ? (
<Skeleton className="h-44 w-full rounded-lg" />
) : activeDoc ? (
<ActiveReservationCard
doc={activeDoc}
portSlug={portSlug ?? null}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
) : (
<EmptyReservationState
onUploadSigned={() => setUploadSignedOpen(true)}
onUploadForSigning={() => setUploadForSigningOpen(true)}
/>
)}
{completedDocs.length > 0 && (
<section className="rounded-lg border bg-background">
<header className="flex items-center justify-between border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Reservation history
</h3>
<span className="text-xs text-muted-foreground">
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
</span>
</header>
<ul className="divide-y">
{completedDocs.map((d) => (
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<StatusBadge status={d.status} />
<span className="flex-1 truncate font-medium">{d.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${d.id}` as any}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Open
<ExternalLink className="size-3" />
</Link>
)}
</li>
))}
</ul>
</section>
)}
{/* Reuses the external-EOI upload dialog. The endpoint
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
— for reservation paper-uploads we'll need the equivalent
reservation endpoint (deferred to a follow-up; the dialog UI
is the pattern we'll clone). For now the flow is documented
as 'coming soon' rather than misrouting through EOI. */}
{uploadSignedOpen && (
<ExternalEoiUploadDialog
open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen}
interestId={interestId}
/>
)}
{/* Upload-for-Documenso-signing dialog placeholder. The real
dialog (PDF picker + recipient configurator + send button)
is part of the larger custom-doc-upload service that's a
follow-up. For now show a friendly "coming soon" card. */}
{uploadForSigningOpen && (
<ComingSoonDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
title="Send reservation for signing"
body="Upload-and-send-via-Documenso for contracts is being built. For now, draft the reservation externally, get it signed via paper or another tool, then upload the signed copy here."
/>
)}
</div>
);
}
// ─── Active reservation hero ────────────────────────────────────────────────────
function ActiveReservationCard({
doc,
portSlug,
onUploadSigned,
}: {
doc: DocumentRow;
portSlug: string | null;
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
queryFn: () => apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${doc.id}/signers`),
refetchInterval: 30_000,
});
const signers = signersRes?.data ?? [];
const signedCount = signers.filter((s) => s.status === 'signed').length;
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('Reservation cancelled.');
},
onError: (err) => toastError(err),
});
const remindAllMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] });
toast.success('Reminder sent.');
},
onError: (err) => toastError(err),
});
return (
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{portSlug && (
<Button asChild variant="outline" size="sm" className="gap-1.5 [&_svg]:size-3.5">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${doc.id}` as any}
>
Open
<ExternalLink />
</Link>
</Button>
)}
{!allSigned && (
<Button
variant="outline"
size="sm"
disabled={remindAllMutation.isPending}
onClick={() => remindAllMutation.mutate()}
className="gap-1.5 [&_svg]:size-3.5"
>
{remindAllMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Remind all
</Button>
)}
</div>
</header>
<div className="mt-4 rounded-lg border bg-background p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signing progress
</h3>
{signersLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading signers
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
The signing service hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
)}
</div>
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={() => {
if (window.confirm('Cancel this contract? Signers will no longer be able to sign.')) {
cancelMutation.mutate();
}
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel contract
</Button>
</div>
</footer>
</section>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyReservationState({
onUploadSigned,
onUploadForSigning,
}: {
onUploadSigned: () => void;
onUploadForSigning: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-6" />
</div>
<h2 className="mt-4 text-base font-semibold text-foreground">
No reservation in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
reservation agreements are drafted custom per deal. Either upload a paper-signed copy you
handled externally, or upload the draft PDF and send for e-signing.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
<FileSignature className="size-4" />
Upload draft for signing
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" />
Upload paper-signed copy
</Button>
</div>
</section>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: DocumentRow['status'] }) {
return (
<Badge
variant="outline"
className={cn(
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
STATUS_TONES[status],
)}
>
{status === 'completed' && <CheckCircle2 className="mr-1 size-3" />}
{STATUS_LABELS[status]}
</Badge>
);
}
/**
* Placeholder for the upload-for-Documenso-signing flow until the
* full upload + recipient + field-placement service is shipped.
* Intentional dead-end so reps know the path exists rather than
* misclicking and getting confusing behaviour.
*/
function ComingSoonDialog({
open,
onOpenChange,
title,
body,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
title: string;
body: string;
}) {
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="max-w-md rounded-lg border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
<div className="mt-4 flex justify-end">
<Button onClick={() => onOpenChange(false)} size="sm" variant="outline">
Got it
</Button>
</div>
</div>
</div>
);
}