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

@@ -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();