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:
2026-04-19 13:24:42 -03:00
parent a41a4ea341
commit 08a4738daf
20 changed files with 1314 additions and 0 deletions

View File

@@ -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 {

View 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
}

View 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}`)
}

View 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
}

View 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
}

View 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
}

View File

@@ -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 &ldquo;{product.nombre}&rdquo;? 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>
)
}

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

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

View 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'] })
},
})
}

View 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'] })
},
})
}

View 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),
})
}

View 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'] })
},
})
}

View File

@@ -0,0 +1,9 @@
export { ProductsPage } from './pages/ProductsPage'
export type {
ProductListItem,
ProductDetail,
CreateProductRequest,
UpdateProductRequest,
PagedResult,
ListProductsParams,
} from './types'

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

View 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
}

View File

@@ -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>
) )

View 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(),
)
})
})

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

View 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'] })
})
})