feat(expenses): allow editing paid expenses (status, amountPayed, paymentDate)
This commit is contained in:
@@ -28,6 +28,15 @@ export const updateExpenseSchema = z.object({
|
|||||||
dueDate: z
|
dueDate: z
|
||||||
.string()
|
.string()
|
||||||
.datetime({ message: "La fecha debe ser una fecha ISO válida" }),
|
.datetime({ message: "La fecha debe ser una fecha ISO válida" }),
|
||||||
|
status: z.enum(["PENDING"]).optional(),
|
||||||
|
amountPayed: z
|
||||||
|
.number()
|
||||||
|
.positive("El monto pagado debe ser mayor a 0")
|
||||||
|
.optional(),
|
||||||
|
paymentDate: z
|
||||||
|
.string()
|
||||||
|
.datetime({ message: "La fecha debe ser una fecha ISO válida" })
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function lastDayOfMonth(year: number, month: number): number {
|
function lastDayOfMonth(year: number, month: number): number {
|
||||||
@@ -199,22 +208,34 @@ export async function updateExpense(
|
|||||||
data: z.infer<typeof updateExpenseSchema>,
|
data: z.infer<typeof updateExpenseSchema>,
|
||||||
) {
|
) {
|
||||||
const existing = await prisma.expense.findUniqueOrThrow({ where: { id } });
|
const existing = await prisma.expense.findUniqueOrThrow({ where: { id } });
|
||||||
if (existing.status !== PaymentStatus.PENDING) {
|
|
||||||
throw new Error("Solo se pueden editar gastos pendientes");
|
|
||||||
}
|
|
||||||
|
|
||||||
const dueDate = new Date(data.dueDate);
|
const dueDate = new Date(data.dueDate);
|
||||||
const year = dueDate.getUTCFullYear();
|
const year = dueDate.getUTCFullYear();
|
||||||
const month = dueDate.getUTCMonth() + 1;
|
const month = dueDate.getUTCMonth() + 1;
|
||||||
|
|
||||||
const result = await prisma.expense.update({
|
const updateData: Record<string, unknown> = {
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
dueDate,
|
dueDate,
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
if (data.status === "PENDING") {
|
||||||
|
updateData.status = PaymentStatus.PENDING;
|
||||||
|
updateData.amountPayed = null;
|
||||||
|
updateData.paymentDate = null;
|
||||||
|
} else if (existing.status === PaymentStatus.PAYED) {
|
||||||
|
if (data.amountPayed !== undefined) {
|
||||||
|
updateData.amountPayed = data.amountPayed;
|
||||||
|
}
|
||||||
|
if (data.paymentDate !== undefined) {
|
||||||
|
updateData.paymentDate = new Date(data.paymentDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.expense.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
});
|
});
|
||||||
cache.invalidateByPrefix("expenses:");
|
cache.invalidateByPrefix("expenses:");
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -471,6 +471,8 @@ describe("updateExpense", () => {
|
|||||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||||
year: 2026,
|
year: 2026,
|
||||||
month: 6,
|
month: 6,
|
||||||
|
amountPayed: null,
|
||||||
|
paymentDate: null,
|
||||||
};
|
};
|
||||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||||
mockPrisma.expense.update.mockImplementationOnce(
|
mockPrisma.expense.update.mockImplementationOnce(
|
||||||
@@ -486,18 +488,82 @@ describe("updateExpense", () => {
|
|||||||
expect(result.month).toBe(7);
|
expect(result.month).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects update for PAYED expense", async () => {
|
test("allows updating a PAYED expense", async () => {
|
||||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce({
|
const existing = {
|
||||||
id: 1,
|
id: 1,
|
||||||
status: PaymentStatus.PAYED,
|
status: PaymentStatus.PAYED,
|
||||||
|
amount: 1500,
|
||||||
|
amountPayed: 1500,
|
||||||
|
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||||
|
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||||
|
year: 2026,
|
||||||
|
month: 6,
|
||||||
|
};
|
||||||
|
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||||
|
mockPrisma.expense.update.mockImplementationOnce(
|
||||||
|
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await updateExpense(1, {
|
||||||
|
amount: 2000,
|
||||||
|
dueDate: "2026-07-20T00:00:00.000Z",
|
||||||
|
amountPayed: 1800,
|
||||||
|
});
|
||||||
|
expect(result.amount).toBe(2000);
|
||||||
|
expect(result.year).toBe(2026);
|
||||||
|
expect(result.month).toBe(7);
|
||||||
|
expect(result.amountPayed).toBe(1800);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
test("reverting a PAYED expense to PENDING clears payment info", async () => {
|
||||||
updateExpense(1, {
|
const existing = {
|
||||||
amount: 100,
|
id: 1,
|
||||||
dueDate: "2026-07-15T00:00:00.000Z",
|
status: PaymentStatus.PAYED,
|
||||||
}),
|
amount: 1500,
|
||||||
).rejects.toThrow("Solo se pueden editar gastos pendientes");
|
amountPayed: 1500,
|
||||||
|
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||||
|
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||||
|
year: 2026,
|
||||||
|
month: 6,
|
||||||
|
};
|
||||||
|
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||||
|
mockPrisma.expense.update.mockImplementationOnce(
|
||||||
|
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await updateExpense(1, {
|
||||||
|
amount: 1500,
|
||||||
|
dueDate: "2026-06-15T00:00:00.000Z",
|
||||||
|
status: "PENDING",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(PaymentStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets amountPayed and paymentDate to null when reverting to PENDING", async () => {
|
||||||
|
const existing = {
|
||||||
|
id: 1,
|
||||||
|
status: PaymentStatus.PAYED,
|
||||||
|
amount: 1500,
|
||||||
|
amountPayed: 1500,
|
||||||
|
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||||
|
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||||
|
year: 2026,
|
||||||
|
month: 6,
|
||||||
|
};
|
||||||
|
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||||
|
mockPrisma.expense.update.mockImplementationOnce(
|
||||||
|
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await updateExpense(1, {
|
||||||
|
amount: 1500,
|
||||||
|
dueDate: "2026-06-15T00:00:00.000Z",
|
||||||
|
status: "PENDING",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.amountPayed).toBeNull();
|
||||||
|
expect(result.paymentDate).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useRef } from "react";
|
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 { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
@@ -17,6 +17,9 @@ import { useExpensesContext } from "../ExpensesProvider";
|
|||||||
const editSchema = z.object({
|
const editSchema = z.object({
|
||||||
amount: z.number().positive("El monto debe ser mayor a 0"),
|
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
dueDate: z.date({ message: "La fecha es obligatoria" }),
|
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>;
|
type EditFormValues = z.infer<typeof editSchema>;
|
||||||
@@ -35,6 +38,8 @@ export function EditExpenseDialog({
|
|||||||
const { updateExpense } = useExpensesContext();
|
const { updateExpense } = useExpensesContext();
|
||||||
const amountInputRef = useRef<HTMLInputElement | null>(null);
|
const amountInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const isPayed = expense?.status === "PAYED";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -46,17 +51,29 @@ export function EditExpenseDialog({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
amount: 0,
|
amount: 0,
|
||||||
dueDate: undefined,
|
dueDate: undefined,
|
||||||
|
amountPayed: undefined,
|
||||||
|
paymentDate: undefined,
|
||||||
|
markAsPending: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { ref: amountRef, ...amountField } = register("amount", {
|
const { ref: amountRef, ...amountField } = register("amount", {
|
||||||
setValueAs: (value) => Number(value),
|
setValueAs: (value) => Number(value),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const markAsPending = useWatch({ control, name: "markAsPending" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expense) {
|
if (expense) {
|
||||||
reset({
|
reset({
|
||||||
amount: Number(expense.amount),
|
amount: Number(expense.amount),
|
||||||
dueDate: new Date(expense.dueDate),
|
dueDate: new Date(expense.dueDate),
|
||||||
|
amountPayed: expense.amountPayed
|
||||||
|
? Number(expense.amountPayed)
|
||||||
|
: undefined,
|
||||||
|
paymentDate: expense.paymentDate
|
||||||
|
? new Date(expense.paymentDate)
|
||||||
|
: undefined,
|
||||||
|
markAsPending: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [expense, reset]);
|
}, [expense, reset]);
|
||||||
@@ -79,10 +96,27 @@ export function EditExpenseDialog({
|
|||||||
|
|
||||||
async function onSubmit(data: EditFormValues) {
|
async function onSubmit(data: EditFormValues) {
|
||||||
if (!expense) return;
|
if (!expense) return;
|
||||||
|
|
||||||
|
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, {
|
await updateExpense(expense.id, {
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
dueDate: data.dueDate.toISOString(),
|
dueDate: data.dueDate.toISOString(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleClose(false);
|
handleClose(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +180,111 @@ export function EditExpenseDialog({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
<ResponsiveDialogFooter>
|
<ResponsiveDialogFooter>
|
||||||
|
|||||||
@@ -138,13 +138,13 @@ export function ExpensesTable({
|
|||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const expense = row.original;
|
const expense = row.original;
|
||||||
if (expense.status === "PAYED") return null;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="xs" onClick={() => onEdit(expense)}>
|
<Button variant="ghost" size="xs" onClick={() => onEdit(expense)}>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
|
{expense.status !== "PAYED" && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -153,6 +153,7 @@ export function ExpensesTable({
|
|||||||
<CircleDollarSign className="size-3.5" />
|
<CircleDollarSign className="size-3.5" />
|
||||||
Pagar
|
Pagar
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -233,7 +234,6 @@ export function ExpensesTable({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expense.status !== "PAYED" && (
|
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -243,6 +243,7 @@ export function ExpensesTable({
|
|||||||
>
|
>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{expense.status !== "PAYED" && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -252,10 +253,10 @@ export function ExpensesTable({
|
|||||||
<CircleDollarSign className="size-3.5" />
|
<CircleDollarSign className="size-3.5" />
|
||||||
Pagar
|
Pagar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.length === 0 && (
|
{data.length === 0 && (
|
||||||
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ export type PayExpenseInput = {
|
|||||||
export type UpdateExpenseInput = {
|
export type UpdateExpenseInput = {
|
||||||
amount: number;
|
amount: number;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
|
status?: "PENDING";
|
||||||
|
amountPayed?: number;
|
||||||
|
paymentDate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
|
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user