diff --git a/src/components/cases/expenses-tab.tsx b/src/components/cases/expenses-tab.tsx index 4bc0889..193b6b4 100644 --- a/src/components/cases/expenses-tab.tsx +++ b/src/components/cases/expenses-tab.tsx @@ -6,8 +6,9 @@ import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; -import { Plus, Trash2, Loader2, Receipt as ReceiptIcon } from "lucide-react"; +import { Plus, Trash2, Loader2, Receipt as ReceiptIcon, CheckCircle2, Undo2 } from "lucide-react"; import { formatCurrency, formatDate } from "@/lib/format"; +import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation"; import { toast } from "sonner"; import { Select, @@ -28,6 +29,7 @@ interface FeeItem { export function CaseExpensesTab({ caseId }: { caseId: string }) { const { user } = useAuth(); const [items, setItems] = useState([]); + const [clientId, setClientId] = useState(null); const [showForm, setShowForm] = useState(false); const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState({ @@ -53,6 +55,35 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { useEffect(() => { load(); }, [caseId]); + // Load case client_id for placeholder invoice creation + useEffect(() => { + (async () => { + const { data } = await supabase.from("cases").select("client_id").eq("id", caseId).maybeSingle(); + setClientId((data as any)?.client_id ?? null); + })(); + }, [caseId]); + + const toggleInvoiced = async (item: any) => { + if (item.invoice_id) { + const { error } = await supabase.from("expenses").update({ invoice_id: null }).eq("id", item.id); + if (error) { toast.error(error.message); return; } + toast.success("Marked as unbilled"); + load(); + } else { + if (!user?.id) { toast.error("Not signed in"); return; } + if (!clientId) { toast.error("Case has no client to invoice against"); return; } + try { + const invId = await ensureMarkedInvoicedPlaceholder(clientId, caseId, user.id); + const { error } = await supabase.from("expenses").update({ invoice_id: invId }).eq("id", item.id); + if (error) throw error; + toast.success("Marked as invoiced"); + load(); + } catch (err: any) { + toast.error(err?.message ?? "Could not mark as invoiced"); + } + } + }; + useEffect(() => { (async () => { const { data } = await supabase @@ -234,7 +265,24 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) { {formatCurrency(e.amount)} {!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"} - {!e.invoice_id && } +
+ {e.billable && ( + + )} + {!e.invoice_id && } +
))} diff --git a/src/components/cases/time-tab.tsx b/src/components/cases/time-tab.tsx index 5623590..79d5c14 100644 --- a/src/components/cases/time-tab.tsx +++ b/src/components/cases/time-tab.tsx @@ -7,8 +7,9 @@ import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; -import { Plus, Trash2, Loader2, FilePlus, PlusCircle } from "lucide-react"; +import { Plus, Trash2, Loader2, FilePlus, PlusCircle, CheckCircle2, Undo2 } from "lucide-react"; import { formatCurrency, formatDate } from "@/lib/format"; +import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation"; import { roundToSixth } from "@/lib/timer"; import { toast } from "sonner"; import { @@ -134,6 +135,28 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) { if (error) toast.error(error.message); else { toast.success("Deleted"); load(); } }; + const toggleInvoiced = async (entry: any) => { + if (entry.invoice_id) { + // Unmark + const { error } = await supabase.from("time_entries").update({ invoice_id: null }).eq("id", entry.id); + if (error) { toast.error(error.message); return; } + toast.success("Marked as unbilled"); + load(); + } else { + if (!user?.id) { toast.error("Not signed in"); return; } + if (!caseRecord.client_id) { toast.error("Case has no client to invoice against"); return; } + try { + const invId = await ensureMarkedInvoicedPlaceholder(caseRecord.client_id, caseRecord.id, user.id); + const { error } = await supabase.from("time_entries").update({ invoice_id: invId }).eq("id", entry.id); + if (error) throw error; + toast.success("Marked as invoiced"); + load(); + } catch (err: any) { + toast.error(err?.message ?? "Could not mark as invoiced"); + } + } + }; + const totals = entries.reduce( (acc, e) => ({ hours: acc.hours + Number(e.hours), @@ -286,7 +309,24 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) { {e.billable ? formatCurrency(Number(e.hours) * Number(e.hourly_rate)) : "—"} {!e.billable ? "Non-billable" : e.invoice_id ? "Invoiced" : "Unbilled"} - {!e.invoice_id && } +
+ {e.billable && ( + + )} + {!e.invoice_id && } +
))} diff --git a/src/lib/invoice-generation.ts b/src/lib/invoice-generation.ts index b7c9eac..6038a97 100644 --- a/src/lib/invoice-generation.ts +++ b/src/lib/invoice-generation.ts @@ -145,6 +145,47 @@ export async function generateInvoiceForClient(args: GenerateInvoiceArgs): Promi return { invoiceId: inv.id, invoiceNumber: inv.invoice_number }; } +// Find or create a per-client placeholder invoice used to mark items +// as "already invoiced" outside of normal invoice generation (e.g. manual +// flagging or imports). Items linked to this invoice are treated as billed +// and won't appear on unbilled lists. +export async function ensureMarkedInvoicedPlaceholder( + clientId: string, + caseId: string | null, + createdBy: string, +): Promise { + // Look for an existing placeholder for this client (any case). + const { data: existing } = await supabase + .from("invoices") + .select("id") + .eq("client_id", clientId) + .like("invoice_number", "MARKED-INVOICED-%") + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (existing?.id) return existing.id; + + const invNumber = `MARKED-INVOICED-${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const { data, error } = await supabase + .from("invoices") + .insert({ + client_id: clientId, + case_id: caseId, + invoice_number: invNumber, + status: "void", + issue_date: new Date().toISOString().slice(0, 10), + subtotal: 0, + tax: 0, + total: 0, + notes: "Placeholder invoice for items manually marked as already invoiced.", + created_by: createdBy, + } as any) + .select("id") + .single(); + if (error || !data) throw error ?? new Error("Could not create placeholder invoice"); + return data.id; +} + export async function recalcInvoiceTotals(invoiceId: string, taxRate?: number) { const { data: items } = await supabase .from("invoice_line_items") diff --git a/src/routes/invoices.index.tsx b/src/routes/invoices.index.tsx index 69ee1a4..a7b030c 100644 --- a/src/routes/invoices.index.tsx +++ b/src/routes/invoices.index.tsx @@ -6,11 +6,15 @@ 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 { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { supabase } from "@/integrations/supabase/client"; -import { Receipt, Search, FilePlus, Clock, DollarSign } from "lucide-react"; +import { Receipt, Search, FilePlus, Clock, DollarSign, CheckCircle2, Undo2 } from "lucide-react"; import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format"; +import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation"; +import { useAuth } from "@/lib/auth"; +import { toast } from "sonner"; export const Route = createFileRoute("/invoices/")({ component: () => ( @@ -20,16 +24,27 @@ export const Route = createFileRoute("/invoices/")({ ), }); +type UnbilledStatus = "unbilled" | "marked" | "all"; + function InvoicesIndex() { const navigate = useNavigate(); + const { user } = useAuth(); const [invoices, setInvoices] = useState([]); const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); const [status, setStatus] = useState("all"); - const [unbilledTime, setUnbilledTime] = useState([]); - const [unbilledExpenses, setUnbilledExpenses] = useState([]); + const [time, setTime] = useState([]); + const [expenses, setExpenses] = useState([]); const [tabsLoaded, setTabsLoaded] = useState(false); + const [timeFilter, setTimeFilter] = useState("unbilled"); + const [expFilter, setExpFilter] = useState("unbilled"); + const [selectedTime, setSelectedTime] = useState>(new Set()); + const [selectedExp, setSelectedExp] = useState>(new Set()); + const [working, setWorking] = useState(false); + + // Map of placeholder invoice id -> true (placeholder = MARKED-INVOICED-* or IMPORT-PREBILLED-*) + const [placeholderIds, setPlaceholderIds] = useState>(new Set()); useEffect(() => { (async () => { @@ -39,32 +54,41 @@ function InvoicesIndex() { .order("created_at", { ascending: false }); setInvoices(data ?? []); setLoading(false); + + // Track placeholder invoices so we can identify "marked-invoiced" items + const placeholders = new Set( + (data ?? []) + .filter((i: any) => + typeof i.invoice_number === "string" && + (i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-")) + ) + .map((i: any) => i.id), + ); + setPlaceholderIds(placeholders); })(); }, []); - 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 loadUnbilled = async () => { + const [{ data: t }, { data: e }] = await Promise.all([ + supabase + .from("time_entries") + .select("id, work_date, description, hours, hourly_rate, billable, user_id, invoice_id, case_id, case:cases(id, case_number, title, client_id, client:clients(id, name)), profile:profiles!time_entries_user_id_fkey(id, full_name, email)") + .eq("billable", true) + .order("work_date", { ascending: false }) + .limit(2000), + supabase + .from("expenses") + .select("id, expense_date, description, amount, billable, user_id, invoice_id, case_id, case:cases(id, case_number, title, client_id, client:clients(id, name)), profile:profiles!expenses_user_id_fkey(id, full_name, email)") + .eq("billable", true) + .order("expense_date", { ascending: false }) + .limit(2000), + ]); + setTime(t ?? []); + setExpenses(e ?? []); + setTabsLoaded(true); + }; + + useEffect(() => { loadUnbilled(); }, []); const filtered = useMemo(() => { return invoices.filter((i) => { @@ -85,15 +109,102 @@ 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] + // Classify each row: unbilled (no invoice_id), marked (linked to placeholder), real (linked to real invoice — not shown) + const isPlaceholder = (invId: string | null) => !!invId && placeholderIds.has(invId); + const rowStatus = (invId: string | null): UnbilledStatus => + !invId ? "unbilled" : isPlaceholder(invId) ? "marked" : "all"; // "all" sentinel for real-invoice rows we hide + + const visibleTime = useMemo(() => { + return time.filter((t) => { + const s = rowStatus(t.invoice_id); + if (s === "all" /* real invoice */) return false; + if (timeFilter === "all") return true; + return s === timeFilter; + }); + }, [time, timeFilter, placeholderIds]); + + const visibleExp = useMemo(() => { + return expenses.filter((e) => { + const s = rowStatus(e.invoice_id); + if (s === "all") return false; + if (expFilter === "all") return true; + return s === expFilter; + }); + }, [expenses, expFilter, placeholderIds]); + + const visibleTimeTotal = useMemo( + () => visibleTime.reduce((s, t) => s + Number(t.hours) * Number(t.hourly_rate), 0), + [visibleTime], ); - const unbilledExpenseTotal = useMemo( - () => unbilledExpenses.reduce((s, e) => s + Number(e.amount), 0), - [unbilledExpenses] + const visibleExpTotal = useMemo( + () => visibleExp.reduce((s, e) => s + Number(e.amount), 0), + [visibleExp], ); + const toggleSel = (set: Set, id: string) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }; + + const bulkUpdate = async ( + table: "time_entries" | "expenses", + rows: any[], + ids: Set, + markInvoiced: boolean, + ) => { + if (!user?.id) { toast.error("Not signed in"); return; } + if (ids.size === 0) { toast.error("Select at least one item"); return; } + setWorking(true); + try { + if (markInvoiced) { + // Group by client_id; create/find placeholder per client. + const byClient = new Map(); + const skipped: string[] = []; + for (const r of rows) { + if (!ids.has(r.id)) continue; + const cid = r.case?.client_id ?? r.case?.client?.id ?? null; + if (!cid || !r.case_id) { skipped.push(r.id); continue; } + const slot = byClient.get(cid); + if (slot) slot.rowIds.push(r.id); + else byClient.set(cid, { caseId: r.case_id, rowIds: [r.id] }); + } + for (const [cid, slot] of byClient) { + const invId = await ensureMarkedInvoicedPlaceholder(cid, slot.caseId, user.id); + const { error } = await supabase.from(table).update({ invoice_id: invId }).in("id", slot.rowIds); + if (error) throw error; + } + if (skipped.length) toast.warning(`${skipped.length} item(s) skipped — missing client/case`); + toast.success(`Marked ${ids.size - skipped.length} item(s) as invoiced`); + } else { + const idArr = Array.from(ids); + const { error } = await supabase.from(table).update({ invoice_id: null }).in("id", idArr); + if (error) throw error; + toast.success(`Marked ${idArr.length} item(s) as unbilled`); + } + // Refetch invoices to refresh placeholder set + unbilled lists + const { data: inv } = await supabase + .from("invoices") + .select("*, client:clients(id, name), case:cases(id, case_number, title)") + .order("created_at", { ascending: false }); + setInvoices(inv ?? []); + setPlaceholderIds(new Set( + (inv ?? []) + .filter((i: any) => + typeof i.invoice_number === "string" && + (i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-")) + ) + .map((i: any) => i.id), + )); + await loadUnbilled(); + if (table === "time_entries") setSelectedTime(new Set()); else setSelectedExp(new Set()); + } catch (err: any) { + toast.error(err?.message ?? "Bulk update failed"); + } finally { + setWorking(false); + } + }; + return ( Invoices - Unbilled time - {unbilledTime.length > 0 && ( - {unbilledTime.length} - )} + Time - Unbilled expenses - {unbilledExpenses.length > 0 && ( - {unbilledExpenses.length} - )} + Expenses @@ -203,17 +308,29 @@ function InvoicesIndex() { -
-
- {unbilledTime.length} unbilled time entries -
-
{formatCurrency(unbilledTimeTotal)}
-
+ bulkUpdate("time_entries", visibleTime, selectedTime, true)} + onMarkUnbilled={() => bulkUpdate("time_entries", visibleTime, selectedTime, false)} + onClearSel={() => setSelectedTime(new Set())} + /> + @@ -221,20 +338,28 @@ function InvoicesIndex() { + - {!tabsLoaded && } - {tabsLoaded && unbilledTime.length === 0 && ( - } + {tabsLoaded && visibleTime.length === 0 && ( + )} - {unbilledTime.map((t) => { + {visibleTime.map((t) => { const amount = Number(t.hours) * Number(t.hourly_rate); + const marked = isPlaceholder(t.invoice_id); return ( + + ); })} @@ -265,54 +397,83 @@ function InvoicesIndex() { -
-
- {unbilledExpenses.length} unbilled expenses -
-
{formatCurrency(unbilledExpenseTotal)}
-
+ bulkUpdate("expenses", visibleExp, selectedExp, true)} + onMarkUnbilled={() => bulkUpdate("expenses", visibleExp, selectedExp, false)} + onClearSel={() => setSelectedExp(new Set())} + />
+ 0 && selectedTime.size === visibleTime.length} + onCheckedChange={(c) => setSelectedTime(c ? new Set(visibleTime.map((t) => t.id)) : new Set())} + /> + Date Client / Case DescriptionHours Rate AmountStatus
Loading…
+ {!tabsLoaded &&
Loading…
- No unbilled time entries. + No matching time entries.
+ setSelectedTime((s) => toggleSel(s, t.id))} + /> + {formatDate(t.work_date)} {t.case ? ( @@ -255,6 +380,13 @@ function InvoicesIndex() { {Number(t.hours).toFixed(2)} {formatCurrency(t.hourly_rate)} {formatCurrency(amount)} + {marked ? ( + Marked invoiced + ) : ( + Unbilled + )} +
+ + - {!tabsLoaded && } - {tabsLoaded && unbilledExpenses.length === 0 && ( - } + {tabsLoaded && visibleExp.length === 0 && ( + )} - {unbilledExpenses.map((e) => ( - - - + + + - - - - - ))} + + ) : "—"} + + + + + + + ); + })}
+ 0 && selectedExp.size === visibleExp.length} + onCheckedChange={(c) => setSelectedExp(c ? new Set(visibleExp.map((e) => e.id)) : new Set())} + /> + Date Client / Case Description User AmountStatus
Loading…
+ {!tabsLoaded &&
Loading…
- No unbilled expenses. + No matching expenses.
{formatDate(e.expense_date)} - {e.case ? ( -
- {e.case.client && ( - - {e.case.client.name} + {visibleExp.map((e) => { + const marked = isPlaceholder(e.invoice_id); + return ( +
+ setSelectedExp((s) => toggleSel(s, e.id))} + /> + {formatDate(e.expense_date)} + {e.case ? ( +
+ {e.case.client && ( + + {e.case.client.name} + + )} + + {e.case.case_number} · {e.case.title} - )} - - {e.case.case_number} · {e.case.title} - -
- ) : "—"} -
{e.description}{e.profile?.full_name || e.profile?.email || "—"}{formatCurrency(e.amount)}
{e.description}{e.profile?.full_name || e.profile?.email || "—"}{formatCurrency(e.amount)} + {marked ? ( + Marked invoiced + ) : ( + Unbilled + )} +
@@ -323,6 +484,61 @@ function InvoicesIndex() { ); } +function UnbilledToolbar({ + label, + filter, + setFilter, + count, + total, + selectedCount, + working, + onMarkInvoiced, + onMarkUnbilled, + onClearSel, +}: { + label: string; + filter: UnbilledStatus; + setFilter: (v: UnbilledStatus) => void; + count: number; + total: number; + selectedCount: number; + working: boolean; + onMarkInvoiced: () => void; + onMarkUnbilled: () => void; + onClearSel: () => void; +}) { + return ( +
+
+ +
{count} {label}
+
+
+ {selectedCount > 0 && ( + <> + {selectedCount} selected + + + + + )} +
{formatCurrency(total)}
+
+
+ ); +} + function Stat({ label, value }: { label: string; value: string }) { return (