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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user