feat/expenses-management #1
@@ -7,6 +7,8 @@ import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense
|
|||||||
import { listExpensesHandler } from "./handlers/listExpenses";
|
import { listExpensesHandler } from "./handlers/listExpenses";
|
||||||
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
|
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
|
||||||
import { payExpenseHandler } from "./handlers/payExpense";
|
import { payExpenseHandler } from "./handlers/payExpense";
|
||||||
|
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
|
||||||
|
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
|
|||||||
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
|
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
|
||||||
app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler);
|
app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler);
|
||||||
|
|
||||||
|
app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
|
||||||
|
app.get("/expenses/totals", getMonthlyTotalsHandler);
|
||||||
app.get("/expenses", listExpensesHandler);
|
app.get("/expenses", listExpensesHandler);
|
||||||
app.post("/expenses", createNonPeriodicExpenseHandler);
|
app.post("/expenses", createNonPeriodicExpenseHandler);
|
||||||
app.put("/expenses/:id/pay", payExpenseHandler);
|
app.put("/expenses/:id/pay", payExpenseHandler);
|
||||||
|
|||||||
@@ -84,16 +84,27 @@ export async function generateMonthlyExpense(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listExpenses(status?: string) {
|
export async function listExpenses(params: {
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
|
const { status, page = 1, pageSize = 10 } = params;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (status === "PENDING" || status === "PAYED") {
|
if (status === "PENDING" || status === "PAYED") {
|
||||||
where.status = status;
|
where.status = status;
|
||||||
}
|
}
|
||||||
return prisma.expense.findMany({
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.expense.findMany({
|
||||||
where,
|
where,
|
||||||
include: { periodicExpense: true },
|
include: { periodicExpense: true },
|
||||||
orderBy: { dueDate: "asc" },
|
orderBy: { dueDate: "desc" },
|
||||||
});
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.expense.count({ where }),
|
||||||
|
]);
|
||||||
|
return { data, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
|
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
|
||||||
@@ -126,6 +137,32 @@ export async function payExpense(id: number, data: z.infer<typeof payExpenseSche
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMonthlyPayedTotal(year: number, month: number) {
|
||||||
|
const expenses = await prisma.expense.findMany({
|
||||||
|
where: { year, month, status: PaymentStatus.PAYED },
|
||||||
|
select: { amountPayed: true },
|
||||||
|
});
|
||||||
|
const total = expenses.reduce((sum, e) => sum + Number(e.amountPayed ?? 0), 0);
|
||||||
|
return { total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMonthlyTotals(year: number, month: number) {
|
||||||
|
const [payed, pending] = await Promise.all([
|
||||||
|
prisma.expense.findMany({
|
||||||
|
where: { year, month, status: PaymentStatus.PAYED },
|
||||||
|
select: { amountPayed: true },
|
||||||
|
}),
|
||||||
|
prisma.expense.findMany({
|
||||||
|
where: { year, month, status: PaymentStatus.PENDING },
|
||||||
|
select: { amount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
payed: payed.reduce((sum, e) => sum + Number(e.amountPayed ?? 0), 0),
|
||||||
|
pending: pending.reduce((sum, e) => sum + Number(e.amount), 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateExpensesForCurrentMonth() {
|
export async function generateExpensesForCurrentMonth() {
|
||||||
const { year, month } = getCurrentYearMonthUTC();
|
const { year, month } = getCurrentYearMonthUTC();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { getMonthlyPayedTotal } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function getMonthlyPayedTotalHandler(c: Context) {
|
||||||
|
const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10);
|
||||||
|
const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10);
|
||||||
|
const result = await getMonthlyPayedTotal(year, month);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { getMonthlyTotals } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function getMonthlyTotalsHandler(c: Context) {
|
||||||
|
const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10);
|
||||||
|
const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10);
|
||||||
|
const result = await getMonthlyTotals(year, month);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { listExpenses } from "../expenses.service";
|
|||||||
|
|
||||||
export async function listExpensesHandler(c: Context) {
|
export async function listExpensesHandler(c: Context) {
|
||||||
const status = c.req.query("status");
|
const status = c.req.query("status");
|
||||||
const expenses = await listExpenses(status);
|
const page = parseInt(c.req.query("page") ?? "1", 10);
|
||||||
return c.json(expenses);
|
const pageSize = parseInt(c.req.query("pageSize") ?? "10", 10);
|
||||||
|
const result = await listExpenses({ status, page, pageSize });
|
||||||
|
return c.json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
129
apps/frontend/src/components/ui/pagination.tsx
Normal file
129
apps/frontend/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex items-center gap-0.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={isActive ? "outline" : "ghost"}
|
||||||
|
size={size}
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
text = "Previous",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pl-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon data-icon="inline-start" />
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
text = "Next",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pr-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
<ChevronRightIcon data-icon="inline-end" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
|
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
|
||||||
import { RelativeTime } from "@/lib/time";
|
import { RelativeTime } from "@/lib/time";
|
||||||
import type { Quote } from "@/lib/api";
|
import type { Quote } from "@/lib/api";
|
||||||
import { useQuotes, useFetchQuotes } from "@/lib/queries";
|
import { useQuotes, useFetchQuotes, useMonthlyPayedTotal } from "@/lib/queries";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -101,6 +101,9 @@ export function DashboardPage() {
|
|||||||
const belo = quotes?.find((q) => q.type === "BELO");
|
const belo = quotes?.find((q) => q.type === "BELO");
|
||||||
const blue = quotes?.find((q) => q.type === "BLUE");
|
const blue = quotes?.find((q) => q.type === "BLUE");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const { data: monthlyExpenses } = useMonthlyPayedTotal(now.getFullYear(), now.getMonth() + 1);
|
||||||
|
|
||||||
const isFetching = isLoading || isPending;
|
const isFetching = isLoading || isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +127,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Gastos del mes</p>
|
<p className="text-sm text-muted-foreground">Gastos del mes</p>
|
||||||
<p className="text-2xl font-bold">$0.00</p>
|
<p className="text-2xl font-bold">${priceFormatter.format(monthlyExpenses?.total ?? 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
|
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
|
||||||
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
|
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useExpenses,
|
useExpenses,
|
||||||
useCreateNonPeriodicExpense,
|
useCreateNonPeriodicExpense,
|
||||||
usePayExpense,
|
usePayExpense,
|
||||||
|
useMonthlyTotals,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import type {
|
import type {
|
||||||
PeriodicExpense,
|
PeriodicExpense,
|
||||||
@@ -15,16 +16,24 @@ import type {
|
|||||||
CreatePeriodicExpenseInput,
|
CreatePeriodicExpenseInput,
|
||||||
CreateNonPeriodicExpenseInput,
|
CreateNonPeriodicExpenseInput,
|
||||||
PayExpenseInput,
|
PayExpenseInput,
|
||||||
|
PaginatedResponse,
|
||||||
|
MonthlyTotals,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
interface ExpensesContextValue {
|
interface ExpensesContextValue {
|
||||||
periodicExpenses: PeriodicExpense[];
|
periodicExpenses: PeriodicExpense[];
|
||||||
isLoadingPeriodic: boolean;
|
isLoadingPeriodic: boolean;
|
||||||
|
|
||||||
expenses: Expense[];
|
expenses: PaginatedResponse<Expense>;
|
||||||
isLoadingExpenses: boolean;
|
isLoadingExpenses: boolean;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
setStatusFilter: (status: string) => void;
|
setStatusFilter: (status: string) => void;
|
||||||
|
page: number;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
pageSize: number;
|
||||||
|
|
||||||
|
monthlyTotals: MonthlyTotals | undefined;
|
||||||
|
isLoadingTotals: boolean;
|
||||||
|
|
||||||
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>;
|
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>;
|
||||||
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>;
|
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>;
|
||||||
@@ -38,9 +47,15 @@ const ExpensesContext = createContext<ExpensesContextValue | null>(null);
|
|||||||
|
|
||||||
export function ExpensesProvider({ children }: { children: ReactNode }) {
|
export function ExpensesProvider({ children }: { children: ReactNode }) {
|
||||||
const [statusFilter, setStatusFilter] = useState("PENDING");
|
const [statusFilter, setStatusFilter] = useState("PENDING");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses();
|
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 createPeriodicMutation = useCreatePeriodicExpense();
|
||||||
const updatePeriodicMutation = useUpdatePeriodicExpense();
|
const updatePeriodicMutation = useUpdatePeriodicExpense();
|
||||||
@@ -100,6 +115,11 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
|
|||||||
isLoadingExpenses,
|
isLoadingExpenses,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
monthlyTotals,
|
||||||
|
isLoadingTotals,
|
||||||
createPeriodicExpense,
|
createPeriodicExpense,
|
||||||
updatePeriodicExpense: updatePeriodicExpenseFn,
|
updatePeriodicExpense: updatePeriodicExpenseFn,
|
||||||
deletePeriodicExpense: deletePeriodicExpenseFn,
|
deletePeriodicExpense: deletePeriodicExpenseFn,
|
||||||
|
|||||||
@@ -7,8 +7,23 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import type { Expense } from "@/lib/api";
|
import type { Expense } from "@/lib/api";
|
||||||
|
|
||||||
|
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
export function ExpensesTabContent() {
|
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 [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null);
|
||||||
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
||||||
@@ -28,7 +43,7 @@ export function ExpensesTabContent() {
|
|||||||
key={f.value}
|
key={f.value}
|
||||||
variant={statusFilter === f.value ? "default" : "outline"}
|
variant={statusFilter === f.value ? "default" : "outline"}
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => setStatusFilter(f.value)}
|
onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -43,9 +58,35 @@ export function ExpensesTabContent() {
|
|||||||
{isLoadingExpenses ? (
|
{isLoadingExpenses ? (
|
||||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
<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
|
<PayExpenseDialog
|
||||||
expense={payDialogExpense}
|
expense={payDialogExpense}
|
||||||
open={payDialogExpense !== null}
|
open={payDialogExpense !== null}
|
||||||
|
|||||||
@@ -8,13 +8,25 @@ import {
|
|||||||
import type { Expense } from "@/lib/api";
|
import type { Expense } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CircleDollarSign } from "lucide-react";
|
import { CircleDollarSign } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
interface ExpensesTableProps {
|
interface ExpensesTableProps {
|
||||||
data: Expense[];
|
data: Expense[];
|
||||||
onPay: (expense: Expense) => void;
|
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>[]>(
|
const columns = useMemo<ColumnDef<Expense>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -101,7 +113,10 @@ export function ExpensesTable({ data, onPay }: ExpensesTableProps) {
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -138,5 +153,38 @@ export function ExpensesTable({ data, onPay }: ExpensesTableProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,9 +158,41 @@ export function generateMonthlyExpense(id: number): Promise<Expense> {
|
|||||||
return mutator<Expense>(`/api/periodic-expenses/${id}/generate-month`, "POST");
|
return mutator<Expense>(`/api/periodic-expenses/${id}/generate-month`, "POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExpenses(status?: string): Promise<Expense[]> {
|
export type MonthlyExpensesTotal = { total: number };
|
||||||
const params = status ? `?status=${status}` : "";
|
|
||||||
return fetcher<Expense[]>(`/api/expenses${params}`);
|
export function getMonthlyPayedTotal(year?: number, month?: number): Promise<MonthlyExpensesTotal> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (year) params.set("year", String(year));
|
||||||
|
if (month) params.set("month", String(month));
|
||||||
|
return fetcher<MonthlyExpensesTotal>(`/api/expenses/monthly-total?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaginatedResponse<T> = {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MonthlyTotals = {
|
||||||
|
payed: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getExpenses(status?: string, page?: number, pageSize?: number): Promise<PaginatedResponse<Expense>> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
if (page) params.set("page", String(page));
|
||||||
|
if (pageSize) params.set("pageSize", String(pageSize));
|
||||||
|
const qs = params.toString();
|
||||||
|
return fetcher<PaginatedResponse<Expense>>(`/api/expenses${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthlyTotals(year?: number, month?: number): Promise<MonthlyTotals> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (year) params.set("year", String(year));
|
||||||
|
if (month) params.set("month", String(month));
|
||||||
|
return fetcher<MonthlyTotals>(`/api/expenses/totals?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNonPeriodicExpense(data: CreateNonPeriodicExpenseInput): Promise<Expense> {
|
export function createNonPeriodicExpense(data: CreateNonPeriodicExpenseInput): Promise<Expense> {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
generateMonthlyExpense as generateMonthlyExpenseApi,
|
generateMonthlyExpense as generateMonthlyExpenseApi,
|
||||||
getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi,
|
getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi,
|
||||||
payExpense as payExpenseApi,
|
payExpense as payExpenseApi,
|
||||||
|
getMonthlyPayedTotal,
|
||||||
|
getMonthlyTotals,
|
||||||
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
|
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
@@ -72,7 +74,10 @@ export function useDailyQuotes(type: string, startDate: string, endDate: string)
|
|||||||
export const expenseKeys = {
|
export const expenseKeys = {
|
||||||
periodic: ["periodic-expenses"] as const,
|
periodic: ["periodic-expenses"] as const,
|
||||||
all: ["expenses"] as const,
|
all: ["expenses"] as const,
|
||||||
byStatus: (status: string) => ["expenses", status] as const,
|
byStatus: (status: string, page: number, pageSize: number) =>
|
||||||
|
["expenses", status, page, pageSize] as const,
|
||||||
|
monthlyTotal: (year: number, month: number) => ["expenses", "monthly-total", year, month] as const,
|
||||||
|
monthlyTotals: (year: number, month: number) => ["expenses", "totals", year, month] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePeriodicExpenses() {
|
export function usePeriodicExpenses() {
|
||||||
@@ -123,10 +128,24 @@ export function useGenerateMonthlyExpense() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExpenses(status: string) {
|
export function useExpenses(status: string, page: number = 1, pageSize: number = 10) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: expenseKeys.byStatus(status),
|
queryKey: expenseKeys.byStatus(status, page, pageSize),
|
||||||
queryFn: () => getExpenses(status === "all" ? undefined : status),
|
queryFn: () => getExpenses(status === "all" ? undefined : status, page, pageSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMonthlyTotals(year: number, month: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: expenseKeys.monthlyTotals(year, month),
|
||||||
|
queryFn: () => getMonthlyTotals(year, month),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMonthlyPayedTotal(year: number, month: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: expenseKeys.monthlyTotal(year, month),
|
||||||
|
queryFn: () => getMonthlyPayedTotal(year, month),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user