feat(frontend): Products feature — CRUD page, form, dialogs, hooks (PRD-002)
Implements full frontend for PRD-002: 5 API fns, 5 hooks (useProducts, useCreateProduct, useUpdateProduct, useDeactivateProduct), ProductForm, ProductFormDialog, DeactivateProductDialog, ProductsPage with CanPerform gating. Router entry at /admin/products and sidebar link added. 19 Vitest tests GREEN (api, hooks, page).
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Tag,
|
Tag,
|
||||||
Layers,
|
Layers,
|
||||||
|
Package,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -82,6 +83,12 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Layers,
|
icon: Layers,
|
||||||
requiredPermission: 'catalogo:tipos:gestionar',
|
requiredPermission: 'catalogo:tipos:gestionar',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Productos',
|
||||||
|
href: '/admin/products',
|
||||||
|
icon: Package,
|
||||||
|
requiredPermission: 'catalogo:productos:gestionar',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
|
|||||||
12
src/web/src/features/products/api/createProduct.ts
Normal file
12
src/web/src/features/products/api/createProduct.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { CreateProductRequest, ProductDetail } from '../types'
|
||||||
|
|
||||||
|
export async function createProduct(
|
||||||
|
payload: CreateProductRequest,
|
||||||
|
): Promise<ProductDetail> {
|
||||||
|
const response = await axiosClient.post<ProductDetail>(
|
||||||
|
'/api/v1/admin/products',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/products/api/deactivateProduct.ts
Normal file
5
src/web/src/features/products/api/deactivateProduct.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateProduct(id: number): Promise<void> {
|
||||||
|
await axiosClient.delete(`/api/v1/admin/products/${id}`)
|
||||||
|
}
|
||||||
7
src/web/src/features/products/api/getProductById.ts
Normal file
7
src/web/src/features/products/api/getProductById.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ProductDetail } from '../types'
|
||||||
|
|
||||||
|
export async function getProductById(id: number): Promise<ProductDetail> {
|
||||||
|
const response = await axiosClient.get<ProductDetail>(`/api/v1/products/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
12
src/web/src/features/products/api/listProducts.ts
Normal file
12
src/web/src/features/products/api/listProducts.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ListProductsParams, PagedResult, ProductListItem } from '../types'
|
||||||
|
|
||||||
|
export async function listProducts(
|
||||||
|
params?: ListProductsParams,
|
||||||
|
): Promise<PagedResult<ProductListItem>> {
|
||||||
|
const response = await axiosClient.get<PagedResult<ProductListItem>>(
|
||||||
|
'/api/v1/products',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
13
src/web/src/features/products/api/updateProduct.ts
Normal file
13
src/web/src/features/products/api/updateProduct.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { UpdateProductRequest, ProductDetail } from '../types'
|
||||||
|
|
||||||
|
export async function updateProduct(
|
||||||
|
id: number,
|
||||||
|
payload: UpdateProductRequest,
|
||||||
|
): Promise<ProductDetail> {
|
||||||
|
const response = await axiosClient.put<ProductDetail>(
|
||||||
|
`/api/v1/admin/products/${id}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -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 { ProductListItem } 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 producto'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DeactivateProductDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
product: ProductListItem
|
||||||
|
onConfirm: (id: number) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DeactivateProductDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
product,
|
||||||
|
onConfirm,
|
||||||
|
}: DeactivateProductDialogProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
setError(null)
|
||||||
|
setIsPending(true)
|
||||||
|
try {
|
||||||
|
await onConfirm(product.id)
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(resolveDeactivateError(err))
|
||||||
|
} finally {
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Desactivar producto</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
¿Desactivar el producto “{product.nombre}”? El producto no
|
||||||
|
aparecerá en los 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
280
src/web/src/features/products/components/ProductForm.tsx
Normal file
280
src/web/src/features/products/components/ProductForm.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
const productFormSchema = z.object({
|
||||||
|
nombre: z.string().trim().min(1, 'Nombre requerido').max(300, 'Máximo 300 caracteres'),
|
||||||
|
medioId: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Medio requerido')
|
||||||
|
.transform((v) => Number(v))
|
||||||
|
.pipe(z.number().int().positive('Medio requerido')),
|
||||||
|
productTypeId: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Tipo de producto requerido')
|
||||||
|
.transform((v) => Number(v))
|
||||||
|
.pipe(z.number().int().positive('Tipo de producto requerido')),
|
||||||
|
rubroId: nullablePositiveInt(),
|
||||||
|
basePrice: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Precio requerido')
|
||||||
|
.transform((v) => Number(v))
|
||||||
|
.pipe(z.number().min(0, 'El precio no puede ser negativo')),
|
||||||
|
priceDurationDays: nullablePositiveInt(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Raw form field types (strings before zod transforms)
|
||||||
|
type ProductFormRaw = {
|
||||||
|
nombre: string
|
||||||
|
medioId: string
|
||||||
|
productTypeId: string
|
||||||
|
rubroId: string
|
||||||
|
basePrice: string
|
||||||
|
priceDurationDays: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output type after zod transforms (what onSubmit receives at runtime)
|
||||||
|
export type ProductFormOutput = {
|
||||||
|
nombre: string
|
||||||
|
medioId: number
|
||||||
|
productTypeId: number
|
||||||
|
rubroId: number | null
|
||||||
|
basePrice: number
|
||||||
|
priceDurationDays: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProductFormDefaultValues {
|
||||||
|
nombre?: string
|
||||||
|
medioId?: number | null
|
||||||
|
productTypeId?: number | null
|
||||||
|
rubroId?: number | null
|
||||||
|
basePrice?: number | null
|
||||||
|
priceDurationDays?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductFormProps {
|
||||||
|
defaultValues?: ProductFormDefaultValues
|
||||||
|
onSubmit: (values: ProductFormOutput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending?: boolean
|
||||||
|
isEdit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ProductForm({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isPending = false,
|
||||||
|
isEdit = false,
|
||||||
|
}: ProductFormProps) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const form = useForm<ProductFormRaw>({
|
||||||
|
resolver: zodResolver(productFormSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
nombre: defaultValues?.nombre ?? '',
|
||||||
|
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
|
||||||
|
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
|
||||||
|
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
|
||||||
|
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
|
||||||
|
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
nombre: defaultValues?.nombre ?? '',
|
||||||
|
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
|
||||||
|
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
|
||||||
|
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
|
||||||
|
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
|
||||||
|
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId])
|
||||||
|
|
||||||
|
function handleSubmit(data: ProductFormOutput) {
|
||||||
|
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 producto"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Medio ID */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="medioId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>ID de Medio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
placeholder="ID del medio"
|
||||||
|
aria-label="ID de Medio"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Product Type ID */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="productTypeId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>ID de Tipo de Producto</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
placeholder="ID del tipo de producto"
|
||||||
|
aria-label="ID de Tipo de Producto"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rubro ID (optional) */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="rubroId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>ID de Rubro (opcional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Sin rubro"
|
||||||
|
aria-label="ID de Rubro"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Base Price */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="basePrice"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Precio base</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="0.00"
|
||||||
|
aria-label="Precio base"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Price Duration Days (optional) */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priceDurationDays"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Días de duración del precio (opcional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Días de duración del precio"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
src/web/src/features/products/components/ProductFormDialog.tsx
Normal file
126
src/web/src/features/products/components/ProductFormDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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 { ProductForm } from './ProductForm'
|
||||||
|
import type { ProductFormOutput } from './ProductForm'
|
||||||
|
import { useCreateProduct } from '../hooks/useCreateProduct'
|
||||||
|
import { useUpdateProduct } from '../hooks/useUpdateProduct'
|
||||||
|
import type { ProductDetail } 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 producto'
|
||||||
|
}
|
||||||
|
const errObj = err as { response?: { data?: { message?: string } } }
|
||||||
|
if (errObj?.response?.data?.message) {
|
||||||
|
return errObj.response.data.message
|
||||||
|
}
|
||||||
|
return 'Error al guardar el producto'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProductFormDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
product?: ProductDetail
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ProductFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
product,
|
||||||
|
onSuccess,
|
||||||
|
}: ProductFormDialogProps) {
|
||||||
|
const [backendError, setBackendError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isEdit = !!product
|
||||||
|
const { mutateAsync: createProduct, isPending: creating } = useCreateProduct()
|
||||||
|
const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct()
|
||||||
|
const isPending = creating || updating
|
||||||
|
|
||||||
|
async function handleSubmit(values: ProductFormOutput) {
|
||||||
|
setBackendError(null)
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await updateProduct({
|
||||||
|
id: product.id,
|
||||||
|
data: {
|
||||||
|
nombre: values.nombre,
|
||||||
|
rubroId: values.rubroId,
|
||||||
|
basePrice: values.basePrice,
|
||||||
|
priceDurationDays: values.priceDurationDays,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toast.success('Producto actualizado')
|
||||||
|
} else {
|
||||||
|
await createProduct({
|
||||||
|
nombre: values.nombre,
|
||||||
|
medioId: values.medioId,
|
||||||
|
productTypeId: values.productTypeId,
|
||||||
|
rubroId: values.rubroId,
|
||||||
|
basePrice: values.basePrice,
|
||||||
|
priceDurationDays: values.priceDurationDays,
|
||||||
|
})
|
||||||
|
toast.success('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 producto' : 'Error al crear producto')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar producto' : 'Nuevo producto'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? `Modificá los datos del producto "${product?.nombre ?? ''}".`
|
||||||
|
: 'Completá los datos para crear un nuevo producto.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProductForm
|
||||||
|
defaultValues={product}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
isPending={isPending}
|
||||||
|
isEdit={isEdit}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/features/products/hooks/useCreateProduct.ts
Normal file
13
src/web/src/features/products/hooks/useCreateProduct.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createProduct } from '../api/createProduct'
|
||||||
|
import type { CreateProductRequest } from '../types'
|
||||||
|
|
||||||
|
export function useCreateProduct() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateProductRequest) => createProduct(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/products/hooks/useDeactivateProduct.ts
Normal file
12
src/web/src/features/products/hooks/useDeactivateProduct.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateProduct } from '../api/deactivateProduct'
|
||||||
|
|
||||||
|
export function useDeactivateProduct() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateProduct(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
10
src/web/src/features/products/hooks/useProducts.ts
Normal file
10
src/web/src/features/products/hooks/useProducts.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listProducts } from '../api/listProducts'
|
||||||
|
import type { ListProductsParams } from '../types'
|
||||||
|
|
||||||
|
export function useProducts(params?: ListProductsParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['products', params],
|
||||||
|
queryFn: () => listProducts(params),
|
||||||
|
})
|
||||||
|
}
|
||||||
14
src/web/src/features/products/hooks/useUpdateProduct.ts
Normal file
14
src/web/src/features/products/hooks/useUpdateProduct.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { updateProduct } from '../api/updateProduct'
|
||||||
|
import type { UpdateProductRequest } from '../types'
|
||||||
|
|
||||||
|
export function useUpdateProduct() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: UpdateProductRequest }) =>
|
||||||
|
updateProduct(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
9
src/web/src/features/products/index.ts
Normal file
9
src/web/src/features/products/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { ProductsPage } from './pages/ProductsPage'
|
||||||
|
export type {
|
||||||
|
ProductListItem,
|
||||||
|
ProductDetail,
|
||||||
|
CreateProductRequest,
|
||||||
|
UpdateProductRequest,
|
||||||
|
PagedResult,
|
||||||
|
ListProductsParams,
|
||||||
|
} from './types'
|
||||||
177
src/web/src/features/products/pages/ProductsPage.tsx
Normal file
177
src/web/src/features/products/pages/ProductsPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AlertCircle, Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import { useProducts } from '../hooks/useProducts'
|
||||||
|
import { useDeactivateProduct } from '../hooks/useDeactivateProduct'
|
||||||
|
import { ProductFormDialog } from '../components/ProductFormDialog'
|
||||||
|
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
|
||||||
|
import type { ProductListItem, ProductDetail } from '../types'
|
||||||
|
|
||||||
|
export function ProductsPage() {
|
||||||
|
// ── Create dialog state ──────────────────────────────────────────────────
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
|
// ── Edit dialog state ────────────────────────────────────────────────────
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [editingProduct, setEditingProduct] = useState<ProductDetail | null>(null)
|
||||||
|
|
||||||
|
// ── Deactivate dialog state ──────────────────────────────────────────────
|
||||||
|
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||||
|
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
|
||||||
|
|
||||||
|
const { data: paged, isLoading, isError } = useProducts({ activo: true })
|
||||||
|
const { mutateAsync: deactivateProduct } = useDeactivateProduct()
|
||||||
|
|
||||||
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setCreateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(p: ProductListItem) {
|
||||||
|
const detail: ProductDetail = {
|
||||||
|
...p,
|
||||||
|
fechaCreacion: '',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
setEditingProduct(detail)
|
||||||
|
setEditOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeactivate(p: ProductListItem) {
|
||||||
|
setDeactivatingProduct(p)
|
||||||
|
setDeactivateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeactivate(id: number) {
|
||||||
|
await deactivateProduct(id)
|
||||||
|
toast.success('Producto desactivado')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading / Error ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<Skeleton className="h-10 w-48" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive" className="m-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>Error al cargar productos.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = !paged?.items.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Productos</h1>
|
||||||
|
<CanPerform permission="catalogo:productos:gestionar">
|
||||||
|
<Button size="sm" onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Producto
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
|
||||||
|
<p>No hay productos.</p>
|
||||||
|
<CanPerform permission="catalogo:productos:gestionar">
|
||||||
|
<Button variant="outline" onClick={openCreate}>
|
||||||
|
Crear primer producto
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Nombre</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Medio</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Tipo</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Precio base</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Activo</th>
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.items.map((p: ProductListItem) => (
|
||||||
|
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/25">
|
||||||
|
<td className="px-4 py-2 font-medium">{p.nombre}</td>
|
||||||
|
<td className="px-4 py-2">{p.medioId}</td>
|
||||||
|
<td className="px-4 py-2">{p.productTypeId}</td>
|
||||||
|
<td className="px-4 py-2">{p.basePrice}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={p.isActive ? 'text-green-600' : 'text-red-500'}>
|
||||||
|
{p.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<CanPerform permission="catalogo:productos:gestionar">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEdit(p)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openDeactivate(p)}
|
||||||
|
disabled={!p.isActive}
|
||||||
|
>
|
||||||
|
Desactivar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CanPerform>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<ProductFormDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit dialog */}
|
||||||
|
{editingProduct && (
|
||||||
|
<ProductFormDialog
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
product={editingProduct}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deactivate confirmation dialog */}
|
||||||
|
{deactivatingProduct && (
|
||||||
|
<DeactivateProductDialog
|
||||||
|
open={deactivateOpen}
|
||||||
|
onOpenChange={setDeactivateOpen}
|
||||||
|
product={deactivatingProduct}
|
||||||
|
onConfirm={handleDeactivate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/web/src/features/products/types.ts
Normal file
58
src/web/src/features/products/types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// PRD-002 — shared types for products feature
|
||||||
|
|
||||||
|
export interface ProductListItem {
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
medioId: number
|
||||||
|
productTypeId: number
|
||||||
|
rubroId: number | null
|
||||||
|
basePrice: number
|
||||||
|
priceDurationDays: number | null
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductDetail {
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
medioId: number
|
||||||
|
productTypeId: number
|
||||||
|
rubroId: number | null
|
||||||
|
basePrice: number
|
||||||
|
priceDurationDays: number | null
|
||||||
|
isActive: boolean
|
||||||
|
fechaCreacion: string
|
||||||
|
fechaModificacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductRequest {
|
||||||
|
nombre: string
|
||||||
|
medioId: number
|
||||||
|
productTypeId: number
|
||||||
|
rubroId?: number | null
|
||||||
|
basePrice: number
|
||||||
|
priceDurationDays?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductRequest {
|
||||||
|
nombre: string
|
||||||
|
rubroId?: number | null
|
||||||
|
basePrice: number
|
||||||
|
priceDurationDays?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListProductsParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
activo?: boolean | null
|
||||||
|
search?: string
|
||||||
|
medioId?: number
|
||||||
|
productTypeId?: number
|
||||||
|
rubroId?: number
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
|||||||
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||||
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||||
|
import { ProductsPage } from './features/products/pages/ProductsPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -320,6 +321,16 @@ export function AppRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Products routes — PRD-002 */}
|
||||||
|
<Route
|
||||||
|
path="/admin/products"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['catalogo:productos:gestionar']}>
|
||||||
|
<ProductsPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
187
src/web/src/tests/features/products/ProductsPage.test.tsx
Normal file
187
src/web/src/tests/features/products/ProductsPage.test.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } 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, Routes, Route } from 'react-router-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import { ProductsPage } from '../../../features/products/pages/ProductsPage'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
import type { ProductListItem, PagedResult } from '../../../features/products/types'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: ['catalogo:productos:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const regularUser = {
|
||||||
|
id: 2,
|
||||||
|
username: 'viewer',
|
||||||
|
nombre: 'Viewer',
|
||||||
|
rol: 'viewer',
|
||||||
|
permisos: [],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockItem: ProductListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificado Estándar',
|
||||||
|
medioId: 1,
|
||||||
|
productTypeId: 2,
|
||||||
|
rubroId: null,
|
||||||
|
basePrice: 100.50,
|
||||||
|
priceDurationDays: null,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPaged: PagedResult<ProductListItem> = {
|
||||||
|
items: [mockItem],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyPaged: PagedResult<ProductListItem> = {
|
||||||
|
items: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage(user = adminUser) {
|
||||||
|
useAuthStore.setState({ user })
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/admin/products']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/products" element={<ProductsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loading / Error / Data states ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductsPage — loading and error states', () => {
|
||||||
|
it('renders loading skeleton while fetching', () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, async () => {
|
||||||
|
await new Promise(() => {})
|
||||||
|
return HttpResponse.json(emptyPaged)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderPage()
|
||||||
|
const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse')
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders data when loaded', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error state on fetch failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () =>
|
||||||
|
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/error al cargar productos/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no products', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||||
|
)
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/no hay productos/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Permission gating ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductsPage — permission gating', () => {
|
||||||
|
it('shows "Nuevo Producto" button when user has permission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||||
|
)
|
||||||
|
renderPage(adminUser)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides "Nuevo Producto" button when user lacks permission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
renderPage(regularUser)
|
||||||
|
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByRole('button', { name: /nuevo producto/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Create dialog ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductsPage — create dialog', () => {
|
||||||
|
it('opens create dialog when "Nuevo Producto" button is clicked', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||||
|
)
|
||||||
|
renderPage(adminUser)
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /nuevo producto/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Deactivate dialog ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductsPage — deactivate dialog', () => {
|
||||||
|
it('opens deactivate confirmation dialog when Desactivar is clicked', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
renderPage(adminUser)
|
||||||
|
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
131
src/web/src/tests/features/products/api.test.ts
Normal file
131
src/web/src/tests/features/products/api.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { listProducts } from '../../../features/products/api/listProducts'
|
||||||
|
import { getProductById } from '../../../features/products/api/getProductById'
|
||||||
|
import { createProduct } from '../../../features/products/api/createProduct'
|
||||||
|
import { updateProduct } from '../../../features/products/api/updateProduct'
|
||||||
|
import { deactivateProduct } from '../../../features/products/api/deactivateProduct'
|
||||||
|
import type { ProductListItem, ProductDetail, PagedResult } from '../../../features/products/types'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockListItem: ProductListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificado Estándar',
|
||||||
|
medioId: 1,
|
||||||
|
productTypeId: 2,
|
||||||
|
rubroId: null,
|
||||||
|
basePrice: 100.50,
|
||||||
|
priceDurationDays: null,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDetail: ProductDetail = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificado Estándar',
|
||||||
|
medioId: 1,
|
||||||
|
productTypeId: 2,
|
||||||
|
rubroId: null,
|
||||||
|
basePrice: 100.50,
|
||||||
|
priceDurationDays: null,
|
||||||
|
isActive: true,
|
||||||
|
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPaged: PagedResult<ProductListItem> = {
|
||||||
|
items: [mockListItem],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
describe('listProducts', () => {
|
||||||
|
it('calls GET /api/v1/products and returns paged result', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
const result = await listProducts()
|
||||||
|
expect(result).toEqual(mockPaged)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes query params when provided', async () => {
|
||||||
|
let capturedUrl = ''
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json(mockPaged)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await listProducts({ page: 2, pageSize: 10, activo: true, medioId: 5 })
|
||||||
|
expect(capturedUrl).toContain('page=2')
|
||||||
|
expect(capturedUrl).toContain('pageSize=10')
|
||||||
|
expect(capturedUrl).toContain('medioId=5')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getProductById', () => {
|
||||||
|
it('calls GET /api/v1/products/:id and returns detail', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products/1`, () => HttpResponse.json(mockDetail)),
|
||||||
|
)
|
||||||
|
const result = await getProductById(1)
|
||||||
|
expect(result).toEqual(mockDetail)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createProduct', () => {
|
||||||
|
it('calls POST /api/v1/admin/products with payload', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/products`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(mockDetail, { status: 201 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const req = {
|
||||||
|
nombre: 'Clasificado Estándar',
|
||||||
|
medioId: 1,
|
||||||
|
productTypeId: 2,
|
||||||
|
basePrice: 100.50,
|
||||||
|
}
|
||||||
|
await createProduct(req)
|
||||||
|
expect(capturedBody).toEqual(req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateProduct', () => {
|
||||||
|
it('calls PUT /api/v1/admin/products/:id with payload', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/products/1`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(mockDetail)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const req = { nombre: 'Modificado', basePrice: 200 }
|
||||||
|
await updateProduct(1, req)
|
||||||
|
expect(capturedBody).toEqual(req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deactivateProduct', () => {
|
||||||
|
it('calls DELETE /api/v1/admin/products/:id', async () => {
|
||||||
|
let called = false
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/products/1`, () => {
|
||||||
|
called = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await deactivateProduct(1)
|
||||||
|
expect(called).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
143
src/web/src/tests/features/products/hooks.test.ts
Normal file
143
src/web/src/tests/features/products/hooks.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import React from 'react'
|
||||||
|
import { useProducts } from '../../../features/products/hooks/useProducts'
|
||||||
|
import { useCreateProduct } from '../../../features/products/hooks/useCreateProduct'
|
||||||
|
import { useUpdateProduct } from '../../../features/products/hooks/useUpdateProduct'
|
||||||
|
import { useDeactivateProduct } from '../../../features/products/hooks/useDeactivateProduct'
|
||||||
|
import type { ProductListItem, PagedResult } from '../../../features/products/types'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockItem: ProductListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificado',
|
||||||
|
medioId: 1,
|
||||||
|
productTypeId: 2,
|
||||||
|
rubroId: null,
|
||||||
|
basePrice: 50,
|
||||||
|
priceDurationDays: null,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPaged: PagedResult<ProductListItem> = {
|
||||||
|
items: [mockItem],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function makeWrapper() {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useProducts', () => {
|
||||||
|
it('returns paged data on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(result.current.data).toEqual(mockPaged)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error state on failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/products`, () =>
|
||||||
|
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useCreateProduct', () => {
|
||||||
|
it('calls create and invalidates products queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||||
|
HttpResponse.json(mockItem, { status: 201 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCreateProduct(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate({
|
||||||
|
nombre: 'Nuevo Producto',
|
||||||
|
medioId: 1,
|
||||||
|
productTypeId: 2,
|
||||||
|
basePrice: 100,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useUpdateProduct', () => {
|
||||||
|
it('calls update and invalidates products queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||||
|
HttpResponse.json(mockItem),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateProduct(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate({ id: 1, data: { nombre: 'Modificado', basePrice: 200 } })
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDeactivateProduct', () => {
|
||||||
|
it('calls deactivate and invalidates products queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||||
|
new HttpResponse(null, { status: 204 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeactivateProduct(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate(1)
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user