feat(expenses): add edit amount and due date for pending expenses
This commit is contained in:
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user