Files
pn-new-crm/src/components/shared/send-document-dialog.tsx
Matt Ciaccio fc7595faf8 fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
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>
2026-05-05 20:18:05 +02:00

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&rsquo;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>
);
}