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(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([{ description: '', quantity: '1', rate: '' }]); const [selectedTimeIds, setSelectedTimeIds] = useState>(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(); // Only re-run on the open transition (and when the initial ids change). // `create` is a fresh object each render; depending on it would re-fire the // effect every render and wipe user input ("Maximum update depth exceeded"). // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, initialClientId, initialCaseId]); // 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 (

Total{' '} {formatMoney(previewTotal)}

} >
setMode('time')}>From time entries setMode('manual')}>Manual
{mode === 'time' ? (

Unbilled time entries

{selectedTimeIds.size} selected

{!clientId ? (
Select a client first.
) : !filteredEntries.length ? (
No unbilled, billable time entries{caseId ? ' for this case' : ' for this client'}.
) : (
    {filteredEntries.map((e) => { const checked = selectedTimeIds.has(e.id); const amount = (Number(e.rate) || 0) * (e.minutes / 60); return (
  • ); })}
)}
) : (

Line items

    {items.map((it, i) => (
  • 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" /> 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" /> 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" />
  • ))}
)}
setTaxRate(e.target.value)} /> setDueDate(e.target.value)} />