Files
elegalsoftware/apps/web/src/components/app/CreateInvoiceDrawer.tsx
T

361 lines
14 KiB
TypeScript
Raw Normal View History

2026-04-26 02:42:42 -04:00
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, FileText } from 'lucide-react';
import { Drawer } from '@/components/ui/Drawer';
import { Button } from '@/components/ui/Button';
import { Input, Select, Textarea } from '@/components/ui/Input';
import { useClients } from '@/hooks/useClients';
import { useCases } from '@/hooks/useCases';
import { useTimeEntries } from '@/hooks/useTime';
import { useCreateInvoice, type CreateInvoiceInput } from '@/hooks/useInvoices';
import { formatDate, formatHours, formatMoney, planLimitMessage } from '@/lib/format';
import { cn } from '@/lib/cn';
interface ManualItem {
description: string;
quantity: string;
rate: string;
}
type Mode = 'manual' | 'time';
interface Props {
open: boolean;
onClose: () => void;
initialClientId?: string;
initialCaseId?: string;
onCreated?: (invoiceId: string) => void;
}
function defaultDueDate(): string {
const d = new Date();
d.setDate(d.getDate() + 30);
return d.toISOString().slice(0, 10);
}
export function CreateInvoiceDrawer({ open, onClose, initialClientId, initialCaseId, onCreated }: Props) {
const clients = useClients();
const cases = useCases();
const create = useCreateInvoice();
const [mode, setMode] = useState<Mode>(initialCaseId ? 'time' : 'manual');
const [clientId, setClientId] = useState(initialClientId ?? '');
const [caseId, setCaseId] = useState(initialCaseId ?? '');
const [taxRate, setTaxRate] = useState('0');
const [dueDate, setDueDate] = useState(defaultDueDate());
const [notes, setNotes] = useState('');
const [items, setItems] = useState<ManualItem[]>([{ description: '', quantity: '1', rate: '' }]);
const [selectedTimeIds, setSelectedTimeIds] = useState<Set<string>>(new Set());
// Reset on open
useEffect(() => {
if (!open) return;
setMode(initialCaseId ? 'time' : 'manual');
setClientId(initialClientId ?? '');
setCaseId(initialCaseId ?? '');
setTaxRate('0');
setDueDate(defaultDueDate());
setNotes('');
setItems([{ description: '', quantity: '1', rate: '' }]);
setSelectedTimeIds(new Set());
create.reset();
}, [open, initialClientId, initialCaseId, create]);
// When client changes, clear case selection if the case doesn't belong to that client
useEffect(() => {
if (!caseId) return;
const c = cases.data?.items.find((x) => x.id === caseId);
if (c && c.clientId !== clientId) setCaseId('');
}, [clientId, caseId, cases.data]);
// Pull unbilled time entries for the chosen case (or for any case of the client if no case)
const unbilledTime = useTimeEntries(
mode === 'time'
? caseId
? { caseId, invoiced: 'false' }
: { invoiced: 'false' }
: { invoiced: 'false' },
);
const filteredEntries = useMemo(() => {
const all = unbilledTime.data?.items ?? [];
return all.filter((e) => {
if (!e.billable) return false;
if (e.endedAt === null) return false; // skip running timer
if (clientId && e.clientId !== clientId) return false;
if (caseId && e.caseId !== caseId) return false;
return true;
});
}, [unbilledTime.data, clientId, caseId]);
function toggleTime(id: string) {
setSelectedTimeIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// Live totals preview
const previewSubtotal = useMemo(() => {
if (mode === 'manual') {
return items.reduce((acc, it) => acc + (Number(it.quantity) || 0) * (Number(it.rate) || 0), 0);
}
return filteredEntries
.filter((e) => selectedTimeIds.has(e.id))
.reduce((acc, e) => acc + (Number(e.rate) || 0) * (e.minutes / 60), 0);
}, [mode, items, filteredEntries, selectedTimeIds]);
const previewTotal = previewSubtotal * (1 + (Number(taxRate) || 0) / 100);
const clientCases = useMemo(
() => (cases.data?.items ?? []).filter((c) => !clientId || c.clientId === clientId),
[cases.data, clientId],
);
async function onSubmit() {
if (!clientId) return;
const payload: CreateInvoiceInput = {
clientId,
caseId: caseId || null,
notes: notes.trim() || null,
taxRate: Number(taxRate) || 0,
dueAt: dueDate ? new Date(`${dueDate}T00:00:00`).toISOString() : null,
};
if (mode === 'manual') {
payload.items = items
.filter((it) => it.description.trim() && Number(it.quantity) > 0 && Number(it.rate) >= 0)
.map((it) => ({
description: it.description.trim(),
quantity: Number(it.quantity),
rate: Number(it.rate),
}));
} else {
payload.timeEntryIds = Array.from(selectedTimeIds);
}
if (!payload.items?.length && !payload.timeEntryIds?.length) return;
const created = await create.mutateAsync(payload);
onCreated?.(created.id);
onClose();
}
const apiErr = create.error ? planLimitMessage(create.error.code, 'Could not create the invoice.') : null;
const canSubmit =
!!clientId &&
((mode === 'manual' &&
items.some((it) => it.description.trim() && Number(it.quantity) > 0 && Number(it.rate) >= 0)) ||
(mode === 'time' && selectedTimeIds.size > 0));
return (
<Drawer
open={open}
onClose={onClose}
title="New invoice"
description="Create a draft from time entries or build it manually."
width="max-w-2xl"
footer={
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-ink-600">
<span className="text-xs text-ink-500">Total</span>{' '}
<span className="font-semibold text-ink-900">{formatMoney(previewTotal)}</span>
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={!canSubmit || create.isPending}>
{create.isPending ? 'Creating…' : 'Create draft'}
</Button>
</div>
</div>
}
>
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<Select label="Client" value={clientId} onChange={(e) => setClientId(e.target.value)}>
<option value="">Select a client</option>
{clients.data?.items.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Select label="Case (optional)" value={caseId} onChange={(e) => setCaseId(e.target.value)} disabled={!clientId}>
<option value="">No case</option>
{clientCases.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</Select>
</div>
<div className="flex rounded-xl border border-ink-200 p-1 bg-ink-50/50 w-fit">
<ModeButton active={mode === 'time'} onClick={() => setMode('time')}>From time entries</ModeButton>
<ModeButton active={mode === 'manual'} onClick={() => setMode('manual')}>Manual</ModeButton>
</div>
{mode === 'time' ? (
<div className="rounded-xl border border-ink-100">
<div className="border-b border-ink-100 px-4 py-3 flex items-center justify-between">
<p className="text-sm font-medium text-ink-700">Unbilled time entries</p>
<p className="text-xs text-ink-500">{selectedTimeIds.size} selected</p>
</div>
{!clientId ? (
<div className="px-6 py-10 text-center text-sm text-ink-500">Select a client first.</div>
) : !filteredEntries.length ? (
<div className="px-6 py-10 text-center text-sm text-ink-500">
No unbilled, billable time entries{caseId ? ' for this case' : ' for this client'}.
</div>
) : (
<ul className="max-h-72 overflow-y-auto divide-y divide-ink-100">
{filteredEntries.map((e) => {
const checked = selectedTimeIds.has(e.id);
const amount = (Number(e.rate) || 0) * (e.minutes / 60);
return (
<li key={e.id}>
<label
className={cn(
'flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-ink-50/50 transition',
checked && 'bg-brand-50/50',
)}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTime(e.id)}
className="h-4 w-4 rounded border-ink-300"
/>
<div className="min-w-0 flex-1">
<p className="text-sm text-ink-900 truncate">{e.description}</p>
<p className="text-xs text-ink-500 truncate">
{e.caseTitle} · {formatDate(e.startedAt)}
</p>
</div>
<p className="text-xs text-ink-500 tabular-nums">{formatHours(e.minutes)}</p>
<p className="text-sm font-semibold text-ink-900 tabular-nums w-20 text-right">
{formatMoney(amount)}
</p>
</label>
</li>
);
})}
</ul>
)}
</div>
) : (
<div className="rounded-xl border border-ink-100">
<div className="border-b border-ink-100 px-4 py-3 flex items-center justify-between">
<p className="text-sm font-medium text-ink-700">Line items</p>
<Button
size="sm"
variant="secondary"
onClick={() => setItems((s) => [...s, { description: '', quantity: '1', rate: '' }])}
>
<Plus className="h-3.5 w-3.5" />
Add line
</Button>
</div>
<ul className="divide-y divide-ink-100">
{items.map((it, i) => (
<li key={i} className="grid grid-cols-12 gap-2 px-4 py-3 items-start">
<input
placeholder="Description"
value={it.description}
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, description: e.target.value } : x)))}
className="col-span-6 rounded-lg border border-ink-200 px-3 py-2 text-sm focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
<input
type="number"
min="0"
step="0.01"
placeholder="Qty"
value={it.quantity}
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, quantity: e.target.value } : x)))}
className="col-span-2 rounded-lg border border-ink-200 px-3 py-2 text-sm text-right focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
<input
type="number"
min="0"
step="0.01"
placeholder="Rate"
value={it.rate}
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, rate: e.target.value } : x)))}
className="col-span-3 rounded-lg border border-ink-200 px-3 py-2 text-sm text-right focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
<button
type="button"
onClick={() => setItems((s) => (s.length > 1 ? s.filter((_, j) => j !== i) : s))}
className="col-span-1 grid place-items-center text-ink-400 hover:text-rose-600 h-9"
aria-label="Remove line"
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Tax rate (%)"
type="number"
min="0"
max="100"
step="0.01"
value={taxRate}
onChange={(e) => setTaxRate(e.target.value)}
/>
<Input
label="Due date"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<Textarea
label="Notes"
rows={3}
placeholder="Payment terms, thank-you note, etc."
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
{apiErr && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiErr}</p>}
<p className="flex items-start gap-2 text-xs text-ink-500">
<FileText className="h-3.5 w-3.5 mt-0.5 flex-none" />
The invoice will be created as a draft. You can review and send it from the invoice page.
</p>
</div>
</Drawer>
);
}
function ModeButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
active ? 'bg-white text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-800',
)}
>
{children}
</button>
);
}