diff --git a/apps/backend/src/modules/expenses/expenses.routes.ts b/apps/backend/src/modules/expenses/expenses.routes.ts index 53ff3f3..fe5daa6 100644 --- a/apps/backend/src/modules/expenses/expenses.routes.ts +++ b/apps/backend/src/modules/expenses/expenses.routes.ts @@ -9,6 +9,9 @@ import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExp import { payExpenseHandler } from "./handlers/payExpense"; import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal"; import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals"; +import { getTotalPendingHandler } from "./handlers/getTotalPending"; +import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses"; +import { importExpensesHandler } from "./handlers/importExpenses"; const app = new Hono(); @@ -16,12 +19,15 @@ app.get("/periodic-expenses", listPeriodicExpensesHandler); app.post("/periodic-expenses", createPeriodicExpenseHandler); app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler); app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler); +app.post("/periodic-expenses/import", importPeriodicExpensesHandler); app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler); app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler); app.get("/expenses/totals", getMonthlyTotalsHandler); +app.get("/expenses/pending-total", getTotalPendingHandler); app.get("/expenses", listExpensesHandler); app.post("/expenses", createNonPeriodicExpenseHandler); +app.post("/expenses/import", importExpensesHandler); app.put("/expenses/:id/pay", payExpenseHandler); export default app; diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index 9842930..affb9ed 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -39,7 +39,7 @@ export function getCurrentYearMonthUTC(): { year: number; month: number } { export async function listPeriodicExpenses() { return prisma.periodicExpense.findMany({ where: { isDeleted: false }, - orderBy: { createdAt: "desc" }, + orderBy: { description: "asc" }, }); } @@ -163,6 +163,15 @@ export async function getMonthlyTotals(year: number, month: number) { }; } +export async function getTotalPending() { + const expenses = await prisma.expense.findMany({ + where: { status: PaymentStatus.PENDING }, + select: { amount: true }, + }); + const total = expenses.reduce((sum, e) => sum + Number(e.amount), 0); + return { total }; +} + export async function generateExpensesForCurrentMonth() { const { year, month } = getCurrentYearMonthUTC(); diff --git a/apps/backend/src/modules/expenses/handlers/getTotalPending.ts b/apps/backend/src/modules/expenses/handlers/getTotalPending.ts new file mode 100644 index 0000000..d6b30bc --- /dev/null +++ b/apps/backend/src/modules/expenses/handlers/getTotalPending.ts @@ -0,0 +1,7 @@ +import type { Context } from "hono"; +import { getTotalPending } from "../expenses.service"; + +export async function getTotalPendingHandler(c: Context) { + const result = await getTotalPending(); + return c.json(result); +} diff --git a/apps/backend/src/modules/expenses/handlers/importExpenses.ts b/apps/backend/src/modules/expenses/handlers/importExpenses.ts new file mode 100644 index 0000000..b91376a --- /dev/null +++ b/apps/backend/src/modules/expenses/handlers/importExpenses.ts @@ -0,0 +1,121 @@ +import type { Context } from "hono"; +import { prisma } from "../../../lib/prisma"; +import { logger } from "../../../lib/logger"; +import { PaymentStatus } from "../../../generated/prisma/client"; + +function parseAmount(raw: string): number | null { + const cleaned = raw.replace("$", "").replace(/,/g, "").trim(); + if (!cleaned) return null; + const n = Number(cleaned); + return isNaN(n) ? null : n; +} + +function parseDate(raw: string): Date | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const d = new Date(trimmed); + return isNaN(d.getTime()) ? null : d; +} + +export async function importExpensesHandler(c: Context) { + const body = await c.req.parseBody(); + const file = body["file"]; + + if (!file || !(file instanceof File)) { + return c.json({ error: "No se envió ningún archivo" }, 400); + } + + const text = await file.text(); + const lines = text.trim().split("\n"); + + if (lines.length < 2) { + return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400); + } + + let imported = 0; + let skipped = 0; + const errors: string[] = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + const parts = line.split("|"); + if (parts.length < 10) { + errors.push(`Línea ${i + 1}: formato inválido (${parts.length} columnas)`); + continue; + } + + const description = parts[9].trim(); + const year = Number(parts[2].trim()); + const month = Number(parts[3].trim()); + const rawAmount = parts[4].trim(); + const rawAmountPayed = parts[5].trim(); + const rawStatus = parts[6].trim(); + const rawDueDate = parts[7].trim(); + const rawPaymentDate = parts[8].trim(); + + try { + const amount = parseAmount(rawAmount); + if (amount === null) { + errors.push(`Línea ${i + 1} ("${description}"): monto inválido`); + continue; + } + + const amountPayed = parseAmount(rawAmountPayed); + const dueDate = parseDate(rawDueDate); + const paymentDate = parseDate(rawPaymentDate); + + if (!dueDate) { + errors.push(`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`); + continue; + } + + const existing = await prisma.expense.findFirst({ + where: { description, year, month }, + }); + if (existing) { + skipped++; + continue; + } + + let periodicExpenseId: number | null = null; + if (parts[1].trim()) { + const periodic = await prisma.periodicExpense.findFirst({ + where: { description: { equals: description, mode: "insensitive" } }, + }); + if (periodic) { + periodicExpenseId = periodic.id; + } + } + + const status = rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING; + + await prisma.expense.create({ + data: { + description, + year, + month, + amount, + amountPayed, + status, + dueDate, + paymentDate, + periodicExpenseId, + }, + }); + + imported++; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`Línea ${i + 1} ("${description}"): ${message}`); + logger.error({ err, description }, "Error importing expense"); + } + } + + return c.json({ + imported, + skipped, + errors: errors.length > 0 ? errors : undefined, + }); +} diff --git a/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts b/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts new file mode 100644 index 0000000..eebe56c --- /dev/null +++ b/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts @@ -0,0 +1,94 @@ +import type { Context } from "hono"; +import { prisma } from "../../../lib/prisma"; +import { logger } from "../../../lib/logger"; + +function parseArgentineAmount(raw: string): number { + const cleaned = raw.replace("$", "").replace(/,/g, "").trim(); + return Number(cleaned); +} + +function parsePeriods(raw: string): number[] { + const inner = raw.replace(/^\{|\}$/g, "").trim(); + if (!inner) return []; + return inner.split(",").map(Number); +} + +export async function importPeriodicExpensesHandler(c: Context) { + const body = await c.req.parseBody(); + const file = body["file"]; + + if (!file || !(file instanceof File)) { + return c.json({ error: "No se envió ningún archivo" }, 400); + } + + const text = await file.text(); + const lines = text.trim().split("\n"); + + if (lines.length < 2) { + return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400); + } + + let imported = 0; + let skippedNoPeriods = 0; + let skippedDuplicate = 0; + const errors: string[] = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + const parts = line.split("|"); + if (parts.length < 5) { + errors.push(`Línea ${i + 1}: formato inválido`); + continue; + } + + const description = parts[1].trim(); + const defaultDueDay = Number(parts[2].trim()); + const rawAmount = parts[3].trim(); + const rawPeriods = parts[4].trim(); + + const periods = parsePeriods(rawPeriods); + + if (periods.length === 0) { + skippedNoPeriods++; + continue; + } + + try { + const existing = await prisma.periodicExpense.findFirst({ + where: { description: { equals: description, mode: "insensitive" } }, + }); + + if (existing) { + skippedDuplicate++; + continue; + } + + const defaultAmount = parseArgentineAmount(rawAmount); + + await prisma.periodicExpense.create({ + data: { + description, + defaultDueDay: isNaN(defaultDueDay) ? 1 : defaultDueDay, + defaultAmount: isNaN(defaultAmount) ? 0 : defaultAmount, + periods, + }, + }); + + imported++; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`Línea ${i + 1} ("${description}"): ${message}`); + logger.error({ err, description }, "Error importing periodic expense"); + } + } + + return c.json({ + imported, + skipped: skippedNoPeriods + skippedDuplicate, + skippedNoPeriods, + skippedDuplicate, + errors: errors.length > 0 ? errors : undefined, + }); +} diff --git a/apps/frontend/src/features/expenses/ExpensesProvider.tsx b/apps/frontend/src/features/expenses/ExpensesProvider.tsx index f995905..0ae0684 100644 --- a/apps/frontend/src/features/expenses/ExpensesProvider.tsx +++ b/apps/frontend/src/features/expenses/ExpensesProvider.tsx @@ -9,6 +9,9 @@ import { useCreateNonPeriodicExpense, usePayExpense, useMonthlyTotals, + useTotalPending, + useImportPeriodicExpenses, + useImportExpenses, } from "@/lib/queries"; import type { PeriodicExpense, @@ -18,6 +21,9 @@ import type { PayExpenseInput, PaginatedResponse, MonthlyTotals, + MonthlyExpensesTotal, + ImportResult, + ImportExpensesResult, } from "@/lib/api"; interface ExpensesContextValue { @@ -34,6 +40,8 @@ interface ExpensesContextValue { monthlyTotals: MonthlyTotals | undefined; isLoadingTotals: boolean; + totalPending: MonthlyExpensesTotal | undefined; + isLoadingTotalPending: boolean; createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise; updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise; @@ -41,6 +49,8 @@ interface ExpensesContextValue { generateMonthlyExpense: (id: number) => Promise; createNonPeriodicExpense: (data: CreateNonPeriodicExpenseInput) => Promise; payExpense: (id: number, data: PayExpenseInput) => Promise; + importPeriodicExpenses: (file: File) => Promise; + importExpenses: (file: File) => Promise; } const ExpensesContext = createContext(null); @@ -56,6 +66,7 @@ export function ExpensesProvider({ children }: { children: ReactNode }) { const now = new Date(); const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1); + const { data: totalPending, isLoading: isLoadingTotalPending } = useTotalPending(); const createPeriodicMutation = useCreatePeriodicExpense(); const updatePeriodicMutation = useUpdatePeriodicExpense(); @@ -63,6 +74,22 @@ export function ExpensesProvider({ children }: { children: ReactNode }) { const generateMonthlyMutation = useGenerateMonthlyExpense(); const createExpenseMutation = useCreateNonPeriodicExpense(); const payExpenseMutation = usePayExpense(); + const importPeriodicMutation = useImportPeriodicExpenses(); + const importExpensesMutation = useImportExpenses(); + + const importPeriodicExpensesFn = useCallback( + async (file: File): Promise => { + return importPeriodicMutation.mutateAsync(file); + }, + [importPeriodicMutation], + ); + + const importExpensesFn = useCallback( + async (file: File): Promise => { + return importExpensesMutation.mutateAsync(file); + }, + [importExpensesMutation], + ); const createPeriodicExpense = useCallback( async (data: CreatePeriodicExpenseInput): Promise => { @@ -120,12 +147,16 @@ export function ExpensesProvider({ children }: { children: ReactNode }) { pageSize, monthlyTotals, isLoadingTotals, + totalPending, + isLoadingTotalPending, createPeriodicExpense, updatePeriodicExpense: updatePeriodicExpenseFn, deletePeriodicExpense: deletePeriodicExpenseFn, generateMonthlyExpense: generateMonthlyExpenseFn, createNonPeriodicExpense: createNonPeriodicExpenseFn, payExpense: payExpenseFn, + importPeriodicExpenses: importPeriodicExpensesFn, + importExpenses: importExpensesFn, }} > {children} diff --git a/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx b/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx index b6679b9..1fb0b58 100644 --- a/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx +++ b/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx @@ -1,11 +1,18 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { useExpensesContext } from "../ExpensesProvider"; import { ExpensesTable } from "./ExpensesTable"; import { PayExpenseDialog } from "./PayExpenseDialog"; import { NewExpenseDialog } from "./NewExpenseDialog"; +import { + ResponsiveDialog, + ResponsiveDialogContent, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogDescription, +} from "@/components/ui/responsive-dialog"; import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; -import type { Expense } from "@/lib/api"; +import { Plus, Upload } from "lucide-react"; +import type { Expense, ImportExpensesResult } from "@/lib/api"; const priceFormatter = new Intl.NumberFormat("es-AR", { minimumFractionDigits: 2, @@ -23,11 +30,19 @@ export function ExpensesTabContent() { pageSize, monthlyTotals, isLoadingTotals, + totalPending, + isLoadingTotalPending, + importExpenses, } = useExpensesContext(); const [payDialogExpense, setPayDialogExpense] = useState(null); const [newExpenseOpen, setNewExpenseOpen] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + const fileInputRef = useRef(null); + const filters = [ { value: "PENDING", label: "Pendientes" }, { value: "PAYED", label: "Pagados" }, @@ -49,10 +64,16 @@ export function ExpensesTabContent() { ))} - +
+ + +
{isLoadingExpenses ? ( @@ -68,7 +89,7 @@ export function ExpensesTabContent() { /> )} -
+

Total Pagado

@@ -78,13 +99,21 @@ export function ExpensesTabContent() {

-

Total Pendiente

+

Total Pendiente del Mes

{isLoadingTotals ? "..." : `$${priceFormatter.format(monthlyTotals?.pending ?? 0)}`}

+
+

Total Pendiente General

+

+ {isLoadingTotalPending + ? "..." + : `$${priceFormatter.format(totalPending?.total ?? 0)}`} +

+
+ + { + setImportDialogOpen(open); + if (!open) { + setImportResult(null); + } + }}> + + + Importar gastos + + Seleccioná un archivo CSV (delimitado por pipes) para importar gastos. + + + + {importResult ? ( +
+

Importación completada

+
    +
  • + + Importados: {importResult.imported} +
  • +
  • + + Omitidos (duplicados): {importResult.skipped} +
  • +
+ {importResult.errors && importResult.errors.length > 0 && ( +
+

Errores:

+
    + {importResult.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} + +
+ ) : ( +
+ + +
+ )} +
+
); } diff --git a/apps/frontend/src/features/expenses/components/ExpensesTable.tsx b/apps/frontend/src/features/expenses/components/ExpensesTable.tsx index 134685e..be9516e 100644 --- a/apps/frontend/src/features/expenses/components/ExpensesTable.tsx +++ b/apps/frontend/src/features/expenses/components/ExpensesTable.tsx @@ -7,7 +7,7 @@ import { } from "@tanstack/react-table"; import type { Expense } from "@/lib/api"; import { Button } from "@/components/ui/button"; -import { CircleDollarSign } from "lucide-react"; +import { CircleDollarSign, ChevronsLeftIcon, ChevronsRightIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react"; import { Pagination, PaginationContent, @@ -114,6 +114,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange }); const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const GROUP_SIZE = 3; + const currentGroup = Math.floor((page - 1) / GROUP_SIZE); + const startPage = currentGroup * GROUP_SIZE + 1; + const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages); return (
@@ -157,6 +161,16 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange {totalPages > 1 && ( + + { e.preventDefault(); onPageChange(1); }} + href="#" + aria-label="Ir a la primera página" + className={page <= 1 ? "pointer-events-none opacity-50" : ""} + > + + + { e.preventDefault(); if (page > 1) onPageChange(page - 1); }} @@ -164,7 +178,18 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange className={page <= 1 ? "pointer-events-none opacity-50" : ""} /> - {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + {currentGroup > 0 && ( + + { e.preventDefault(); onPageChange(startPage - 1); }} + href="#" + aria-label="Ir al grupo anterior" + > + + + + )} + {Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i).map((p) => ( ))} + {(currentGroup + 1) * GROUP_SIZE < totalPages && ( + + { e.preventDefault(); onPageChange(endPage + 1); }} + href="#" + aria-label="Ir al siguiente grupo" + > + + + + )} { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }} @@ -182,6 +218,16 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange className={page >= totalPages ? "pointer-events-none opacity-50" : ""} /> + + { e.preventDefault(); onPageChange(totalPages); }} + href="#" + aria-label="Ir a la última página" + className={page >= totalPages ? "pointer-events-none opacity-50" : ""} + > + + + )} diff --git a/apps/frontend/src/features/expenses/components/PeriodicExpensesTabContent.tsx b/apps/frontend/src/features/expenses/components/PeriodicExpensesTabContent.tsx index beb5d25..bc38519 100644 --- a/apps/frontend/src/features/expenses/components/PeriodicExpensesTabContent.tsx +++ b/apps/frontend/src/features/expenses/components/PeriodicExpensesTabContent.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { useExpensesContext } from "../ExpensesProvider"; import { PeriodicExpensesTable } from "./PeriodicExpensesTable"; import { PeriodicExpenseForm } from "./PeriodicExpenseForm"; @@ -8,10 +8,11 @@ import { ResponsiveDialogContent, ResponsiveDialogHeader, ResponsiveDialogTitle, + ResponsiveDialogDescription, } from "@/components/ui/responsive-dialog"; import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; -import type { PeriodicExpense } from "@/lib/api"; +import { Plus, Upload } from "lucide-react"; +import type { PeriodicExpense, ImportResult } from "@/lib/api"; export function PeriodicExpensesTabContent() { const { @@ -21,6 +22,7 @@ export function PeriodicExpensesTabContent() { updatePeriodicExpense, deletePeriodicExpense, generateMonthlyExpense, + importPeriodicExpenses, } = useExpensesContext(); const [dialogOpen, setDialogOpen] = useState(false); @@ -31,6 +33,11 @@ export function PeriodicExpensesTabContent() { periodicExpenseId: number; }>({ open: false, description: "", periodicExpenseId: 0 }); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + const fileInputRef = useRef(null); + function openCreate() { setEditingItem(null); setDialogOpen(true); @@ -89,10 +96,16 @@ export function PeriodicExpensesTabContent() {

Administrá tus gastos recurrentes.

- +
+ + +
{isLoadingPeriodic ? ( @@ -127,6 +140,84 @@ export function PeriodicExpensesTabContent() { onConfirm={handleConfirmGenerate} onSkip={handleSkipGenerate} /> + + { + setImportDialogOpen(open); + if (!open) { + setImportResult(null); + } + }}> + + + Importar gastos periódicos + + Seleccioná un archivo CSV (delimitado por pipes) para importar gastos periódicos. + + + + {importResult ? ( +
+

Importación completada

+
    +
  • + + Importados: {importResult.imported} +
  • +
  • + + Omitidos (sin períodos): {importResult.skippedNoPeriods} +
  • +
  • + + Omitidos (duplicados): {importResult.skippedDuplicate} +
  • +
+ {importResult.errors && importResult.errors.length > 0 && ( +
+

Errores:

+
    + {importResult.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} + +
+ ) : ( +
+ + +
+ )} +
+
); } diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 09ad8c9..1184159 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -188,6 +188,10 @@ export function getExpenses(status?: string, page?: number, pageSize?: number): return fetcher>(`/api/expenses${qs ? `?${qs}` : ""}`); } +export function getTotalPending(): Promise { + return fetcher("/api/expenses/pending-total"); +} + export function getMonthlyTotals(year?: number, month?: number): Promise { const params = new URLSearchParams(); if (year) params.set("year", String(year)); @@ -202,3 +206,41 @@ export function createNonPeriodicExpense(data: CreateNonPeriodicExpenseInput): P export function payExpense(id: number, data: PayExpenseInput): Promise { return mutator(`/api/expenses/${id}/pay`, "PUT", data); } + +export type ImportResult = { + imported: number; + skipped: number; + skippedNoPeriods: number; + skippedDuplicate: number; + errors?: string[]; +}; + +export type ImportExpensesResult = { + imported: number; + skipped: number; + errors?: string[]; +}; + +export function importExpenses(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + return fetch("/api/expenses/import", { + method: "POST", + body: formData, + }).then((r) => { + if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`); + return r.json(); + }); +} + +export function importPeriodicExpenses(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + return fetch("/api/periodic-expenses/import", { + method: "POST", + body: formData, + }).then((r) => { + if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`); + return r.json(); + }); +} diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index b826bb4..911b491 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -9,6 +9,9 @@ import { payExpense as payExpenseApi, getMonthlyPayedTotal, getMonthlyTotals, + getTotalPending, + importPeriodicExpenses, + importExpenses, type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput, } from "./api"; @@ -78,6 +81,7 @@ export const expenseKeys = { ["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, + totalPending: ["expenses", "pending-total"] as const, }; export function usePeriodicExpenses() { @@ -142,6 +146,13 @@ export function useMonthlyTotals(year: number, month: number) { }); } +export function useTotalPending() { + return useQuery({ + queryKey: expenseKeys.totalPending, + queryFn: getTotalPending, + }); +} + export function useMonthlyPayedTotal(year: number, month: number) { return useQuery({ queryKey: expenseKeys.monthlyTotal(year, month), @@ -159,6 +170,26 @@ export function useCreateNonPeriodicExpense() { }); } +export function useImportExpenses() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (file: File) => importExpenses(file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: expenseKeys.all }); + }, + }); +} + +export function useImportPeriodicExpenses() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (file: File) => importPeriodicExpenses(file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: expenseKeys.periodic }); + }, + }); +} + export function usePayExpense() { const queryClient = useQueryClient(); return useMutation({