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:
@@ -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>
|
||||
);
|
||||
}
|
||||
142
apps/frontend/src/features/expenses/components/ExpensesTable.tsx
Normal file
142
apps/frontend/src/features/expenses/components/ExpensesTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
Sí, generar
|
||||
</Button>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user