289 lines
8.8 KiB
TypeScript
289 lines
8.8 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
|
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";
|
|
import {
|
|
ResponsiveDialog,
|
|
ResponsiveDialogContent,
|
|
ResponsiveDialogFooter,
|
|
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>;
|
|
|
|
interface PayExpenseDialogProps {
|
|
expense: Expense | null;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export function PayExpenseDialog({
|
|
expense,
|
|
open,
|
|
onOpenChange,
|
|
}: 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,
|
|
handleSubmit,
|
|
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, 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;
|
|
|
|
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: PayFormValues) {
|
|
if (!expense) return;
|
|
await payExpense(expense.id, {
|
|
amountPayed: data.amountPayed,
|
|
paymentDate: data.paymentDate.toISOString(),
|
|
walletId: data.walletId,
|
|
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
|
|
});
|
|
handleClose(false);
|
|
}
|
|
|
|
if (!expense) return null;
|
|
|
|
return (
|
|
<ResponsiveDialog open={open} onOpenChange={handleClose}>
|
|
<ResponsiveDialogContent>
|
|
<ResponsiveDialogHeader>
|
|
<ResponsiveDialogTitle>Registrar pago</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>
|
|
<span className="text-muted-foreground">
|
|
Monto original: $
|
|
{Number(expense.amount).toLocaleString("es-AR", {
|
|
minimumFractionDigits: 2,
|
|
})}
|
|
</span>
|
|
</div>
|
|
|
|
<form
|
|
id="pay-form"
|
|
onSubmit={handleSubmit(onSubmit)}
|
|
className="flex flex-col gap-4"
|
|
>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="amountPayed" className="text-sm font-medium">
|
|
Monto pagado
|
|
</label>
|
|
<input
|
|
id="amountPayed"
|
|
type="text"
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
{...amountPayedField}
|
|
ref={(node) => {
|
|
amountPayedRef(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.amountPayed && (
|
|
<span className="text-xs text-destructive">
|
|
{errors.amountPayed.message}
|
|
</span>
|
|
)}
|
|
</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
|
|
control={control}
|
|
name="paymentDate"
|
|
render={({ field }) => (
|
|
<DatePicker
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
placeholder="Seleccionar fecha"
|
|
/>
|
|
)}
|
|
/>
|
|
{errors.paymentDate && (
|
|
<span className="text-xs text-destructive">
|
|
{errors.paymentDate.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
<ResponsiveDialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => handleClose(false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button type="submit" form="pay-form" disabled={isSubmitting}>
|
|
Confirmar pago
|
|
</Button>
|
|
</ResponsiveDialogFooter>
|
|
</ResponsiveDialogContent>
|
|
</ResponsiveDialog>
|
|
);
|
|
}
|