Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
import { createFileRoute, useNavigate, Outlet, useMatches } from "@tanstack/react-router";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { ProtectedLayout } from "@/components/protected-layout";
|
|
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 { supabase } from "@/integrations/supabase/client";
|
|
import { Search, ArrowLeft } from "lucide-react";
|
|
import { formatCurrency } from "@/lib/format";
|
|
|
|
export const Route = createFileRoute("/invoices/new")({
|
|
component: NewInvoiceLayout,
|
|
});
|
|
|
|
function NewInvoiceLayout() {
|
|
// If a child route (e.g. /invoices/new/$clientId) is matched, render only it.
|
|
const matches = useMatches();
|
|
const hasChild = matches.some((m) => m.routeId !== "/invoices/new" && m.routeId.startsWith("/invoices/new/"));
|
|
if (hasChild) return <Outlet />;
|
|
return (
|
|
<ProtectedLayout>
|
|
<NewInvoicePickClient />
|
|
</ProtectedLayout>
|
|
);
|
|
}
|
|
|
|
interface ClientLite {
|
|
id: string;
|
|
name: string;
|
|
unbilledTotal: number;
|
|
unbilledTimeAmount: number;
|
|
unbilledExpenseAmount: number;
|
|
caseCount: number;
|
|
}
|
|
|
|
function NewInvoicePickClient() {
|
|
const navigate = useNavigate();
|
|
const [clients, setClients] = useState<ClientLite[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [q, setQ] = useState("");
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
setLoading(true);
|
|
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,
|
|
};
|
|
});
|
|
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);
|
|
})();
|
|
}, []);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!q) return clients;
|
|
const s = q.toLowerCase();
|
|
return clients.filter((c) => c.name.toLowerCase().includes(s));
|
|
}, [clients, q]);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="New invoice — choose a client"
|
|
description="Pick a client to see their cases with unbilled time and expenses."
|
|
actions={
|
|
<Button variant="outline" onClick={() => navigate({ to: "/invoices" })}>
|
|
<ArrowLeft className="h-4 w-4 mr-2" /> Back to invoices
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<Card className="border-border/60 mb-4">
|
|
<CardContent className="p-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>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-0 divide-y">
|
|
{loading && <div className="py-12 text-center text-sm text-muted-foreground">Loading clients…</div>}
|
|
{!loading && filtered.length === 0 && (
|
|
<div className="py-12 text-center text-sm text-muted-foreground">No matching clients.</div>
|
|
)}
|
|
{!loading && filtered.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => navigate({ to: "/invoices/new/$clientId", params: { clientId: c.id } })}
|
|
className="w-full text-left p-4 hover:bg-muted/40 transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="font-medium truncate">{c.name}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
{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 shrink-0">
|
|
{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>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|