Files
personal-admin-2026/apps/frontend/src/features/expenses/components/PayExpenseDialog.tsx

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>
);
}