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:
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();
|
||||
|
||||
Reference in New Issue
Block a user