Added per-case invoicing UI

X-Lovable-Edit-ID: edt-41644b4a-5c6b-4566-b4e5-af10e92b1cff
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 16:11:17 +00:00
co-authored by renee-png
2 changed files with 92 additions and 12 deletions
@@ -42,6 +42,7 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
const [dueDays, setDueDays] = useState("30");
const [notes, setNotes] = useState("");
const [isRetainer, setIsRetainer] = useState(false);
const [splitPerCase, setSplitPerCase] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -152,6 +153,24 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
navigate({ to: "/invoices/$invoiceId", params: { invoiceId: inv.id } });
return;
}
if (splitPerCase && selectedIds.length > 1) {
const results: { id: string; number: string }[] = [];
for (const cid of selectedIds) {
const r = await generateInvoiceForClient({
clientId,
caseIds: [cid],
createdBy: user.id,
taxRate: (Number(taxPct) || 0) / 100,
dueDays: Number(dueDays) || 30,
notes: notes || undefined,
});
results.push({ id: r.invoiceId, number: r.invoiceNumber });
}
toast.success(`${results.length} invoices created`);
onOpenChange(false);
navigate({ to: "/invoices" });
return;
}
const { invoiceId, invoiceNumber } = await generateInvoiceForClient({
clientId,
caseIds: selectedIds,
@@ -216,8 +235,27 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
) : (
<>
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cases to bill</Label>
<div className="mt-2 border rounded-md divide-y max-h-[260px] overflow-auto">
<div className="flex items-center justify-between mb-2">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cases to bill</Label>
<div className="flex items-center gap-2 text-xs">
<button
type="button"
className="text-primary hover:underline"
onClick={() => setSelected(Object.fromEntries(cases.map((c) => [c.id, true])))}
>
Select all
</button>
<span className="text-muted-foreground">·</span>
<button
type="button"
className="text-primary hover:underline"
onClick={() => setSelected({})}
>
Clear
</button>
</div>
</div>
<div className="border rounded-md divide-y max-h-[260px] overflow-auto">
{cases.map((c) => (
<label key={c.id} className="flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/40">
<Checkbox
@@ -239,6 +277,22 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
</div>
</div>
{selectedIds.length > 1 && (
<label className="flex items-start gap-3 p-3 rounded-md border cursor-pointer hover:bg-muted/40">
<Checkbox
checked={splitPerCase}
onCheckedChange={(v) => setSplitPerCase(!!v)}
className="mt-0.5"
/>
<div className="flex-1">
<div className="text-sm font-medium">Generate one invoice per selected case</div>
<div className="text-xs text-muted-foreground">
Creates {selectedIds.length} separate draft invoices instead of one combined invoice.
</div>
</div>
</label>
)}
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Tax rate (%)</Label>
@@ -250,7 +304,9 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
</div>
<div className="col-span-1 flex items-end justify-end">
<div className="text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Estimated total</div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{splitPerCase && selectedIds.length > 1 ? "Combined total" : "Estimated total"}
</div>
<div className="font-serif text-xl">{formatCurrency(total)}</div>
{tax > 0 && <div className="text-[11px] text-muted-foreground">{formatCurrency(subtotal)} + {formatCurrency(tax)} tax</div>}
</div>
@@ -272,7 +328,11 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
disabled={saving || (!isRetainer && (cases.length === 0 || selectedIds.length === 0))}
>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <FilePlus className="h-4 w-4 mr-2" />}
{isRetainer ? "Create retainer invoice" : "Generate draft invoice"}
{isRetainer
? "Create retainer invoice"
: splitPerCase && selectedIds.length > 1
? `Generate ${selectedIds.length} draft invoices`
: "Generate draft invoice"}
</Button>
</DialogFooter>
</DialogContent>
+28 -8
View File
@@ -35,6 +35,7 @@ import { CaseTimeTab } from "@/components/cases/time-tab";
import { CaseExpensesTab } from "@/components/cases/expenses-tab";
import { CaseStatusTab } from "@/components/cases/status-tab";
import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
import { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
import { formatDate } from "@/lib/format";
@@ -55,6 +56,7 @@ import {
Clock,
DollarSign,
Receipt,
FilePlus,
Phone,
Tag,
} from "lucide-react";
@@ -101,6 +103,7 @@ function CollectionDetailRoute() {
const [escalating, setEscalating] = useState(false);
const [addTaskOpen, setAddTaskOpen] = useState(false);
const [reload, setReload] = useState(0);
const [invoiceOpen, setInvoiceOpen] = useState(false);
useEffect(() => {
let active = true;
@@ -289,14 +292,21 @@ function CollectionDetailRoute() {
return (
<ProtectedLayout>
<PageContainer>
<Button
variant="ghost"
size="sm"
onClick={() => navigate({ to: "/collections" })}
className="-ml-2 mb-3"
>
<ArrowLeft className="h-4 w-4 mr-1" /> All collections
</Button>
<div className="flex items-center justify-between gap-2 mb-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate({ to: "/collections" })}
className="-ml-2"
>
<ArrowLeft className="h-4 w-4 mr-1" /> All collections
</Button>
{collection.case?.client?.id && (
<Button size="sm" onClick={() => setInvoiceOpen(true)}>
<FilePlus className="h-4 w-4 mr-1.5" /> Generate invoice
</Button>
)}
</div>
{/* Tabs: Workflow (collections-specific) + same case tabs */}
<Tabs defaultValue="workflow">
@@ -506,6 +516,16 @@ function CollectionDetailRoute() {
userId={user?.id}
onSaved={refreshTasks}
/>
{collection.case?.client?.id && (
<GenerateInvoiceDialog
open={invoiceOpen}
onOpenChange={setInvoiceOpen}
clientId={collection.case.client.id}
clientName={collection.case.client.name}
presetCaseId={collection.case.id}
/>
)}
</PageContainer>
</ProtectedLayout>
);