feat(expenses): add import functionality for expenses and total pending calculation

This commit is contained in:
Jose Selesan
2026-05-29 14:30:10 -03:00
parent 4b17940134
commit b1968cece9
11 changed files with 600 additions and 19 deletions

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);
}

View 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,
});
}

View File

@@ -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,
});
}

View File

@@ -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}

View File

@@ -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>
); );
} }

View File

@@ -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>
)} )}

View File

@@ -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>
); );
} }

View File

@@ -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();
});
}

View File

@@ -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({