- Install @biomejs/biome as devDependency at root - Configure biome.json with 2-space indent, double quotes, Tailwind CSS support - Add lint/lint:fix/format/format:fix scripts to root and app package.json - Fix noNonNullAssertion: env vars extracted to variables with suppression, <div role=button> replaced with <button> - Fix noUnusedVariables: remove unused destructured vars - Fix useIterableCallbackReturn: arrow functions with block body - Fix noExplicitAny: recharts Tooltip formatters - Fix noLabelWithoutControl: add htmlFor+id or use <span> for non-input labels - Fix noStaticElementInteractions/useKeyWithClickEvents: role+keyboard events for overlays - Fix noArrayIndexKey: use error string as key - Fix CSS parse: enable tailwindDirectives parser - Normalize formatting across 80 files with biome check --write
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useForm } from "react-hook-form";
|
|
import { z } from "zod";
|
|
import { Button } from "@/components/ui/button";
|
|
import type { PeriodicExpense } from "@/lib/api";
|
|
|
|
const months = [
|
|
{ value: 1, label: "Ene" },
|
|
{ value: 2, label: "Feb" },
|
|
{ value: 3, label: "Mar" },
|
|
{ value: 4, label: "Abr" },
|
|
{ value: 5, label: "May" },
|
|
{ value: 6, label: "Jun" },
|
|
{ value: 7, label: "Jul" },
|
|
{ value: 8, label: "Ago" },
|
|
{ value: 9, label: "Sep" },
|
|
{ value: 10, label: "Oct" },
|
|
{ value: 11, label: "Nov" },
|
|
{ value: 12, label: "Dic" },
|
|
];
|
|
|
|
const periodicExpenseFormSchema = z.object({
|
|
description: z
|
|
.string()
|
|
.min(1, "La descripción es obligatoria")
|
|
.max(50, "Máximo 50 caracteres"),
|
|
defaultDueDay: z
|
|
.number()
|
|
.int()
|
|
.min(1, "Día entre 1 y 31")
|
|
.max(31, "Día entre 1 y 31"),
|
|
defaultAmount: z.number().positive("El monto debe ser mayor a 0"),
|
|
periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"),
|
|
});
|
|
|
|
type PeriodicExpenseFormValues = z.infer<typeof periodicExpenseFormSchema>;
|
|
|
|
interface PeriodicExpenseFormProps {
|
|
initialData?: PeriodicExpense;
|
|
onSubmit: (data: PeriodicExpenseFormValues) => Promise<void>;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export function PeriodicExpenseForm({
|
|
initialData,
|
|
onSubmit,
|
|
onCancel,
|
|
}: PeriodicExpenseFormProps) {
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
watch,
|
|
setValue,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm<PeriodicExpenseFormValues>({
|
|
resolver: zodResolver(periodicExpenseFormSchema),
|
|
defaultValues: initialData
|
|
? {
|
|
description: initialData.description,
|
|
defaultDueDay: initialData.defaultDueDay,
|
|
defaultAmount: Number(initialData.defaultAmount),
|
|
periods: initialData.periods,
|
|
}
|
|
: {
|
|
description: "",
|
|
defaultDueDay: 15,
|
|
defaultAmount: 0,
|
|
periods: [],
|
|
},
|
|
});
|
|
|
|
const selectedPeriods = watch("periods");
|
|
|
|
function toggleMonth(month: number) {
|
|
if (selectedPeriods.includes(month)) {
|
|
setValue(
|
|
"periods",
|
|
selectedPeriods.filter((m) => m !== month),
|
|
{ shouldValidate: true },
|
|
);
|
|
} else {
|
|
setValue("periods", [...selectedPeriods, month], {
|
|
shouldValidate: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="description" className="text-sm font-medium">
|
|
Descripción
|
|
</label>
|
|
<input
|
|
id="description"
|
|
{...register("description")}
|
|
placeholder="Ej: Gimnasio"
|
|
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.description && (
|
|
<span className="text-xs text-destructive">
|
|
{errors.description.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
<label htmlFor="defaultAmount" className="text-sm font-medium">
|
|
Monto
|
|
</label>
|
|
<input
|
|
id="defaultAmount"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
{...register("defaultAmount", { valueAsNumber: true })}
|
|
placeholder="1500"
|
|
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.defaultAmount && (
|
|
<span className="text-xs text-destructive">
|
|
{errors.defaultAmount.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
<label htmlFor="defaultDueDay" className="text-sm font-medium">
|
|
Día de vencimiento
|
|
</label>
|
|
<input
|
|
id="defaultDueDay"
|
|
type="number"
|
|
min="1"
|
|
max="31"
|
|
{...register("defaultDueDay", { valueAsNumber: true })}
|
|
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.defaultDueDay && (
|
|
<span className="text-xs text-destructive">
|
|
{errors.defaultDueDay.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Meses de aplicación</span>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const all = months.map((m) => m.value);
|
|
setValue("periods", all, { shouldValidate: true });
|
|
}}
|
|
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
|
>
|
|
Seleccionar todos
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setValue("periods", [], { shouldValidate: true })}
|
|
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
|
>
|
|
Deseleccionar todos
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{months.map((m) => (
|
|
<button
|
|
key={m.value}
|
|
type="button"
|
|
onClick={() => toggleMonth(m.value)}
|
|
className={
|
|
selectedPeriods.includes(m.value)
|
|
? "h-7 rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground transition-colors"
|
|
: "h-7 rounded-md border border-input bg-background px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
|
|
}
|
|
>
|
|
{m.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{errors.periods && (
|
|
<span className="text-xs text-destructive">
|
|
{errors.periods.message}
|
|
</span>
|
|
)}
|
|
<input type="hidden" {...register("periods")} />
|
|
</div>
|
|
|
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onCancel}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
{initialData ? "Guardar cambios" : "Crear gasto periódico"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|