feat(wallets): add isDefault field to wallet model and implement setDefaultWallet functionality

This commit is contained in:
Jose Selesan
2026-06-17 10:15:44 -03:00
parent 798d657869
commit 5671b4a14b
11 changed files with 459 additions and 43 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "wallets" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -134,6 +134,7 @@ model Wallet {
name String @db.VarChar(100) name String @db.VarChar(100)
currency String @db.VarChar(10) currency String @db.VarChar(10)
balance Decimal @db.Decimal(14, 4) balance Decimal @db.Decimal(14, 4)
isDefault Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { PaymentStatus, QuoteType, Prisma } from "../../generated/prisma/client"; import { PaymentStatus, QuoteType, type Prisma } from "../../generated/prisma/client";
import { cache } from "../../lib/cache"; import { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import { roundTo } from "../../lib/utils"; import { roundTo } from "../../lib/utils";
@@ -17,11 +17,15 @@ export const createNonPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50), description: z.string().min(1).max(50),
amount: z.number().positive(), amount: z.number().positive(),
dueDate: z.string().datetime(), dueDate: z.string().datetime(),
walletId: z.number().int().positive(),
usdcConversionRate: z.number().positive().optional(),
}); });
export const payExpenseSchema = z.object({ export const payExpenseSchema = z.object({
amountPayed: z.number().positive(), amountPayed: z.number().positive(),
paymentDate: z.string().datetime().optional(), paymentDate: z.string().datetime().optional(),
walletId: z.number().int().positive(),
usdcConversionRate: z.number().positive().optional(),
}); });
export const updateExpenseSchema = z.object({ export const updateExpenseSchema = z.object({
@@ -226,23 +230,64 @@ export async function createNonPeriodicExpense(
const year = dueDate.getUTCFullYear(); const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1; const month = dueDate.getUTCMonth() + 1;
const belo = await getBeloSellPriceForDate(dueDate); const wallet = await prisma.wallet.findUnique({
where: { id: data.walletId },
const result = await prisma.expense.create({
data: {
description: data.description,
amount: data.amount,
amountPayed: data.amount,
dueDate,
paymentDate: dueDate,
year,
month,
status: PaymentStatus.PAYED,
...(belo !== null
? { beloPrice: belo, usdcEquivalent: roundTo(data.amount / belo, 6) }
: {}),
},
}); });
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const isUSDC = wallet.currency === "USDC";
if (isUSDC && !data.usdcConversionRate) {
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
}
const belo = isUSDC ? null : await getBeloSellPriceForDate(dueDate);
const movementAmount =
isUSDC && data.usdcConversionRate
? -roundTo(data.amount / data.usdcConversionRate, 4)
: -data.amount;
const expenseData: Prisma.ExpenseUncheckedCreateInput = {
description: data.description,
amount: data.amount,
amountPayed: data.amount,
dueDate,
paymentDate: dueDate,
year,
month,
status: PaymentStatus.PAYED,
};
if (isUSDC && data.usdcConversionRate) {
expenseData.beloPrice = data.usdcConversionRate;
expenseData.usdcEquivalent = roundTo(
data.amount / data.usdcConversionRate,
6,
);
} else if (belo !== null) {
expenseData.beloPrice = belo;
expenseData.usdcEquivalent = roundTo(data.amount / belo, 6);
}
const [result] = await prisma.$transaction([
prisma.expense.create({ data: expenseData }),
prisma.wallet.update({
where: { id: data.walletId },
data: { balance: { increment: movementAmount } },
}),
prisma.walletMovement.create({
data: {
walletId: data.walletId,
type: "WITHDRAWAL",
amount: movementAmount,
description: `Pago: ${data.description}`,
},
}),
]);
cache.invalidateByPrefix("expenses:"); cache.invalidateByPrefix("expenses:");
return result; return result;
} }
@@ -251,23 +296,61 @@ export async function payExpense(
id: number, id: number,
data: z.infer<typeof payExpenseSchema>, data: z.infer<typeof payExpenseSchema>,
) { ) {
const expense = await prisma.expense.findUniqueOrThrow({ where: { id } });
const paymentDate = data.paymentDate const paymentDate = data.paymentDate
? new Date(data.paymentDate) ? new Date(data.paymentDate)
: new Date(); : new Date();
const belo = await getBeloSellPriceForDate(paymentDate); const wallet = await prisma.wallet.findUnique({
where: { id: data.walletId },
const result = await prisma.expense.update({
where: { id },
data: {
status: PaymentStatus.PAYED,
amountPayed: data.amountPayed,
paymentDate,
...(belo !== null
? { beloPrice: belo, usdcEquivalent: roundTo(data.amountPayed / belo, 6) }
: {}),
},
}); });
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const isUSDC = wallet.currency === "USDC";
if (isUSDC && !data.usdcConversionRate) {
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
}
const belo = isUSDC ? null : await getBeloSellPriceForDate(paymentDate);
const movementAmount = isUSDC && data.usdcConversionRate
? -roundTo(data.amountPayed / data.usdcConversionRate, 4)
: -data.amountPayed;
const expenseUpdateData: Prisma.ExpenseUncheckedUpdateInput = {
status: PaymentStatus.PAYED,
amountPayed: data.amountPayed,
paymentDate,
};
if (isUSDC && data.usdcConversionRate) {
expenseUpdateData.beloPrice = data.usdcConversionRate;
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / data.usdcConversionRate, 6);
} else if (belo !== null) {
expenseUpdateData.beloPrice = belo;
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / belo, 6);
}
const [result] = await prisma.$transaction([
prisma.expense.update({ where: { id }, data: expenseUpdateData }),
prisma.wallet.update({
where: { id: data.walletId },
data: { balance: { increment: movementAmount } },
}),
prisma.walletMovement.create({
data: {
walletId: data.walletId,
type: "WITHDRAWAL",
amount: movementAmount,
description: `Pago: ${expense.description}`,
},
}),
]);
cache.invalidateByPrefix("expenses:"); cache.invalidateByPrefix("expenses:");
return result; return result;
} }

