feat(expenses): add edit amount and due date for pending expenses

This commit is contained in:
Jose Selesan
2026-06-04 11:00:36 -03:00
parent 5b6ccf0fa6
commit 55098a7b95
10 changed files with 366 additions and 11 deletions

View File

@@ -18,6 +18,7 @@ import type {
PaginatedResponse,
PayExpenseInput,
PeriodicExpense,
UpdateExpenseInput,
} from "@/lib/api";
import {
useCreateNonPeriodicExpense,
@@ -31,6 +32,7 @@ import {
usePayExpense,
usePeriodicExpenses,
useTotalPending,
useUpdateExpense,
useUpdatePeriodicExpense,
} from "@/lib/queries";
@@ -68,6 +70,7 @@ interface ExpensesContextValue {
data: CreateNonPeriodicExpenseInput,
) => Promise<void>;
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
updateExpense: (id: number, data: UpdateExpenseInput) => Promise<void>;
importPeriodicExpenses: (file: File) => Promise<ImportResult>;
importExpenses: (file: File) => Promise<ImportExpensesResult>;
}
@@ -133,6 +136,7 @@ export function ExpensesProvider({
const generateMonthlyMutation = useGenerateMonthlyExpense();
const createExpenseMutation = useCreateNonPeriodicExpense();
const payExpenseMutation = usePayExpense();
const updateExpenseMutation = useUpdateExpense();
const importPeriodicMutation = useImportPeriodicExpenses();
const importExpensesMutation = useImportExpenses();
@@ -192,6 +196,13 @@ export function ExpensesProvider({
[payExpenseMutation],
);
const updateExpenseFn = useCallback(
async (id: number, data: UpdateExpenseInput) => {
await updateExpenseMutation.mutateAsync({ id, data });
},
[updateExpenseMutation],
);
return (
<ExpensesContext
value={{
@@ -218,6 +229,7 @@ export function ExpensesProvider({
generateMonthlyExpense: generateMonthlyExpenseFn,
createNonPeriodicExpense: createNonPeriodicExpenseFn,
payExpense: payExpenseFn,
updateExpense: updateExpenseFn,
importPeriodicExpenses: importPeriodicExpensesFn,
importExpenses: importExpensesFn,
}}

View File

@@ -0,0 +1,170 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import type { Expense } from "@/lib/api";
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" }),
});
type EditFormValues = z.infer<typeof editSchema>;
interface EditExpenseDialogProps {
expense: Expense | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditExpenseDialog({
expense,
open,
onOpenChange,
}: EditExpenseDialogProps) {
const { updateExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null);
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<EditFormValues>({
resolver: zodResolver(editSchema),
defaultValues: {
amount: 0,
dueDate: undefined,
},
});
const { ref: amountRef, ...amountField } = register("amount", {
setValueAs: (value) => Number(value),
});
useEffect(() => {
if (expense) {
reset({
amount: Number(expense.amount),
dueDate: new Date(expense.dueDate),
});
}
}, [expense, reset]);
useEffect(() => {
if (!open || !expense) return;
const frame = requestAnimationFrame(() => {
amountInputRef.current?.focus();
amountInputRef.current?.select();
});
return () => cancelAnimationFrame(frame);
}, [open, expense]);
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: EditFormValues) {
if (!expense) return;
await updateExpense(expense.id, {
amount: data.amount,
dueDate: data.dueDate.toISOString(),
});
handleClose(false);
}
if (!expense) return null;
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Editar gasto</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>
</div>
<form
id="edit-expense-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="amount" className="text-sm font-medium">
Monto
</label>
<input
id="amount"
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...amountField}
ref={(node) => {
amountRef(node);
amountInputRef.current = node;
}}
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">
<span className="text-sm font-medium">Fecha de vencimiento</span>
<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="edit-expense-form"
disabled={isSubmitting}
>
Guardar cambios
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/select";
import type { Expense, ImportExpensesResult } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
import { EditExpenseDialog } from "./EditExpenseDialog";
import { ExpensesTable } from "./ExpensesTable";
import { NewExpenseDialog } from "./NewExpenseDialog";
import { PayExpenseDialog } from "./PayExpenseDialog";
@@ -49,6 +50,9 @@ export function ExpensesTabContent() {
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(
null,
);
const [editDialogExpense, setEditDialogExpense] = useState<Expense | null>(
null,
);
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
@@ -153,6 +157,7 @@ export function ExpensesTabContent() {
<ExpensesTable
data={expenses.data}
onPay={setPayDialogExpense}
onEdit={setEditDialogExpense}
page={expenses.page}
total={expenses.total}
pageSize={expenses.pageSize}
@@ -199,6 +204,14 @@ export function ExpensesTabContent() {
}}
/>
<EditExpenseDialog
expense={editDialogExpense}
open={editDialogExpense !== null}
onOpenChange={(open) => {
if (!open) setEditDialogExpense(null);
}}
/>
<NewExpenseDialog
open={newExpenseOpen}
onOpenChange={setNewExpenseOpen}

View File

@@ -8,6 +8,7 @@ import {
ChevronsLeftIcon,
ChevronsRightIcon,
CircleDollarSign,
Pencil,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react";
@@ -26,6 +27,7 @@ import type { Expense } from "@/lib/api";
interface ExpensesTableProps {
data: Expense[];
onPay: (expense: Expense) => void;
onEdit: (expense: Expense) => void;
page: number;
total: number;
pageSize: number;
@@ -70,6 +72,7 @@ function ExpenseStatusBadge({ status }: { status: Expense["status"] }) {
export function ExpensesTable({
data,
onPay,
onEdit,
page,
total,
pageSize,
@@ -137,7 +140,15 @@ export function ExpensesTable({
const expense = row.original;
if (expense.status === "PAYED") return null;
return (
<div className="flex justify-end">
<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"
@@ -151,7 +162,7 @@ export function ExpensesTable({
},
},
],
[onPay],
[onPay, onEdit],
);
const table = useReactTable({
@@ -227,15 +238,25 @@ export function ExpensesTable({
</div>
{expense.status !== "PAYED" && (
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onPay(expense)}
>
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => onEdit(expense)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onPay(expense)}
>
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
</div>
)}
</div>
</div>

View File

@@ -154,6 +154,11 @@ export type PayExpenseInput = {
paymentDate?: string;
};
export type UpdateExpenseInput = {
amount: number;
dueDate: string;
};
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
}
@@ -263,6 +268,13 @@ export function payExpense(
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
}
export function updateExpense(
id: number,
data: UpdateExpenseInput,
): Promise<Expense> {
return mutator<Expense>(`/api/expenses/${id}`, "PUT", data);
}
export type ImportResult = {
imported: number;
skipped: number;

View File

@@ -27,6 +27,8 @@ import {
importPeriodicExpenses,
type PayExpenseInput,
payExpense as payExpenseApi,
type UpdateExpenseInput,
updateExpense as updateExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi,
} from "./api";
@@ -279,3 +281,14 @@ export function usePayExpense() {
},
});
}
export function useUpdateExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateExpenseInput }) =>
updateExpenseApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
},
});
}