2026-05-06 18:33:15 +02:00
|
|
|
'use client';
|
|
|
|
|
|
2026-05-21 17:01:35 +02:00
|
|
|
import { useMemo, useState } from 'react';
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
2026-05-21 17:34:59 +02:00
|
|
|
import { Loader2, Plus, Trash2, Upload } from 'lucide-react';
|
2026-05-06 18:33:15 +02:00
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
2026-05-21 17:10:02 +02:00
|
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
|
|
|
import { FileInputButton } from '@/components/ui/file-input-button';
|
2026-05-06 18:33:15 +02:00
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
2026-05-21 17:34:59 +02:00
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from '@/components/ui/select';
|
2026-05-06 18:33:15 +02:00
|
|
|
import { Textarea } from '@/components/ui/textarea';
|
2026-05-21 17:01:35 +02:00
|
|
|
import { apiFetch } from '@/lib/api/client';
|
fix(audit-wave-11): dossier sweep — error-ux + webhook + storage + search + maintainability
Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the
tractable Critical/High items from each:
error-ux-auditor (5 items)
- C2: 17 toast.error(err.message) sites swept to toastError(err, …) so
every user-visible failure carries a copy-paste Reference ID
- C3: apiFetch synthesizes a client-side correlation id when a 5xx
comes back with a non-JSON body (reverse-proxy HTML pages); message
becomes "The server is unreachable. Please try again." with code
UPSTREAM_UNREACHABLE
- C4: checkRateLimit fails OPEN when Redis is unavailable so an outage
no longer 500s login + portal sign-in; logged at warn so monitoring
catches it
- H2: StorageTimeoutError (name='TimeoutError') replaces the plain
Error throw in s3.ts withTimeout — error-classifier hints fire now
- H5: errorResponse() adopted across /api/storage/[token],
/api/public/website-inquiries, and the Documenso webhook body (drops
the "Invalid secret" reconnaissance string)
outbound-webhook-auditor (5 items)
- C1: signature is now HMAC(secret, `${ts}.${body}`) with the
timestamp surfaced as X-Webhook-Timestamp so receivers can reject
replays outside a freshness window
- C3: dead-letter with reason missing_signing_secret when secret is
null (defence-in-depth against DB tampering / future migration
mistakes)
- H2: webhooks queue bumped to maxAttempts=8 with 30 s base
exponential backoff so a 30 s receiver blip during a deploy no
longer dead-letters every in-flight event; per-queue
backoffDelayMs added to QUEUE_CONFIGS
- M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192
- M2: dispatch-time https:// assertion before fetch, so a bad DB edit
can't slip plaintext through
storage-pathing-auditor (2 items)
- H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…`
with portSlug threaded into backend.presignUpload — engages the
filesystem-proxy port-binding `p` token verifier
- H2: presignDownloadUrl auto-derives portSlug from the key's first
segment when callers don't pass it, so all 8 download sites engage
the `p`-token guard without per-site plumbing
search-auditor (1 item)
- H3: removed dead void wantEmail; void wantPhone; pair plus the
unused looksLikeEmail helper — the bucket-reorder it was scaffolded
for was never wired
maintainability-auditor (1 item)
- M2: swept seven abandoned `void <symbol>` markers and their dead
imports across clients/bulk, interests/bulk, admin/email-templates,
admin/website-submissions, alert-rules, and notes.service
Deferred to future work (substantial refactors, schema migrations, or
multi-file UI work):
- error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage,
ErrorBanner component, /api/ready route, worker DLQ admin surface)
- maintainability C1-C4 (documents/search/notes service splits,
interest-tabs split — multi-hour refactors)
- currency C1-H5 (mixed-currency dashboard aggregation, FX history
table, rounding policy) — wait for second non-USD port
- outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU
with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy)
- storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type
binding)
Tests: 1315/1315 vitest ✅ ; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:27:32 +02:00
|
|
|
import { toastError } from '@/lib/api/toast-error';
|
2026-05-21 17:01:35 +02:00
|
|
|
import { formatBerthRange } from '@/lib/templates/berth-range';
|
2026-05-21 17:34:59 +02:00
|
|
|
|
|
|
|
|
type SignatoryRole = 'client' | 'developer' | 'rep' | 'witness' | 'cc';
|
|
|
|
|
|
|
|
|
|
interface SignatoryRow {
|
|
|
|
|
name: string;
|
|
|
|
|
email: string;
|
|
|
|
|
role: SignatoryRole;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ROLE_LABELS: Record<SignatoryRole, string> = {
|
|
|
|
|
client: 'Client',
|
|
|
|
|
developer: 'Developer',
|
|
|
|
|
rep: 'Rep',
|
|
|
|
|
witness: 'Witness',
|
|
|
|
|
cc: 'CC (no signing)',
|
|
|
|
|
};
|
2026-05-06 18:33:15 +02:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (next: boolean) => void;
|
|
|
|
|
interestId: string;
|
|
|
|
|
onSuccess?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSuccess }: Props) {
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const [file, setFile] = useState<File | null>(null);
|
|
|
|
|
const [title, setTitle] = useState('');
|
|
|
|
|
const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10));
|
2026-05-21 17:34:59 +02:00
|
|
|
// `null` means "rep hasn't touched the list yet — show the
|
|
|
|
|
// derived-from-interest seed". Once edited (add/remove/change),
|
|
|
|
|
// the explicit array takes over. Avoids a setState-in-effect that
|
|
|
|
|
// the React Compiler bans.
|
|
|
|
|
const [signatoriesOverride, setSignatoriesOverride] = useState<SignatoryRow[] | null>(null);
|
2026-05-06 18:33:15 +02:00
|
|
|
const [notes, setNotes] = useState('');
|
|
|
|
|
|
2026-05-21 17:01:35 +02:00
|
|
|
// Fetched on open to power the default title:
|
|
|
|
|
// "External EOI — <Client> — <berth range> — YYYY-MM-DD". Without
|
|
|
|
|
// this the file lands as just "External EOI - <date>" which is
|
|
|
|
|
// unscannable in any list when a port has multiple deals closing on
|
2026-05-21 17:34:59 +02:00
|
|
|
// the same day. Also drives auto-fill on signatory rows tagged
|
|
|
|
|
// role=client.
|
|
|
|
|
const { data: interestData } = useQuery<{
|
|
|
|
|
data: { clientName: string | null; clientPrimaryEmail: string | null };
|
|
|
|
|
}>({
|
2026-05-21 17:01:35 +02:00
|
|
|
queryKey: ['interests', interestId],
|
|
|
|
|
queryFn: () =>
|
2026-05-21 17:34:59 +02:00
|
|
|
apiFetch<{ data: { clientName: string | null; clientPrimaryEmail: string | null } }>(
|
|
|
|
|
`/api/v1/interests/${interestId}`,
|
|
|
|
|
),
|
2026-05-21 17:01:35 +02:00
|
|
|
enabled: open,
|
|
|
|
|
staleTime: 60_000,
|
|
|
|
|
});
|
2026-05-21 17:34:59 +02:00
|
|
|
|
|
|
|
|
// Compute the effective signatory list — when the rep hasn't touched
|
|
|
|
|
// anything, seed from the interest's client. Once they edit, the
|
|
|
|
|
// explicit override takes over.
|
|
|
|
|
const signatories: SignatoryRow[] = useMemo(() => {
|
|
|
|
|
if (signatoriesOverride !== null) return signatoriesOverride;
|
|
|
|
|
if (!interestData?.data) return [];
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
name: interestData.data.clientName ?? '',
|
|
|
|
|
email: interestData.data.clientPrimaryEmail ?? '',
|
|
|
|
|
role: 'client' as const,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}, [signatoriesOverride, interestData]);
|
2026-05-21 17:01:35 +02:00
|
|
|
const { data: berthsData } = useQuery<{ data: Array<{ mooringNumber: string | null }> }>({
|
|
|
|
|
queryKey: ['interests', interestId, 'berths'],
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
apiFetch<{ data: Array<{ mooringNumber: string | null }> }>(
|
|
|
|
|
`/api/v1/interests/${interestId}/berths`,
|
|
|
|
|
),
|
|
|
|
|
enabled: open,
|
|
|
|
|
staleTime: 60_000,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const defaultTitle = useMemo(() => {
|
|
|
|
|
const date = signedAt || new Date().toISOString().slice(0, 10);
|
|
|
|
|
const moorings = (berthsData?.data ?? [])
|
|
|
|
|
.map((b) => b.mooringNumber)
|
|
|
|
|
.filter((m): m is string => !!m);
|
|
|
|
|
const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null;
|
|
|
|
|
const clientName = interestData?.data?.clientName ?? null;
|
|
|
|
|
const parts = ['External EOI'];
|
|
|
|
|
if (clientName) parts.push(clientName);
|
|
|
|
|
if (berthLabel) parts.push(berthLabel);
|
|
|
|
|
parts.push(date);
|
|
|
|
|
return parts.join(' — ');
|
|
|
|
|
}, [interestData, berthsData, signedAt]);
|
|
|
|
|
|
|
|
|
|
const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({
|
2026-05-06 18:33:15 +02:00
|
|
|
mutationFn: async () => {
|
|
|
|
|
if (!file) throw new Error('No file selected');
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
form.append('file', file);
|
2026-05-21 17:01:35 +02:00
|
|
|
const effectiveTitle = title.trim() || defaultTitle;
|
|
|
|
|
if (effectiveTitle) form.append('title', effectiveTitle);
|
2026-05-06 18:33:15 +02:00
|
|
|
if (signedAt) form.append('signedAt', signedAt);
|
2026-05-21 17:34:59 +02:00
|
|
|
const cleanSignatories = signatories
|
|
|
|
|
.map((s) => ({ name: s.name.trim(), email: s.email.trim(), role: s.role }))
|
|
|
|
|
.filter((s) => s.name && s.email);
|
|
|
|
|
if (cleanSignatories.length > 0) {
|
|
|
|
|
form.append('signatories', JSON.stringify(cleanSignatories));
|
|
|
|
|
// Back-compat for any consumer that still reads signerNames.
|
|
|
|
|
form.append('signerNames', cleanSignatories.map((s) => s.name).join(', '));
|
|
|
|
|
}
|
2026-05-06 18:33:15 +02:00
|
|
|
if (notes) form.append('notes', notes);
|
|
|
|
|
const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: form,
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
2026-05-12 18:16:18 +02:00
|
|
|
const err = (await res.json().catch(() => ({ error: 'Upload failed' }))) as {
|
|
|
|
|
error?: string;
|
|
|
|
|
};
|
2026-05-06 18:33:15 +02:00
|
|
|
throw new Error(err.error ?? 'Upload failed');
|
|
|
|
|
}
|
2026-05-21 17:01:35 +02:00
|
|
|
return (await res.json()) as { data?: { stageChanged?: boolean } };
|
2026-05-06 18:33:15 +02:00
|
|
|
},
|
2026-05-21 17:01:35 +02:00
|
|
|
onSuccess: (response) => {
|
|
|
|
|
const stageChanged = response?.data?.stageChanged === true;
|
|
|
|
|
toast.success(
|
|
|
|
|
stageChanged
|
|
|
|
|
? 'External EOI uploaded. Stage advanced to EOI Signed.'
|
|
|
|
|
: 'External EOI uploaded. Filed against this deal (stage unchanged).',
|
|
|
|
|
);
|
2026-05-06 18:33:15 +02:00
|
|
|
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['interests'] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['documents'] });
|
|
|
|
|
setFile(null);
|
|
|
|
|
setTitle('');
|
2026-05-21 17:34:59 +02:00
|
|
|
setSignatoriesOverride(null);
|
2026-05-06 18:33:15 +02:00
|
|
|
setNotes('');
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
onSuccess?.();
|
|
|
|
|
},
|
|
|
|
|
onError: (err: unknown) => {
|
fix(audit-wave-11): dossier sweep — error-ux + webhook + storage + search + maintainability
Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the
tractable Critical/High items from each:
error-ux-auditor (5 items)
- C2: 17 toast.error(err.message) sites swept to toastError(err, …) so
every user-visible failure carries a copy-paste Reference ID
- C3: apiFetch synthesizes a client-side correlation id when a 5xx
comes back with a non-JSON body (reverse-proxy HTML pages); message
becomes "The server is unreachable. Please try again." with code
UPSTREAM_UNREACHABLE
- C4: checkRateLimit fails OPEN when Redis is unavailable so an outage
no longer 500s login + portal sign-in; logged at warn so monitoring
catches it
- H2: StorageTimeoutError (name='TimeoutError') replaces the plain
Error throw in s3.ts withTimeout — error-classifier hints fire now
- H5: errorResponse() adopted across /api/storage/[token],
/api/public/website-inquiries, and the Documenso webhook body (drops
the "Invalid secret" reconnaissance string)
outbound-webhook-auditor (5 items)
- C1: signature is now HMAC(secret, `${ts}.${body}`) with the
timestamp surfaced as X-Webhook-Timestamp so receivers can reject
replays outside a freshness window
- C3: dead-letter with reason missing_signing_secret when secret is
null (defence-in-depth against DB tampering / future migration
mistakes)
- H2: webhooks queue bumped to maxAttempts=8 with 30 s base
exponential backoff so a 30 s receiver blip during a deploy no
longer dead-letters every in-flight event; per-queue
backoffDelayMs added to QUEUE_CONFIGS
- M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192
- M2: dispatch-time https:// assertion before fetch, so a bad DB edit
can't slip plaintext through
storage-pathing-auditor (2 items)
- H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…`
with portSlug threaded into backend.presignUpload — engages the
filesystem-proxy port-binding `p` token verifier
- H2: presignDownloadUrl auto-derives portSlug from the key's first
segment when callers don't pass it, so all 8 download sites engage
the `p`-token guard without per-site plumbing
search-auditor (1 item)
- H3: removed dead void wantEmail; void wantPhone; pair plus the
unused looksLikeEmail helper — the bucket-reorder it was scaffolded
for was never wired
maintainability-auditor (1 item)
- M2: swept seven abandoned `void <symbol>` markers and their dead
imports across clients/bulk, interests/bulk, admin/email-templates,
admin/website-submissions, alert-rules, and notes.service
Deferred to future work (substantial refactors, schema migrations, or
multi-file UI work):
- error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage,
ErrorBanner component, /api/ready route, worker DLQ admin surface)
- maintainability C1-C4 (documents/search/notes service splits,
interest-tabs split — multi-hour refactors)
- currency C1-H5 (mixed-currency dashboard aggregation, FX history
table, rounding policy) — wait for second non-USD port
- outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU
with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy)
- storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type
binding)
Tests: 1315/1315 vitest ✅ ; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:27:32 +02:00
|
|
|
toastError(err, 'Upload failed');
|
2026-05-06 18:33:15 +02:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Upload externally-signed EOI</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
pipeline stage of any active linked interest (server-aggregated, ranks by
PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
combobox: search, recent-first sort, stage-coloured pills
Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
"10% Deposit → Contract Sent"
EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
framed by short copy explaining what's inline vs what needs the canonical
page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
PATCH without an extra round-trip
Company form
- New "Connections" section lets the rep attach members (clients) and yachts
during create. Yacht attach uses the existing transfer endpoint so audit
log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
client owns yachts not yet linked) and an optional "Create interest" step
pre-filled with the first attached client
Admin
- /admin landing gains a searchable index — typed query flattens groups into
a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
with the user-facing language rename from round 1)
Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
the rep's literal entry (ft OR m) is preserved verbatim instead of being
reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
derived from the ft canonical to keep the recommender SQL unchanged
Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
to include the new id + unit fields on the EoiContext / Berth shapes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
|
|
|
For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor).
|
|
|
|
|
The uploaded PDF is filed against this interest and the pipeline stage is advanced to
|
|
|
|
|
EOI Signed.
|
2026-05-06 18:33:15 +02:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3 py-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label>PDF file *</Label>
|
2026-05-21 17:10:02 +02:00
|
|
|
<div className="mt-1">
|
|
|
|
|
<FileInputButton
|
|
|
|
|
accept="application/pdf,.pdf"
|
|
|
|
|
label={file ? file.name : 'Choose PDF'}
|
|
|
|
|
onFilesPicked={(files) => setFile(files[0] ?? null)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-06 18:33:15 +02:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label>Title (optional)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={title}
|
|
|
|
|
onChange={(e) => setTitle(e.target.value)}
|
2026-05-21 17:01:35 +02:00
|
|
|
placeholder={defaultTitle}
|
2026-05-06 18:33:15 +02:00
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
2026-05-21 17:01:35 +02:00
|
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
|
|
|
Leave blank to use the default shown above.
|
|
|
|
|
</p>
|
2026-05-06 18:33:15 +02:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label>Date signed</Label>
|
2026-05-21 17:10:02 +02:00
|
|
|
<div className="mt-1">
|
|
|
|
|
<DatePicker value={signedAt} onChange={setSignedAt} toDate={new Date()} />
|
|
|
|
|
</div>
|
2026-05-06 18:33:15 +02:00
|
|
|
</div>
|
2026-05-21 17:34:59 +02:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>Signatories</Label>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{signatories.map((s, i) => (
|
|
|
|
|
<div key={i} className="flex gap-2 items-center">
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Name"
|
|
|
|
|
value={s.name}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setSignatoriesOverride(
|
|
|
|
|
signatories.map((row, idx) =>
|
|
|
|
|
idx === i ? { ...row, name: e.target.value } : row,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
className="flex-1 min-w-0"
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
type="email"
|
|
|
|
|
placeholder="email@example.com"
|
|
|
|
|
value={s.email}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setSignatoriesOverride(
|
|
|
|
|
signatories.map((row, idx) =>
|
|
|
|
|
idx === i ? { ...row, email: e.target.value } : row,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
className="flex-[2] min-w-0"
|
|
|
|
|
/>
|
|
|
|
|
<Select
|
|
|
|
|
value={s.role}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
setSignatoriesOverride(
|
|
|
|
|
signatories.map((row, idx) =>
|
|
|
|
|
idx === i ? { ...row, role: v as SignatoryRole } : row,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="w-36 shrink-0">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{(Object.keys(ROLE_LABELS) as SignatoryRole[]).map((r) => (
|
|
|
|
|
<SelectItem key={r} value={r}>
|
|
|
|
|
{ROLE_LABELS[r]}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setSignatoriesOverride(signatories.filter((_, idx) => idx !== i))
|
|
|
|
|
}
|
|
|
|
|
aria-label="Remove signatory"
|
|
|
|
|
className="shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="size-4" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setSignatoriesOverride([...signatories, { name: '', email: '', role: 'client' }])
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="size-4 mr-1.5" aria-hidden />
|
|
|
|
|
Add signatory
|
|
|
|
|
</Button>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Recorded against the document for the audit trail and the signed-count badge. CC
|
|
|
|
|
recipients aren't counted as signatories.
|
2026-05-06 18:33:15 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label>Notes (optional)</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={notes}
|
|
|
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
|
|
|
placeholder="Where / how this EOI was signed"
|
|
|
|
|
rows={2}
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={mutation.isPending}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={() => mutation.mutate()} disabled={!file || mutation.isPending}>
|
|
|
|
|
{mutation.isPending ? (
|
fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:
- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/
The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.
Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.
Test suite stays at 1315/1315 vitest. typescript clean.
Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" aria-hidden />
|
2026-05-06 18:33:15 +02:00
|
|
|
) : (
|
fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:
- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/
The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.
Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.
Test suite stays at 1315/1315 vitest. typescript clean.
Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00
|
|
|
<Upload className="h-3.5 w-3.5 mr-1.5" aria-hidden />
|
2026-05-06 18:33:15 +02:00
|
|
|
)}
|
|
|
|
|
Upload
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|