View File

@@ -0,0 +1,8 @@
import type { Context } from "hono";
import { setDefaultWallet } from "../wallets.service";
export async function setDefaultWalletHandler(c: Context) {
const id = Number(c.req.param("id"));
const result = await setDefaultWallet(id);
return c.json(result);
}

View File

@@ -7,6 +7,7 @@ import { listMovementsHandler } from "./handlers/listMovements";
import { listWalletsHandler } from "./handlers/listWallets"; import { listWalletsHandler } from "./handlers/listWallets";
import { transferWalletHandler } from "./handlers/transferWallet"; import { transferWalletHandler } from "./handlers/transferWallet";
import { updateWalletHandler } from "./handlers/updateWallet"; import { updateWalletHandler } from "./handlers/updateWallet";
import { setDefaultWalletHandler } from "./handlers/setDefaultWallet";
const app = new Hono(); const app = new Hono();
@@ -17,6 +18,7 @@ app.delete("/:id", deleteWalletHandler);
app.post("/:id/deposit", depositWalletHandler); app.post("/:id/deposit", depositWalletHandler);
app.post("/:id/adjust", adjustBalanceHandler); app.post("/:id/adjust", adjustBalanceHandler);
app.post("/transfer", transferWalletHandler); app.post("/transfer", transferWalletHandler);
app.put("/:id/default", setDefaultWalletHandler);
app.get("/:id/movements", listMovementsHandler); app.get("/:id/movements", listMovementsHandler);
export default app; export default app;

View File

