diff --git a/src/components/cases/invoices-tab.tsx b/src/components/cases/invoices-tab.tsx index a755064..fd0d1bb 100644 --- a/src/components/cases/invoices-tab.tsx +++ b/src/components/cases/invoices-tab.tsx @@ -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([]); const [unbilledTime, setUnbilledTime] = useState([]); const [unbilledExpenses, setUnbilledExpenses] = useState([]); - 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 }) {
{formatCurrency(subtotal)}
- @@ -80,14 +86,6 @@ export function CaseInvoicesTab({ caseRecord }: { caseRecord: any }) { - - ); } 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/clients.$clientId.tsx b/src/routes/clients.$clientId.tsx index 0630839..8d7c0dd 100644 --- a/src/routes/clients.$clientId.tsx +++ b/src/routes/clients.$clientId.tsx @@ -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([]); 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() { )} )} - {collection.case?.client?.id && ( - )} @@ -539,16 +539,6 @@ function CollectionDetailRoute() { caseId={collection.case?.id ?? null} onApplied={refreshTasks} /> - - {collection.case?.client?.id && ( - - )} ); 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. -

-
- - - -
-
- ); -} diff --git a/src/routes/invoices.new.$clientId.tsx b/src/routes/invoices.new.$clientId.tsx new file mode 100644 index 0000000..57b039d --- /dev/null +++ b/src/routes/invoices.new.$clientId.tsx @@ -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): SearchParams => ({ + caseId: typeof search.caseId === "string" ? search.caseId : undefined, + }), + component: () => ( + + + + ), +}); + +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(""); + const [cases, setCases] = useState([]); + const [selected, setSelected] = useState>({}); + 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 = {}; + 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 = {}; + 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 ( + + navigate({ to: "/invoices/new" })}> + Change client + + } + /> + +
+ + + + + + + {isRetainer ? ( + + +
+ + setRetainerAmount(e.target.value)} + /> +
+
+ + setTaxPct(e.target.value)} /> +
+
+ + setDueDays(e.target.value)} /> +
+
+
+ ) : loading ? ( + + Loading unbilled work… + + ) : cases.length === 0 ? ( + + + No unbilled time or expenses across this client's cases. + + + ) : ( + <> + + +
+ +
+ + · + +
+
+
+ {cases.map((c) => ( + + ))} +
+
+
+ + {selectedIds.length > 1 && ( + + + + + + )} + + + +
+ + setTaxPct(e.target.value)} /> +
+
+ + setDueDays(e.target.value)} /> +
+
+
+ {splitPerCase && selectedIds.length > 1 ? "Combined total" : "Estimated total"} +
+
{formatCurrency(total)}
+ {tax > 0 &&
{formatCurrency(subtotal)} + {formatCurrency(tax)} tax
} +
+
+
+ + )} + + + + +