diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index ca0d0b8..6152a4c 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -3,7 +3,10 @@ import { useMemo, useState } from "react"; import { api } from "~/trpc/react"; import { DashboardPageHeader } from "~/components/layout/page-header"; -import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; +import { + DashboardPage, + dashboardStatGridClass, +} from "~/components/layout/dashboard-page"; import { EmptyState } from "~/components/layout/page-layout"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; @@ -31,9 +34,22 @@ import { NumberInput } from "~/components/ui/number-input"; import { ExpenseReceiptsPanel } from "~/components/expenses/expense-receipts-panel"; import { ExpenseReceiptIndicator } from "~/components/expenses/expense-receipt-indicator"; import { toast } from "sonner"; -import { Plus, Pencil, Trash2, Receipt, Eye } from "lucide-react"; +import { + MoreHorizontal, + Pencil, + Plus, + Receipt, + Search, + Trash2, +} from "lucide-react"; import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; interface ExpenseFormData { date: Date; @@ -64,6 +80,7 @@ const defaultForm: ExpenseFormData = { }; type ExpenseDialogMode = "create" | "view" | "edit"; +type ExpenseFilter = "all" | "billable" | "deductible" | "receipts"; function expenseToForm( expense: { @@ -103,6 +120,8 @@ export default function ExpensesPage() { const [form, setForm] = useState(defaultForm); const [deleteId, setDeleteId] = useState(null); const [businessFilter, setBusinessFilter] = useState("all"); + const [expenseFilter, setExpenseFilter] = useState("all"); + const [search, setSearch] = useState(""); const utils = api.useUtils(); const { data: businesses = [] } = api.businesses.getAll.useQuery(); @@ -192,7 +211,29 @@ export default function ExpensesPage() { else create.mutate(payload); }; + const filteredExpenses = useMemo(() => { + const needle = search.trim().toLowerCase(); + return expenses.filter((expense) => { + if (expenseFilter === "billable" && !expense.billable) return false; + if (expenseFilter === "deductible" && !expense.taxDeductible) + return false; + if (expenseFilter === "receipts" && expense.receiptCount === 0) + return false; + if (!needle) return true; + return [ + expense.description, + expense.category, + expense.notes, + expense.business?.name, + expense.client?.name, + ] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(needle)); + }); + }, [expenseFilter, expenses, search]); + const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); + const visibleTotal = filteredExpenses.reduce((s, e) => s + e.amount, 0); const billableTotal = expenses .filter((e) => e.billable) .reduce((s, e) => s + e.amount, 0); @@ -200,6 +241,10 @@ export default function ExpensesPage() { .filter((e) => e.taxDeductible) .reduce((s, e) => s + e.amount, 0); const withReceipts = expenses.filter((e) => e.receiptCount > 0).length; + const hasActiveFilters = + search.trim().length > 0 || + expenseFilter !== "all" || + businessFilter !== "all"; const isViewMode = dialogMode === "view"; const isEditMode = dialogMode === "edit"; @@ -239,23 +284,6 @@ export default function ExpensesPage() { -
- - -
-
@@ -298,10 +326,64 @@ export default function ExpensesPage() {
- - - All Expenses - + +
+
+ + Expenses + +

+ {filteredExpenses.length === expenses.length + ? `${expenses.length} recorded` + : `${filteredExpenses.length} of ${expenses.length} shown`} + {filteredExpenses.length !== expenses.length + ? ` · ${formatCurrency(visibleTotal)} visible` + : ""} +

+
+
+
+ + setSearch(e.target.value)} + placeholder="Search expenses" + className="pl-9" + /> +
+ +
+
+
+ {[ + ["all", "All"] as const, + ["billable", "Billable"] as const, + ["deductible", "Deductible"] as const, + ["receipts", "With receipts"] as const, + ].map(([value, label]) => ( + + ))} +
{isLoading ? ( @@ -320,16 +402,36 @@ export default function ExpensesPage() { } /> + ) : filteredExpenses.length === 0 ? ( + } + title="No matching expenses" + description="Adjust the search or filters to bring expenses back into view." + action={ + hasActiveFilters ? ( + + ) : undefined + } + /> ) : ( <> -
+
Expense Receipts Amount - +
- {expenses.map((expense) => ( + {filteredExpenses.map((expense) => (
@@ -400,42 +502,43 @@ export default function ExpensesPage() { />
-
-

- {formatCurrency(expense.amount, expense.currency)} -

-
e.stopPropagation()} - > - - - -
+

+ {formatCurrency(expense.amount, expense.currency)} +

+ +
e.stopPropagation()} + > + + + + + + handleView(expense)}> + + View details + + handleEdit(expense)}> + + Edit + + setDeleteId(expense.id)} + > + + Delete + + +
))} @@ -540,163 +643,171 @@ export default function ExpensesPage() {
) : ( <> -
- - - setForm((p) => ({ ...p, description: e.target.value })) - } - placeholder="e.g. Laptop charger" - /> -
-
-
- - setForm((p) => ({ ...p, amount: v }))} - min={0} - step={0.01} - /> -
-
- - -
-
-
-
- - - setForm((p) => ({ ...p, date: d ?? new Date() })) - } - className="w-full" - /> -
-
- - -
-
-
- - -
-
- - -
-
- - - -
-
- - - setForm((p) => ({ ...p, notes: e.target.value })) - } - placeholder="Additional details…" - /> -
+
+ + + setForm((p) => ({ ...p, description: e.target.value })) + } + placeholder="e.g. Laptop charger" + /> +
+
+
+ + setForm((p) => ({ ...p, amount: v }))} + min={0} + step={0.01} + /> +
+
+ + +
+
+
+
+ + + setForm((p) => ({ ...p, date: d ?? new Date() })) + } + className="w-full" + /> +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + + +
+
+ + + setForm((p) => ({ ...p, notes: e.target.value })) + } + placeholder="Additional details…" + /> +
)} @@ -715,19 +826,19 @@ export default function ExpensesPage() { ) : ( <> - - + + )}