Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
|
import { useEffect, useState } from "react";
|
|
import { ProtectedLayout } from "@/components/protected-layout";
|
|
import { PageContainer, PageHeader } from "@/components/app-shell";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { Loader2, FilePlus, ArrowLeft } from "lucide-react";
|
|
import { formatCurrency } from "@/lib/format";
|
|
import { toast } from "sonner";
|
|
import { generateInvoiceForClient } from "@/lib/invoice-generation";
|
|
|
|
interface SearchParams {
|
|
caseId?: string;
|
|
}
|
|
|
|
export const Route = createFileRoute("/invoices/new/$clientId")({
|
|
validateSearch: (search: Record<string, unknown>): SearchParams => ({
|
|
caseId: typeof search.caseId === "string" ? search.caseId : undefined,
|
|
}),
|
|
component: () => (
|
|
<ProtectedLayout>
|
|
<NewInvoicePage />
|
|
</ProtectedLayout>
|
|
),
|
|
});
|
|
|
|
interface CaseUnbilled {
|
|
id: string;
|
|
case_number: string;
|
|
title: string;
|
|
timeCount: number;
|
|
timeAmount: number;
|
|
expenseCount: number;
|
|
expenseAmount: number;
|
|
total: number;
|
|
}
|
|
|
|
function NewInvoicePage() {
|
|
const { clientId } = Route.useParams();
|
|
const { caseId: presetCaseId } = useSearch({ from: "/invoices/new/$clientId" });
|
|
const { user } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const [clientName, setClientName] = useState<string>("");
|
|
const [cases, setCases] = useState<CaseUnbilled[]>([]);
|
|
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
|
const [taxPct, setTaxPct] = useState("0");
|
|
const [dueDays, setDueDays] = useState("30");
|
|
const [notes, setNotes] = useState("");
|
|
const [retainerAmount, setRetainerAmount] = useState("");
|
|
const [isRetainer, setIsRetainer] = useState(false);
|
|
const [splitPerCase, setSplitPerCase] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
setLoading(true);
|
|
const [{ data: client }, { data: cs }, { data: firm }] = await Promise.all([
|
|
supabase.from("clients").select("name").eq("id", clientId).maybeSingle(),
|
|
supabase.from("cases").select("id, case_number, title").eq("client_id", clientId).is("archived_at", null),
|
|
supabase.from("firm_settings").select("default_tax_rate").maybeSingle(),
|
|
]);
|
|
setClientName(client?.name ?? "");
|
|
const caseIds = (cs ?? []).map((c) => c.id);
|
|
if (firm?.default_tax_rate != null) setTaxPct(String(firm.default_tax_rate));
|
|
if (caseIds.length === 0) {
|
|
setCases([]); setLoading(false); return;
|
|
}
|
|
const [{ data: time }, { data: exp }] = await Promise.all([
|
|
supabase.from("time_entries").select("case_id, hours, hourly_rate")
|
|
.in("case_id", caseIds).eq("billable", true).is("invoice_id", null),
|
|
supabase.from("expenses").select("case_id, amount")
|
|
.in("case_id", caseIds).eq("billable", true).is("invoice_id", null),
|
|
]);
|
|
const tally: Record<string, { tc: number; ta: number; ec: number; ea: number }> = {};
|
|
for (const t of time ?? []) {
|
|
const k = t.case_id;
|
|
if (!tally[k]) tally[k] = { tc: 0, ta: 0, ec: 0, ea: 0 };
|
|
tally[k].tc += 1;
|
|
tally[k].ta += Number(t.hours) * Number(t.hourly_rate);
|
|
}
|
|
for (const e of exp ?? []) {
|
|
const k = e.case_id;
|
|
if (!tally[k]) tally[k] = { tc: 0, ta: 0, ec: 0, ea: 0 };
|
|
tally[k].ec += 1;
|
|
tally[k].ea += Number(e.amount);
|
|
}
|
|
const enriched: CaseUnbilled[] = (cs ?? []).map((c) => {
|
|
const t = tally[c.id] ?? { tc: 0, ta: 0, ec: 0, ea: 0 };
|
|
return {
|
|
id: c.id, case_number: c.case_number, title: c.title,
|
|
timeCount: t.tc, timeAmount: t.ta, expenseCount: t.ec, expenseAmount: t.ea,
|
|
total: t.ta + t.ea,
|
|
};
|
|
}).filter((c) => c.total > 0);
|
|
setCases(enriched);
|
|
const sel: Record<string, boolean> = {};
|
|
if (presetCaseId) {
|
|
sel[presetCaseId] = true;
|
|
} else {
|
|
enriched.forEach((c) => { sel[c.id] = true; });
|
|
}
|
|
setSelected(sel);
|
|
setLoading(false);
|
|
})();
|
|
}, [clientId, presetCaseId]);
|
|
|
|
const selectedIds = Object.entries(selected).filter(([, v]) => v).map(([k]) => k);
|
|
const subtotal = cases.filter((c) => selected[c.id]).reduce((s, c) => s + c.total, 0);
|
|
const tax = +(subtotal * (Number(taxPct) || 0) / 100).toFixed(2);
|
|
const total = subtotal + tax;
|
|
|
|
const submit = async () => {
|
|
if (!user?.id) return;
|
|
if (selectedIds.length === 0 && !isRetainer) return toast.error("Select at least one case");
|
|
setSaving(true);
|
|
try {
|
|
if (isRetainer) {
|
|
const yr = new Date().getFullYear();
|
|
const num = `INV-${yr}-${Math.floor(1000 + Math.random() * 9000)}`;
|
|
const due = new Date();
|
|
due.setDate(due.getDate() + (Number(dueDays) || 30));
|
|
const amt = Math.max(0, Number(retainerAmount) || 0);
|
|
if (amt <= 0) {
|
|
setSaving(false);
|
|
return toast.error("Enter a retainer amount");
|
|
}
|
|
const taxAmt = +(amt * (Number(taxPct) || 0) / 100).toFixed(2);
|
|
const { data: inv, error: ie } = await supabase
|
|
.from("invoices")
|
|
.insert({
|
|
invoice_number: num,
|
|
client_id: clientId,
|
|
status: "draft",
|
|
issue_date: new Date().toISOString().slice(0, 10),
|
|
due_date: due.toISOString().slice(0, 10),
|
|
subtotal: amt,
|
|
tax: taxAmt,
|
|
total: +(amt + taxAmt).toFixed(2),
|
|
notes: notes || "Retainer deposit",
|
|
is_retainer: true,
|
|
created_by: user.id,
|
|
})
|
|
.select("id, invoice_number")
|
|
.single();
|
|
if (ie || !inv) throw ie ?? new Error("Failed to create");
|
|
await supabase.from("invoice_line_items").insert({
|
|
invoice_id: inv.id,
|
|
kind: "manual",
|
|
description: "Retainer deposit into trust account",
|
|
quantity: 1,
|
|
rate: amt,
|
|
amount: amt,
|
|
sort_order: 0,
|
|
});
|
|
toast.success(`Retainer invoice ${inv.invoice_number} created`);
|
|
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`);
|
|
navigate({ to: "/invoices" });
|
|
return;
|
|
}
|
|
const { invoiceId, invoiceNumber } = await generateInvoiceForClient({
|
|
clientId,
|
|
caseIds: selectedIds,
|
|
createdBy: user.id,
|
|
taxRate: (Number(taxPct) || 0) / 100,
|
|
dueDays: Number(dueDays) || 30,
|
|
notes: notes || undefined,
|
|
});
|
|
toast.success(`Invoice ${invoiceNumber} created`);
|
|
navigate({ to: "/invoices/$invoiceId", params: { invoiceId } });
|
|
} catch (e: any) {
|
|
toast.error(e?.message || "Failed to generate");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title={`Generate invoice${clientName ? ` — ${clientName}` : ""}`}
|
|
description="Pick which cases to bill, set tax/due, and create a draft invoice."
|
|
actions={
|
|
<Button variant="outline" onClick={() => navigate({ to: "/invoices/new" })}>
|
|
<ArrowLeft className="h-4 w-4 mr-2" /> Change client
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="max-w-4xl space-y-4">
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-4">
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<Checkbox
|
|
checked={isRetainer}
|
|
onCheckedChange={(v) => setIsRetainer(!!v)}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="text-sm font-medium">Retainer / trust deposit invoice</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
When paid, the amount is automatically deposited into this client's trust account.
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{isRetainer ? (
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<div>
|
|
<Label>Retainer amount</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
value={retainerAmount}
|
|
onChange={(e) => setRetainerAmount(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Tax rate (%)</Label>
|
|
<Input type="number" step="0.01" value={taxPct} onChange={(e) => setTaxPct(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>Due in (days)</Label>
|
|
<Input type="number" value={dueDays} onChange={(e) => setDueDays(e.target.value)} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : loading ? (
|
|
<Card className="border-border/60">
|
|
<CardContent className="py-12 text-center text-muted-foreground">Loading unbilled work…</CardContent>
|
|
</Card>
|
|
) : cases.length === 0 ? (
|
|
<Card className="border-border/60">
|
|
<CardContent className="py-12 text-center text-muted-foreground text-sm">
|
|
No unbilled time or expenses across this client's cases.
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<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">
|
|
{cases.map((c) => (
|
|
<label key={c.id} className="flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/40">
|
|
<Checkbox
|
|
checked={!!selected[c.id]}
|
|
onCheckedChange={(v) => setSelected((p) => ({ ...p, [c.id]: !!v }))}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="font-medium text-sm truncate">{c.title}</span>
|
|
<span className="text-sm tabular-nums font-medium">{formatCurrency(c.total)}</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{c.case_number} · {c.timeCount} time entries ({formatCurrency(c.timeAmount)}) · {c.expenseCount} expenses ({formatCurrency(c.expenseAmount)})
|
|
</div>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{selectedIds.length > 1 && (
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-4">
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<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>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
|
|
<div>
|
|
<Label>Tax rate (%)</Label>
|
|
<Input type="number" step="0.01" value={taxPct} onChange={(e) => setTaxPct(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>Due in (days)</Label>
|
|
<Input type="number" value={dueDays} onChange={(e) => setDueDays(e.target.value)} />
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
{splitPerCase && selectedIds.length > 1 ? "Combined total" : "Estimated total"}
|
|
</div>
|
|
<div className="font-serif text-2xl">{formatCurrency(total)}</div>
|
|
{tax > 0 && <div className="text-[11px] text-muted-foreground">{formatCurrency(subtotal)} + {formatCurrency(tax)} tax</div>}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-4">
|
|
<Label>Notes (optional)</Label>
|
|
<Textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Payment terms, thank-you message…" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end gap-2 pb-8">
|
|
<Button variant="outline" onClick={() => navigate({ to: "/invoices" })}>Cancel</Button>
|
|
<Button
|
|
onClick={submit}
|
|
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"
|
|
: splitPerCase && selectedIds.length > 1
|
|
? `Generate ${selectedIds.length} draft invoices`
|
|
: "Generate draft invoice"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|