Converted invoices to full pages
X-Lovable-Edit-ID: edt-6e4008fc-1b8e-4b2d-9dc2-8e4e0a35da03 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -1,18 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { FilePlus } from "lucide-react";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
|
||||
|
||||
export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) {
|
||||
const navigate = useNavigate();
|
||||
const [invoices, setInvoices] = useState<any[]>([]);
|
||||
const [unbilledTime, setUnbilledTime] = useState<any[]>([]);
|
||||
const [unbilledExpenses, setUnbilledExpenses] = useState<any[]>([]);
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const [{ data: invs }, { data: t }, { data: ex }] = await Promise.all([
|
||||
@@ -42,7 +41,14 @@ export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) {
|
||||
</div>
|
||||
<div className="font-serif text-2xl text-foreground mt-1">{formatCurrency(subtotal)}</div>
|
||||
</div>
|
||||
<Button onClick={() => setGenOpen(true)} disabled={subtotal <= 0}>
|
||||
<Button
|
||||
onClick={() => navigate({
|
||||
to: "/invoices/new/$clientId",
|
||||
params: { clientId: caseRecord.client.id },
|
||||
search: { caseId: caseRecord.id },
|
||||
})}
|
||||
disabled={subtotal <= 0}
|
||||
>
|
||||
<FilePlus className="h-4 w-4 mr-2" />
|
||||
Generate invoice
|
||||
</Button>
|
||||
@@ -80,14 +86,6 @@ export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) {
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<GenerateInvoiceDialog
|
||||
open={genOpen}
|
||||
onOpenChange={setGenOpen}
|
||||
clientId={caseRecord.client.id}
|
||||
clientName={caseRecord.client.name}
|
||||
presetCaseId={caseRecord.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ClientCustomFieldsTab } from "@/components/clients/client-custom-fields
|
||||
import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/lib/format";
|
||||
import { toast } from "sonner";
|
||||
import { downloadStatusReport } from "@/lib/status-pdf";
|
||||
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
|
||||
|
||||
import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
|
||||
import { setArchived } from "@/lib/archive";
|
||||
|
||||
@@ -36,7 +36,6 @@ function ClientDetail() {
|
||||
const [statusEntries, setStatusEntries] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -149,7 +148,7 @@ function ClientDetail() {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setInvoiceOpen(true)}>
|
||||
<Button variant="outline" onClick={() => navigate({ to: "/invoices/new/$clientId", params: { clientId: client.id } })}>
|
||||
<Receipt className="h-4 w-4 mr-2" /> Generate invoice
|
||||
</Button>
|
||||
<Button onClick={() => navigate({ to: "/cases/new", search: { clientId: client.id } })}>
|
||||
@@ -320,12 +319,6 @@ function ClientDetail() {
|
||||
</div>
|
||||
|
||||
<ClientFormDialog open={editOpen} onOpenChange={setEditOpen} client={client} onSaved={load} />
|
||||
<GenerateInvoiceDialog
|
||||
open={invoiceOpen}
|
||||
onOpenChange={setInvoiceOpen}
|
||||
clientId={client.id}
|
||||
clientName={client.name}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { CaseTimeTab } from "@/components/cases/time-tab";
|
||||
import { CaseExpensesTab } from "@/components/cases/expenses-tab";
|
||||
import { CaseStatusTab } from "@/components/cases/status-tab";
|
||||
import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
|
||||
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
|
||||
|
||||
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
|
||||
import { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -106,7 +106,7 @@ function CollectionDetailRoute() {
|
||||
const [addTaskOpen, setAddTaskOpen] = useState(false);
|
||||
const [applyWfOpen, setApplyWfOpen] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -312,7 +312,7 @@ function CollectionDetailRoute() {
|
||||
<ArrowLeft className="h-4 w-4 mr-1" /> All collections
|
||||
</Button>
|
||||
{collection.case?.client?.id && (
|
||||
<Button size="sm" onClick={() => setInvoiceOpen(true)}>
|
||||
<Button size="sm" onClick={() => navigate({ to: "/invoices/new/$clientId", params: { clientId: collection.case.client.id }, search: { caseId: collection.case.id } })}>
|
||||
<FilePlus className="h-4 w-4 mr-1.5" /> Generate invoice
|
||||
</Button>
|
||||
)}
|
||||
@@ -539,16 +539,6 @@ function CollectionDetailRoute() {
|
||||
caseId={collection.case?.id ?? null}
|
||||
onApplied={refreshTasks}
|
||||
/>
|
||||
|
||||
{collection.case?.client?.id && (
|
||||
<GenerateInvoiceDialog
|
||||
open={invoiceOpen}
|
||||
onOpenChange={setInvoiceOpen}
|
||||
clientId={collection.case.client.id}
|
||||
clientName={collection.case.client.name}
|
||||
presetCaseId={collection.case.id}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
</ProtectedLayout>
|
||||
);
|
||||
|
||||
+244
-262
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createFileRoute, useNavigate } 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: () => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user