feat(expenses): add edit amount and due date for pending expenses
This commit is contained in:
@@ -12,6 +12,7 @@ import { listExpensesHandler } from "./handlers/listExpenses";
|
|||||||
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
|
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
|
||||||
import { payExpenseHandler } from "./handlers/payExpense";
|
import { payExpenseHandler } from "./handlers/payExpense";
|
||||||
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
|
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
|
||||||
|
import { updateExpenseHandler } from "./handlers/updateExpense";
|
||||||
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
|
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -34,5 +35,6 @@ app.get("/expenses", listExpensesHandler);
|
|||||||
app.post("/expenses", createNonPeriodicExpenseHandler);
|
app.post("/expenses", createNonPeriodicExpenseHandler);
|
||||||
app.post("/expenses/import", importExpensesHandler);
|
app.post("/expenses/import", importExpensesHandler);
|
||||||
app.put("/expenses/:id/pay", payExpenseHandler);
|
app.put("/expenses/:id/pay", payExpenseHandler);
|
||||||
|
app.put("/expenses/:id", updateExpenseHandler);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export const payExpenseSchema = z.object({
|
|||||||
paymentDate: z.string().datetime().optional(),
|
paymentDate: z.string().datetime().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateExpenseSchema = z.object({
|
||||||
|
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
|
dueDate: z.string().datetime({ message: "La fecha debe ser una fecha ISO válida" }),
|
||||||
|
});
|
||||||
|
|
||||||
function lastDayOfMonth(year: number, month: number): number {
|
function lastDayOfMonth(year: number, month: number): number {
|
||||||
return new Date(year, month, 0).getDate();
|
return new Date(year, month, 0).getDate();
|
||||||
}
|
}
|
||||||
@@ -187,6 +192,32 @@ export async function payExpense(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateExpense(
|
||||||
|
id: number,
|
||||||
|
data: z.infer<typeof updateExpenseSchema>,
|
||||||
|
) {
|
||||||
|
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 year = dueDate.getUTCFullYear();
|
||||||
|
const month = dueDate.getUTCMonth() + 1;
|
||||||
|
|
||||||
|
const result = await prisma.expense.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
amount: data.amount,
|
||||||
|
dueDate,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMonthlyPayedTotal(year: number, month: number) {
|
export async function getMonthlyPayedTotal(year: number, month: number) {
|
||||||
const cacheKey = `expenses:monthly-total:${year}:${month}`;
|
const cacheKey = `expenses:monthly-total:${year}:${month}`;
|
||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
|
|||||||
10
apps/backend/src/modules/expenses/handlers/updateExpense.ts
Normal file
10
apps/backend/src/modules/expenses/handlers/updateExpense.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { updateExpense, updateExpenseSchema } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function updateExpenseHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = updateExpenseSchema.parse(body);
|
||||||
|
const result = await updateExpense(id, parsed);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const mockPrisma = {
|
|||||||
expense: {
|
expense: {
|
||||||
findMany: mock(),
|
findMany: mock(),
|
||||||
findFirst: mock(),
|
findFirst: mock(),
|
||||||
|
findUniqueOrThrow: mock(),
|
||||||
create: mock(),
|
create: mock(),
|
||||||
update: mock(),
|
update: mock(),
|
||||||
count: mock(),
|
count: mock(),
|
||||||
@@ -36,6 +37,7 @@ const {
|
|||||||
createPeriodicExpenseSchema,
|
createPeriodicExpenseSchema,
|
||||||
createNonPeriodicExpenseSchema,
|
createNonPeriodicExpenseSchema,
|
||||||
payExpenseSchema,
|
payExpenseSchema,
|
||||||
|
updateExpenseSchema,
|
||||||
buildDueDate,
|
buildDueDate,
|
||||||
getCurrentYearMonthUTC,
|
getCurrentYearMonthUTC,
|
||||||
listPeriodicExpenses,
|
listPeriodicExpenses,
|
||||||
@@ -45,6 +47,7 @@ const {
|
|||||||
listExpenses,
|
listExpenses,
|
||||||
createNonPeriodicExpense,
|
createNonPeriodicExpense,
|
||||||
payExpense,
|
payExpense,
|
||||||
|
updateExpense,
|
||||||
generateExpensesForCurrentMonth,
|
generateExpensesForCurrentMonth,
|
||||||
generateMonthlyExpense,
|
generateMonthlyExpense,
|
||||||
} = await import("../src/modules/expenses/expenses.service");
|
} = await import("../src/modules/expenses/expenses.service");
|
||||||
@@ -179,6 +182,35 @@ describe("schemas", () => {
|
|||||||
expect(() => payExpenseSchema.parse({ amountPayed: 0 })).toThrow();
|
expect(() => payExpenseSchema.parse({ amountPayed: 0 })).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("updateExpenseSchema", () => {
|
||||||
|
test("accepts valid input", () => {
|
||||||
|
const result = updateExpenseSchema.parse({
|
||||||
|
amount: 2500,
|
||||||
|
dueDate: "2026-07-15T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(result.amount).toBe(2500);
|
||||||
|
expect(result.dueDate).toBe("2026-07-15T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-positive amount", () => {
|
||||||
|
expect(() =>
|
||||||
|
updateExpenseSchema.parse({
|
||||||
|
amount: 0,
|
||||||
|
dueDate: "2026-07-15T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid dueDate", () => {
|
||||||
|
expect(() =>
|
||||||
|
updateExpenseSchema.parse({
|
||||||
|
amount: 100,
|
||||||
|
dueDate: "not-a-date",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildDueDate", () => {
|
describe("buildDueDate", () => {
|
||||||
@@ -430,6 +462,45 @@ describe("payExpense", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("updateExpense", () => {
|
||||||
|
test("updates amount and dueDate for a PENDING expense", async () => {
|
||||||
|
const existing = {
|
||||||
|
id: 1,
|
||||||
|
status: PaymentStatus.PENDING,
|
||||||
|
amount: 1500,
|
||||||
|
dueDate: new Date("2026-06-15T00: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",
|
||||||
|
});
|
||||||
|
expect(result.amount).toBe(2000);
|
||||||
|
expect(result.year).toBe(2026);
|
||||||
|
expect(result.month).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects update for PAYED expense", async () => {
|
||||||
|
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce({
|
||||||
|
id: 1,
|
||||||
|
status: PaymentStatus.PAYED,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
updateExpense(1, {
|
||||||
|
amount: 100,
|
||||||
|
dueDate: "2026-07-15T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Solo se pueden editar gastos pendientes");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("generateExpensesForCurrentMonth", () => {
|
describe("generateExpensesForCurrentMonth", () => {
|
||||||
test("skips existing expenses to avoid duplicates", async () => {
|
test("skips existing expenses to avoid duplicates", async () => {
|
||||||
const { month } = getCurrentYearMonthUTC();
|
const { month } = getCurrentYearMonthUTC();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PayExpenseInput,
|
PayExpenseInput,
|
||||||
PeriodicExpense,
|
PeriodicExpense,
|
||||||
|
UpdateExpenseInput,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
useCreateNonPeriodicExpense,
|
useCreateNonPeriodicExpense,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
usePayExpense,
|
usePayExpense,
|
||||||
usePeriodicExpenses,
|
usePeriodicExpenses,
|
||||||
useTotalPending,
|
useTotalPending,
|
||||||
|
useUpdateExpense,
|
||||||
useUpdatePeriodicExpense,
|
useUpdatePeriodicExpense,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ interface ExpensesContextValue {
|
|||||||
data: CreateNonPeriodicExpenseInput,
|
data: CreateNonPeriodicExpenseInput,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
|
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
|
||||||
|
updateExpense: (id: number, data: UpdateExpenseInput) => Promise<void>;
|
||||||
importPeriodicExpenses: (file: File) => Promise<ImportResult>;
|
importPeriodicExpenses: (file: File) => Promise<ImportResult>;
|
||||||
importExpenses: (file: File) => Promise<ImportExpensesResult>;
|
importExpenses: (file: File) => Promise<ImportExpensesResult>;
|
||||||
}
|
}
|
||||||
@@ -133,6 +136,7 @@ export function ExpensesProvider({
|
|||||||
const generateMonthlyMutation = useGenerateMonthlyExpense();
|
const generateMonthlyMutation = useGenerateMonthlyExpense();
|
||||||
const createExpenseMutation = useCreateNonPeriodicExpense();
|
const createExpenseMutation = useCreateNonPeriodicExpense();
|
||||||
const payExpenseMutation = usePayExpense();
|
const payExpenseMutation = usePayExpense();
|
||||||
|
const updateExpenseMutation = useUpdateExpense();
|
||||||
const importPeriodicMutation = useImportPeriodicExpenses();
|
const importPeriodicMutation = useImportPeriodicExpenses();
|
||||||
const importExpensesMutation = useImportExpenses();
|
const importExpensesMutation = useImportExpenses();
|
||||||
|
|
||||||
@@ -192,6 +196,13 @@ export function ExpensesProvider({
|
|||||||
[payExpenseMutation],
|
[payExpenseMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateExpenseFn = useCallback(
|
||||||
|
async (id: number, data: UpdateExpenseInput) => {
|
||||||
|
await updateExpenseMutation.mutateAsync({ id, data });
|
||||||
|
},
|
||||||
|
[updateExpenseMutation],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpensesContext
|
<ExpensesContext
|
||||||
value={{
|
value={{
|
||||||
@@ -218,6 +229,7 @@ export function ExpensesProvider({
|
|||||||
generateMonthlyExpense: generateMonthlyExpenseFn,
|
generateMonthlyExpense: generateMonthlyExpenseFn,
|
||||||
createNonPeriodicExpense: createNonPeriodicExpenseFn,
|
createNonPeriodicExpense: createNonPeriodicExpenseFn,
|
||||||
payExpense: payExpenseFn,
|
payExpense: payExpenseFn,
|
||||||
|
updateExpense: updateExpenseFn,
|
||||||
importPeriodicExpenses: importPeriodicExpensesFn,
|
importPeriodicExpenses: importPeriodicExpensesFn,
|
||||||
importExpenses: importExpensesFn,
|
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";
|
} from "@/components/ui/select";
|
||||||
import type { Expense, ImportExpensesResult } from "@/lib/api";
|
import type { Expense, ImportExpensesResult } from "@/lib/api";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
|
import { EditExpenseDialog } from "./EditExpenseDialog";
|
||||||
import { ExpensesTable } from "./ExpensesTable";
|
import { ExpensesTable } from "./ExpensesTable";
|
||||||
import { NewExpenseDialog } from "./NewExpenseDialog";
|
import { NewExpenseDialog } from "./NewExpenseDialog";
|
||||||
import { PayExpenseDialog } from "./PayExpenseDialog";
|
import { PayExpenseDialog } from "./PayExpenseDialog";
|
||||||
@@ -49,6 +50,9 @@ export function ExpensesTabContent() {
|
|||||||
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(
|
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [editDialogExpense, setEditDialogExpense] = useState<Expense | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
||||||
|
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
@@ -153,6 +157,7 @@ export function ExpensesTabContent() {
|
|||||||
<ExpensesTable
|
<ExpensesTable
|
||||||
data={expenses.data}
|
data={expenses.data}
|
||||||
onPay={setPayDialogExpense}
|
onPay={setPayDialogExpense}
|
||||||
|
onEdit={setEditDialogExpense}
|
||||||
page={expenses.page}
|
page={expenses.page}
|
||||||
total={expenses.total}
|
total={expenses.total}
|
||||||
pageSize={expenses.pageSize}
|
pageSize={expenses.pageSize}
|
||||||
@@ -199,6 +204,14 @@ export function ExpensesTabContent() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditExpenseDialog
|
||||||
|
expense={editDialogExpense}
|
||||||
|
open={editDialogExpense !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditDialogExpense(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<NewExpenseDialog
|
<NewExpenseDialog
|
||||||
open={newExpenseOpen}
|
open={newExpenseOpen}
|
||||||
onOpenChange={setNewExpenseOpen}
|
onOpenChange={setNewExpenseOpen}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ChevronsLeftIcon,
|
ChevronsLeftIcon,
|
||||||
ChevronsRightIcon,
|
ChevronsRightIcon,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
|
Pencil,
|
||||||
SkipBackIcon,
|
SkipBackIcon,
|
||||||
SkipForwardIcon,
|
SkipForwardIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -26,6 +27,7 @@ import type { Expense } from "@/lib/api";
|
|||||||
interface ExpensesTableProps {
|
interface ExpensesTableProps {
|
||||||
data: Expense[];
|
data: Expense[];
|
||||||
onPay: (expense: Expense) => void;
|
onPay: (expense: Expense) => void;
|
||||||
|
onEdit: (expense: Expense) => void;
|
||||||
page: number;
|
page: number;
|
||||||
total: number;
|
total: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -70,6 +72,7 @@ function ExpenseStatusBadge({ status }: { status: Expense["status"] }) {
|
|||||||
export function ExpensesTable({
|
export function ExpensesTable({
|
||||||
data,
|
data,
|
||||||
onPay,
|
onPay,
|
||||||
|
onEdit,
|
||||||
page,
|
page,
|
||||||
total,
|
total,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -137,7 +140,15 @@ export function ExpensesTable({
|
|||||||
const expense = row.original;
|
const expense = row.original;
|
||||||
if (expense.status === "PAYED") return null;
|
if (expense.status === "PAYED") return null;
|
||||||
return (
|
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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -151,7 +162,7 @@ export function ExpensesTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[onPay],
|
[onPay, onEdit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -227,6 +238,15 @@ export function ExpensesTable({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expense.status !== "PAYED" && (
|
{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>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -236,6 +256,7 @@ export function ExpensesTable({
|
|||||||
<CircleDollarSign className="size-3.5" />
|
<CircleDollarSign className="size-3.5" />
|
||||||
Pagar
|
Pagar
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ export type PayExpenseInput = {
|
|||||||
paymentDate?: string;
|
paymentDate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateExpenseInput = {
|
||||||
|
amount: number;
|
||||||
|
dueDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
|
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
|
||||||
return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
|
return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
|
||||||
}
|
}
|
||||||
@@ -263,6 +268,13 @@ export function payExpense(
|
|||||||
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
|
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 = {
|
export type ImportResult = {
|
||||||
imported: number;
|
imported: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
importPeriodicExpenses,
|
importPeriodicExpenses,
|
||||||
type PayExpenseInput,
|
type PayExpenseInput,
|
||||||
payExpense as payExpenseApi,
|
payExpense as payExpenseApi,
|
||||||
|
type UpdateExpenseInput,
|
||||||
|
updateExpense as updateExpenseApi,
|
||||||
updatePeriodicExpense as updatePeriodicExpenseApi,
|
updatePeriodicExpense as updatePeriodicExpenseApi,
|
||||||
} from "./api";
|
} 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