feat/expenses-management #1
@@ -9,6 +9,9 @@ import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExp
|
|||||||
import { payExpenseHandler } from "./handlers/payExpense";
|
import { payExpenseHandler } from "./handlers/payExpense";
|
||||||
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
|
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
|
||||||
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
|
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
|
||||||
|
import { getTotalPendingHandler } from "./handlers/getTotalPending";
|
||||||
|
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
|
||||||
|
import { importExpensesHandler } from "./handlers/importExpenses";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -16,12 +19,15 @@ app.get("/periodic-expenses", listPeriodicExpensesHandler);
|
|||||||
app.post("/periodic-expenses", createPeriodicExpenseHandler);
|
app.post("/periodic-expenses", createPeriodicExpenseHandler);
|
||||||
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
|
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
|
||||||
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
|
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
|
||||||
|
app.post("/periodic-expenses/import", importPeriodicExpensesHandler);
|
||||||
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/monthly-total", getMonthlyPayedTotalHandler);
|
||||||
app.get("/expenses/totals", getMonthlyTotalsHandler);
|
app.get("/expenses/totals", getMonthlyTotalsHandler);
|
||||||
|
app.get("/expenses/pending-total", getTotalPendingHandler);
|
||||||
app.get("/expenses", listExpensesHandler);
|
app.get("/expenses", listExpensesHandler);
|
||||||
app.post("/expenses", createNonPeriodicExpenseHandler);
|
app.post("/expenses", createNonPeriodicExpenseHandler);
|
||||||
|
app.post("/expenses/import", importExpensesHandler);
|
||||||
app.put("/expenses/:id/pay", payExpenseHandler);
|
app.put("/expenses/:id/pay", payExpenseHandler);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function getCurrentYearMonthUTC(): { year: number; month: number } {
|
|||||||
export async function listPeriodicExpenses() {
|
export async function listPeriodicExpenses() {
|
||||||
return prisma.periodicExpense.findMany({
|
return prisma.periodicExpense.findMany({
|
||||||
where: { isDeleted: false },
|
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() {
|
export async function generateExpensesForCurrentMonth() {
|
||||||
const { year, month } = getCurrentYearMonthUTC();
|
const { year, month } = getCurrentYearMonthUTC();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
121
apps/backend/src/modules/expenses/handlers/importExpenses.ts
Normal file
121
apps/backend/src/modules/expenses/handlers/importExpenses.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
useCreateNonPeriodicExpense,
|
useCreateNonPeriodicExpense,
|
||||||
usePayExpense,
|
usePayExpense,
|
||||||
useMonthlyTotals,
|
useMonthlyTotals,
|
||||||
|
useTotalPending,
|
||||||
|
useImportPeriodicExpenses,
|
||||||
|
useImportExpenses,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import type {
|
import type {
|
||||||
PeriodicExpense,
|
PeriodicExpense,
|
||||||
@@ -18,6 +21,9 @@ import type {
|
|||||||
PayExpenseInput,
|
PayExpenseInput,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
MonthlyTotals,
|
MonthlyTotals,
|
||||||
|
MonthlyExpensesTotal,
|
||||||
|
ImportResult,
|
||||||
|
ImportExpensesResult,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
interface ExpensesContextValue {
|
interface ExpensesContextValue {
|
||||||
@@ -34,6 +40,8 @@ interface ExpensesContextValue {
|
|||||||
|
|
||||||
monthlyTotals: MonthlyTotals | undefined;
|
monthlyTotals: MonthlyTotals | undefined;
|
||||||
isLoadingTotals: boolean;
|
isLoadingTotals: boolean;
|
||||||
|
totalPending: MonthlyExpensesTotal | undefined;
|
||||||
|
isLoadingTotalPending: 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>;
|
||||||
@@ -41,6 +49,8 @@ interface ExpensesContextValue {
|
|||||||
generateMonthlyExpense: (id: number) => Promise<Expense>;
|
generateMonthlyExpense: (id: number) => Promise<Expense>;
|
||||||
createNonPeriodicExpense: (data: CreateNonPeriodicExpenseInput) => Promise<void>;
|
createNonPeriodicExpense: (data: CreateNonPeriodicExpenseInput) => Promise<void>;
|
||||||
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
|
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
|
||||||
|
importPeriodicExpenses: (file: File) => Promise<ImportResult>;
|
||||||
|
importExpenses: (file: File) => Promise<ImportExpensesResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExpensesContext = createContext<ExpensesContextValue | null>(null);
|
const ExpensesContext = createContext<ExpensesContextValue | null>(null);
|
||||||
@@ -56,6 +66,7 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1);
|
const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1);
|
||||||
|
const { data: totalPending, isLoading: isLoadingTotalPending } = useTotalPending();
|
||||||
|
|
||||||
const createPeriodicMutation = useCreatePeriodicExpense();
|
const createPeriodicMutation = useCreatePeriodicExpense();
|
||||||
const updatePeriodicMutation = useUpdatePeriodicExpense();
|
const updatePeriodicMutation = useUpdatePeriodicExpense();
|
||||||
@@ -63,6 +74,22 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
|
|||||||
const generateMonthlyMutation = useGenerateMonthlyExpense();
|
const generateMonthlyMutation = useGenerateMonthlyExpense();
|
||||||
const createExpenseMutation = useCreateNonPeriodicExpense();
|
const createExpenseMutation = useCreateNonPeriodicExpense();
|
||||||
const payExpenseMutation = usePayExpense();
|
const payExpenseMutation = usePayExpense();
|
||||||
|
const importPeriodicMutation = useImportPeriodicExpenses();
|
||||||
|
const importExpensesMutation = useImportExpenses();
|
||||||
|
|
||||||
|
const importPeriodicExpensesFn = useCallback(
|
||||||
|
async (file: File): Promise<ImportResult> => {
|
||||||
|
return importPeriodicMutation.mutateAsync(file);
|
||||||
|
},
|
||||||
|
[importPeriodicMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const importExpensesFn = useCallback(
|
||||||
|
async (file: File): Promise<ImportExpensesResult> => {
|
||||||
|
return importExpensesMutation.mutateAsync(file);
|
||||||
|
},
|
||||||
|
[importExpensesMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const createPeriodicExpense = useCallback(
|
const createPeriodicExpense = useCallback(
|
||||||
async (data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> => {
|
async (data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> => {
|
||||||
@@ -120,12 +147,16 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
|
|||||||
pageSize,
|
pageSize,
|
||||||
monthlyTotals,
|
monthlyTotals,
|
||||||
isLoadingTotals,
|
isLoadingTotals,
|
||||||
|
totalPending,
|
||||||
|
isLoadingTotalPending,
|
||||||
createPeriodicExpense,
|
createPeriodicExpense,
|
||||||
updatePeriodicExpense: updatePeriodicExpenseFn,
|
updatePeriodicExpense: updatePeriodicExpenseFn,
|
||||||
deletePeriodicExpense: deletePeriodicExpenseFn,
|
deletePeriodicExpense: deletePeriodicExpenseFn,
|
||||||
generateMonthlyExpense: generateMonthlyExpenseFn,
|
generateMonthlyExpense: generateMonthlyExpenseFn,
|
||||||
createNonPeriodicExpense: createNonPeriodicExpenseFn,
|
createNonPeriodicExpense: createNonPeriodicExpenseFn,
|
||||||
payExpense: payExpenseFn,
|
payExpense: payExpenseFn,
|
||||||
|
importPeriodicExpenses: importPeriodicExpensesFn,
|
||||||
|
importExpenses: importExpensesFn,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
import { ExpensesTable } from "./ExpensesTable";
|
import { ExpensesTable } from "./ExpensesTable";
|
||||||
import { PayExpenseDialog } from "./PayExpenseDialog";
|
import { PayExpenseDialog } from "./PayExpenseDialog";
|
||||||
import { NewExpenseDialog } from "./NewExpenseDialog";
|
import { NewExpenseDialog } from "./NewExpenseDialog";
|
||||||
|
import {
|
||||||
|
ResponsiveDialog,
|
||||||
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
|
} from "@/components/ui/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Upload } from "lucide-react";
|
||||||
import type { Expense } from "@/lib/api";
|
import type { Expense, ImportExpensesResult } from "@/lib/api";
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
@@ -23,11 +30,19 @@ export function ExpensesTabContent() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
monthlyTotals,
|
monthlyTotals,
|
||||||
isLoadingTotals,
|
isLoadingTotals,
|
||||||
|
totalPending,
|
||||||
|
isLoadingTotalPending,
|
||||||
|
importExpenses,
|
||||||
} = useExpensesContext();
|
} = 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);
|
||||||
|
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState<ImportExpensesResult | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ value: "PENDING", label: "Pendientes" },
|
{ value: "PENDING", label: "Pendientes" },
|
||||||
{ value: "PAYED", label: "Pagados" },
|
{ value: "PAYED", label: "Pagados" },
|
||||||
@@ -49,11 +64,17 @@ export function ExpensesTabContent() {
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm">
|
||||||
|
<Upload className="size-4" />
|
||||||
|
Importar
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
|
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Nuevo gasto
|
Nuevo gasto
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoadingExpenses ? (
|
{isLoadingExpenses ? (
|
||||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||||
@@ -68,7 +89,7 @@ export function ExpensesTabContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Total Pagado</p>
|
<p className="text-sm text-muted-foreground">Total Pagado</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
@@ -78,13 +99,21 @@ export function ExpensesTabContent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Total Pendiente</p>
|
<p className="text-sm text-muted-foreground">Total Pendiente del Mes</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
{isLoadingTotals
|
{isLoadingTotals
|
||||||
? "..."
|
? "..."
|
||||||
: `$${priceFormatter.format(monthlyTotals?.pending ?? 0)}`}
|
: `$${priceFormatter.format(monthlyTotals?.pending ?? 0)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Total Pendiente General</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
|
{isLoadingTotalPending
|
||||||
|
? "..."
|
||||||
|
: `$${priceFormatter.format(totalPending?.total ?? 0)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PayExpenseDialog
|
<PayExpenseDialog
|
||||||
@@ -96,6 +125,80 @@ export function ExpensesTabContent() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} />
|
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} />
|
||||||
|
|
||||||
|
<ResponsiveDialog open={importDialogOpen} onOpenChange={(open) => {
|
||||||
|
setImportDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setImportResult(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<ResponsiveDialogContent>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>Importar gastos</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
{importResult ? (
|
||||||
|
<div className="space-y-3 py-4">
|
||||||
|
<p className="text-sm font-medium">Importación completada</p>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-green-500" />
|
||||||
|
Importados: {importResult.imported}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-yellow-500" />
|
||||||
|
Omitidos (duplicados): {importResult.skipped}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{importResult.errors && importResult.errors.length > 0 && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||||
|
<p className="mb-1 text-xs font-medium text-destructive">Errores:</p>
|
||||||
|
<ul className="list-inside list-disc text-xs text-destructive/80">
|
||||||
|
{importResult.errors.map((err, i) => (
|
||||||
|
<li key={i}>{err}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button className="w-full" onClick={() => setImportDialogOpen(false)}>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.txt"
|
||||||
|
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-xs file:text-primary-foreground hover:file:bg-primary/90"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={importing}
|
||||||
|
onClick={async () => {
|
||||||
|
const file = fileInputRef.current?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const result = await importExpenses(file);
|
||||||
|
setImportResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setImportResult({ imported: 0, skipped: 0, errors: [message] });
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importing ? "Importando..." : "Importar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
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, ChevronsLeftIcon, ChevronsRightIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
@@ -114,6 +114,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -157,6 +161,16 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<Pagination>
|
<Pagination>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={(e) => { e.preventDefault(); onPageChange(1); }}
|
||||||
|
href="#"
|
||||||
|
aria-label="Ir a la primera página"
|
||||||
|
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
||||||
|
>
|
||||||
|
<SkipBackIcon className="size-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious
|
<PaginationPrevious
|
||||||
onClick={(e) => { e.preventDefault(); if (page > 1) onPageChange(page - 1); }}
|
onClick={(e) => { 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" : ""}
|
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
{currentGroup > 0 && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={(e) => { e.preventDefault(); onPageChange(startPage - 1); }}
|
||||||
|
href="#"
|
||||||
|
aria-label="Ir al grupo anterior"
|
||||||
|
>
|
||||||
|
<ChevronsLeftIcon className="size-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
{Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i).map((p) => (
|
||||||
<PaginationItem key={p}>
|
<PaginationItem key={p}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
isActive={p === page}
|
isActive={p === page}
|
||||||
@@ -175,6 +200,17 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
|
|||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
|
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={(e) => { e.preventDefault(); onPageChange(endPage + 1); }}
|
||||||
|
href="#"
|
||||||
|
aria-label="Ir al siguiente grupo"
|
||||||
|
>
|
||||||
|
<ChevronsRightIcon className="size-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext
|
<PaginationNext
|
||||||
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }}
|
onClick={(e) => { 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" : ""}
|
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={(e) => { e.preventDefault(); onPageChange(totalPages); }}
|
||||||
|
href="#"
|
||||||
|
aria-label="Ir a la última página"
|
||||||
|
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
|
||||||
|
>
|
||||||
|
<SkipForwardIcon className="size-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
|
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
|
||||||
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
|
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
|
||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
ResponsiveDialogContent,
|
ResponsiveDialogContent,
|
||||||
ResponsiveDialogHeader,
|
ResponsiveDialogHeader,
|
||||||
ResponsiveDialogTitle,
|
ResponsiveDialogTitle,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
} from "@/components/ui/responsive-dialog";
|
} from "@/components/ui/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Upload } from "lucide-react";
|
||||||
import type { PeriodicExpense } from "@/lib/api";
|
import type { PeriodicExpense, ImportResult } from "@/lib/api";
|
||||||
|
|
||||||
export function PeriodicExpensesTabContent() {
|
export function PeriodicExpensesTabContent() {
|
||||||
const {
|
const {
|
||||||
@@ -21,6 +22,7 @@ export function PeriodicExpensesTabContent() {
|
|||||||
updatePeriodicExpense,
|
updatePeriodicExpense,
|
||||||
deletePeriodicExpense,
|
deletePeriodicExpense,
|
||||||
generateMonthlyExpense,
|
generateMonthlyExpense,
|
||||||
|
importPeriodicExpenses,
|
||||||
} = useExpensesContext();
|
} = useExpensesContext();
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -31,6 +33,11 @@ export function PeriodicExpensesTabContent() {
|
|||||||
periodicExpenseId: number;
|
periodicExpenseId: number;
|
||||||
}>({ open: false, description: "", periodicExpenseId: 0 });
|
}>({ open: false, description: "", periodicExpenseId: 0 });
|
||||||
|
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -89,11 +96,17 @@ export function PeriodicExpensesTabContent() {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Administrá tus gastos recurrentes.
|
Administrá tus gastos recurrentes.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm">
|
||||||
|
<Upload className="size-4" />
|
||||||
|
Importar
|
||||||
|
</Button>
|
||||||
<Button onClick={openCreate} size="sm">
|
<Button onClick={openCreate} size="sm">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Nuevo
|
Nuevo
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoadingPeriodic ? (
|
{isLoadingPeriodic ? (
|
||||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||||
@@ -127,6 +140,84 @@ export function PeriodicExpensesTabContent() {
|
|||||||
onConfirm={handleConfirmGenerate}
|
onConfirm={handleConfirmGenerate}
|
||||||
onSkip={handleSkipGenerate}
|
onSkip={handleSkipGenerate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ResponsiveDialog open={importDialogOpen} onOpenChange={(open) => {
|
||||||
|
setImportDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setImportResult(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<ResponsiveDialogContent>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>Importar gastos periódicos</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos periódicos.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
{importResult ? (
|
||||||
|
<div className="space-y-3 py-4">
|
||||||
|
<p className="text-sm font-medium">Importación completada</p>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-green-500" />
|
||||||
|
Importados: {importResult.imported}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-yellow-500" />
|
||||||
|
Omitidos (sin períodos): {importResult.skippedNoPeriods}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-yellow-500" />
|
||||||
|
Omitidos (duplicados): {importResult.skippedDuplicate}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{importResult.errors && importResult.errors.length > 0 && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||||
|
<p className="mb-1 text-xs font-medium text-destructive">Errores:</p>
|
||||||
|
<ul className="list-inside list-disc text-xs text-destructive/80">
|
||||||
|
{importResult.errors.map((err, i) => (
|
||||||
|
<li key={i}>{err}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button className="w-full" onClick={() => setImportDialogOpen(false)}>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.txt"
|
||||||
|
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-xs file:text-primary-foreground hover:file:bg-primary/90"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={importing}
|
||||||
|
onClick={async () => {
|
||||||
|
const file = fileInputRef.current?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const result = await importPeriodicExpenses(file);
|
||||||
|
setImportResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setImportResult({ imported: 0, skipped: 0, skippedNoPeriods: 0, skippedDuplicate: 0, errors: [message] });
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importing ? "Importando..." : "Importar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,10 @@ export function getExpenses(status?: string, page?: number, pageSize?: number):
|
|||||||
return fetcher<PaginatedResponse<Expense>>(`/api/expenses${qs ? `?${qs}` : ""}`);
|
return fetcher<PaginatedResponse<Expense>>(`/api/expenses${qs ? `?${qs}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTotalPending(): Promise<MonthlyExpensesTotal> {
|
||||||
|
return fetcher<MonthlyExpensesTotal>("/api/expenses/pending-total");
|
||||||
|
}
|
||||||
|
|
||||||
export function getMonthlyTotals(year?: number, month?: number): Promise<MonthlyTotals> {
|
export function getMonthlyTotals(year?: number, month?: number): Promise<MonthlyTotals> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (year) params.set("year", String(year));
|
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<Expense> {
|
export function payExpense(id: number, data: PayExpenseInput): Promise<Expense> {
|
||||||
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
|
return mutator<Expense>(`/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<ImportExpensesResult> {
|
||||||
|
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<ImportResult> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
payExpense as payExpenseApi,
|
payExpense as payExpenseApi,
|
||||||
getMonthlyPayedTotal,
|
getMonthlyPayedTotal,
|
||||||
getMonthlyTotals,
|
getMonthlyTotals,
|
||||||
|
getTotalPending,
|
||||||
|
importPeriodicExpenses,
|
||||||
|
importExpenses,
|
||||||
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
|
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
@@ -78,6 +81,7 @@ export const expenseKeys = {
|
|||||||
["expenses", status, page, pageSize] as const,
|
["expenses", status, page, pageSize] as const,
|
||||||
monthlyTotal: (year: number, month: number) => ["expenses", "monthly-total", year, month] 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,
|
monthlyTotals: (year: number, month: number) => ["expenses", "totals", year, month] as const,
|
||||||
|
totalPending: ["expenses", "pending-total"] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePeriodicExpenses() {
|
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) {
|
export function useMonthlyPayedTotal(year: number, month: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: expenseKeys.monthlyTotal(year, month),
|
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() {
|
export function usePayExpense() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
Reference in New Issue
Block a user