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:
@@ -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