@@ -6,6 +6,7 @@ export const createWalletSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
currency: z.string().min(1).max(10), currency: z.string().min(1).max(10),
initialBalance: z.number().optional().default(0), initialBalance: z.number().optional().default(0),
isDefault: z.boolean().optional().default(false),
}); });
export const updateWalletSchema = z.object({ export const updateWalletSchema = z.object({
@@ -13,6 +14,10 @@ export const updateWalletSchema = z.object({
currency: z.string().min(1).max(10), currency: z.string().min(1).max(10),
}); });
export const setDefaultWalletSchema = z.object({
walletId: z.number().int().positive(),
});
export const depositSchema = z.object({ export const depositSchema = z.object({
amount: z.number().positive(), amount: z.number().positive(),
description: z.string().max(255).optional(), description: z.string().max(255).optional(),
@@ -36,9 +41,12 @@ export type UpdateWalletInput = z.infer<typeof updateWalletSchema>;
export type DepositInput = z.infer<typeof depositSchema>; export type DepositInput = z.infer<typeof depositSchema>;
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>; export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
export type TransferInput = z.infer<typeof transferSchema>; export type TransferInput = z.infer<typeof transferSchema>;
export type SetDefaultWalletInput = z.infer<typeof setDefaultWalletSchema>;
export async function listWallets() { export async function listWallets() {
const wallets = await prisma.wallet.findMany({ orderBy: { createdAt: "desc" } }); const wallets = await prisma.wallet.findMany({
orderBy: [{ isDefault: "desc" }, { createdAt: "desc" }],
});
return wallets.map((w) => ({ ...w, balance: Number(w.balance) })); return wallets.map((w) => ({ ...w, balance: Number(w.balance) }));
} }
@@ -48,8 +56,15 @@ export async function createWallet(input: CreateWalletInput) {
name: input.name, name: input.name,
currency: input.currency, currency: input.currency,
balance: input.initialBalance ?? 0, balance: input.initialBalance ?? 0,
isDefault: input.isDefault ?? false,
}, },
}); });
if (wallet.isDefault) {
await prisma.wallet.updateMany({
where: { id: { not: wallet.id }, isDefault: true },
data: { isDefault: false },
});
}
return { ...wallet, balance: Number(wallet.balance) }; return { ...wallet, balance: Number(wallet.balance) };
} }
@@ -155,6 +170,21 @@ export async function transferWallet(input: TransferInput) {
}; };
} }
export async function setDefaultWallet(walletId: number) {
await prisma.$transaction([
prisma.wallet.updateMany({
where: { isDefault: true },
data: { isDefault: false },
}),
prisma.wallet.update({
where: { id: walletId },
data: { isDefault: true },
}),
]);
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id: walletId } });
return { ...wallet, balance: Number(wallet.balance) };
}
export async function listMovements(walletId: number) { export async function listMovements(walletId: number) {
const movements = await prisma.walletMovement.findMany({ const movements = await prisma.walletMovement.findMany({
where: { walletId }, where: { walletId },

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { useEffect, useMemo, useRef } from "react";
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";
@@ -10,7 +11,15 @@ import {
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useExpensesContext } from "../ExpensesProvider"; import { useExpensesContext } from "../ExpensesProvider";
import { useQuotes, useWallets } from "@/lib/queries";
const newExpenseSchema = z.object({ const newExpenseSchema = z.object({
description: z description: z
@@ -19,6 +28,11 @@ const newExpenseSchema = z.object({
.max(50, "Máximo 50 caracteres"), .max(50, "Máximo 50 caracteres"),
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" }),
walletId: z.number().positive("Seleccioná una billetera"),
usdcConversionRate: z
.number()
.positive("La cotización debe ser mayor a 0")
.optional(),
}); });
type NewExpenseFormValues = z.infer<typeof newExpenseSchema>; type NewExpenseFormValues = z.infer<typeof newExpenseSchema>;
@@ -33,6 +47,19 @@ export function NewExpenseDialog({
onOpenChange, onOpenChange,
}: NewExpenseDialogProps) { }: NewExpenseDialogProps) {
const { createNonPeriodicExpense } = useExpensesContext(); const { createNonPeriodicExpense } = useExpensesContext();
const prevWalletRef = useRef(0);
const { data: wallets } = useWallets();
const { data: quotes } = useQuotes();
const defaultWallet = useMemo(
() => wallets?.find((w) => w.isDefault),
[wallets],
);
const beloSell = useMemo(() => {
if (!quotes) return null;
const belo = quotes.find((q) => q.type === "BELO");
return belo ? Number(belo.sell) : null;
}, [quotes]);
const { const {
register, register,
@@ -40,15 +67,48 @@ export function NewExpenseDialog({
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
reset, reset,
setValue,
trigger,
} = useForm<NewExpenseFormValues>({ } = useForm<NewExpenseFormValues>({
resolver: zodResolver(newExpenseSchema), resolver: zodResolver(newExpenseSchema),
defaultValues: { defaultValues: {
description: "", description: "",
amount: 0, amount: 0,
dueDate: new Date(), dueDate: new Date(),
walletId: defaultWallet?.id ?? 0,
usdcConversionRate: undefined,
}, },
}); });
useEffect(() => {
if (defaultWallet && !open) return;
if (defaultWallet) {
setValue("walletId", defaultWallet.id);
prevWalletRef.current = defaultWallet.id;
}
}, [defaultWallet, setValue, open]);
const usdcConversionRateField = register("usdcConversionRate", {
setValueAs: (value) => (value === "" ? undefined : Number(value)),
});
const selectedWalletId = useWatch({ control, name: "walletId" });
const selectedWallet = wallets?.find((w) => w.id === selectedWalletId);
const isUSDC = selectedWallet?.currency === "USDC";
useEffect(() => {
if (
isUSDC &&
beloSell &&
selectedWalletId &&
selectedWalletId !== prevWalletRef.current
) {
setValue("usdcConversionRate", beloSell);
trigger("usdcConversionRate");
prevWalletRef.current = selectedWalletId;
}
}, [isUSDC, beloSell, selectedWalletId, setValue, trigger]);
function handleClose(open: boolean) { function handleClose(open: boolean) {
onOpenChange(open); onOpenChange(open);
if (!open) reset(); if (!open) reset();
@@ -59,6 +119,8 @@ export function NewExpenseDialog({
description: data.description, description: data.description,
amount: data.amount, amount: data.amount,
dueDate: data.dueDate.toISOString(), dueDate: data.dueDate.toISOString(),
walletId: data.walletId,
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
}); });
handleClose(false); handleClose(false);
} }
@@ -112,6 +174,62 @@ export function NewExpenseDialog({
)} )}
</div> </div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Billetera</label>
<Controller
control={control}
name="walletId"
render={({ field }) => (
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Seleccionar billetera" />
</SelectTrigger>
<SelectContent>
{wallets?.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.walletId && (
<span className="text-xs text-destructive">
{errors.walletId.message}
</span>
)}
</div>
{isUSDC && (
<div className="flex flex-col gap-1.5">
<label
htmlFor="usdcConversionRate"
className="text-sm font-medium"
>
Cotización USDC (ARS por USDC)
</label>
<input
id="usdcConversionRate"
type="text"
inputMode="decimal"
{...usdcConversionRateField}
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.usdcConversionRate && (
<span className="text-xs text-destructive">
{errors.usdcConversionRate.message}
</span>
)}
<span className="text-xs text-muted-foreground">
Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO)
</span>
</div>
)}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha del gasto</span> <span className="text-sm font-medium">Fecha del gasto</span>
<Controller <Controller

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef } from "react"; import { useEffect, useMemo, 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";
@@ -11,12 +11,28 @@ import {
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Expense } from "@/lib/api"; import type { Expense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider"; import { useExpensesContext } from "../ExpensesProvider";
import { useQuotes, useWallets } from "@/lib/queries";
const paySchema = z.object({ const paySchema = z.object({
amountPayed: z.number().positive("El monto debe ser mayor a 0"), amountPayed: z.number().positive("El monto debe ser mayor a 0"),
paymentDate: z.date({ message: "La fecha es obligatoria" }), paymentDate: z.date({ message: "La fecha es obligatoria" }),
walletId: z
.number()
.positive("Seleccioná una billetera")
.refine((v) => v > 0, "Seleccioná una billetera"),
usdcConversionRate: z
.number()
.positive("La cotización debe ser mayor a 0")
.optional(),
}); });
type PayFormValues = z.infer<typeof paySchema>; type PayFormValues = z.infer<typeof paySchema>;
@@ -34,6 +50,19 @@ export function PayExpenseDialog({
}: PayExpenseDialogProps) { }: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext(); const { payExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null); const amountInputRef = useRef<HTMLInputElement | null>(null);
const prevWalletRef = useRef(0);
const { data: wallets } = useWallets();
const { data: quotes } = useQuotes();
const defaultWallet = useMemo(
() => wallets?.find((w) => w.isDefault),
[wallets],
);
const beloSell = useMemo(() => {
if (!quotes) return null;
const belo = quotes.find((q) => q.type === "BELO");
return belo ? Number(belo.sell) : null;
}, [quotes]);
const { const {
register, register,
@@ -41,25 +70,54 @@ export function PayExpenseDialog({
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
reset, reset,
setValue,
trigger,
} = useForm<PayFormValues>({ } = useForm<PayFormValues>({
resolver: zodResolver(paySchema), resolver: zodResolver(paySchema),
defaultValues: { defaultValues: {
amountPayed: 0, amountPayed: 0,
paymentDate: undefined, paymentDate: undefined,
walletId: 0,
usdcConversionRate: undefined,
}, },
}); });
const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", { const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", {
setValueAs: (value) => Number(value), setValueAs: (value) => Number(value),
}); });
const usdcConversionRateField = register("usdcConversionRate", {
setValueAs: (value) => (value === "" ? undefined : Number(value)),
});
const selectedWalletId = useWatch({ control, name: "walletId" });
const selectedWallet = wallets?.find((w) => w.id === selectedWalletId);
const isUSDC = selectedWallet?.currency === "USDC";
useEffect(() => { useEffect(() => {
if (expense) { if (expense) {
const defaultId = defaultWallet?.id ?? 0;
reset({ reset({
amountPayed: Number(expense.amount), amountPayed: Number(expense.amount),
paymentDate: new Date(), paymentDate: new Date(),
walletId: defaultId,
usdcConversionRate: undefined,
}); });
prevWalletRef.current = defaultId;
} }
}, [expense, reset]); }, [expense, defaultWallet, reset]);
useEffect(() => {
if (
isUSDC &&
beloSell &&
selectedWalletId &&
selectedWalletId !== prevWalletRef.current
) {
setValue("usdcConversionRate", beloSell);
trigger("usdcConversionRate");
prevWalletRef.current = selectedWalletId;
}
}, [isUSDC, beloSell, selectedWalletId, setValue, trigger]);
useEffect(() => { useEffect(() => {
if (!open || !expense) return; if (!open || !expense) return;
@@ -82,6 +140,8 @@ export function PayExpenseDialog({
await payExpense(expense.id, { await payExpense(expense.id, {
amountPayed: data.amountPayed, amountPayed: data.amountPayed,
paymentDate: data.paymentDate.toISOString(), paymentDate: data.paymentDate.toISOString(),
walletId: data.walletId,
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
}); });
handleClose(false); handleClose(false);
} }
@@ -133,6 +193,62 @@ export function PayExpenseDialog({
)} )}
</div> </div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Billetera</label>
<Controller
control={control}
name="walletId"
render={({ field }) => (
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Seleccionar billetera" />
</SelectTrigger>
<SelectContent>
{wallets?.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.walletId && (
<span className="text-xs text-destructive">
{errors.walletId.message}
</span>
)}
</div>
{isUSDC && (
<div className="flex flex-col gap-1.5">
<label
htmlFor="usdcConversionRate"
className="text-sm font-medium"
>
Cotización USDC (ARS por USDC)
</label>
<input
id="usdcConversionRate"
type="text"
inputMode="decimal"
{...usdcConversionRateField}
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.usdcConversionRate && (
<span className="text-xs text-destructive">
{errors.usdcConversionRate.message}
</span>
)}
<span className="text-xs text-muted-foreground">
Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO)
</span>
</div>
)}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha de pago</span> <span className="text-sm font-medium">Fecha de pago</span>
<Controller <Controller

View File

@@ -5,6 +5,7 @@ import {
ArrowUpDown, ArrowUpDown,
History, History,
PencilLine, PencilLine,
Star,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -14,6 +15,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { Wallet } from "@/lib/api"; import type { Wallet } from "@/lib/api";
import { useSetDefaultWallet } from "@/lib/queries";
import { DepositDialog } from "./DepositDialog"; import { DepositDialog } from "./DepositDialog";
import { AdjustBalanceDialog } from "./AdjustBalanceDialog"; import { AdjustBalanceDialog } from "./AdjustBalanceDialog";
import { TransferDialog } from "./TransferDialog"; import { TransferDialog } from "./TransferDialog";
@@ -30,6 +32,7 @@ interface WalletCardProps {
} }
export function WalletCard({ wallet }: WalletCardProps) { export function WalletCard({ wallet }: WalletCardProps) {
const setDefaultWallet = useSetDefaultWallet();
const [depositOpen, setDepositOpen] = useState(false); const [depositOpen, setDepositOpen] = useState(false);
const [adjustOpen, setAdjustOpen] = useState(false); const [adjustOpen, setAdjustOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false);
@@ -40,17 +43,48 @@ export function WalletCard({ wallet }: WalletCardProps) {
<> <>
<div className="rounded-lg border p-4 space-y-3"> <div className="rounded-lg border p-4 space-y-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div className="flex items-center gap-2">
<p className="font-medium">{wallet.name}</p> <p className="font-medium">{wallet.name}</p>
<p className="text-xs text-muted-foreground">{wallet.currency}</p> {wallet.isDefault && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
Default
</span>
)}
</div>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setDefaultWallet.mutate(wallet.id)}
className={`transition-colors cursor-pointer ${
wallet.isDefault
? "text-yellow-500 hover:text-yellow-600"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Star
className="size-4"
fill={wallet.isDefault ? "currentColor" : "none"}
/>
</button>
</TooltipTrigger>
<TooltipContent>
{wallet.isDefault
? "Billetera predeterminada"
: "Marcar como predeterminada"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
onClick={() => setEditOpen(true)}
className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<PencilLine className="size-4" />
</button>
</div> </div>
<button
type="button"
onClick={() => setEditOpen(true)}
className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<PencilLine className="size-4" />
</button>
</div> </div>
<p className="text-2xl font-bold tabular-nums"> <p className="text-2xl font-bold tabular-nums">

View File

@@ -149,11 +149,15 @@ export type CreateNonPeriodicExpenseInput = {
description: string; description: string;
amount: number; amount: number;
dueDate: string; dueDate: string;
walletId: number;
usdcConversionRate?: number;
}; };
export type PayExpenseInput = { export type PayExpenseInput = {
amountPayed: number; amountPayed: number;
paymentDate?: string; paymentDate?: string;
walletId: number;
usdcConversionRate?: number;
}; };
export type UpdateExpenseInput = { export type UpdateExpenseInput = {
@@ -327,6 +331,7 @@ export type Wallet = {
name: string; name: string;
currency: string; currency: string;
balance: number; balance: number;
isDefault: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -409,6 +414,10 @@ export function transferWallet(
); );
} }
export function setDefaultWallet(id: number): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/default`, "PUT");
}
export function getWalletMovements( export function getWalletMovements(
id: number, id: number,
): Promise<WalletMovement[]> { ): Promise<WalletMovement[]> {

View File

@@ -33,6 +33,7 @@ import {
getTotalPending, getTotalPending,
getWalletMovements, getWalletMovements,
getWallets, getWallets,
setDefaultWallet as setDefaultWalletApi,
importExpenses, importExpenses,
importPeriodicExpenses, importPeriodicExpenses,
type PayExpenseInput, type PayExpenseInput,
@@ -261,6 +262,7 @@ export function useCreateNonPeriodicExpense() {
createNonPeriodicExpenseApi(data), createNonPeriodicExpenseApi(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all }); queryClient.invalidateQueries({ queryKey: expenseKeys.all });
queryClient.invalidateQueries({ queryKey: walletKeys.all });
}, },
}); });
} }
@@ -292,6 +294,7 @@ export function usePayExpense() {
payExpenseApi(id, data), payExpenseApi(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all }); queryClient.invalidateQueries({ queryKey: expenseKeys.all });
queryClient.invalidateQueries({ queryKey: walletKeys.all });
}, },
}); });
} }
@@ -358,6 +361,16 @@ export function useDeleteWallet() {
}); });
} }
export function useSetDefaultWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => setDefaultWalletApi(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useDepositWallet() { export function useDepositWallet() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({