224 lines
7.9 KiB
TypeScript
224 lines
7.9 KiB
TypeScript
import { useState, useRef } from "react";
|
|
import { useExpensesContext } from "../ExpensesProvider";
|
|
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
|
|
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
|
|
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
|
|
import {
|
|
ResponsiveDialog,
|
|
ResponsiveDialogContent,
|
|
ResponsiveDialogHeader,
|
|
ResponsiveDialogTitle,
|
|
ResponsiveDialogDescription,
|
|
} from "@/components/ui/responsive-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus, Upload } from "lucide-react";
|
|
import type { PeriodicExpense, ImportResult } from "@/lib/api";
|
|
|
|
export function PeriodicExpensesTabContent() {
|
|
const {
|
|
periodicExpenses,
|
|
isLoadingPeriodic,
|
|
createPeriodicExpense,
|
|
updatePeriodicExpense,
|
|
deletePeriodicExpense,
|
|
generateMonthlyExpense,
|
|
importPeriodicExpenses,
|
|
} = useExpensesContext();
|
|
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<PeriodicExpense | null>(null);
|
|
const [generateDialog, setGenerateDialog] = useState<{
|
|
open: boolean;
|
|
description: string;
|
|
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);
|
|
}
|
|
|
|
function openEdit(item: PeriodicExpense) {
|
|
setEditingItem(item);
|
|
setDialogOpen(true);
|
|
}
|
|
|
|
function closeDialog() {
|
|
setDialogOpen(false);
|
|
setEditingItem(null);
|
|
}
|
|
|
|
async function handleSubmit(data: { description: string; defaultDueDay: number; defaultAmount: number; periods: number[] }) {
|
|
if (editingItem) {
|
|
await updatePeriodicExpense(editingItem.id, data);
|
|
} else {
|
|
const numericData = {
|
|
description: data.description,
|
|
defaultDueDay: data.defaultDueDay,
|
|
defaultAmount: data.defaultAmount,
|
|
periods: data.periods,
|
|
};
|
|
const result = await createPeriodicExpense(numericData);
|
|
if (result.currentMonthApplicable) {
|
|
setGenerateDialog({
|
|
open: true,
|
|
description: result.description,
|
|
periodicExpenseId: result.id,
|
|
});
|
|
}
|
|
}
|
|
closeDialog();
|
|
}
|
|
|
|
async function handleConfirmGenerate() {
|
|
await generateMonthlyExpense(generateDialog.periodicExpenseId);
|
|
setGenerateDialog({ open: false, description: "", periodicExpenseId: 0 });
|
|
}
|
|
|
|
function handleSkipGenerate() {
|
|
setGenerateDialog({ open: false, description: "", periodicExpenseId: 0 });
|
|
}
|
|
|
|
async function handleDelete(id: number) {
|
|
if (confirm("¿Eliminar este gasto periódico?")) {
|
|
await deletePeriodicExpense(id);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
Administrá tus gastos recurrentes.
|
|
</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">
|
|
<Plus className="size-4" />
|
|
Nuevo
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoadingPeriodic ? (
|
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
|
) : (
|
|
<PeriodicExpensesTable
|
|
data={periodicExpenses}
|
|
onEdit={openEdit}
|
|
onDelete={handleDelete}
|
|
/>
|
|
)}
|
|
|
|
<ResponsiveDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<ResponsiveDialogContent>
|
|
<ResponsiveDialogHeader>
|
|
<ResponsiveDialogTitle>
|
|
{editingItem ? "Editar gasto periódico" : "Nuevo gasto periódico"}
|
|
</ResponsiveDialogTitle>
|
|
</ResponsiveDialogHeader>
|
|
<PeriodicExpenseForm
|
|
initialData={editingItem ?? undefined}
|
|
onSubmit={handleSubmit}
|
|
onCancel={closeDialog}
|
|
/>
|
|
</ResponsiveDialogContent>
|
|
</ResponsiveDialog>
|
|
|
|
<GenerateCurrentMonthDialog
|
|
open={generateDialog.open}
|
|
onOpenChange={(open) => setGenerateDialog((prev) => ({ ...prev, open }))}
|
|
description={generateDialog.description}
|
|
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>
|
|
);
|
|
}
|