Files
invoices/web/SendDocumentDialog.tsx

153 lines
5.8 KiB
TypeScript

import type { Currency } from './shared';
import { useEffect, useState } from 'react';
import { Loader2, Send } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useDocumentAction, useSummary } from './useInvoiceShelfData';
// The one dialog in /invoices that causes something irreversible to leave the building: `send` hands the
// document to InvoiceShelf's mailer, which posts it to the customer. There is no unsend.
//
// So it is deliberately not a menu item that fires on click. The recipient is shown, editable, and
// pre-filled from the customer record rather than assumed; the button says who it is emailing; and nothing
// else in this app calls the send action. Everything that *looks* like sending but isn't — "mark as sent",
// which only moves the status — goes through a plain confirm instead, and says so.
export type SendTarget = {
/** Sidecar resource slug: invoices | estimates | payments. */
resource: string;
id: number;
/** Shown in the title, e.g. "000145". */
number: string;
customerName: string;
customerEmail: string | null;
/** Whether this is a first send or a resend, purely for wording. */
resend?: boolean;
};
const DEFAULT_BODY = [
'<p>Hi {CUSTOMER_NAME},</p>',
'<p>Please find the attached document.</p>',
'<p>Thanks,<br/>{COMPANY_NAME}</p>',
].join('\n');
const KIND_LABEL: Record<string, string> = {
invoices: 'invoice',
estimates: 'estimate',
payments: 'payment receipt',
};
export const SendDocumentDialog = ({
target,
currency: _currency,
onClose,
}: {
target: SendTarget | null;
currency?: Currency | null;
onClose: () => void;
}) => {
const { company, summary } = useSummary();
const action = useDocumentAction(target?.resource ?? 'invoices');
const kind = KIND_LABEL[target?.resource ?? ''] ?? 'document';
const [to, setTo] = useState('');
const [from, setFrom] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
// Re-seed whenever a different document is opened. Keyed on id so reopening the same one keeps nothing
// stale from a previous edit of another document's mail.
useEffect(() => {
if (!target) return;
setTo(target.customerEmail ?? '');
setFrom(summary?.me?.email ?? '');
setSubject(`${company?.name ?? 'Invoice'} — ${kind} ${target.number}`);
setBody(
DEFAULT_BODY.replace('{CUSTOMER_NAME}', target.customerName || 'there').replace(
'{COMPANY_NAME}',
company?.name ?? '',
),
);
}, [target?.id, target?.resource, company?.name, summary?.me?.email, kind, target]);
if (!target) return null;
const missingRecipient = !to.trim();
const submit = (ev?: React.FormEvent) => {
ev?.preventDefault();
action.mutate({ id: target.id, action: 'send', payload: { subject, body, from, to } }, { onSuccess: onClose });
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
{/* `contents` keeps the form invisible to DialogContent's grid layout — it still owns the gap between
header, body and footer, the form just carries Enter-to-submit across all three. */}
<form onSubmit={submit} className="contents">
<DialogHeader>
<DialogTitle>
{target.resend ? 'Resend' : 'Send'} {kind} {target.number}
</DialogTitle>
<DialogDescription>
This emails {target.customerName} through InvoiceShelf. It cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">To</Label>
<Input value={to} onChange={(e) => setTo(e.target.value)} className="h-8 text-xs" type="email" />
</div>
<div className="space-y-1">
<Label className="text-xs">From</Label>
<Input value={from} onChange={(e) => setFrom(e.target.value)} className="h-8 text-xs" type="email" />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-8 text-xs" />
</div>
<div className="space-y-1">
<Label className="text-xs">Message</Label>
<Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={7} className="text-xs" />
<p className="text-[10px] text-muted-foreground">
HTML. InvoiceShelf attaches the PDF itself — do not link it here.
</p>
</div>
{missingRecipient && (
<p className="text-[10px] text-warning">This customer has no email address on file. Enter one to send.</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" type="button" onClick={onClose} disabled={action.isPending}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={action.isPending || missingRecipient}>
{action.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Send to {to || '—'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};