Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings: * 38 client components / 56 toast.error sites converted to toastError(err) so the new admin error inspector becomes usable from user-reported issues — every failed inline-edit, save, send, archive, upload, etc. now carries the request-id + error-code (Copy ID action). * 26 service files / 62 bare-Error throws converted to CodedError or the existing AppError subclasses. Adds new error codes: DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502), DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502), IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502), UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for post-insert returning-empty guards. * Five vitest assertions updated to match the new user-facing wording (client-merge "already been merged", expense/interest "couldn't find that …", documenso "signing service didn't respond"). Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1) + MED §11 (auditor-G Issue 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
279 lines
9.6 KiB
TypeScript
279 lines
9.6 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Shared send-document dialog (Phase 7).
|
|
*
|
|
* Used by:
|
|
* - {@link SendBerthPdfDialog} (berths/) — single berth, recipient picker.
|
|
* - {@link SendBrochureDialog} (clients/, interests/) — brochure picker.
|
|
* - The interest "send from interest" pattern reuses both via a wrapper.
|
|
*
|
|
* §14.7 mitigations enforced client-side:
|
|
* - Recipient email is shown verbatim in the confirm step (no quick-send).
|
|
* - Pre-send dry-run calls /preview first; the Send button is disabled
|
|
* until the unresolved-tokens list is empty.
|
|
* - Body length capped at 50KB; char count visible.
|
|
*/
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Input } from '@/components/ui/input';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
|
|
const BODY_MAX = 50_000;
|
|
|
|
export type DocumentKind = 'berth_pdf' | 'brochure';
|
|
|
|
interface SendDocumentDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
documentKind: DocumentKind;
|
|
/** Pre-filled recipient. Leave both blank to let the rep type one. */
|
|
recipient: { clientId?: string; email?: string; interestId?: string };
|
|
/** Either a berthId (for berth_pdf) or brochureId (for brochure). */
|
|
context: { berthId?: string; brochureId?: string };
|
|
/** Title displayed in the dialog header. */
|
|
title: string;
|
|
/** Short context line under the title (e.g. "Berth A1 — primary version"). */
|
|
subtitle?: string;
|
|
onSent?: () => void;
|
|
}
|
|
|
|
interface PreviewResponse {
|
|
data: { html: string; markdown: string; unresolved: string[] };
|
|
}
|
|
|
|
export function SendDocumentDialog({
|
|
open,
|
|
onOpenChange,
|
|
documentKind,
|
|
recipient,
|
|
context,
|
|
title,
|
|
subtitle,
|
|
onSent,
|
|
}: SendDocumentDialogProps) {
|
|
const [step, setStep] = useState<'compose' | 'confirm'>('compose');
|
|
const [emailOverride, setEmailOverride] = useState(recipient.email ?? '');
|
|
const [customBody, setCustomBody] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setStep('compose');
|
|
setEmailOverride(recipient.email ?? '');
|
|
setCustomBody('');
|
|
}
|
|
}, [open, recipient.email]);
|
|
|
|
const recipientForApi = useMemo(
|
|
() => ({
|
|
clientId: recipient.clientId,
|
|
email: emailOverride || recipient.email,
|
|
interestId: recipient.interestId,
|
|
}),
|
|
[recipient.clientId, recipient.email, recipient.interestId, emailOverride],
|
|
);
|
|
|
|
// Live preview via /api/v1/document-sends/preview. Re-runs whenever the
|
|
// body text or recipient changes (debounce-by-react-query for free).
|
|
const previewQuery = useQuery<PreviewResponse>({
|
|
queryKey: [
|
|
'document-sends-preview',
|
|
documentKind,
|
|
context.berthId ?? null,
|
|
context.brochureId ?? null,
|
|
recipientForApi.clientId ?? null,
|
|
recipientForApi.email ?? null,
|
|
customBody,
|
|
],
|
|
queryFn: () =>
|
|
apiFetch('/api/v1/document-sends/preview', {
|
|
method: 'POST',
|
|
body: {
|
|
documentKind,
|
|
recipient: recipientForApi,
|
|
berthId: context.berthId,
|
|
brochureId: context.brochureId,
|
|
customBodyMarkdown: customBody.trim() ? customBody : undefined,
|
|
},
|
|
}),
|
|
enabled: open && Boolean(recipientForApi.clientId || recipientForApi.email),
|
|
});
|
|
|
|
type SendResp = { data: { error?: string; deliveredAsAttachment: boolean } };
|
|
const sendMutation = useMutation<SendResp, Error, void>({
|
|
mutationFn: async (): Promise<SendResp> => {
|
|
const endpoint =
|
|
documentKind === 'berth_pdf'
|
|
? '/api/v1/document-sends/berth-pdf'
|
|
: '/api/v1/document-sends/brochure';
|
|
const body =
|
|
documentKind === 'berth_pdf'
|
|
? {
|
|
berthId: context.berthId,
|
|
recipient: recipientForApi,
|
|
customBodyMarkdown: customBody.trim() ? customBody : undefined,
|
|
}
|
|
: {
|
|
brochureId: context.brochureId,
|
|
recipient: recipientForApi,
|
|
customBodyMarkdown: customBody.trim() ? customBody : undefined,
|
|
};
|
|
return (await apiFetch<SendResp>(endpoint, { method: 'POST', body })) as SendResp;
|
|
},
|
|
onSuccess: (resp) => {
|
|
if (resp.data.error) {
|
|
toast.error(`Send failed: ${resp.data.error}`);
|
|
} else {
|
|
toast.success(
|
|
resp.data.deliveredAsAttachment
|
|
? 'Sent as attachment'
|
|
: 'Sent (large file delivered as download link)',
|
|
);
|
|
onSent?.();
|
|
onOpenChange(false);
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
toastError(err);
|
|
},
|
|
});
|
|
|
|
const unresolved = previewQuery.data?.data.unresolved ?? [];
|
|
const previewHtml = previewQuery.data?.data.html ?? '';
|
|
|
|
const recipientResolved = Boolean(recipientForApi.clientId || recipientForApi.email);
|
|
const canPreview = recipientResolved;
|
|
const canSend = step === 'confirm' && unresolved.length === 0 && !sendMutation.isPending;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
{subtitle && <DialogDescription>{subtitle}</DialogDescription>}
|
|
</DialogHeader>
|
|
|
|
{step === 'compose' ? (
|
|
<div className="space-y-4">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="ds-email">Recipient email</Label>
|
|
<Input
|
|
id="ds-email"
|
|
type="email"
|
|
value={emailOverride}
|
|
onChange={(e) => setEmailOverride(e.target.value)}
|
|
placeholder={recipient.email ? '' : 'recipient@example.com'}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{recipient.clientId
|
|
? 'Defaults to the client primary email; override here if needed.'
|
|
: 'Type the address you want to send to.'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="ds-body">Message body</Label>
|
|
<Textarea
|
|
id="ds-body"
|
|
rows={10}
|
|
value={customBody}
|
|
onChange={(e) => setCustomBody(e.target.value.slice(0, BODY_MAX))}
|
|
placeholder="Leave blank to use the port template…"
|
|
className="font-mono text-sm"
|
|
/>
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>Markdown supported. Tokens like {'{{client.fullName}}'} are expanded.</span>
|
|
<span>
|
|
{customBody.length} / {BODY_MAX}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{canPreview && previewQuery.isSuccess && (
|
|
<div className="rounded border bg-muted/30 p-3">
|
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Preview
|
|
</p>
|
|
<div
|
|
className="prose prose-sm max-w-none"
|
|
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
|
/>
|
|
{unresolved.length > 0 && (
|
|
<p className="mt-3 rounded border border-amber-300 bg-amber-50 p-2 text-xs text-amber-900">
|
|
Unresolved merge fields: {unresolved.join(', ')}. Replace these before sending.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{previewQuery.isLoading && canPreview && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Rendering preview…
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="rounded border bg-muted/30 p-3 text-sm">
|
|
<p>
|
|
Sending to: <span className="font-mono">{recipientForApi.email}</span>
|
|
</p>
|
|
{context.berthId && <p>Document: berth PDF</p>}
|
|
{context.brochureId !== undefined && <p>Document: brochure</p>}
|
|
</div>
|
|
<div
|
|
className="prose prose-sm max-w-none rounded border p-3"
|
|
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
The PDF file is added by the system after the body — your text won’t override
|
|
it.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="gap-2">
|
|
{step === 'compose' ? (
|
|
<>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => setStep('confirm')}
|
|
disabled={!recipientResolved || unresolved.length > 0 || previewQuery.isLoading}
|
|
>
|
|
Review & send
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button variant="outline" onClick={() => setStep('compose')}>
|
|
Back
|
|
</Button>
|
|
<Button onClick={() => sendMutation.mutate()} disabled={!canSend}>
|
|
{sendMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Confirm and send
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|