Files
pn-new-crm/src/components/documents/eoi-generate-dialog.tsx
Matt Ciaccio e3e0e69c04 fix(documenso): expired event, real signer emails, query invalidation, double-fire
- Wire the `DOCUMENT_EXPIRED` webhook event to `handleDocumentExpired`.
  Previously the handler existed but was never called, leaving expired
  EOIs stuck in `sent` / `partially_signed` forever.
- `sendForSigning` now resolves real port-configured signer emails via
  `getPortEoiSigners(portId)` instead of fabricating
  `developer@{slug}.com` / `sales@{slug}.com`. The Documenso-template
  pathway was already using these; the upload-PDF pathway now matches.
- `handleRecipientSigned` logs a warning when the email match returns
  zero rows so a misconfigured signer isn't a silent no-op.
- `handleDocumentCompleted` skips berth-rule re-evaluation when the
  interest is already at or past `eoi_signed`, preventing a double-fire
  when `DOCUMENT_SIGNED` and `DOCUMENT_COMPLETED` arrive close together.
- EOI generate dialog now invalidates by predicate (any queryKey
  starting with `'documents'`) so the Documents tab and hub counts
  refresh after generation, instead of missing because the actual
  query key shape didn't match the targeted invalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:58 +02:00

228 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
/** Required for the EOI's top paragraph (Section 2) — without these the
* document is unsignable, so generation is blocked. Yacht and berth fields
* belong to Section 3 and may be left blank. */
interface EoiPrerequisites {
hasName: boolean;
hasEmail: boolean;
hasAddress: boolean;
/** Optional — info-only checks. Generation proceeds without them. */
hasYacht: boolean;
hasBerth: boolean;
}
interface EoiGenerateDialogProps {
interestId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
prerequisites: EoiPrerequisites;
}
const REQUIRED_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
{ key: 'hasName', label: 'Client name' },
{ key: 'hasAddress', label: 'Client address' },
{ key: 'hasEmail', label: 'Client email' },
];
const OPTIONAL_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
{ key: 'hasYacht', label: 'Yacht linked (name + dimensions)' },
{ key: 'hasBerth', label: 'Berth linked (mooring number)' },
];
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
interface InAppTemplate {
id: string;
name: string;
description?: string | null;
templateType: string;
}
interface ListResponse {
data: InAppTemplate[];
}
export function EoiGenerateDialog({
interestId,
open,
onOpenChange,
prerequisites,
}: EoiGenerateDialogProps) {
const queryClient = useQueryClient();
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
const requiredMet = REQUIRED_LABELS.every(({ key }) => prerequisites[key]);
// Load in-app EOI templates so the operator can pick one as an alternative
// to the Documenso external-signing flow.
const { data: templatesRes } = useQuery<ListResponse>({
queryKey: ['document-templates', { templateType: 'eoi', isActive: true }],
queryFn: () =>
apiFetch<ListResponse>('/api/v1/document-templates?templateType=eoi&isActive=true'),
enabled: open,
});
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
const handleGenerate = async () => {
if (!requiredMet) return;
setIsGenerating(true);
setError(null);
try {
const isDocumensoPath = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
await apiFetch(url, {
method: 'POST',
body: {
interestId,
pathway: isDocumensoPath ? 'documenso-template' : 'inapp',
// Signers are derived server-side from EOI context for both pathways
// when the template type is EOI, so the dialog doesn't collect them.
signers: [],
},
});
// Invalidate all document list queries (hub counts + per-interest lists).
// The DocumentList component uses ['documents', { interestId, clientId }]
// and the hub uses ['documents', 'hub', ...] / ['documents', 'hub-counts'].
// Using a predicate avoids key-shape drift between callers.
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
});
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
} finally {
setIsGenerating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate Expression of Interest</DialogTitle>
<DialogDescription>
Pick how to render the EOI. Documenso is the primary path; in-app templates use the same
source PDF but render and store the PDF locally before sending for signing.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Documenso Standard EOI (recommended)
</SelectItem>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Required (Section 2 of the EOI)
</p>
{REQUIRED_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 text-sm">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}
>
{prerequisites[key] ? '✓' : '✗'}
</span>
<span
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
>
{label}
</span>
</div>
))}
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Optional (Section 3 left blank if absent)
</p>
{OPTIONAL_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 text-sm">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key]
? 'bg-green-100 text-green-700'
: 'bg-muted text-muted-foreground'
}`}
>
{prerequisites[key] ? '✓' : ''}
</span>
<span
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
>
{label}
</span>
</div>
))}
</div>
{!requiredMet ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Add the missing required details on the client&apos;s record before generating the
EOI.
</p>
) : null}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating}>
{isGenerating ? 'Generating…' : 'Generate EOI'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}