- 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>
228 lines
7.7 KiB
TypeScript
228 lines
7.7 KiB
TypeScript
'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'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>
|
||
);
|
||
}
|