Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|