153 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
};
|