feat: PRD-001 ProductType (flags + multimedia) #38
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import type { ProductTypeListItem } from '../types'
|
||||||
|
|
||||||
|
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveDeactivateError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
const errObj = err as { response?: { status?: number; data?: { message?: string } } }
|
||||||
|
if (errObj?.response?.data?.message) {
|
||||||
|
return errObj.response.data.message
|
||||||
|
}
|
||||||
|
return 'Error al desactivar el tipo de producto'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DeactivateProductTypeDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
productType: ProductTypeListItem
|
||||||
|
onConfirm: (id: number) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DeactivateProductTypeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
productType,
|
||||||
|
onConfirm,
|
||||||
|
}: DeactivateProductTypeDialogProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
setError(null)
|
||||||
|
setIsPending(true)
|
||||||
|
try {
|
||||||
|
await onConfirm(productType.id)
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(resolveDeactivateError(err))
|
||||||
|
} finally {
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Desactivar tipo de producto</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
¿Desactivar el tipo “{productType.nombre}”? Los productos asociados conservan
|
||||||
|
la referencia pero el tipo no aparecerá en listados activos.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||||
|
{isPending ? 'Procesando...' : 'Desactivar'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
|
||||||
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function nullablePositiveInt() {
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === '' || v == null ? null : Number(v)))
|
||||||
|
.pipe(z.number().int().positive().nullable())
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullablePositiveDecimal() {
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === '' || v == null ? null : Number(v)))
|
||||||
|
.pipe(z.number().positive().nullable())
|
||||||
|
}
|
||||||
|
|
||||||
|
const productTypeFormSchema = z.object({
|
||||||
|
nombre: z.string().trim().min(1, 'Nombre requerido').max(100, 'Máximo 100 caracteres'),
|
||||||
|
hasDuration: z.boolean(),
|
||||||
|
requiresText: z.boolean(),
|
||||||
|
requiresCategory: z.boolean(),
|
||||||
|
isBundle: z.boolean(),
|
||||||
|
allowImages: z.boolean(),
|
||||||
|
maxImages: nullablePositiveInt(),
|
||||||
|
maxImageSizeMB: nullablePositiveDecimal(),
|
||||||
|
maxImageWidth: nullablePositiveInt(),
|
||||||
|
maxImageHeight: nullablePositiveInt(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Raw form field types (strings before zod transforms)
|
||||||
|
type ProductTypeFormRaw = {
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages: string
|
||||||
|
maxImageSizeMB: string
|
||||||
|
maxImageWidth: string
|
||||||
|
maxImageHeight: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output type after zod transforms (what onSubmit receives at runtime)
|
||||||
|
export type ProductTypeFormOutput = {
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages: number | null
|
||||||
|
maxImageSizeMB: number | null
|
||||||
|
maxImageWidth: number | null
|
||||||
|
maxImageHeight: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProductTypeFormDefaultValues {
|
||||||
|
nombre?: string
|
||||||
|
hasDuration?: boolean
|
||||||
|
requiresText?: boolean
|
||||||
|
requiresCategory?: boolean
|
||||||
|
isBundle?: boolean
|
||||||
|
allowImages?: boolean
|
||||||
|
maxImages?: number | null
|
||||||
|
maxImageSizeMB?: number | null
|
||||||
|
maxImageWidth?: number | null
|
||||||
|
maxImageHeight?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductTypeFormProps {
|
||||||
|
defaultValues?: ProductTypeFormDefaultValues
|
||||||
|
onSubmit: (values: ProductTypeFormOutput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending?: boolean
|
||||||
|
isEdit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ProductTypeForm({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isPending = false,
|
||||||
|
isEdit = false,
|
||||||
|
}: ProductTypeFormProps) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const form = useForm<ProductTypeFormRaw>({
|
||||||
|
resolver: zodResolver(productTypeFormSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
nombre: defaultValues?.nombre ?? '',
|
||||||
|
hasDuration: defaultValues?.hasDuration ?? false,
|
||||||
|
requiresText: defaultValues?.requiresText ?? false,
|
||||||
|
requiresCategory: defaultValues?.requiresCategory ?? false,
|
||||||
|
isBundle: defaultValues?.isBundle ?? false,
|
||||||
|
allowImages: defaultValues?.allowImages ?? false,
|
||||||
|
maxImages: defaultValues?.maxImages != null ? String(defaultValues.maxImages) : '',
|
||||||
|
maxImageSizeMB: defaultValues?.maxImageSizeMB != null ? String(defaultValues.maxImageSizeMB) : '',
|
||||||
|
maxImageWidth: defaultValues?.maxImageWidth != null ? String(defaultValues.maxImageWidth) : '',
|
||||||
|
maxImageHeight: defaultValues?.maxImageHeight != null ? String(defaultValues.maxImageHeight) : '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const allowImages = form.watch('allowImages')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
nombre: defaultValues?.nombre ?? '',
|
||||||
|
hasDuration: defaultValues?.hasDuration ?? false,
|
||||||
|
requiresText: defaultValues?.requiresText ?? false,
|
||||||
|
requiresCategory: defaultValues?.requiresCategory ?? false,
|
||||||
|
isBundle: defaultValues?.isBundle ?? false,
|
||||||
|
allowImages: defaultValues?.allowImages ?? false,
|
||||||
|
maxImages: defaultValues?.maxImages != null ? String(defaultValues.maxImages) : '',
|
||||||
|
maxImageSizeMB: defaultValues?.maxImageSizeMB != null ? String(defaultValues.maxImageSizeMB) : '',
|
||||||
|
maxImageWidth: defaultValues?.maxImageWidth != null ? String(defaultValues.maxImageWidth) : '',
|
||||||
|
maxImageHeight: defaultValues?.maxImageHeight != null ? String(defaultValues.maxImageHeight) : '',
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [defaultValues?.nombre, defaultValues?.allowImages])
|
||||||
|
|
||||||
|
function handleSubmit(data: ProductTypeFormOutput) {
|
||||||
|
// Normalize multimedia to null when allowImages=false
|
||||||
|
if (!data.allowImages) {
|
||||||
|
data = {
|
||||||
|
...data,
|
||||||
|
maxImages: null,
|
||||||
|
maxImageSizeMB: null,
|
||||||
|
maxImageWidth: null,
|
||||||
|
maxImageHeight: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSubmit(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(
|
||||||
|
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{/* Nombre */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nombre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Nombre del tipo de producto"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Flags */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="hasDuration"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="hasDuration"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Tiene duración"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="hasDuration" className="font-normal cursor-pointer">
|
||||||
|
Tiene duración
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requiresText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="requiresText"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Requiere texto"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="requiresText" className="font-normal cursor-pointer">
|
||||||
|
Requiere texto
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requiresCategory"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="requiresCategory"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Requiere categoría"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="requiresCategory" className="font-normal cursor-pointer">
|
||||||
|
Requiere categoría
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isBundle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="isBundle"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Es bundle"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="isBundle" className="font-normal cursor-pointer">
|
||||||
|
Es bundle
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allow Images toggle */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowImages"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="allowImages"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Permite imágenes"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="allowImages" className="font-normal cursor-pointer">
|
||||||
|
Permite imágenes
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Multimedia fields — always rendered, disabled when allowImages=false */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 border rounded-md p-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxImages"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Máx. imágenes</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || !allowImages}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Máx. imágenes"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxImageSizeMB"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Máx. tamaño (MB)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
disabled={isPending || !allowImages}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Máx. tamaño (MB)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxImageWidth"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ancho máx. (px)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || !allowImages}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Ancho máx. (px)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxImageHeight"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Alto máx. (px)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || !allowImages}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Alto máx. (px)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { ProductTypeForm } from './ProductTypeForm'
|
||||||
|
import type { ProductTypeFormOutput } from './ProductTypeForm'
|
||||||
|
import { useCreateProductType } from '../hooks/useCreateProductType'
|
||||||
|
import { useUpdateProductType } from '../hooks/useUpdateProductType'
|
||||||
|
import type { ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar el tipo de producto'
|
||||||
|
}
|
||||||
|
const errObj = err as { response?: { data?: { message?: string } } }
|
||||||
|
if (errObj?.response?.data?.message) {
|
||||||
|
return errObj.response.data.message
|
||||||
|
}
|
||||||
|
return 'Error al guardar el tipo de producto'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProductTypeFormDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
productType?: ProductTypeDetail
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ProductTypeFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
productType,
|
||||||
|
onSuccess,
|
||||||
|
}: ProductTypeFormDialogProps) {
|
||||||
|
const [backendError, setBackendError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isEdit = !!productType
|
||||||
|
const { mutateAsync: createProductType, isPending: creating } = useCreateProductType()
|
||||||
|
const { mutateAsync: updateProductType, isPending: updating } = useUpdateProductType()
|
||||||
|
const isPending = creating || updating
|
||||||
|
|
||||||
|
async function handleSubmit(values: ProductTypeFormOutput) {
|
||||||
|
setBackendError(null)
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await updateProductType({ id: productType.id, data: values })
|
||||||
|
toast.success('Tipo de producto actualizado')
|
||||||
|
} else {
|
||||||
|
await createProductType(values)
|
||||||
|
toast.success('Tipo de producto creado')
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
onSuccess?.()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = resolveBackendError(err)
|
||||||
|
setBackendError(msg)
|
||||||
|
if (
|
||||||
|
!isAxiosError(err) ||
|
||||||
|
(err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400)
|
||||||
|
) {
|
||||||
|
toast.error(isEdit ? 'Error al actualizar tipo de producto' : 'Error al crear tipo de producto')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar tipo de producto' : 'Nuevo tipo de producto'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? `Modificá los datos del tipo "${productType?.nombre ?? ''}".`
|
||||||
|
: 'Completá los datos para crear un nuevo tipo de producto.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProductTypeForm
|
||||||
|
defaultValues={productType}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
isPending={isPending}
|
||||||
|
isEdit={isEdit}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import { DeactivateProductTypeDialog } from '../../../features/product-types/components/DeactivateProductTypeDialog'
|
||||||
|
import type { ProductTypeListItem } from '../../../features/product-types/types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
const sampleProductType: ProductTypeListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(children: React.ReactNode) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeactivateProductTypeDialog', () => {
|
||||||
|
it('renders confirmation message with product type name', () => {
|
||||||
|
wrap(
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
productType={sampleProductType}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/Clasificados/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { name: /desactivar tipo/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm with product type id when user confirms', async () => {
|
||||||
|
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||||
|
wrap(
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
productType={sampleProductType}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||||
|
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
|
||||||
|
await userEvent.click(confirmBtn)
|
||||||
|
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(sampleProductType.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows inline error when backend returns 409 EnUso', async () => {
|
||||||
|
const onConfirm = vi.fn(() =>
|
||||||
|
Promise.reject({
|
||||||
|
response: { status: 409, data: { message: 'No se puede desactivar: el tipo está en uso por Products.' } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
wrap(
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
productType={sampleProductType}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||||
|
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
|
||||||
|
await userEvent.click(confirmBtn)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/en uso/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes dialog when cancel is clicked', async () => {
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
productType={sampleProductType}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import { ProductTypeForm } from '../../../features/product-types/components/ProductTypeForm'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
function wrap(children: React.ReactNode) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ProductTypeForm — field rendering ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeForm — field rendering', () => {
|
||||||
|
it('renders nombre field and all flag checkboxes', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText(/nombre/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Tiene duración')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Requiere texto')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Requiere categoría')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Es bundle')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Permite imágenes')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders multimedia fields when allowImages is true', async () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: true }} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('Máx. imágenes')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Máx. tamaño (MB)')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Ancho máx. (px)')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Alto máx. (px)')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables multimedia fields when allowImages is false', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('Máx. imágenes')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('Máx. tamaño (MB)')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('Ancho máx. (px)')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('Alto máx. (px)')).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ProductTypeForm — allowImages toggle ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeForm — allowImages toggle', () => {
|
||||||
|
it('enables multimedia fields after toggling allowImages on', async () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||||
|
)
|
||||||
|
const allowImagesCheckbox = screen.getByLabelText('Permite imágenes')
|
||||||
|
await userEvent.click(allowImagesCheckbox)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Máx. imágenes')).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ProductTypeForm — validation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeForm — zod validation', () => {
|
||||||
|
it('shows validation error when nombre is empty', async () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/nombre.*requerido/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with correct payload when form is valid', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={onSubmit} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Clasificados')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalled()
|
||||||
|
const payload = onSubmit.mock.calls[0][0]
|
||||||
|
expect(payload).toMatchObject({ nombre: 'Clasificados' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes multimedia fields to null when allowImages is false on submit', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={onSubmit} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||||
|
)
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Sin imágenes')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalled()
|
||||||
|
const payload = onSubmit.mock.calls[0][0]
|
||||||
|
expect(payload.maxImages).toBeNull()
|
||||||
|
expect(payload.maxImageSizeMB).toBeNull()
|
||||||
|
expect(payload.maxImageWidth).toBeNull()
|
||||||
|
expect(payload.maxImageHeight).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ProductTypeForm — cancel ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeForm — cancel', () => {
|
||||||
|
it('calls onCancel when cancel button is clicked', async () => {
|
||||||
|
const onCancel = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={onCancel} />,
|
||||||
|
)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
expect(onCancel).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import { ProductTypeFormDialog } from '../../../features/product-types/components/ProductTypeFormDialog'
|
||||||
|
import type { ProductTypeDetail } from '../../../features/product-types/types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockDetail: ProductTypeDetail = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
maxImages: null,
|
||||||
|
maxImageSizeMB: null,
|
||||||
|
maxImageWidth: null,
|
||||||
|
maxImageHeight: null,
|
||||||
|
isActive: true,
|
||||||
|
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function wrap(children: React.ReactNode) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ProductTypeFormDialog — create mode ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeFormDialog — create mode', () => {
|
||||||
|
it('renders create dialog title when no productType prop', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('heading', { name: /nuevo tipo/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has aria-describedby on DialogDescription (NFR8)', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
)
|
||||||
|
const desc = screen.getByText(/completá los datos/i)
|
||||||
|
expect(desc).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls create mutation and closes dialog on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/product-types`, () =>
|
||||||
|
HttpResponse.json({ id: 5, nombre: 'Nuevo Tipo' }, { status: 201 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={onOpenChange} />,
|
||||||
|
)
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Tipo')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows inline error when backend returns 409 NombreDuplicado', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/product-types`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ error: 'producto_tipo_nombre_duplicado', message: 'Ya existe un tipo con ese nombre' },
|
||||||
|
{ status: 409 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
)
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/ya existe un tipo con ese nombre/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ProductTypeFormDialog — edit mode ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeFormDialog — edit mode', () => {
|
||||||
|
it('renders edit dialog title and pre-fills nombre', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} productType={mockDetail} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('heading', { name: /editar tipo/i })).toBeInTheDocument()
|
||||||
|
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||||
|
expect(input.value).toBe('Clasificados')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls update mutation and closes dialog on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/product-types/1`, () =>
|
||||||
|
HttpResponse.json({ ...mockDetail, nombre: 'Modificado' }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={onOpenChange} productType={mockDetail} />,
|
||||||
|
)
|
||||||
|
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||||
|
await userEvent.clear(input)
|
||||||
|
await userEvent.type(input, 'Modificado')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user