diff --git a/src/web/src/features/product-types/pages/ProductTypesPage.tsx b/src/web/src/features/product-types/pages/ProductTypesPage.tsx index 026d7cc..3f6c68e 100644 --- a/src/web/src/features/product-types/pages/ProductTypesPage.tsx +++ b/src/web/src/features/product-types/pages/ProductTypesPage.tsx @@ -1,50 +1,64 @@ 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' +import { ProductTypeFormDialog } from '../components/ProductTypeFormDialog' +import { DeactivateProductTypeDialog } from '../components/DeactivateProductTypeDialog' +import type { ProductTypeListItem, ProductTypeDetail } from '../types' export function ProductTypesPage() { - const [formError, setFormError] = useState(null) + // ── Create dialog state ────────────────────────────────────────────────── + const [createOpen, setCreateOpen] = useState(false) + + // ── Edit dialog state ──────────────────────────────────────────────────── + const [editOpen, setEditOpen] = useState(false) + const [editingProductType, setEditingProductType] = useState(null) + + // ── Deactivate dialog state ────────────────────────────────────────────── + const [deactivateOpen, setDeactivateOpen] = useState(false) + const [deactivatingProductType, setDeactivatingProductType] = 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') - } + // ── Handlers ───────────────────────────────────────────────────────────── + + function openCreate() { + setCreateOpen(true) + } + + function openEdit(pt: ProductTypeListItem) { + // Map list item to detail shape for pre-filling (multimedia nulls are fine for list item) + const detail: ProductTypeDetail = { + ...pt, + maxImages: null, + maxImageSizeMB: null, + maxImageWidth: null, + maxImageHeight: null, + fechaCreacion: '', + fechaModificacion: null, } + setEditingProductType(detail) + setEditOpen(true) + } + + function openDeactivate(pt: ProductTypeListItem) { + setDeactivatingProductType(pt) + setDeactivateOpen(true) } async function handleDeactivate(id: number) { - try { - await deactivateProductType(id) - toast.success('Tipo de producto desactivado') - } catch { - toast.error('Error al desactivar tipo de producto') - } + await deactivateProductType(id) + toast.success('Tipo de producto desactivado') } + // ── Loading / Error ─────────────────────────────────────────────────────── + if (isLoading) { return (
@@ -63,79 +77,110 @@ export function ProductTypesPage() { ) } + const isEmpty = !paged?.items.length + return (

Tipos de Producto

-
- {formError && ( - - - - {isAxiosError(formError) ? formError.message : 'Error inesperado'} - - + {isEmpty ? ( +
+

No hay tipos de producto.

+ + + +
+ ) : ( +
+ + + + + + + + + + + + + + {paged.items.map((pt: ProductTypeListItem) => ( + + + + + + + + + + + ))} + +
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'} + + + +
+ + +
+
+
+
)} -
- - - - - - - - - - - - - - {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. -
-
+ {/* Create dialog */} + + + {/* Edit dialog */} + {editingProductType && ( + + )} + + {/* Deactivate confirmation dialog */} + {deactivatingProductType && ( + + )}
) } diff --git a/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx b/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx new file mode 100644 index 0000000..b96fe1a --- /dev/null +++ b/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx @@ -0,0 +1,204 @@ +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 { ProductTypesPage } from '../../../features/product-types/pages/ProductTypesPage' +import { useAuthStore } from '../../../stores/authStore' +import type { ProductTypeListItem, PagedResult } from '../../../features/product-types/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:tipos:gestionar'], + mustChangePassword: false, +} + +const regularUser = { + id: 2, + username: 'viewer', + nombre: 'Viewer', + rol: 'viewer', + permisos: [], + mustChangePassword: false, +} + +const mockItem: ProductTypeListItem = { + id: 1, + nombre: 'Clasificados', + hasDuration: true, + requiresText: true, + requiresCategory: false, + isBundle: false, + allowImages: false, + isActive: true, +} + +const mockPaged: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 1, +} + +const emptyPaged: PagedResult = { + 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( + + + + } /> + + + , + ) +} + +// ─── Loading / Error / Data states ────────────────────────────────────────── + +describe('ProductTypesPage — loading and error states', () => { + it('renders loading skeleton while fetching', () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, async () => { + await new Promise(() => {}) + return HttpResponse.json(emptyPaged) + }), + ) + renderPage() + // During loading, skeletons are shown — verify they are rendered + 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/product-types`, () => HttpResponse.json(mockPaged)), + ) + renderPage() + await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) + }) + + it('shows error state on fetch failure', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => + HttpResponse.json({ error: 'server_error' }, { status: 500 }), + ), + ) + renderPage() + await waitFor(() => + expect(screen.getByText(/error al cargar tipos de producto/i)).toBeInTheDocument(), + ) + }) + + it('shows empty state when no product types', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(emptyPaged)), + ) + renderPage() + await waitFor(() => + expect(screen.getByText(/no hay tipos de producto/i)).toBeInTheDocument(), + ) + }) +}) + +// ─── Create dialog ─────────────────────────────────────────────────────────── + +describe('ProductTypesPage — create dialog', () => { + it('opens create dialog when "Nuevo Tipo" button is clicked', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByRole('button', { name: /nuevo tipo/i })).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /nuevo tipo/i })) + await waitFor(() => + expect(screen.getByRole('heading', { name: /nuevo tipo de producto/i })).toBeInTheDocument(), + ) + }) + + it('opens create dialog from empty state CTA', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(emptyPaged)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByRole('button', { name: /crear primer tipo/i })).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /crear primer tipo/i })) + await waitFor(() => + expect(screen.getByRole('heading', { name: /nuevo tipo de producto/i })).toBeInTheDocument(), + ) + }) + + it('hides "Nuevo Tipo" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + ) + renderPage(regularUser) + await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /nuevo tipo/i })).not.toBeInTheDocument() + }) +}) + +// ─── Edit dialog ───────────────────────────────────────────────────────────── + +describe('ProductTypesPage — edit dialog', () => { + it('opens edit dialog pre-filled when Edit button is clicked', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /editar/i })) + await waitFor(() => + expect(screen.getByRole('heading', { name: /editar tipo/i })).toBeInTheDocument(), + ) + const input = screen.getByLabelText(/nombre/i) as HTMLInputElement + expect(input.value).toBe('Clasificados') + }) +}) + +// ─── Deactivate dialog ─────────────────────────────────────────────────────── + +describe('ProductTypesPage — deactivate dialog', () => { + it('opens deactivate confirmation dialog when Desactivar is clicked', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByRole('heading', { name: /desactivar tipo de producto/i })).toBeInTheDocument(), + ) + }) +})