feat(expenses): allow editing paid expenses (status, amountPayed, paymentDate)

This commit is contained in:
Jose Selesan
2026-06-08 08:12:46 -03:00
parent 6f06ee18da
commit b08399908b
5 changed files with 273 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
@@ -17,6 +17,9 @@ import { useExpensesContext } from "../ExpensesProvider";
const editSchema = z.object({
amount: z.number().positive("El monto debe ser mayor a 0"),
dueDate: z.date({ message: "La fecha es obligatoria" }),
amountPayed: z.number().positive("El monto pagado debe ser mayor a 0").optional(),
paymentDate: z.date().optional(),
markAsPending: z.boolean(),
});
type EditFormValues = z.infer<typeof editSchema>;
@@ -35,6 +38,8 @@ export function EditExpenseDialog({
const { updateExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null);
const isPayed = expense?.status === "PAYED";
const {
register,
handleSubmit,
@@ -46,17 +51,29 @@ export function EditExpenseDialog({
defaultValues: {
amount: 0,
dueDate: undefined,
amountPayed: undefined,
paymentDate: undefined,
markAsPending: false,
},
});
const { ref: amountRef, ...amountField } = register("amount", {
setValueAs: (value) => Number(value),
});
const markAsPending = useWatch({ control, name: "markAsPending" });
useEffect(() => {
if (expense) {
reset({
amount: Number(expense.amount),
dueDate: new Date(expense.dueDate),
amountPayed: expense.amountPayed
? Number(expense.amountPayed)
: undefined,
paymentDate: expense.paymentDate
? new Date(expense.paymentDate)
: undefined,
markAsPending: false,
});
}
}, [expense, reset]);
@@ -79,10 +96,27 @@ export function EditExpenseDialog({
async function onSubmit(data: EditFormValues) {
if (!expense) return;
await updateExpense(expense.id, {
amount: data.amount,
dueDate: data.dueDate.toISOString(),
});
if (isPayed && data.markAsPending) {
await updateExpense(expense.id, {
amount: data.amount,
dueDate: data.dueDate.toISOString(),
status: "PENDING",
});
} else if (isPayed) {
await updateExpense(expense.id, {
amount: data.amount,
dueDate: data.dueDate.toISOString(),
amountPayed: data.amountPayed!,
paymentDate: data.paymentDate!.toISOString(),
});
} else {
await updateExpense(expense.id, {
amount: data.amount,
dueDate: data.dueDate.toISOString(),
});
}
handleClose(false);
}
@@ -146,6 +180,111 @@ export function EditExpenseDialog({
</span>
)}
</div>
{isPayed && (
<>
<hr className="border-border" />
<div className="flex flex-col gap-3">
<span className="text-sm font-medium">
Información del pago
</span>
{!markAsPending && (
<>
<div className="flex flex-col gap-1.5">
<label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado
</label>
<input
id="amountPayed"
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...register("amountPayed", {
setValueAs: (value) => Number(value),
})}
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">
<span className="text-sm font-medium">
Fecha de pago
</span>
<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>
</>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Controller
control={control}
name="markAsPending"
render={({ field }) => (
<button
type="button"
role="checkbox"
aria-checked={field.value}
onClick={() => field.onChange(!field.value)}
className={
field.value
? "flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-primary text-primary-foreground ring-offset-background transition-colors"
: "flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-background ring-offset-background transition-colors hover:border-ring"
}
>
{field.value && (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 5.5L4 7.5L8 3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
)}
/>
<span>Marcar como pendiente</span>
</label>
{markAsPending && (
<p className="text-xs text-muted-foreground">
Al marcar como pendiente, se borrarán el monto pagado y la
fecha de pago.
</p>
)}
</div>
</>
)}
</form>
<ResponsiveDialogFooter>

View File

@@ -138,21 +138,22 @@ export function ExpensesTable({
id: "actions",
cell: ({ row }) => {
const expense = row.original;
if (expense.status === "PAYED") return null;
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="xs" onClick={() => onEdit(expense)}>
<Pencil className="size-3.5" />
Editar
</Button>
<Button
variant="outline"
size="xs"
onClick={() => onPay(expense)}
>
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
{expense.status !== "PAYED" && (
<Button
variant="outline"
size="xs"
onClick={() => onPay(expense)}
>
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
)}
</div>
);
},
@@ -233,16 +234,16 @@ export function ExpensesTable({
</p>
</div>
{expense.status !== "PAYED" && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => onEdit(expense)}
>
<Pencil className="size-3.5" />
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => onEdit(expense)}
>
<Pencil className="size-3.5" />
</Button>
{expense.status !== "PAYED" && (
<Button
variant="outline"
size="sm"
@@ -252,8 +253,8 @@ export function ExpensesTable({
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
</div>
)}
)}
</div>
</div>
</div>
))}

View File

@@ -157,6 +157,9 @@ export type PayExpenseInput = {
export type UpdateExpenseInput = {
amount: number;
dueDate: string;
status?: "PENDING";
amountPayed?: number;
paymentDate?: string;
};
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {