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