diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8921027..a1a797c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/invoices.index.tsx b/src/routes/invoices.index.tsx index 5f8a165..69ee1a4 100644 --- a/src/routes/invoices.index.tsx +++ b/src/routes/invoices.index.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); const [status, setStatus] = useState("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([]); + const [unbilledExpenses, setUnbilledExpenses] = useState([]); + 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 ( setPickerOpen(true)}> + } @@ -88,92 +112,213 @@ function InvoicesIndex() { -
-
- - setQ(e.target.value)} /> -
- -
+ + + + Invoices + + + Unbilled time + {unbilledTime.length > 0 && ( + {unbilledTime.length} + )} + + + Unbilled expenses + {unbilledExpenses.length > 0 && ( + {unbilledExpenses.length} + )} + + - - - - - - - - - - - - - - - - {loading && } - {!loading && filtered.length === 0 && ( - - )} - {filtered.map((i) => { - const balance = Number(i.total) - Number(i.amount_paid); - return ( - - - - - - - - + +
+
+ + setQ(e.target.value)} /> +
+ +
+ + + +
Invoice #ClientIssuedDueStatusTotalBalance
Loading…
- - No invoices yet. Click New invoice to generate one. -
- - {i.invoice_number} - - - {i.client ? ( - - {i.client.name} - - ) : "—"} - {formatDate(i.issue_date)}{formatDate(i.due_date)}{i.status}{formatCurrency(i.total)}{formatCurrency(balance)}
+ + + + + + + + + - ); - })} - -
Invoice #ClientIssuedDueStatusTotalBalance
-
-
+ + + {loading && Loading…} + {!loading && filtered.length === 0 && ( + + + No invoices yet. Click New invoice to generate one. + + )} + {filtered.map((i) => { + const balance = Number(i.total) - Number(i.amount_paid); + return ( + + + + {i.invoice_number} + + + + {i.client ? ( + + {i.client.name} + + ) : "—"} + + {formatDate(i.issue_date)} + {formatDate(i.due_date)} + {i.status} + {formatCurrency(i.total)} + {formatCurrency(balance)} + + ); + })} + + + + + - { - setPickerOpen(false); - setPickedClient(c); - }} - /> + +
+
+ {unbilledTime.length} unbilled time entries +
+
{formatCurrency(unbilledTimeTotal)}
+
+ + + + + + + + + + + + + + + + {!tabsLoaded && } + {tabsLoaded && unbilledTime.length === 0 && ( + + )} + {unbilledTime.map((t) => { + const amount = Number(t.hours) * Number(t.hourly_rate); + return ( + + + + + + + + + + ); + })} + +
DateClient / CaseDescriptionUserHoursRateAmount
Loading…
+ + No unbilled time entries. +
{formatDate(t.work_date)} + {t.case ? ( +
+ {t.case.client && ( + + {t.case.client.name} + + )} + + {t.case.case_number} · {t.case.title} + +
+ ) : "—"} +
{t.description}{t.profile?.full_name || t.profile?.email || "—"}{Number(t.hours).toFixed(2)}{formatCurrency(t.hourly_rate)}{formatCurrency(amount)}
+
+
+
- {pickedClient && ( - { if (!b) setPickedClient(null); }} - clientId={pickedClient.id} - clientName={pickedClient.name} - /> - )} + +
+
+ {unbilledExpenses.length} unbilled expenses +
+
{formatCurrency(unbilledExpenseTotal)}
+
+ + + + + + + + + + + + + + {!tabsLoaded && } + {tabsLoaded && unbilledExpenses.length === 0 && ( + + )} + {unbilledExpenses.map((e) => ( + + + + + + + + ))} + +
DateClient / CaseDescriptionUserAmount
Loading…
+ + No unbilled expenses. +
{formatDate(e.expense_date)} + {e.case ? ( +
+ {e.case.client && ( + + {e.case.client.name} + + )} + + {e.case.case_number} · {e.case.title} + +
+ ) : "—"} +
{e.description}{e.profile?.full_name || e.profile?.email || "—"}{formatCurrency(e.amount)}
+
+
+
+
); } @@ -188,166 +333,3 @@ function Stat({ label, value }: { label: string; value: string }) { ); } - -function ClientPickerDialog({ - open, - onOpenChange, - onPick, -}: { - open: boolean; - onOpenChange: (b: boolean) => void; - onPick: (c: { id: string; name: string }) => void; -}) { - const [clients, setClients] = useState([]); - 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(); - const clientCaseCount = new Map(); - 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(); - 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 ( - - - - New invoice — choose a client - -
-
- - setQ(e.target.value)} - /> -
-
- {loading &&
Loading clients…
} - {!loading && filtered.length === 0 && ( -
No matching clients.
- )} - {!loading && filtered.map((c) => ( - - ))} -
-

- Pick a client to see a checklist of their cases and a summary of unbilled time and expenses on each. -

-
- - - -
-
- ); -}