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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { apiFetch } from '@/lib/api/client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Check, Clock, X, Mail, Eye, Bell, Send } from 'lucide-react';
import { Check, Clock, X, Mail, Eye, Bell, Send, Link2 } from 'lucide-react';
import { toastError } from '@/lib/api/toast-error';
import { Button } from '@/components/ui/button';
@@ -21,6 +21,11 @@ interface Signer {
invitedAt?: string | null;
openedAt?: string | null;
lastReminderSentAt?: string | null;
/** Documenso-issued URL the recipient hits to sign. Available as soon
* as the doc has been created+sent - independent of whether the
* invitation email has actually been dispatched, so reps can copy it
* for manual delivery / QA before triggering the auto-send. */
signingUrl?: string | null;
}
interface SigningProgressProps {
@@ -47,10 +52,10 @@ const STATUS_META: Record<string, { label: string; tone: Tone; icon: typeof Chec
declined: { label: 'Declined', tone: 'declined', icon: X },
};
// Card styling per status colour-tinted background + left accent stripe.
// Card styling per status - colour-tinted background + left accent stripe.
// `opened` is a runtime-derived tone (pending status + openedAt set) so a
// signer who's actually looked at the doc reads visually distinct from one
// who hasn't yet the rep can tell at a glance who's stalling vs who
// who hasn't yet - the rep can tell at a glance who's stalling vs who
// hasn't engaged at all.
const TONE_STYLES: Record<
Tone,
@@ -138,7 +143,7 @@ function compactAbsolute(isoOrNull: string | null | undefined): string | null {
/** Tick state every minute so relative-time strings ("Signed 3 min ago")
* re-render without a manual refresh. Returns a number that increments
* every 60s components read it to invalidate memoization. */
* every 60s - components read it to invalidate memoization. */
function useMinuteTick(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
@@ -322,7 +327,7 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) {
</div>
<p className="truncate text-xs text-muted-foreground">{signer.signerEmail}</p>
{/* Activity timeline explicit "Not yet invited" state so
{/* Activity timeline - explicit "Not yet invited" state so
reps in manual-send mode know an action is required.
Once invited, each event surfaces with a precise
timestamp tooltip (the relative-time is the headline). */}
@@ -379,13 +384,38 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) {
</div>
</div>
{/* Per-signer action button — semantics depend on send state:
• `invitedAt === null` → "Send invitation" (the rep is the
one dispatching the first email; this fires the branded
invite + stamps invitedAt).
• `invitedAt !== null` → "Send reminder" (Documenso-side
nudge, rate-limited per cooldown).
• Signed/declined → no button. */}
{/* Per-signer actions. Order: Copy link (when available)
then the primary action button.
• Copy: surfaces the Documenso signing URL for QA /
manual delivery. Available the moment Documenso has
issued the URL - independent of whether the
invitation email has gone out - so reps can preview
the page before triggering auto-send.
• `invitedAt === null` → "Send invitation" (rep
dispatches the first email; fires branded invite +
stamps invitedAt).
• `invitedAt !== null` → "Send reminder"
(Documenso-side nudge, rate-limited per cooldown).
• Signed/declined → no action buttons. */}
{signer.status === 'pending' && signer.signingUrl ? (
<Button
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground [&_svg]:size-3"
onClick={async () => {
try {
await navigator.clipboard.writeText(signer.signingUrl!);
toast.success(`Signing link for ${signer.signerName} copied`);
} catch {
toast.error('Could not copy to clipboard');
}
}}
title="Copy this signer's Documenso URL to the clipboard - for QA or manual delivery."
>
<Link2 />
Copy link
</Button>
) : null}
{signer.status === 'pending' &&
(signer.invitedAt ? (
<Button

View File

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