feat(expenses): implement responsive dialog for expense management and create expense provider

- Added ResponsiveDialog component for handling responsive dialogs in the UI.
- Created ExpensesProvider to manage state and API interactions for expenses.
- Developed ExpensesTabContent to display expenses with filtering options and dialogs for new and pay expense actions.
- Implemented ExpensesTable for rendering expense data in a tabular format with actions.
- Added dialogs for creating new expenses and paying existing ones with form validation.
- Introduced PeriodicExpenseForm for managing periodic expenses with month selection.
- Created PeriodicExpensesTabContent to manage and display periodic expenses with create/edit functionality.
- Added GenerateCurrentMonthDialog for confirming monthly expense generation.
- Implemented PeriodicExpensesTable for displaying periodic expenses with edit and delete actions.
This commit is contained in:
Jose Selesan
2026-05-29 10:29:20 -03:00
parent bd5e29236b
commit e1786f7384
39 changed files with 4338 additions and 6435 deletions

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import { useExpensesContext } from "../ExpensesProvider";
import { ExpensesTable } from "./ExpensesTable";
import { PayExpenseDialog } from "./PayExpenseDialog";
import { NewExpenseDialog } from "./NewExpenseDialog";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import type { Expense } from "@/lib/api";
export function ExpensesTabContent() {
const { expenses, isLoadingExpenses, statusFilter, setStatusFilter } = useExpensesContext();
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null);
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
const filters = [
{ value: "PENDING", label: "Pendientes" },
{ value: "PAYED", label: "Pagados" },
{ value: "all", label: "Todos" },
] as const;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex gap-1">
{filters.map((f) => (
<Button
key={f.value}
variant={statusFilter === f.value ? "default" : "outline"}
size="xs"
onClick={() => setStatusFilter(f.value)}
>
{f.label}
</Button>
))}
</div>
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
<Plus className="size-4" />
Nuevo gasto
</Button>
</div>
{isLoadingExpenses ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<ExpensesTable data={expenses} onPay={setPayDialogExpense} />
)}
<PayExpenseDialog
expense={payDialogExpense}
open={payDialogExpense !== null}
onOpenChange={(open) => {
if (!open) setPayDialogExpense(null);
}}
/>
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} />
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { useMemo } from "react";
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import type { Expense } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { CircleDollarSign } from "lucide-react";
interface ExpensesTableProps {
data: Expense[];
onPay: (expense: Expense) => void;
}
export function ExpensesTable({ data, onPay }: ExpensesTableProps) {
const columns = useMemo<ColumnDef<Expense>[]>(
() => [
{
header: "Descripción",
accessorKey: "description",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("description")}</span>
),
},
{
header: "Monto",
accessorKey: "amount",
cell: ({ row }) => {
const amount = Number(row.getValue("amount"));
return (
<span className="tabular-nums">
${amount.toLocaleString("es-AR", { minimumFractionDigits: 2 })}
</span>
);
},
},
{
header: "Vencimiento",
accessorKey: "dueDate",
cell: ({ row }) => {
const dueDate = new Date(row.getValue("dueDate"));
return (
<span className="text-muted-foreground">
{dueDate.toLocaleDateString("es-AR", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span>
);
},
},
{
header: "Estado",
accessorKey: "status",
cell: ({ row }) => {
const status = row.getValue("status");
const isPayed = status === "PAYED";
return (
<span
className={
isPayed
? "inline-flex h-5 items-center rounded-md bg-green-100 px-1.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "inline-flex h-5 items-center rounded-md bg-yellow-100 px-1.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
}
>
{isPayed ? "Pagado" : "Pendiente"}
</span>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const expense = row.original;
if (expense.status === "PAYED") return null;
return (
<div className="flex justify-end">
<Button
variant="outline"
size="xs"
onClick={() => onPay(expense)}
>
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
</div>
);
},
},
],
[onPay],
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b bg-muted/50">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
{table.getRowModel().rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
No hay gastos para mostrar.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
interface GenerateCurrentMonthDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
description: string;
onConfirm: () => void;
onSkip: () => void;
}
export function GenerateCurrentMonthDialog({
open,
onOpenChange,
description,
onConfirm,
onSkip,
}: GenerateCurrentMonthDialogProps) {
return (
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>¿Generar gasto para este mes?</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
<strong>{description}</strong> aplica al mes actual. ¿Querés generar el gasto ahora?
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={onSkip}>
No, gracias
</Button>
<Button onClick={onConfirm}>
, generar
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,134 @@
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useExpensesContext } from "../ExpensesProvider";
import { DatePicker } from "@/components/ui/date-picker";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
const newExpenseSchema = z.object({
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"),
amount: z.number().positive("El monto debe ser mayor a 0"),
dueDate: z.date({ message: "La fecha es obligatoria" }),
});
type NewExpenseFormValues = z.infer<typeof newExpenseSchema>;
interface NewExpenseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps) {
const { createNonPeriodicExpense } = useExpensesContext();
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<NewExpenseFormValues>({
resolver: zodResolver(newExpenseSchema),
defaultValues: {
description: "",
amount: 0,
dueDate: new Date(),
},
});
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: NewExpenseFormValues) {
await createNonPeriodicExpense({
description: data.description,
amount: data.amount,
dueDate: data.dueDate.toISOString(),
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<form id="new-expense-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="description" className="text-sm font-medium">
Descripción
</label>
<input
id="description"
{...register("description")}
placeholder="Ej: Ropa"
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.description && (
<span className="text-xs text-destructive">{errors.description.message}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="amount" className="text-sm font-medium">
Monto
</label>
<input
id="amount"
type="number"
step="0.01"
min="0"
{...register("amount", { valueAsNumber: true })}
placeholder="2500"
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.amount && (
<span className="text-xs text-destructive">{errors.amount.message}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
Fecha del gasto
</label>
<Controller
control={control}
name="dueDate"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
placeholder="Seleccionar fecha"
/>
)}
/>
{errors.dueDate && (
<span className="text-xs text-destructive">{errors.dueDate.message}</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" form="new-expense-form" disabled={isSubmitting}>
Crear gasto
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,136 @@
import { useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Expense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
import { DatePicker } from "@/components/ui/date-picker";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
const paySchema = z.object({
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
paymentDate: z.date({ message: "La fecha es obligatoria" }),
});
type PayFormValues = z.infer<typeof paySchema>;
interface PayExpenseDialogProps {
expense: Expense | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext();
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<PayFormValues>({
resolver: zodResolver(paySchema),
defaultValues: {
amountPayed: 0,
paymentDate: undefined,
},
});
useEffect(() => {
if (expense) {
reset({
amountPayed: Number(expense.amount),
paymentDate: new Date(),
});
}
}, [expense, reset]);
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: PayFormValues) {
if (!expense) return;
await payExpense(expense.id, {
amountPayed: data.amountPayed,
paymentDate: data.paymentDate.toISOString(),
});
handleClose(false);
}
if (!expense) return null;
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Registrar pago</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
<span className="font-medium">{expense.description}</span>
<span className="text-muted-foreground">
Monto original: ${Number(expense.amount).toLocaleString("es-AR", { minimumFractionDigits: 2 })}
</span>
</div>
<form id="pay-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado
</label>
<input
id="amountPayed"
type="number"
step="0.01"
min="0"
{...register("amountPayed", { valueAsNumber: true })}
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.amountPayed && (
<span className="text-xs text-destructive">{errors.amountPayed.message}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
Fecha de pago
</label>
<Controller
control={control}
name="paymentDate"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
placeholder="Seleccionar fecha"
/>
)}
/>
{errors.paymentDate && (
<span className="text-xs text-destructive">{errors.paymentDate.message}</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" form="pay-form" disabled={isSubmitting}>
Confirmar pago
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,180 @@
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import type { PeriodicExpense } from "@/lib/api";
import { Button } from "@/components/ui/button";
const months = [
{ value: 1, label: "Ene" },
{ value: 2, label: "Feb" },
{ value: 3, label: "Mar" },
{ value: 4, label: "Abr" },
{ value: 5, label: "May" },
{ value: 6, label: "Jun" },
{ value: 7, label: "Jul" },
{ value: 8, label: "Ago" },
{ value: 9, label: "Sep" },
{ value: 10, label: "Oct" },
{ value: 11, label: "Nov" },
{ value: 12, label: "Dic" },
];
const periodicExpenseFormSchema = z.object({
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"),
defaultDueDay: z.number().int().min(1, "Día entre 1 y 31").max(31, "Día entre 1 y 31"),
defaultAmount: z.number().positive("El monto debe ser mayor a 0"),
periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"),
});
type PeriodicExpenseFormValues = z.infer<typeof periodicExpenseFormSchema>;
interface PeriodicExpenseFormProps {
initialData?: PeriodicExpense;
onSubmit: (data: PeriodicExpenseFormValues) => Promise<void>;
onCancel: () => void;
}
export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: PeriodicExpenseFormProps) {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<PeriodicExpenseFormValues>({
resolver: zodResolver(periodicExpenseFormSchema),
defaultValues: initialData
? {
description: initialData.description,
defaultDueDay: initialData.defaultDueDay,
defaultAmount: Number(initialData.defaultAmount),
periods: initialData.periods,
}
: {
description: "",
defaultDueDay: 15,
defaultAmount: 0,
periods: [],
},
});
const selectedPeriods = watch("periods");
function toggleMonth(month: number) {
if (selectedPeriods.includes(month)) {
setValue("periods", selectedPeriods.filter((m) => m !== month), { shouldValidate: true });
} else {
setValue("periods", [...selectedPeriods, month], { shouldValidate: true });
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="description" className="text-sm font-medium">
Descripción
</label>
<input
id="description"
{...register("description")}
placeholder="Ej: Gimnasio"
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.description && (
<span className="text-xs text-destructive">{errors.description.message}</span>
)}
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-1.5">
<label htmlFor="defaultAmount" className="text-sm font-medium">
Monto
</label>
<input
id="defaultAmount"
type="number"
step="0.01"
min="0"
{...register("defaultAmount", { valueAsNumber: true })}
placeholder="1500"
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.defaultAmount && (
<span className="text-xs text-destructive">{errors.defaultAmount.message}</span>
)}
</div>
<div className="flex flex-1 flex-col gap-1.5">
<label htmlFor="defaultDueDay" className="text-sm font-medium">
Día de vencimiento
</label>
<input
id="defaultDueDay"
type="number"
min="1"
max="31"
{...register("defaultDueDay", { valueAsNumber: true })}
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.defaultDueDay && (
<span className="text-xs text-destructive">{errors.defaultDueDay.message}</span>
)}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Meses de aplicación</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const all = months.map((m) => m.value);
setValue("periods", all, { shouldValidate: true });
}}
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
Seleccionar todos
</button>
<button
type="button"
onClick={() => setValue("periods", [], { shouldValidate: true })}
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
Deseleccionar todos
</button>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{months.map((m) => (
<button
key={m.value}
type="button"
onClick={() => toggleMonth(m.value)}
className={
selectedPeriods.includes(m.value)
? "h-7 rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground transition-colors"
: "h-7 rounded-md border border-input bg-background px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
}
>
{m.label}
</button>
))}
</div>
{errors.periods && (
<span className="text-xs text-destructive">{errors.periods.message}</span>
)}
<input type="hidden" {...register("periods")} />
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{initialData ? "Guardar cambios" : "Crear gasto periódico"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from "react";
import { useExpensesContext } from "../ExpensesProvider";
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import type { PeriodicExpense } from "@/lib/api";
export function PeriodicExpensesTabContent() {
const {
periodicExpenses,
isLoadingPeriodic,
createPeriodicExpense,
updatePeriodicExpense,
deletePeriodicExpense,
generateMonthlyExpense,
} = 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 });
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>
<Button onClick={openCreate} size="sm">
<Plus className="size-4" />
Nuevo
</Button>
</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}
/>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useMemo } from "react";
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import type { PeriodicExpense } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Pencil, Trash2 } from "lucide-react";
const monthLabels = [
"", "Ene", "Feb", "Mar", "Abr", "May", "Jun",
"Jul", "Ago", "Sep", "Oct", "Nov", "Dic",
];
function formatPeriods(periods: number[]): string {
if (periods.length === 0) return "";
if (periods.length === 12) return "Ene-Dic";
const sorted = [...periods].sort((a, b) => a - b);
const ranges: string[] = [];
let start = sorted[0];
let prev = sorted[0];
for (let i = 1; i <= sorted.length; i++) {
const curr = sorted[i];
if (curr === prev + 1) {
prev = curr;
} else {
ranges.push(start === prev ? monthLabels[start] : `${monthLabels[start]}-${monthLabels[prev]}`);
start = curr;
prev = curr;
}
}
return ranges.join(", ");
}
interface PeriodicExpensesTableProps {
data: PeriodicExpense[];
onEdit: (item: PeriodicExpense) => void;
onDelete: (id: number) => void;
}
export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpensesTableProps) {
const columns = useMemo<ColumnDef<PeriodicExpense>[]>(
() => [
{
header: "Descripción",
accessorKey: "description",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("description")}</span>
),
},
{
header: "Monto",
accessorKey: "defaultAmount",
cell: ({ row }) => {
const amount = Number(row.getValue("defaultAmount"));
return (
<span className="tabular-nums">
${amount.toLocaleString("es-AR", { minimumFractionDigits: 2 })}
</span>
);
},
},
{
header: "Vence",
accessorKey: "defaultDueDay",
cell: ({ row }) => <span>Día {row.getValue("defaultDueDay")}</span>,
},
{
header: "Meses",
accessorKey: "periods",
cell: ({ row }) => {
const periods: number[] = row.getValue("periods");
return (
<span className="text-xs text-muted-foreground">
{formatPeriods(periods)}
</span>
);
},
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon-xs"
onClick={() => onEdit(row.original)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => onDelete(row.original.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
],
[onEdit, onDelete],
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b bg-muted/50">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
{table.getRowModel().rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
No hay gastos periódicos todavía.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}