Added invoice new button
X-Lovable-Edit-ID: edt-f58376ad-77fa-49f4-95da-26de9bc9347a Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -5,10 +5,13 @@ import { PageContainer, PageHeader } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Receipt, Search } from "lucide-react";
|
||||
import { Receipt, Search, FilePlus } from "lucide-react";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
|
||||
|
||||
export const Route = createFileRoute("/invoices/")({
|
||||
component: () => (
|
||||
@@ -18,12 +21,25 @@ export const Route = createFileRoute("/invoices/")({
|
||||
),
|
||||
});
|
||||
|
||||
interface ClientLite {
|
||||
id: string;
|
||||
name: string;
|
||||
unbilledTotal: number;
|
||||
unbilledTimeAmount: number;
|
||||
unbilledExpenseAmount: number;
|
||||
caseCount: number;
|
||||
}
|
||||
|
||||
function InvoicesIndex() {
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState<string>("all");
|
||||
|
||||
// New-invoice flow: first pick a client, then open the generate dialog.
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickedClient, setPickedClient] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { data } = await supabase
|
||||
@@ -56,7 +72,15 @@ function InvoicesIndex() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Invoices" description="All client invoices across the firm" />
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
description="All client invoices across the firm"
|
||||
actions={
|
||||
<Button onClick={() => setPickerOpen(true)}>
|
||||
<FilePlus className="h-4 w-4 mr-2" /> New invoice
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3 mb-5">
|
||||
<Stat label="Invoices" value={totals.count.toString()} />
|
||||
@@ -101,7 +125,7 @@ function InvoicesIndex() {
|
||||
{!loading && filtered.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<Receipt className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No invoices yet. Generate one from a Client or Case page.
|
||||
No invoices yet. Click <strong>New invoice</strong> to generate one.
|
||||
</td></tr>
|
||||
)}
|
||||
{filtered.map((i) => {
|
||||
@@ -132,6 +156,24 @@ function InvoicesIndex() {
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ClientPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
onPick={(c) => {
|
||||
setPickerOpen(false);
|
||||
setPickedClient(c);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pickedClient && (
|
||||
<GenerateInvoiceDialog
|
||||
open={!!pickedClient}
|
||||
onOpenChange={(b) => { if (!b) setPickedClient(null); }}
|
||||
clientId={pickedClient.id}
|
||||
clientName={pickedClient.name}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -146,3 +188,166 @@ function Stat({ label, value }: { label: string; value: string }) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onPick,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (b: boolean) => void;
|
||||
onPick: (c: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [clients, setClients] = useState<ClientLite[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
// Pull every active client and their cases, then aggregate unbilled time/expenses.
|
||||
const { data: cs } = await supabase
|
||||
.from("clients")
|
||||
.select("id, name")
|
||||
.is("archived_at", null)
|
||||
.order("name", { ascending: true });
|
||||
const clientList = cs ?? [];
|
||||
if (clientList.length === 0) {
|
||||
setClients([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { data: cases } = await supabase
|
||||
.from("cases")
|
||||
.select("id, client_id")
|
||||
.is("archived_at", null)
|
||||
.in("client_id", clientList.map((c) => c.id));
|
||||
const caseToClient = new Map<string, string>();
|
||||
const clientCaseCount = new Map<string, number>();
|
||||
for (const c of cases ?? []) {
|
||||
if (!c.client_id) continue;
|
||||
caseToClient.set(c.id, c.client_id);
|
||||
clientCaseCount.set(c.client_id, (clientCaseCount.get(c.client_id) ?? 0) + 1);
|
||||
}
|
||||
const caseIds = Array.from(caseToClient.keys());
|
||||
const tally = new Map<string, { time: number; expense: number }>();
|
||||
if (caseIds.length > 0) {
|
||||
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),
|
||||
]);
|
||||
for (const t of time ?? []) {
|
||||
const cid = caseToClient.get(t.case_id);
|
||||
if (!cid) continue;
|
||||
const cur = tally.get(cid) ?? { time: 0, expense: 0 };
|
||||
cur.time += Number(t.hours) * Number(t.hourly_rate);
|
||||
tally.set(cid, cur);
|
||||
}
|
||||
for (const e of exp ?? []) {
|
||||
const cid = caseToClient.get(e.case_id);
|
||||
if (!cid) continue;
|
||||
const cur = tally.get(cid) ?? { time: 0, expense: 0 };
|
||||
cur.expense += Number(e.amount);
|
||||
tally.set(cid, cur);
|
||||
}
|
||||
}
|
||||
const enriched: ClientLite[] = clientList.map((c) => {
|
||||
const t = tally.get(c.id) ?? { time: 0, expense: 0 };
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
unbilledTimeAmount: t.time,
|
||||
unbilledExpenseAmount: t.expense,
|
||||
unbilledTotal: t.time + t.expense,
|
||||
caseCount: clientCaseCount.get(c.id) ?? 0,
|
||||
};
|
||||
});
|
||||
// Sort: clients with unbilled work first, then alphabetical.
|
||||
enriched.sort((a, b) => {
|
||||
if ((b.unbilledTotal > 0 ? 1 : 0) !== (a.unbilledTotal > 0 ? 1 : 0)) {
|
||||
return (b.unbilledTotal > 0 ? 1 : 0) - (a.unbilledTotal > 0 ? 1 : 0);
|
||||
}
|
||||
if (b.unbilledTotal !== a.unbilledTotal) return b.unbilledTotal - a.unbilledTotal;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
setClients(enriched);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!q) return clients;
|
||||
const s = q.toLowerCase();
|
||||
return clients.filter((c) => c.name.toLowerCase().includes(s));
|
||||
}, [clients, q]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New invoice — choose a client</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
placeholder="Search clients…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border rounded-md divide-y max-h-[400px] overflow-auto">
|
||||
{loading && <div className="py-8 text-center text-sm text-muted-foreground">Loading clients…</div>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">No matching clients.</div>
|
||||
)}
|
||||
{!loading && filtered.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => onPick({ id: c.id, name: c.name })}
|
||||
className="w-full text-left p-3 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm truncate">{c.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{c.caseCount} {c.caseCount === 1 ? "case" : "cases"}
|
||||
{c.unbilledTotal > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
{formatCurrency(c.unbilledTimeAmount)} time
|
||||
{c.unbilledExpenseAmount > 0 && <> · {formatCurrency(c.unbilledExpenseAmount)} expenses</>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{c.unbilledTotal > 0 ? (
|
||||
<Badge variant="outline" className="bg-primary/10 text-primary border-primary/30 tabular-nums">
|
||||
{formatCurrency(c.unbilledTotal)} unbilled
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-foreground">No unbilled work</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Pick a client to see a checklist of their cases and a summary of unbilled time and expenses on each.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user