Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:37:00 +00:00
co-authored by renee-png
parent ef7bc8adc2
commit 970a94c7d5
4 changed files with 953 additions and 0 deletions
@@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
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 } from "lucide-react";
import { formatCurrency } from "@/lib/format";
import { toast } from "sonner";
import { generateInvoiceForClient } from "@/lib/invoice-generation";
interface Props {
open: boolean;
onOpenChange: (b: boolean) => void;
clientId: string;
clientName: string;
/** preselect a single case (used when launched from a case page) */
presetCaseId?: string;
}
interface CaseUnbilled {
id: string;
case_number: string;
title: string;
timeCount: number;
timeAmount: number;
expenseCount: number;
expenseAmount: number;
total: number;
}
export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName, presetCaseId }: Props) {
const { user } = useAuth();
const navigate = useNavigate();
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 [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
(async () => {
setLoading(true);
const [{ data: cs }, { data: firm }] = await Promise.all([
supabase.from("cases").select("id, case_number, title").eq("client_id", clientId),
supabase.from("firm_settings").select("default_tax_rate").maybeSingle(),
]);
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);
// Default selection
const sel: Record<string, boolean> = {};
if (presetCaseId) {
sel[presetCaseId] = true;
} else {
enriched.forEach((c) => { sel[c.id] = true; });
}
setSelected(sel);
setLoading(false);
})();
}, [open, 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) return toast.error("Select at least one case");
setSaving(true);
try {
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`);
onOpenChange(false);
navigate({ to: "/invoices/$invoiceId", params: { invoiceId } });
} catch (e: any) {
toast.error(e?.message || "Failed to generate");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Generate invoice — {clientName}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="py-8 text-center text-muted-foreground">Loading unbilled work…</div>
) : cases.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
No unbilled time or expenses across this client's cases.
</div>
) : (
<div className="space-y-4">
<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">
{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>
</div>
<div className="grid grid-cols-3 gap-3">
<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="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="font-serif text-xl">{formatCurrency(total)}</div>
{tax > 0 && <div className="text-[11px] text-muted-foreground">{formatCurrency(subtotal)} + {formatCurrency(tax)} tax</div>}
</div>
</div>
</div>
<div>
<Label>Notes (optional)</Label>
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Payment terms, thank-you message…" />
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={submit} disabled={saving || 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" />}
Generate draft invoice
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}