Files
pn-new-crm/src/components/interests/external-eoi-upload-dialog.tsx
Matt 221ae5784e 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
2026-05-23 00:52:59 +02:00

332 lines
12 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { FileInputButton } from '@/components/ui/file-input-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { formatBerthRange } from '@/lib/templates/berth-range';
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)',
};
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));
// `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);
const [notes, setNotes] = useState('');
// 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
// the same day. Also drives auto-fill on signatory rows tagged
// role=client.
const { data: interestData } = useQuery<{
data: { clientName: string | null; clientPrimaryEmail: string | null };
}>({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: { clientName: string | null; clientPrimaryEmail: string | null } }>(
`/api/v1/interests/${interestId}`,
),
enabled: open,
staleTime: 60_000,
});
// 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]);
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>({
mutationFn: async () => {
if (!file) throw new Error('No file selected');
const form = new FormData();
form.append('file', file);
const effectiveTitle = title.trim() || defaultTitle;
if (effectiveTitle) form.append('title', effectiveTitle);
if (signedAt) form.append('signedAt', signedAt);
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(', '));
}
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) {
const err = (await res.json().catch(() => ({ error: 'Upload failed' }))) as {
error?: string;
};
throw new Error(err.error ?? 'Upload failed');
}
return (await res.json()) as { data?: { stageChanged?: boolean } };
},
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).',
);
qc.invalidateQueries({ queryKey: ['interests', interestId] });
qc.invalidateQueries({ queryKey: ['interests'] });
qc.invalidateQueries({ queryKey: ['documents'] });
setFile(null);
setTitle('');
setSignatoriesOverride(null);
setNotes('');
onOpenChange(false);
onSuccess?.();
},
onError: (err: unknown) => {
toastError(err, 'Upload failed');
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Upload externally-signed EOI</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<Label>PDF file *</Label>
<div className="mt-1">
<FileInputButton
accept="application/pdf,.pdf"
label={file ? file.name : 'Choose PDF'}
onFilesPicked={(files) => setFile(files[0] ?? null)}
/>
</div>
</div>
<div>
<Label>Title (optional)</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={defaultTitle}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Leave blank to use the default shown above.
</p>
</div>
<div>
<Label>Date signed</Label>
<div className="mt-1">
<DatePicker value={signedAt} onChange={setSignedAt} toDate={new Date()} />
</div>
</div>
<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&apos;t counted as signatories.
</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 ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" aria-hidden />
) : (
<Upload className="h-3.5 w-3.5 mr-1.5" aria-hidden />
)}
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}