Files
invoices/web/SendDocumentDialog.tsx
T
officer 6c70eb8eb3 invoices, extracted from the platform as a plugin
Ninth plugin out, third resurrection: switched off since 2026-08-13 with the
permission reduced to api: [], so the server booted clean and every panel
request 404'd. invoiceshelf/schema was also commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the
frontend route, the table prefix and the install key all at once, and before
extraction those had drifted: the API was /api/invoiceshelf while the screen
was /invoices. The URL is what a person sees and should name what the plugin
shows rather than the daemon behind it — the permission key and label were
already `invoices`, so the API prefix was the odd one out.

Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts
to satisfy the table-prefix rule. That rename was free ONLY because the table
existed on no server, which was checked before deciding. With rows in it,
keeping the vendor name would have been the better trade: db:push sees a rename
as a drop plus a create and this project has no migration mechanism.

The sidecar keeps its Invoiceshelf-named functions deliberately — they name the
upstream API it actually speaks.

91 palette classes converted, 52 of them in STATUS_TONE, which was already the
single source of status colour but held raw palette strings. SENT and VIEWED
now share `info`: the closed six-tone set has no second shade of "in flight,
not yet actioned". Recorded in PLUGIN.md as a real loss.
2026-08-19 03:47:44 +00:00

148 lines
5.3 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 = () => {
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">
<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" onClick={onClose} disabled={action.isPending}>
Cancel
</Button>
<Button size="sm" onClick={submit} 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>
</DialogContent>
</Dialog>
);
};