feat(expenses): add monthly totals and pagination support for expenses
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
useExpenses,
|
||||
useCreateNonPeriodicExpense,
|
||||
usePayExpense,
|
||||
useMonthlyTotals,
|
||||
} from "@/lib/queries";
|
||||
import type {
|
||||
PeriodicExpense,
|
||||
@@ -15,16 +16,24 @@ import type {
|
||||
CreatePeriodicExpenseInput,
|
||||
CreateNonPeriodicExpenseInput,
|
||||
PayExpenseInput,
|
||||
PaginatedResponse,
|
||||
MonthlyTotals,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface ExpensesContextValue {
|
||||
periodicExpenses: PeriodicExpense[];
|
||||
isLoadingPeriodic: boolean;
|
||||
|
||||
expenses: Expense[];
|
||||
expenses: PaginatedResponse<Expense>;
|
||||
isLoadingExpenses: boolean;
|
||||
statusFilter: string;
|
||||
setStatusFilter: (status: string) => void;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
pageSize: number;
|
||||
|
||||
monthlyTotals: MonthlyTotals | undefined;
|
||||
isLoadingTotals: boolean;
|
||||
|
||||
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>;
|
||||
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>;
|
||||
@@ -38,9 +47,15 @@ const ExpensesContext = createContext<ExpensesContextValue | null>(null);
|
||||
|
||||
export function ExpensesProvider({ children }: { children: ReactNode }) {
|
||||
const [statusFilter, setStatusFilter] = useState("PENDING");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses();
|
||||
const { data: expenses = [], isLoading: isLoadingExpenses } = useExpenses(statusFilter);
|
||||
const { data: expenses = { data: [], total: 0, page: 1, pageSize: 10 }, isLoading: isLoadingExpenses } =
|
||||
useExpenses(statusFilter, page, pageSize);
|
||||
|
||||
const now = new Date();
|
||||
const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1);
|
||||
|
||||
const createPeriodicMutation = useCreatePeriodicExpense();
|
||||
const updatePeriodicMutation = useUpdatePeriodicExpense();
|
||||
@@ -100,6 +115,11 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
|
||||
isLoadingExpenses,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
monthlyTotals,
|
||||
isLoadingTotals,
|
||||
createPeriodicExpense,
|
||||
updatePeriodicExpense: updatePeriodicExpenseFn,
|
||||
deletePeriodicExpense: deletePeriodicExpenseFn,
|
||||
|
||||
@@ -7,8 +7,23 @@ import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Expense } from "@/lib/api";
|
||||
|
||||
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function ExpensesTabContent() {
|
||||
const { expenses, isLoadingExpenses, statusFilter, setStatusFilter } = useExpensesContext();
|
||||
const {
|
||||
expenses,
|
||||
isLoadingExpenses,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
monthlyTotals,
|
||||
isLoadingTotals,
|
||||
} = useExpensesContext();
|
||||
|
||||
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null);
|
||||
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
||||
@@ -28,7 +43,7 @@ export function ExpensesTabContent() {
|
||||
key={f.value}
|
||||
variant={statusFilter === f.value ? "default" : "outline"}
|
||||
size="xs"
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
@@ -43,9 +58,35 @@ export function ExpensesTabContent() {
|
||||
{isLoadingExpenses ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
) : (
|
||||
<ExpensesTable data={expenses} onPay={setPayDialogExpense} />
|
||||
<ExpensesTable
|
||||
data={expenses.data}
|
||||
onPay={setPayDialogExpense}
|
||||
page={expenses.page}
|
||||
total={expenses.total}
|
||||
pageSize={expenses.pageSize}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Pagado</p>
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{isLoadingTotals
|
||||
? "..."
|
||||
: `$${priceFormatter.format(monthlyTotals?.payed ?? 0)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Pendiente</p>
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{isLoadingTotals
|
||||
? "..."
|
||||
: `$${priceFormatter.format(monthlyTotals?.pending ?? 0)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PayExpenseDialog
|
||||
expense={payDialogExpense}
|
||||
open={payDialogExpense !== null}
|
||||
|
||||
@@ -8,13 +8,25 @@ import {
|
||||
import type { Expense } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CircleDollarSign } from "lucide-react";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface ExpensesTableProps {
|
||||
data: Expense[];
|
||||
onPay: (expense: Expense) => void;
|
||||
page: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function ExpensesTable({ data, onPay }: ExpensesTableProps) {
|
||||
export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange }: ExpensesTableProps) {
|
||||
const columns = useMemo<ColumnDef<Expense>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -101,42 +113,78 @@ export function ExpensesTable({ data, onPay }: ExpensesTableProps) {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b bg-muted/50">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2.5">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b bg-muted/50">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2.5">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No hay gastos para mostrar.
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No hay gastos para mostrar.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={(e) => { e.preventDefault(); if (page > 1) onPageChange(page - 1); }}
|
||||
href="#"
|
||||
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={p === page}
|
||||
onClick={(e) => { e.preventDefault(); onPageChange(p); }}
|
||||
href="#"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }}
|
||||
href="#"
|
||||
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user