feat(wallets): add isDefault field to wallet model and implement setDefaultWallet functionality
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
@@ -10,7 +11,15 @@ import {
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
} from "@/components/ui/responsive-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useExpensesContext } from "../ExpensesProvider";
|
||||
import { useQuotes, useWallets } from "@/lib/queries";
|
||||
|
||||
const newExpenseSchema = z.object({
|
||||
description: z
|
||||
@@ -19,6 +28,11 @@ const newExpenseSchema = z.object({
|
||||
.max(50, "Máximo 50 caracteres"),
|
||||
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||
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>;
|
||||
@@ -33,6 +47,19 @@ export function NewExpenseDialog({
|
||||
onOpenChange,
|
||||
}: NewExpenseDialogProps) {
|
||||
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 {
|
||||
register,
|
||||
@@ -40,15 +67,48 @@ export function NewExpenseDialog({
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
setValue,
|
||||
trigger,
|
||||
} = useForm<NewExpenseFormValues>({
|
||||
resolver: zodResolver(newExpenseSchema),
|
||||
defaultValues: {
|
||||
description: "",
|
||||
amount: 0,
|
||||
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) {
|
||||
onOpenChange(open);
|
||||
if (!open) reset();
|
||||
@@ -59,6 +119,8 @@ export function NewExpenseDialog({
|
||||
description: data.description,
|
||||
amount: data.amount,
|
||||
dueDate: data.dueDate.toISOString(),
|
||||
walletId: data.walletId,
|
||||
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
|
||||
});
|
||||
handleClose(false);
|
||||
}
|
||||
@@ -112,6 +174,62 @@ export function NewExpenseDialog({
|
||||
)}
|
||||
</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">
|
||||
<span className="text-sm font-medium">Fecha del gasto</span>
|
||||
<Controller
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useRef } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
@@ -11,12 +11,28 @@ import {
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
} from "@/components/ui/responsive-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Expense } from "@/lib/api";
|
||||
import { useExpensesContext } from "../ExpensesProvider";
|
||||
import { useQuotes, useWallets } from "@/lib/queries";
|
||||
|
||||
const paySchema = z.object({
|
||||
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
|
||||
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>;
|
||||
@@ -34,6 +50,19 @@ export function PayExpenseDialog({
|
||||
}: PayExpenseDialogProps) {
|
||||
const { payExpense } = useExpensesContext();
|
||||
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 {
|
||||
register,
|
||||
@@ -41,25 +70,54 @@ export function PayExpenseDialog({
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
setValue,
|
||||
trigger,
|
||||
} = useForm<PayFormValues>({
|
||||
resolver: zodResolver(paySchema),
|
||||
defaultValues: {
|
||||
amountPayed: 0,
|
||||
paymentDate: undefined,
|
||||
walletId: 0,
|
||||
usdcConversionRate: undefined,
|
||||
},
|
||||
});
|
||||
const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", {
|
||||
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(() => {
|
||||
if (expense) {
|
||||
const defaultId = defaultWallet?.id ?? 0;
|
||||
reset({
|
||||
amountPayed: Number(expense.amount),
|
||||
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(() => {
|
||||
if (!open || !expense) return;
|
||||
@@ -82,6 +140,8 @@ export function PayExpenseDialog({
|
||||
await payExpense(expense.id, {
|
||||
amountPayed: data.amountPayed,
|
||||
paymentDate: data.paymentDate.toISOString(),
|
||||
walletId: data.walletId,
|
||||
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
|
||||
});
|
||||
handleClose(false);
|
||||
}
|
||||
@@ -133,6 +193,62 @@ export function PayExpenseDialog({
|
||||
)}
|
||||
</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">
|
||||
<span className="text-sm font-medium">Fecha de pago</span>
|
||||
<Controller
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ArrowUpDown,
|
||||
History,
|
||||
PencilLine,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Wallet } from "@/lib/api";
|
||||
import { useSetDefaultWallet } from "@/lib/queries";
|
||||
import { DepositDialog } from "./DepositDialog";
|
||||
import { AdjustBalanceDialog } from "./AdjustBalanceDialog";
|
||||
import { TransferDialog } from "./TransferDialog";
|
||||
@@ -30,6 +32,7 @@ interface WalletCardProps {
|
||||
}
|
||||
|
||||
export function WalletCard({ wallet }: WalletCardProps) {
|
||||
const setDefaultWallet = useSetDefaultWallet();
|
||||
const [depositOpen, setDepositOpen] = useState(false);
|
||||
const [adjustOpen, setAdjustOpen] = 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="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<PencilLine className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
|
||||
@@ -149,11 +149,15 @@ export type CreateNonPeriodicExpenseInput = {
|
||||
description: string;
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
walletId: number;
|
||||
usdcConversionRate?: number;
|
||||
};
|
||||
|
||||
export type PayExpenseInput = {
|
||||
amountPayed: number;
|
||||
paymentDate?: string;
|
||||
walletId: number;
|
||||
usdcConversionRate?: number;
|
||||
};
|
||||
|
||||
export type UpdateExpenseInput = {
|
||||
@@ -327,6 +331,7 @@ export type Wallet = {
|
||||
name: string;
|
||||
currency: string;
|
||||
balance: number;
|
||||
isDefault: boolean;
|
||||
createdAt: 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(
|
||||
id: number,
|
||||
): Promise<WalletMovement[]> {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getTotalPending,
|
||||
getWalletMovements,
|
||||
getWallets,
|
||||
setDefaultWallet as setDefaultWalletApi,
|
||||
importExpenses,
|
||||
importPeriodicExpenses,
|
||||
type PayExpenseInput,
|
||||
@@ -261,6 +262,7 @@ export function useCreateNonPeriodicExpense() {
|
||||
createNonPeriodicExpenseApi(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: walletKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -292,6 +294,7 @@ export function usePayExpense() {
|
||||
payExpenseApi(id, data),
|
||||
onSuccess: () => {
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
Reference in New Issue
Block a user