Replaced 174 em-dashes (—) with " - " (space-hyphen-space) across 49 files in src/components + src/app. The em-dash reads as a tell-tale "AI-generated" marker per the user's design feedback; hyphens with spaces preserve the connector semantics without the AI tint. Touched only lines outside pure-comment context (// /* * */). Code comments, JSDoc, audit-log strings, structured logging strings, and templates outside the lint scope retain their em-dashes for now — they're not user-visible. Also captured two remaining cases that used the `—` HTML entity instead of the literal character (system-monitoring-dashboard, interest-stage-picker) — replaced with a plain hyphen. Bumped the existing `no-restricted-syntax` rule from `warn` → `error` in eslint.config.mjs scoped to src/components/**/*.tsx + src/app/**/*.tsx. New code reintroducing em-dashes in JSX text now fails the lint gate. Verified: tsc clean, vitest 1448/1448, eslint 0 em-dash warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { AlertTriangle, Loader2, XCircle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
|
|
interface Signer {
|
|
id: string;
|
|
signerName: string;
|
|
signerEmail: string;
|
|
signerRole: string;
|
|
status: string;
|
|
}
|
|
|
|
interface EoiCancelDialogProps {
|
|
documentId: string;
|
|
signers: Signer[];
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
/**
|
|
* Cancel-with-notify modal. Two variants by signedCount:
|
|
* - 0 signed: simple confirm with optional reason. Cancel button.
|
|
* - 1+ signed: list each signer with a checkbox so the rep picks
|
|
* who to email. Pre-checks the signers who have signed (they're
|
|
* the most-affected) — rep can opt out.
|
|
*
|
|
* In both cases the reason textarea is optional and (when present)
|
|
* gets inlined into the cancellation email body + the audit log.
|
|
*
|
|
* On confirm: POST /api/v1/documents/[id]/cancel with
|
|
* { reason, notifyRecipients: [signerId, ...] }
|
|
* The server voids the envelope, marks status=cancelled, sends the
|
|
* branded cancellation email to each picked recipient.
|
|
*/
|
|
export function EoiCancelDialog({ documentId, signers, open, onOpenChange }: EoiCancelDialogProps) {
|
|
const queryClient = useQueryClient();
|
|
const [reason, setReason] = useState('');
|
|
const [notifyIds, setNotifyIds] = useState<Set<string>>(() => {
|
|
// Default: pre-check the signers who have signed — they're the
|
|
// recipients most likely to want to know. Pending signers can be
|
|
// notified too but the rep needs to opt them in.
|
|
return new Set(signers.filter((s) => s.status === 'signed').map((s) => s.id));
|
|
});
|
|
|
|
const signedCount = useMemo(() => signers.filter((s) => s.status === 'signed').length, [signers]);
|
|
|
|
const cancelMutation = useMutation({
|
|
mutationFn: () =>
|
|
apiFetch(`/api/v1/documents/${documentId}/cancel`, {
|
|
method: 'POST',
|
|
body: {
|
|
reason: reason.trim() || null,
|
|
notifyRecipients: Array.from(notifyIds),
|
|
},
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
|
toast.success(
|
|
notifyIds.size > 0
|
|
? `EOI cancelled. ${notifyIds.size} signer${notifyIds.size === 1 ? '' : 's'} notified.`
|
|
: 'EOI cancelled.',
|
|
);
|
|
onOpenChange(false);
|
|
// Reset internal state so a second open of the dialog starts clean.
|
|
setReason('');
|
|
setNotifyIds(new Set());
|
|
},
|
|
onError: (err) => toastError(err),
|
|
});
|
|
|
|
const toggle = (id: string) => {
|
|
setNotifyIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="size-4 text-amber-600" aria-hidden /> Cancel this EOI?
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{signedCount === 0
|
|
? 'No signatures have been collected yet. The signing service will be told to void this envelope.'
|
|
: `${signedCount} signer${signedCount === 1 ? ' has' : 's have'} already signed. The envelope will be voided and pick the signers you want to notify by email below.`}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{signedCount > 0 && (
|
|
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Notify
|
|
</p>
|
|
<ul className="space-y-1.5">
|
|
{signers.map((s) => (
|
|
<li key={s.id} className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
id={`notify-${s.id}`}
|
|
checked={notifyIds.has(s.id)}
|
|
onCheckedChange={() => toggle(s.id)}
|
|
/>
|
|
<Label htmlFor={`notify-${s.id}`} className="flex-1 cursor-pointer font-normal">
|
|
<span className="font-medium">{s.signerName || s.signerEmail}</span>{' '}
|
|
<span className="text-xs text-muted-foreground">
|
|
· {s.signerRole}
|
|
{s.status === 'signed' ? ' · already signed' : ' · pending'}
|
|
</span>
|
|
</Label>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="text-xs italic text-muted-foreground">
|
|
Leave all unchecked to cancel silently - no emails will be sent.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="cancel-reason" className="text-xs font-semibold uppercase tracking-wide">
|
|
Reason (optional)
|
|
</Label>
|
|
<Textarea
|
|
id="cancel-reason"
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="e.g. Yacht owner changed terms; will resend a fresh EOI."
|
|
className="min-h-[80px] resize-y"
|
|
maxLength={2000}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Appears in the cancellation email (if you notify anyone) and the audit log.
|
|
</p>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Keep EOI
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => cancelMutation.mutate()}
|
|
disabled={cancelMutation.isPending}
|
|
className="gap-1.5 [&_svg]:size-3.5"
|
|
>
|
|
{cancelMutation.isPending ? (
|
|
<Loader2 className="animate-spin" aria-hidden />
|
|
) : (
|
|
<XCircle />
|
|
)}
|
|
Cancel EOI
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|