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

@@ -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<Expense | null>(null);
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 = [
{ value: "PENDING", label: "Pendientes" },
{ value: "PAYED", label: "Pagados" },
@@ -49,10 +64,16 @@ export function ExpensesTabContent() {
</Button>
))}
</div>
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
<Plus className="size-4" />
Nuevo gasto
</Button>
<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">
<Plus className="size-4" />
Nuevo gasto
</Button>
</div>
</div>
{isLoadingExpenses ? (
@@ -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">
<p className="text-sm text-muted-foreground">Total Pagado</p>
<p className="text-2xl font-bold tabular-nums">
@@ -78,13 +99,21 @@ export function ExpensesTabContent() {
</p>
</div>
<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">
{isLoadingTotals
? "..."
: `$${priceFormatter.format(monthlyTotals?.pending ?? 0)}`}
</p>
</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>
<PayExpenseDialog
@@ -96,6 +125,80 @@ export function ExpensesTabContent() {
/>
<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>
);
}

View File

@@ -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 (
<div className="space-y-4">
@@ -157,6 +161,16 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
{totalPages > 1 && (
<Pagination>
<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>
<PaginationPrevious
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" : ""}
/>
</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}>
<PaginationLink
isActive={p === page}
@@ -175,6 +200,17 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</PaginationLink>
</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>
<PaginationNext
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" : ""}
/>
</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>
</Pagination>
)}

View File

@@ -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<ImportResult | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
function openCreate() {
setEditingItem(null);
setDialogOpen(true);
@@ -89,10 +96,16 @@ export function PeriodicExpensesTabContent() {
<p className="text-sm text-muted-foreground">
Administrá tus gastos recurrentes.
</p>
<Button onClick={openCreate} size="sm">
<Plus className="size-4" />
Nuevo
</Button>
<div className="flex gap-2">
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm">
<Upload className="size-4" />
Importar
</Button>
<Button onClick={openCreate} size="sm">
<Plus className="size-4" />
Nuevo
</Button>
</div>
</div>
{isLoadingPeriodic ? (
@@ -127,6 +140,84 @@ export function PeriodicExpensesTabContent() {
onConfirm={handleConfirmGenerate}
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>
);
}