Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 16:48:00 +00:00
co-authored by renee-png
parent 16376bdf03
commit ec737b7b0a
2 changed files with 296 additions and 262 deletions
+52
View File
@@ -39,6 +39,7 @@ import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as SettingsCustomFieldsRouteImport } from './routes/settings.custom-fields'
import { Route as SettingsClientFieldsRouteImport } from './routes/settings.client-fields'
import { Route as PayIdRouteImport } from './routes/pay.$id'
import { Route as InvoicesNewRouteImport } from './routes/invoices.new'
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap'
import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId'
@@ -49,6 +50,7 @@ import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId'
import { Route as ApiStripeWebhookRouteImport } from './routes/api.stripe-webhook'
import { Route as AdminUsersRouteImport } from './routes/admin.users'
import { Route as DocumentsTemplatesIndexRouteImport } from './routes/documents.templates.index'
import { Route as InvoicesNewClientIdRouteImport } from './routes/invoices.new.$clientId'
import { Route as DocumentsTemplatesNewRouteImport } from './routes/documents.templates.new'
import { Route as DocumentsTemplatesTemplateIdRouteImport } from './routes/documents.templates.$templateId'
import { Route as DocumentsPleadingNewRouteImport } from './routes/documents.pleading.new'
@@ -203,6 +205,11 @@ const PayIdRoute = PayIdRouteImport.update({
path: '/pay/$id',
getParentRoute: () => rootRouteImport,
} as any)
const InvoicesNewRoute = InvoicesNewRouteImport.update({
id: '/invoices/new',
path: '/invoices/new',
getParentRoute: () => rootRouteImport,
} as any)
const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({
id: '/invoices/$invoiceId',
path: '/invoices/$invoiceId',
@@ -253,6 +260,11 @@ const DocumentsTemplatesIndexRoute = DocumentsTemplatesIndexRouteImport.update({
path: '/documents/templates/',
getParentRoute: () => rootRouteImport,
} as any)
const InvoicesNewClientIdRoute = InvoicesNewClientIdRouteImport.update({
id: '/$clientId',
path: '/$clientId',
getParentRoute: () => InvoicesNewRoute,
} as any)
const DocumentsTemplatesNewRoute = DocumentsTemplatesNewRouteImport.update({
id: '/documents/templates/new',
path: '/documents/templates/new',
@@ -284,6 +296,7 @@ export interface FileRoutesByFullPath {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/pay/$id': typeof PayIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
@@ -313,6 +326,7 @@ export interface FileRoutesByFullPath {
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/documents/templates/': typeof DocumentsTemplatesIndexRoute
}
export interface FileRoutesByTo {
@@ -328,6 +342,7 @@ export interface FileRoutesByTo {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/pay/$id': typeof PayIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
@@ -357,6 +372,7 @@ export interface FileRoutesByTo {
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/documents/templates': typeof DocumentsTemplatesIndexRoute
}
export interface FileRoutesById {
@@ -374,6 +390,7 @@ export interface FileRoutesById {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
'/pay/$id': typeof PayIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
@@ -403,6 +420,7 @@ export interface FileRoutesById {
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/documents/templates/': typeof DocumentsTemplatesIndexRoute
}
export interface FileRouteTypes {
@@ -421,6 +439,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/pay/$id'
| '/settings/client-fields'
| '/settings/custom-fields'
@@ -450,6 +469,7 @@ export interface FileRouteTypes {
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/documents/templates/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -465,6 +485,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/pay/$id'
| '/settings/client-fields'
| '/settings/custom-fields'
@@ -494,6 +515,7 @@ export interface FileRouteTypes {
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/documents/templates'
id:
| '__root__'
@@ -510,6 +532,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
| '/pay/$id'
| '/settings/client-fields'
| '/settings/custom-fields'
@@ -539,6 +562,7 @@ export interface FileRouteTypes {
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/documents/templates/'
fileRoutesById: FileRoutesById
}
@@ -556,6 +580,7 @@ export interface RootRouteChildren {
ContactsContactIdRoute: typeof ContactsContactIdRoute
HooksPollImapRoute: typeof HooksPollImapRoute
InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute
InvoicesNewRoute: typeof InvoicesNewRouteWithChildren
PayIdRoute: typeof PayIdRoute
CalendarIndexRoute: typeof CalendarIndexRoute
CasesIndexRoute: typeof CasesIndexRoute
@@ -789,6 +814,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PayIdRouteImport
parentRoute: typeof rootRouteImport
}
'/invoices/new': {
id: '/invoices/new'
path: '/invoices/new'
fullPath: '/invoices/new'
preLoaderRoute: typeof InvoicesNewRouteImport
parentRoute: typeof rootRouteImport
}
'/invoices/$invoiceId': {
id: '/invoices/$invoiceId'
path: '/invoices/$invoiceId'
@@ -859,6 +891,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocumentsTemplatesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/invoices/new/$clientId': {
id: '/invoices/new/$clientId'
path: '/$clientId'
fullPath: '/invoices/new/$clientId'
preLoaderRoute: typeof InvoicesNewClientIdRouteImport
parentRoute: typeof InvoicesNewRoute
}
'/documents/templates/new': {
id: '/documents/templates/new'
path: '/documents/templates/new'
@@ -915,6 +954,18 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
SettingsRouteChildren,
)
interface InvoicesNewRouteChildren {
InvoicesNewClientIdRoute: typeof InvoicesNewClientIdRoute
}
const InvoicesNewRouteChildren: InvoicesNewRouteChildren = {
InvoicesNewClientIdRoute: InvoicesNewClientIdRoute,
}
const InvoicesNewRouteWithChildren = InvoicesNewRoute._addFileChildren(
InvoicesNewRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
@@ -929,6 +980,7 @@ const rootRouteChildren: RootRouteChildren = {
ContactsContactIdRoute: ContactsContactIdRoute,
HooksPollImapRoute: HooksPollImapRoute,
InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute,
InvoicesNewRoute: InvoicesNewRouteWithChildren,
PayIdRoute: PayIdRoute,
CalendarIndexRoute: CalendarIndexRoute,
CasesIndexRoute: CasesIndexRoute,
+244 -262
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
@@ -7,11 +7,10 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { supabase } from "@/integrations/supabase/client";
import { Receipt, Search, FilePlus } from "lucide-react";
import { Receipt, Search, FilePlus, Clock, DollarSign } from "lucide-react";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
export const Route = createFileRoute("/invoices/")({
component: () => (
@@ -21,24 +20,16 @@ export const Route = createFileRoute("/invoices/")({
),
});
interface ClientLite {
id: string;
name: string;
unbilledTotal: number;
unbilledTimeAmount: number;
unbilledExpenseAmount: number;
caseCount: number;
}
function InvoicesIndex() {
const navigate = useNavigate();
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);
const [unbilledTime, setUnbilledTime] = useState<any[]>([]);
const [unbilledExpenses, setUnbilledExpenses] = useState<any[]>([]);
const [tabsLoaded, setTabsLoaded] = useState(false);
useEffect(() => {
(async () => {
@@ -51,6 +42,30 @@ function InvoicesIndex() {
})();
}, []);
useEffect(() => {
(async () => {
const [{ data: time }, { data: exp }] = await Promise.all([
supabase
.from("time_entries")
.select("id, work_date, description, hours, hourly_rate, billable, user_id, case:cases(id, case_number, title, client:clients(id, name)), profile:profiles!time_entries_user_id_fkey(id, full_name, email)")
.eq("billable", true)
.is("invoice_id", null)
.order("work_date", { ascending: false })
.limit(1000),
supabase
.from("expenses")
.select("id, expense_date, description, amount, billable, user_id, case:cases(id, case_number, title, client:clients(id, name)), profile:profiles!expenses_user_id_fkey(id, full_name, email)")
.eq("billable", true)
.is("invoice_id", null)
.order("expense_date", { ascending: false })
.limit(1000),
]);
setUnbilledTime(time ?? []);
setUnbilledExpenses(exp ?? []);
setTabsLoaded(true);
})();
}, []);
const filtered = useMemo(() => {
return invoices.filter((i) => {
if (status !== "all" && i.status !== status) return false;
@@ -70,13 +85,22 @@ function InvoicesIndex() {
return { outstanding, paid, count: filtered.length };
}, [filtered]);
const unbilledTimeTotal = useMemo(
() => unbilledTime.reduce((s, t) => s + Number(t.hours) * Number(t.hourly_rate), 0),
[unbilledTime]
);
const unbilledExpenseTotal = useMemo(
() => unbilledExpenses.reduce((s, e) => s + Number(e.amount), 0),
[unbilledExpenses]
);
return (
<PageContainer>
<PageHeader
title="Invoices"
description="All client invoices across the firm"
actions={
<Button onClick={() => setPickerOpen(true)}>
<Button onClick={() => navigate({ to: "/invoices/new" })}>
<FilePlus className="h-4 w-4 mr-2" /> New invoice
</Button>
}
@@ -88,92 +112,213 @@ function InvoicesIndex() {
<Stat label="Collected" value={formatCurrency(totals.paid)} />
</div>
<div className="flex flex-col sm:flex-row gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input className="pl-9" placeholder="Search by invoice #, client, case…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="sent">Sent</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="overdue">Overdue</SelectItem>
<SelectItem value="void">Void</SelectItem>
</SelectContent>
</Select>
</div>
<Tabs defaultValue="invoices">
<TabsList>
<TabsTrigger value="invoices">
<Receipt className="h-4 w-4 mr-1.5" /> Invoices
</TabsTrigger>
<TabsTrigger value="time">
<Clock className="h-4 w-4 mr-1.5" /> Unbilled time
{unbilledTime.length > 0 && (
<Badge variant="outline" className="ml-2 h-5 px-1.5 text-[10px]">{unbilledTime.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="expenses">
<DollarSign className="h-4 w-4 mr-1.5" /> Unbilled expenses
{unbilledExpenses.length > 0 && (
<Badge variant="outline" className="ml-2 h-5 px-1.5 text-[10px]">{unbilledExpenses.length}</Badge>
)}
</TabsTrigger>
</TabsList>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Invoice #</th>
<th className="text-left px-4 py-3 font-medium">Client</th>
<th className="text-left px-4 py-3 font-medium">Issued</th>
<th className="text-left px-4 py-3 font-medium">Due</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Total</th>
<th className="text-right px-4 py-3 font-medium">Balance</th>
</tr>
</thead>
<tbody>
{loading && <tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
{!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. Click <strong>New invoice</strong> to generate one.
</td></tr>
)}
{filtered.map((i) => {
const balance = Number(i.total) - Number(i.amount_paid);
return (
<tr key={i.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<Link to="/invoices/$invoiceId" params={{ invoiceId: i.id }} className="font-medium hover:text-primary">
{i.invoice_number}
</Link>
</td>
<td className="px-4 py-3">
{i.client ? (
<Link to="/clients/$clientId" params={{ clientId: i.client.id }} className="hover:text-primary">
{i.client.name}
</Link>
) : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.issue_date)}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.due_date)}</td>
<td className="px-4 py-3"><Badge variant="outline" className={statusBadgeClass(i.status)}>{i.status}</Badge></td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(i.total)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(balance)}</td>
<TabsContent value="invoices" className="mt-4">
<div className="flex flex-col sm:flex-row gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input className="pl-9" placeholder="Search by invoice #, client, case…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="sent">Sent</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="overdue">Overdue</SelectItem>
<SelectItem value="void">Void</SelectItem>
</SelectContent>
</Select>
</div>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Invoice #</th>
<th className="text-left px-4 py-3 font-medium">Client</th>
<th className="text-left px-4 py-3 font-medium">Issued</th>
<th className="text-left px-4 py-3 font-medium">Due</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Total</th>
<th className="text-right px-4 py-3 font-medium">Balance</th>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
</thead>
<tbody>
{loading && <tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
{!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. Click <strong>New invoice</strong> to generate one.
</td></tr>
)}
{filtered.map((i) => {
const balance = Number(i.total) - Number(i.amount_paid);
return (
<tr key={i.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<Link to="/invoices/$invoiceId" params={{ invoiceId: i.id }} className="font-medium hover:text-primary">
{i.invoice_number}
</Link>
</td>
<td className="px-4 py-3">
{i.client ? (
<Link to="/clients/$clientId" params={{ clientId: i.client.id }} className="hover:text-primary">
{i.client.name}
</Link>
) : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.issue_date)}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(i.due_date)}</td>
<td className="px-4 py-3"><Badge variant="outline" className={statusBadgeClass(i.status)}>{i.status}</Badge></td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(i.total)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(balance)}</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<ClientPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
onPick={(c) => {
setPickerOpen(false);
setPickedClient(c);
}}
/>
<TabsContent value="time" className="mt-4">
<div className="flex items-center justify-between mb-3">
<div className="text-sm text-muted-foreground">
{unbilledTime.length} unbilled time entries
</div>
<div className="font-serif text-xl tabular-nums">{formatCurrency(unbilledTimeTotal)}</div>
</div>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">Client / Case</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-right px-4 py-3 font-medium">Hours</th>
<th className="text-right px-4 py-3 font-medium">Rate</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
</tr>
</thead>
<tbody>
{!tabsLoaded && <tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
{tabsLoaded && unbilledTime.length === 0 && (
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-40" />
No unbilled time entries.
</td></tr>
)}
{unbilledTime.map((t) => {
const amount = Number(t.hours) * Number(t.hourly_rate);
return (
<tr key={t.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(t.work_date)}</td>
<td className="px-4 py-3">
{t.case ? (
<div className="min-w-0">
{t.case.client && (
<Link to="/clients/$clientId" params={{ clientId: t.case.client.id }} className="text-xs text-muted-foreground hover:text-primary block truncate">
{t.case.client.name}
</Link>
)}
<Link to="/cases/$caseId" params={{ caseId: t.case.id }} className="hover:text-primary truncate block">
{t.case.case_number} · {t.case.title}
</Link>
</div>
) : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground max-w-md truncate">{t.description}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{t.profile?.full_name || t.profile?.email || "—"}</td>
<td className="px-4 py-3 text-right tabular-nums">{Number(t.hours).toFixed(2)}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{formatCurrency(t.hourly_rate)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(amount)}</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
{pickedClient && (
<GenerateInvoiceDialog
open={!!pickedClient}
onOpenChange={(b) => { if (!b) setPickedClient(null); }}
clientId={pickedClient.id}
clientName={pickedClient.name}
/>
)}
<TabsContent value="expenses" className="mt-4">
<div className="flex items-center justify-between mb-3">
<div className="text-sm text-muted-foreground">
{unbilledExpenses.length} unbilled expenses
</div>
<div className="font-serif text-xl tabular-nums">{formatCurrency(unbilledExpenseTotal)}</div>
</div>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-left px-4 py-3 font-medium">Client / Case</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">User</th>
<th className="text-right px-4 py-3 font-medium">Amount</th>
</tr>
</thead>
<tbody>
{!tabsLoaded && <tr><td colSpan={5} className="text-center py-12 text-muted-foreground">Loading…</td></tr>}
{tabsLoaded && unbilledExpenses.length === 0 && (
<tr><td colSpan={5} className="text-center py-12 text-muted-foreground">
<DollarSign className="h-8 w-8 mx-auto mb-2 opacity-40" />
No unbilled expenses.
</td></tr>
)}
{unbilledExpenses.map((e) => (
<tr key={e.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">{formatDate(e.expense_date)}</td>
<td className="px-4 py-3">
{e.case ? (
<div className="min-w-0">
{e.case.client && (
<Link to="/clients/$clientId" params={{ clientId: e.case.client.id }} className="text-xs text-muted-foreground hover:text-primary block truncate">
{e.case.client.name}
</Link>
)}
<Link to="/cases/$caseId" params={{ caseId: e.case.id }} className="hover:text-primary truncate block">
{e.case.case_number} · {e.case.title}
</Link>
</div>
) : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground max-w-md truncate">{e.description}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{e.profile?.full_name || e.profile?.email || "—"}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{formatCurrency(e.amount)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</PageContainer>
);
}
@@ -188,166 +333,3 @@ 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>
);
}