diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index f587e89..3331aa9 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -16,6 +16,7 @@ import { Columns3, Store, Tag, + Layers, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -75,6 +76,12 @@ const adminItems: NavItem[] = [ icon: Tag, requiredPermission: 'catalogo:rubros:gestionar', }, + { + label: 'Tipos de Producto', + href: '/admin/product-types', + icon: Layers, + requiredPermission: 'catalogo:tipos:gestionar', + }, ] interface SidebarNavProps { diff --git a/src/web/src/features/product-types/api/createProductType.ts b/src/web/src/features/product-types/api/createProductType.ts new file mode 100644 index 0000000..195ca30 --- /dev/null +++ b/src/web/src/features/product-types/api/createProductType.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateProductTypeRequest, ProductTypeDetail } from '../types' + +export async function createProductType( + payload: CreateProductTypeRequest, +): Promise { + const response = await axiosClient.post( + '/api/v1/admin/product-types', + payload, + ) + return response.data +} diff --git a/src/web/src/features/product-types/api/deactivateProductType.ts b/src/web/src/features/product-types/api/deactivateProductType.ts new file mode 100644 index 0000000..041ef7c --- /dev/null +++ b/src/web/src/features/product-types/api/deactivateProductType.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateProductType(id: number): Promise { + await axiosClient.delete(`/api/v1/admin/product-types/${id}`) +} diff --git a/src/web/src/features/product-types/api/getProductTypeById.ts b/src/web/src/features/product-types/api/getProductTypeById.ts new file mode 100644 index 0000000..6582d6e --- /dev/null +++ b/src/web/src/features/product-types/api/getProductTypeById.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ProductTypeDetail } from '../types' + +export async function getProductTypeById(id: number): Promise { + const response = await axiosClient.get(`/api/v1/product-types/${id}`) + return response.data +} diff --git a/src/web/src/features/product-types/api/listProductTypes.ts b/src/web/src/features/product-types/api/listProductTypes.ts new file mode 100644 index 0000000..de72971 --- /dev/null +++ b/src/web/src/features/product-types/api/listProductTypes.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ListProductTypesParams, PagedResult, ProductTypeListItem } from '../types' + +export async function listProductTypes( + params?: ListProductTypesParams, +): Promise> { + const response = await axiosClient.get>( + '/api/v1/product-types', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/product-types/api/updateProductType.ts b/src/web/src/features/product-types/api/updateProductType.ts new file mode 100644 index 0000000..bcaa6f3 --- /dev/null +++ b/src/web/src/features/product-types/api/updateProductType.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UpdateProductTypeRequest, ProductTypeDetail } from '../types' + +export async function updateProductType( + id: number, + payload: UpdateProductTypeRequest, +): Promise { + const response = await axiosClient.put( + `/api/v1/admin/product-types/${id}`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/product-types/hooks/useCreateProductType.ts b/src/web/src/features/product-types/hooks/useCreateProductType.ts new file mode 100644 index 0000000..73cc225 --- /dev/null +++ b/src/web/src/features/product-types/hooks/useCreateProductType.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createProductType } from '../api/createProductType' +import type { CreateProductTypeRequest } from '../types' + +export function useCreateProductType() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateProductTypeRequest) => createProductType(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['product-types'] }) + }, + }) +} diff --git a/src/web/src/features/product-types/hooks/useDeactivateProductType.ts b/src/web/src/features/product-types/hooks/useDeactivateProductType.ts new file mode 100644 index 0000000..9e60cc1 --- /dev/null +++ b/src/web/src/features/product-types/hooks/useDeactivateProductType.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateProductType } from '../api/deactivateProductType' + +export function useDeactivateProductType() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivateProductType(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['product-types'] }) + }, + }) +} diff --git a/src/web/src/features/product-types/hooks/useProductTypes.ts b/src/web/src/features/product-types/hooks/useProductTypes.ts new file mode 100644 index 0000000..dd22848 --- /dev/null +++ b/src/web/src/features/product-types/hooks/useProductTypes.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { listProductTypes } from '../api/listProductTypes' +import type { ListProductTypesParams } from '../types' + +export function useProductTypes(params?: ListProductTypesParams) { + return useQuery({ + queryKey: ['product-types', params], + queryFn: () => listProductTypes(params), + }) +} diff --git a/src/web/src/features/product-types/hooks/useUpdateProductType.ts b/src/web/src/features/product-types/hooks/useUpdateProductType.ts new file mode 100644 index 0000000..ac2b68b --- /dev/null +++ b/src/web/src/features/product-types/hooks/useUpdateProductType.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateProductType } from '../api/updateProductType' +import type { UpdateProductTypeRequest } from '../types' + +export function useUpdateProductType() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateProductTypeRequest }) => + updateProductType(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['product-types'] }) + }, + }) +} diff --git a/src/web/src/features/product-types/index.ts b/src/web/src/features/product-types/index.ts new file mode 100644 index 0000000..d8af869 --- /dev/null +++ b/src/web/src/features/product-types/index.ts @@ -0,0 +1,3 @@ +// PRD-001 — product-types feature public API +export { ProductTypesPage } from './pages/ProductTypesPage' +export type { ProductTypeListItem, ProductTypeDetail, CreateProductTypeRequest, UpdateProductTypeRequest } from './types' diff --git a/src/web/src/features/product-types/pages/ProductTypesPage.tsx b/src/web/src/features/product-types/pages/ProductTypesPage.tsx new file mode 100644 index 0000000..026d7cc --- /dev/null +++ b/src/web/src/features/product-types/pages/ProductTypesPage.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react' +import { AlertCircle, Plus } from 'lucide-react' +import { isAxiosError } from 'axios' +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 { useProductTypes } from '../hooks/useProductTypes' +import { useCreateProductType } from '../hooks/useCreateProductType' +import { useUpdateProductType } from '../hooks/useUpdateProductType' +import { useDeactivateProductType } from '../hooks/useDeactivateProductType' +import type { ProductTypeListItem } from '../types' +import type { CreateProductTypeRequest } from '../types' + +export function ProductTypesPage() { + const [formError, setFormError] = useState(null) + + const { data: paged, isLoading, isError } = useProductTypes({ activo: true }) + const { mutateAsync: createProductType, isPending: creating } = useCreateProductType() + const { mutateAsync: updateProductType, isPending: updating } = useUpdateProductType() + const { mutateAsync: deactivateProductType } = useDeactivateProductType() + + async function handleCreate(payload: CreateProductTypeRequest) { + try { + setFormError(null) + await createProductType(payload) + toast.success('Tipo de producto creado') + } catch (err) { + setFormError(err) + if (isAxiosError(err) && err.response?.status === 409) { + toast.error('Ya existe un tipo de producto con ese nombre') + } else { + toast.error('Error al crear tipo de producto') + } + } + } + + async function handleDeactivate(id: number) { + try { + await deactivateProductType(id) + toast.success('Tipo de producto desactivado') + } catch { + toast.error('Error al desactivar tipo de producto') + } + } + + if (isLoading) { + return ( +
+ + +
+ ) + } + + if (isError) { + return ( + + + Error al cargar tipos de producto. + + ) + } + + return ( +
+
+

Tipos de Producto

+ + + +
+ + {formError && ( + + + + {isAxiosError(formError) ? formError.message : 'Error inesperado'} + + + )} + +
+ + + + + + + + + + + + + + {paged?.items.map((pt: ProductTypeListItem) => ( + + + + + + + + + + + ))} + {paged?.items.length === 0 && ( + + + + )} + +
NombreDuraciónTextoCategoríaBundleImágenesActivo +
{pt.nombre}{pt.hasDuration ? 'Sí' : 'No'}{pt.requiresText ? 'Sí' : 'No'}{pt.requiresCategory ? 'Sí' : 'No'}{pt.isBundle ? 'Sí' : 'No'}{pt.allowImages ? 'Sí' : 'No'} + + {pt.isActive ? 'Activo' : 'Inactivo'} + + + + + +
+ No hay tipos de producto. +
+
+
+ ) +} diff --git a/src/web/src/features/product-types/types.ts b/src/web/src/features/product-types/types.ts new file mode 100644 index 0000000..380b128 --- /dev/null +++ b/src/web/src/features/product-types/types.ts @@ -0,0 +1,69 @@ +// PRD-001 — shared types for product-types feature + +export interface ProductTypeListItem { + id: number + nombre: string + hasDuration: boolean + requiresText: boolean + requiresCategory: boolean + isBundle: boolean + allowImages: boolean + isActive: boolean +} + +export interface ProductTypeDetail { + id: number + nombre: string + hasDuration: boolean + requiresText: boolean + requiresCategory: boolean + isBundle: boolean + allowImages: boolean + maxImages: number | null + maxImageSizeMB: number | null + maxImageWidth: number | null + maxImageHeight: number | null + isActive: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface CreateProductTypeRequest { + nombre: string + hasDuration: boolean + requiresText: boolean + requiresCategory: boolean + isBundle: boolean + allowImages: boolean + maxImages?: number | null + maxImageSizeMB?: number | null + maxImageWidth?: number | null + maxImageHeight?: number | null +} + +export interface UpdateProductTypeRequest { + nombre: string + hasDuration: boolean + requiresText: boolean + requiresCategory: boolean + isBundle: boolean + allowImages: boolean + maxImages?: number | null + maxImageSizeMB?: number | null + maxImageWidth?: number | null + maxImageHeight?: number | null +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} + +export interface ListProductTypesParams { + page?: number + pageSize?: number + activo?: boolean | null + search?: string +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 94559d9..992cc76 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -28,6 +28,7 @@ import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPunto import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage' import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage' import { RubrosPage } from './features/rubros/pages/RubrosPage' +import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -309,6 +310,16 @@ export function AppRoutes() { } /> + {/* ProductTypes routes — PRD-001 */} + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/product-types/api.test.ts b/src/web/src/tests/features/product-types/api.test.ts new file mode 100644 index 0000000..12c178d --- /dev/null +++ b/src/web/src/tests/features/product-types/api.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { listProductTypes } from '../../../features/product-types/api/listProductTypes' +import { getProductTypeById } from '../../../features/product-types/api/getProductTypeById' +import { createProductType } from '../../../features/product-types/api/createProductType' +import { updateProductType } from '../../../features/product-types/api/updateProductType' +import { deactivateProductType } from '../../../features/product-types/api/deactivateProductType' +import type { ProductTypeListItem, ProductTypeDetail, PagedResult } from '../../../features/product-types/types' + +const API_URL = 'http://localhost:5000' + +const mockListItem: ProductTypeListItem = { + id: 1, + nombre: 'Avisos Clasificados', + hasDuration: true, + requiresText: true, + requiresCategory: true, + isBundle: false, + allowImages: true, + isActive: true, +} + +const mockDetail: ProductTypeDetail = { + id: 1, + nombre: 'Avisos Clasificados', + hasDuration: true, + requiresText: true, + requiresCategory: true, + isBundle: false, + allowImages: true, + maxImages: 5, + maxImageSizeMB: 2.5, + maxImageWidth: 800, + maxImageHeight: 600, + isActive: true, + fechaCreacion: '2026-04-19T00:00:00Z', + fechaModificacion: null, +} + +const mockPaged: PagedResult = { + items: [mockListItem], + page: 1, + pageSize: 20, + total: 1, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('listProductTypes', () => { + it('calls GET /api/v1/product-types and returns paged result', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + ) + const result = await listProductTypes() + expect(result).toEqual(mockPaged) + }) + + it('passes query params when provided', async () => { + let capturedUrl = '' + server.use( + http.get(`${API_URL}/api/v1/product-types`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json(mockPaged) + }), + ) + await listProductTypes({ page: 2, pageSize: 10, activo: true, search: 'test' }) + expect(capturedUrl).toContain('page=2') + expect(capturedUrl).toContain('pageSize=10') + expect(capturedUrl).toContain('search=test') + }) +}) + +describe('getProductTypeById', () => { + it('calls GET /api/v1/product-types/:id and returns detail', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types/1`, () => HttpResponse.json(mockDetail)), + ) + const result = await getProductTypeById(1) + expect(result).toEqual(mockDetail) + }) +}) + +describe('createProductType', () => { + it('calls POST /api/v1/admin/product-types with payload', async () => { + let capturedBody: unknown = null + server.use( + http.post(`${API_URL}/api/v1/admin/product-types`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockDetail, { status: 201 }) + }), + ) + const req = { + nombre: 'Avisos Clasificados', + hasDuration: true, + requiresText: true, + requiresCategory: false, + isBundle: false, + allowImages: true, + } + await createProductType(req) + expect(capturedBody).toEqual(req) + }) +}) + +describe('updateProductType', () => { + it('calls PUT /api/v1/admin/product-types/:id with payload', async () => { + let capturedBody: unknown = null + server.use( + http.put(`${API_URL}/api/v1/admin/product-types/1`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockDetail) + }), + ) + const req = { nombre: 'Modificado', hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, allowImages: false } + await updateProductType(1, req) + expect(capturedBody).toEqual(req) + }) +}) + +describe('deactivateProductType', () => { + it('calls DELETE /api/v1/admin/product-types/:id', async () => { + let called = false + server.use( + http.delete(`${API_URL}/api/v1/admin/product-types/1`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + await deactivateProductType(1) + expect(called).toBe(true) + }) +}) diff --git a/src/web/src/tests/features/product-types/hooks.test.ts b/src/web/src/tests/features/product-types/hooks.test.ts new file mode 100644 index 0000000..4613183 --- /dev/null +++ b/src/web/src/tests/features/product-types/hooks.test.ts @@ -0,0 +1,148 @@ +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 { useProductTypes } from '../../../features/product-types/hooks/useProductTypes' +import { useCreateProductType } from '../../../features/product-types/hooks/useCreateProductType' +import { useUpdateProductType } from '../../../features/product-types/hooks/useUpdateProductType' +import { useDeactivateProductType } from '../../../features/product-types/hooks/useDeactivateProductType' +import type { ProductTypeListItem, PagedResult } from '../../../features/product-types/types' + +const API_URL = 'http://localhost:5000' + +const mockItem: ProductTypeListItem = { + id: 1, + nombre: 'Avisos', + hasDuration: false, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + isActive: true, +} + +const mockPaged: PagedResult = { + 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('useProductTypes', () => { + it('returns paged data on success', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + ) + const { result } = renderHook(() => useProductTypes(), { 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/product-types`, () => + HttpResponse.json({ error: 'server_error' }, { status: 500 }), + ), + ) + const { result } = renderHook(() => useProductTypes(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) + +describe('useCreateProductType', () => { + it('calls create and invalidates product-types queries on success', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/product-types`, () => + 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(() => useCreateProductType(), { wrapper }) + await act(async () => { + result.current.mutate({ + nombre: 'Nuevo', + hasDuration: false, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] }) + }) +}) + +describe('useUpdateProductType', () => { + it('calls update and invalidates product-types queries on success', async () => { + server.use( + http.put(`${API_URL}/api/v1/admin/product-types/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(() => useUpdateProductType(), { wrapper }) + await act(async () => { + result.current.mutate({ + id: 1, + data: { nombre: 'Modificado', hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, allowImages: false }, + }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] }) + }) +}) + +describe('useDeactivateProductType', () => { + it('calls deactivate and invalidates product-types queries on success', async () => { + server.use( + http.delete(`${API_URL}/api/v1/admin/product-types/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(() => useDeactivateProductType(), { wrapper }) + await act(async () => { + result.current.mutate(1) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] }) + }) +})