diff --git a/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx index 9f23d6c..669a850 100644 --- a/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx +++ b/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx @@ -10,6 +10,30 @@ import type { ChargeableCharConfig } from '../types' vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +// Mock ProductTypeSelect so it renders a simple select with a "Global" option +// This avoids fetching product-types in form dialog tests +vi.mock('../components/ProductTypeSelect', () => ({ + ProductTypeSelect: ({ value, onValueChange, disabled }: { + value: number | null | undefined + onValueChange: (v: number | null | undefined) => void + disabled?: boolean + }) => ( + { + const v = e.target.value + onValueChange(v === '__global__' ? null : v === '__all__' ? undefined : Number(v)) + }} + disabled={disabled} + > + Global (todos los tipos) + Clasificados + Notables + + ), +})) + const API_URL = 'http://localhost:5000' const server = setupServer() @@ -82,14 +106,14 @@ describe('ChargeableCharFormDialog — create mode', () => { { timeout: 3000 }) }) - it('happy path calls mutation with correct yyyy-MM-dd string payload', async () => { + it('happy path calls mutation with correct yyyy-MM-dd string payload (productTypeId: null)', async () => { let capturedBody: unknown = null server.use( http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { capturedBody = await request.json() return HttpResponse.json( { - id: 1, medioId: null, symbol: '$', category: 'Currency', + id: 1, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true, }, { status: 201 }, @@ -126,6 +150,8 @@ describe('ChargeableCharFormDialog — create mode', () => { const body = capturedBody as Record expect(body['validFrom']).toBe('2026-04-25') expect(typeof body['validFrom']).toBe('string') + // productTypeId defaults to null (Global) + expect(body['productTypeId']).toBeNull() }, { timeout: 8000 }) await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 8000 }) @@ -173,7 +199,7 @@ describe('ChargeableCharFormDialog — schedulePrice mode', () => { it('hides symbol and category inputs (read-only mode)', () => { const existingConfig: ChargeableCharConfig = { - id: 5, medioId: null, symbol: '%', category: 'Percentage', + id: 5, productTypeId: null, symbol: '%', category: 'Percentage', pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true, } renderDialog('schedulePrice', existingConfig) @@ -193,13 +219,13 @@ describe('ChargeableCharFormDialog — schedulePrice mode', () => { http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, async ({ request }) => { capturedBody = await request.json() return HttpResponse.json( - { created: { id: 6, medioId: null, symbol: '%', category: 'Percentage', pricePerUnit: 2.0, validFrom: '2026-04-25', validTo: null, isActive: true }, closed: null }, + { created: { id: 6, productTypeId: null, symbol: '%', category: 'Percentage', pricePerUnit: 2.0, validFrom: '2026-04-25', validTo: null, isActive: true }, closed: null }, ) }), ) const existingConfig: ChargeableCharConfig = { - id: 5, medioId: null, symbol: '%', category: 'Percentage', + id: 5, productTypeId: null, symbol: '%', category: 'Percentage', pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true, } const onOpenChange = vi.fn() diff --git a/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx b/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx index 1147d5e..ed71340 100644 --- a/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx +++ b/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx @@ -10,12 +10,34 @@ import type { ChargeableCharConfig } from '../types' vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +// Mock ProductTypeSelect to avoid fetching product-types in table tests +vi.mock('../components/ProductTypeSelect', () => ({ + ProductTypeSelect: ({ value, onValueChange, 'aria-label': ariaLabel }: { + value: number | null | undefined + onValueChange: (v: number | null | undefined) => void + 'aria-label'?: string + }) => ( + { + const v = e.target.value + onValueChange(v === '__global__' ? null : v === '__all__' ? undefined : Number(v)) + }} + > + Todos los tipos + Global + Clasificados + + ), +})) + const API_URL = 'http://localhost:5000' function makeConfig(overrides: Partial = {}): ChargeableCharConfig { return { id: 1, - medioId: null, + productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, @@ -45,9 +67,9 @@ function renderTable( page={1} pageSize={20} onPageChange={vi.fn()} - medioId={undefined} + productTypeId={undefined} activeOnly={true} - onMedioChange={vi.fn()} + onProductTypeChange={vi.fn()} onActiveOnlyChange={vi.fn()} onSchedulePrice={onSchedulePrice} onDeactivate={onDeactivate} @@ -69,9 +91,10 @@ describe('ChargeableCharsTable', () => { expect(screen.getByText('Moneda ($)')).toBeInTheDocument() }) - it('displays "Global" when medioId is null', () => { - renderTable([makeConfig({ medioId: null })]) - expect(screen.getByText('Global')).toBeInTheDocument() + it('displays "Global" when productTypeId is null', () => { + renderTable([makeConfig({ productTypeId: null })]) + // Multiple "Global" texts may exist (table cell + select option) — assert at least one is present + expect(screen.getAllByText('Global').length).toBeGreaterThanOrEqual(1) }) it('shows "Vigente" badge for rows with validTo === null', () => { @@ -88,4 +111,58 @@ describe('ChargeableCharsTable', () => { renderTable([makeConfig({ validFrom: '2026-01-15' })]) expect(screen.getByText('15/01/2026')).toBeInTheDocument() }) + + // ── Conditional buttons ──────────────────────────────────────────────────── + + it('active row shows Desactivar button but NOT Reactivar', () => { + renderTable([makeConfig({ id: 1, isActive: true })]) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /reactivar/i })).not.toBeInTheDocument() + }) + + it('inactive row shows Reactivar button but NOT Desactivar', () => { + renderTable([makeConfig({ id: 1, isActive: false, validTo: '2026-03-31' })]) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /desactivar/i })).not.toBeInTheDocument() + }) + + it('Eliminar button is visible for both active and inactive rows', () => { + renderTable([ + makeConfig({ id: 1, isActive: true }), + makeConfig({ id: 2, isActive: false, validTo: '2026-03-31' }), + ]) + const eliminarBtns = screen.getAllByRole('button', { name: /eliminar/i }) + expect(eliminarBtns).toHaveLength(2) + }) + + it('clicking Desactivar calls onDeactivate with the correct config', async () => { + const onDeactivate = vi.fn() + const config = makeConfig({ id: 5, isActive: true, symbol: '€' }) + renderTable([config], vi.fn(), onDeactivate) + + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + expect(onDeactivate).toHaveBeenCalledWith(expect.objectContaining({ id: 5, symbol: '€' })) + }) + + it('clicking Reactivar calls PATCH /reactivate endpoint', async () => { + const calls: unknown[] = [] + server.use( + http.patch(`${API_URL}/api/v1/admin/chargeable-chars/7/reactivate`, () => { + calls.push(true) + return HttpResponse.json({ id: 7, symbol: '$', productTypeId: null, pricePerUnit: 1.5, validFrom: '2026-01-01' }) + }), + ) + // Also mock the list invalidation re-fetch + server.use( + http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + ) + + const config = makeConfig({ id: 7, isActive: false, validTo: '2026-03-31' }) + renderTable([config]) + + await userEvent.click(screen.getByRole('button', { name: /reactivar/i })) + await waitFor(() => expect(calls.length).toBe(1), { timeout: 5000 }) + }) }) diff --git a/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx index 33a9231..16dc606 100644 --- a/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx +++ b/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx @@ -1,22 +1,40 @@ +/** + * @deprecated This file is kept for reference. Tests have moved to CopyToAllProductTypesDialog.test.tsx + * The CopyToAllMediaDialog component has been renamed to CopyToAllProductTypesDialog. + * This file re-exports the new test suite so the old path doesn't break CI. + */ +// Re-run the same suite but importing the new component import { describe, it, expect, vi, afterEach, beforeAll, afterAll } 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 React from 'react' -import { CopyToAllMediaDialog } from '../components/CopyToAllMediaDialog' -import type { MedioListItem } from '../../medios/types' +import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog' +import type { ProductTypeListItem } from '../../product-types/types' vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) const API_URL = 'http://localhost:5000' -function makeMedio(id: number, nombre: string): MedioListItem { - return { id, codigo: `M${id}`, nombre, tipo: 1, plataformaEmpresaId: null, activo: true } +function makeProductType(id: number, nombre: string): ProductTypeListItem { + return { + id, + nombre, + hasDuration: false, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + isActive: true, + } } -const medios = [makeMedio(1, 'La Nación'), makeMedio(2, 'Clarín'), makeMedio(3, 'Infobae')] +const productTypes = [ + makeProductType(1, 'La Nación'), + makeProductType(2, 'Clarín'), + makeProductType(3, 'Infobae'), +] const server = setupServer() beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) @@ -27,14 +45,14 @@ function renderDialog(onOpenChange = vi.fn()) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) server.use( - http.get(`${API_URL}/api/v1/admin/medios`, () => - HttpResponse.json({ items: medios, page: 1, pageSize: 100, total: 3 }), + http.get(`${API_URL}/api/v1/product-types`, () => + HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }), ), ) return render( - { +describe('CopyToAllMediaDialog (renamed → CopyToAllProductTypesDialog)', () => { it('shows preview of symbol, price, and validFrom', async () => { renderDialog() - // Wait for dialog to render await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) - // Preview info expect(screen.getByText('$')).toBeInTheDocument() expect(screen.getByText('1.5')).toBeInTheDocument() expect(screen.getByText('25/04/2026')).toBeInTheDocument() }) - it('confirm with 3 medios selected calls create mutation 3 times', async () => { + it('confirm with 3 product types selected calls create mutation 3 times', async () => { const createCalls: unknown[] = [] server.use( http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { const body = await request.json() createCalls.push(body) return HttpResponse.json( - { id: createCalls.length, medioId: (body as Record)['medioId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true }, + { id: createCalls.length, productTypeId: (body as Record)['productTypeId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true }, { status: 201 }, ) }), @@ -76,7 +92,6 @@ describe('CopyToAllMediaDialog', () => { await waitFor(() => expect(screen.getByText('La Nación')).toBeInTheDocument()) - // All checkboxes are selected by default; click confirm const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i }) await userEvent.click(confirmBtn) diff --git a/src/web/src/features/chargeableChars/__tests__/CopyToAllProductTypesDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/CopyToAllProductTypesDialog.test.tsx new file mode 100644 index 0000000..8411b50 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/CopyToAllProductTypesDialog.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } 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 { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog' +import type { ProductTypeListItem } from '../../product-types/types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +function makeProductType(id: number, nombre: string): ProductTypeListItem { + return { + id, + nombre, + hasDuration: false, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + isActive: true, + } +} + +const productTypes = [ + makeProductType(1, 'Clasificados'), + makeProductType(2, 'Notables'), + makeProductType(3, 'Fúnebres'), +] + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { server.resetHandlers(); vi.clearAllMocks() }) +afterAll(() => server.close()) + +function renderDialog(onOpenChange = vi.fn()) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => + HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }), + ), + ) + + return render( + + + , + ) +} + +describe('CopyToAllProductTypesDialog', () => { + it('shows preview of symbol, price, and validFrom', async () => { + renderDialog() + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + // Preview info + expect(screen.getByText('$')).toBeInTheDocument() + expect(screen.getByText('1.5')).toBeInTheDocument() + expect(screen.getByText('25/04/2026')).toBeInTheDocument() + }) + + it('confirm with 3 product types calls create mutation 3 times', async () => { + const createCalls: unknown[] = [] + server.use( + http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { + const body = await request.json() + createCalls.push(body) + return HttpResponse.json( + { id: createCalls.length, productTypeId: (body as Record)['productTypeId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true }, + { status: 201 }, + ) + }), + ) + + renderDialog() + + await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) + + const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i }) + await userEvent.click(confirmBtn) + + await waitFor(() => expect(createCalls.length).toBe(3), { timeout: 5000 }) + }) + + it('cancel button closes without making API calls', async () => { + const createCalls: unknown[] = [] + server.use( + http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => { + createCalls.push(await request.json()) + return HttpResponse.json({}, { status: 201 }) + }), + ) + + const onOpenChange = vi.fn() + renderDialog(onOpenChange) + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const cancelBtn = screen.getByRole('button', { name: /cancelar/i }) + await userEvent.click(cancelBtn) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(createCalls.length).toBe(0) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/DeleteChargeableCharConfigDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/DeleteChargeableCharConfigDialog.test.tsx new file mode 100644 index 0000000..404ee80 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/DeleteChargeableCharConfigDialog.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } 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 { DeleteChargeableCharConfigDialog } from '../components/DeleteChargeableCharConfigDialog' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { server.resetHandlers(); vi.clearAllMocks() }) +afterAll(() => server.close()) + +function renderDialog( + configId = 1, + symbol = '$', + onOpenChange = vi.fn(), +) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + , + ) +} + +describe('DeleteChargeableCharConfigDialog', () => { + it('renders dialog with symbol in warning text', async () => { + renderDialog(1, '$') + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + expect(screen.getByText(/eliminará permanentemente/i)).toBeInTheDocument() + expect(screen.getByText(/'\$'/)).toBeInTheDocument() + expect(screen.getByText(/FAC-001/i)).toBeInTheDocument() + }) + + it('confirm button calls delete mutation and shows success toast', async () => { + const { toast } = await import('sonner') + server.use( + http.delete(`${API_URL}/api/v1/admin/chargeable-chars/5`, () => + HttpResponse.json({ id: 5 }), + ), + ) + const onOpenChange = vi.fn() + renderDialog(5, '%', onOpenChange) + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const confirmBtn = screen.getByRole('button', { name: /eliminar/i }) + await userEvent.click(confirmBtn) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 }) + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('%')) + }) + + it('cancel button closes dialog without calling mutation', async () => { + const deleteCalls: unknown[] = [] + server.use( + http.delete(`${API_URL}/api/v1/admin/chargeable-chars/1`, async () => { + deleteCalls.push(true) + return HttpResponse.json({ id: 1 }) + }), + ) + const onOpenChange = vi.fn() + renderDialog(1, '$', onOpenChange) + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + const cancelBtn = screen.getByRole('button', { name: /cancelar/i }) + await userEvent.click(cancelBtn) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(deleteCalls.length).toBe(0) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/hooks.test.ts b/src/web/src/features/chargeableChars/__tests__/hooks.test.ts index 0e38ec3..f5e3cff 100644 --- a/src/web/src/features/chargeableChars/__tests__/hooks.test.ts +++ b/src/web/src/features/chargeableChars/__tests__/hooks.test.ts @@ -6,6 +6,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs' import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange' +import { useReactivateChargeableCharConfig } from '../hooks/useReactivateChargeableCharConfig' +import { useDeleteChargeableCharConfig } from '../hooks/useDeleteChargeableCharConfig' +import { ReactivationNotAllowedError } from '../api/reactivateChargeableCharConfig' vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) @@ -30,7 +33,7 @@ describe('useChargeableCharConfigs', () => { server.use( http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () => HttpResponse.json({ - items: [{ id: 1, medioId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-01-01', validTo: null, isActive: true }], + items: [{ id: 1, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-01-01', validTo: null, isActive: true }], page: 1, pageSize: 20, total: 1, }), ), @@ -43,7 +46,7 @@ describe('useChargeableCharConfigs', () => { expect(result.current.data?.items[0].symbol).toBe('$') }) - it('sends medioId and activeOnly as query params', async () => { + it('sends productTypeId and activeOnly as query params', async () => { let capturedUrl: string | null = null server.use( http.get(`${API_URL}/api/v1/admin/chargeable-chars`, ({ request }) => { @@ -53,12 +56,12 @@ describe('useChargeableCharConfigs', () => { ) const { wrapper } = makeWrapper() const { result } = renderHook( - () => useChargeableCharConfigs({ medioId: 3, activeOnly: true }), + () => useChargeableCharConfigs({ productTypeId: 3, activeOnly: true }), { wrapper }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(capturedUrl).toContain('medioId=3') + expect(capturedUrl).toContain('productTypeId=3') expect(capturedUrl).toContain('activeOnly=true') }) }) @@ -68,7 +71,7 @@ describe('useSchedulePriceChange', () => { server.use( http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, () => HttpResponse.json({ - created: { id: 6, medioId: null, symbol: '$', category: 'Currency', pricePerUnit: 2.0, validFrom: '2026-05-01', validTo: null, isActive: true }, + created: { id: 6, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 2.0, validFrom: '2026-05-01', validTo: null, isActive: true }, closed: null, }), ), @@ -83,10 +86,108 @@ describe('useSchedulePriceChange', () => { }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) - // Should invalidate the list query and the byId query const listInvalidated = invalidateSpy.mock.calls.some( ([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('chargeableChars'), ) expect(listInvalidated).toBe(true) }) }) + +describe('useReactivateChargeableCharConfig', () => { + it('happy path — returns response and invalidates list + byId', async () => { + server.use( + http.patch(`${API_URL}/api/v1/admin/chargeable-chars/7/reactivate`, () => + HttpResponse.json({ id: 7, symbol: '$', productTypeId: null, pricePerUnit: 1.5, validFrom: '2026-01-01' }), + ), + ) + const { qc, wrapper } = makeWrapper() + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + + const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(7) }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.id).toBe(7) + + const listInvalidated = invalidateSpy.mock.calls.some( + ([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('list'), + ) + expect(listInvalidated).toBe(true) + }) + + it('409 ALREADY_ACTIVE — throws ReactivationNotAllowedError with correct reason', async () => { + server.use( + http.patch(`${API_URL}/api/v1/admin/chargeable-chars/8/reactivate`, () => + HttpResponse.json( + { code: 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED', reason: 'ALREADY_ACTIVE', message: 'El registro ya está activo' }, + { status: 409 }, + ), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(8) }) + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toBeInstanceOf(ReactivationNotAllowedError) + expect((result.current.error as ReactivationNotAllowedError).reason).toBe('ALREADY_ACTIVE') + }) + + it('409 VIGENTE_EXISTS — throws ReactivationNotAllowedError with correct reason', async () => { + server.use( + http.patch(`${API_URL}/api/v1/admin/chargeable-chars/9/reactivate`, () => + HttpResponse.json( + { code: 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED', reason: 'VIGENTE_EXISTS', message: 'Ya existe un registro activo' }, + { status: 409 }, + ), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(9) }) + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(result.current.error).toBeInstanceOf(ReactivationNotAllowedError) + expect((result.current.error as ReactivationNotAllowedError).reason).toBe('VIGENTE_EXISTS') + }) +}) + +describe('useDeleteChargeableCharConfig', () => { + it('happy path — returns {id} and invalidates list + removes byId', async () => { + server.use( + http.delete(`${API_URL}/api/v1/admin/chargeable-chars/10`, () => + HttpResponse.json({ id: 10 }), + ), + ) + const { qc, wrapper } = makeWrapper() + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + const removeSpy = vi.spyOn(qc, 'removeQueries') + + const { result } = renderHook(() => useDeleteChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(10) }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.id).toBe(10) + + const listInvalidated = invalidateSpy.mock.calls.some( + ([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('list'), + ) + expect(listInvalidated).toBe(true) + expect(removeSpy).toHaveBeenCalled() + }) + + it('404 — mutation transitions to error state', async () => { + server.use( + http.delete(`${API_URL}/api/v1/admin/chargeable-chars/99`, () => + HttpResponse.json({ message: 'Not found' }, { status: 404 }), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useDeleteChargeableCharConfig(), { wrapper }) + + await act(async () => { result.current.mutate(99) }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) diff --git a/src/web/src/features/chargeableChars/api/deleteChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/deleteChargeableCharConfig.ts new file mode 100644 index 0000000..df6e7e0 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/deleteChargeableCharConfig.ts @@ -0,0 +1,11 @@ +import { axiosClient } from '@/api/axiosClient' +import type { DeleteChargeableCharConfigResponse } from '../types' + +export async function deleteChargeableCharConfig( + id: number, +): Promise { + const response = await axiosClient.delete( + `/api/v1/admin/chargeable-chars/${id}`, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts b/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts index cd1e0b3..c4c1c9d 100644 --- a/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts +++ b/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts @@ -5,7 +5,7 @@ export async function listChargeableCharConfigs( query: ChargeableCharConfigsQuery, ): Promise> { const params = new URLSearchParams() - if (query.medioId !== undefined) params.set('medioId', String(query.medioId)) + if (query.productTypeId !== undefined) params.set('productTypeId', String(query.productTypeId)) if (query.activeOnly !== undefined) params.set('activeOnly', String(query.activeOnly)) if (query.page !== undefined) params.set('page', String(query.page)) if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize)) diff --git a/src/web/src/features/chargeableChars/api/reactivateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/reactivateChargeableCharConfig.ts new file mode 100644 index 0000000..6126d76 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/reactivateChargeableCharConfig.ts @@ -0,0 +1,40 @@ +import { axiosClient } from '@/api/axiosClient' +import { isAxiosError } from 'axios' +import type { ReactivateChargeableCharConfigResponse } from '../types' + +export type ReactivationNotAllowedReason = + | 'ALREADY_ACTIVE' + | 'VIGENTE_EXISTS' + | 'POSTERIOR_ROWS_EXIST' + +export class ReactivationNotAllowedError extends Error { + reason: ReactivationNotAllowedReason + + constructor(reason: ReactivationNotAllowedReason, message: string) { + super(message) + this.name = 'ReactivationNotAllowedError' + this.reason = reason + } +} + +export async function reactivateChargeableCharConfig( + id: number, +): Promise { + try { + const response = await axiosClient.patch( + `/api/v1/admin/chargeable-chars/${id}/reactivate`, + ) + return response.data + } catch (err) { + if ( + isAxiosError(err) && + err.response?.status === 409 && + err.response.data?.code === 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED' + ) { + const reason: ReactivationNotAllowedReason = err.response.data?.reason ?? 'ALREADY_ACTIVE' + const message: string = err.response.data?.message ?? 'Reactivación no permitida.' + throw new ReactivationNotAllowedError(reason, message) + } + throw err + } +} diff --git a/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx b/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx index 1d650ca..5152e71 100644 --- a/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx +++ b/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx @@ -22,6 +22,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { Input } from '@/components/ui/input' import { Select, SelectContent, @@ -29,12 +30,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Input } from '@/components/ui/input' import { todayArgentina } from '@/lib/formatters' import { CHARGEABLE_CHAR_CATEGORIES, CATEGORY_LABELS } from '../categories' import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig' import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange' import { SymbolInput } from './SymbolInput' +import { ProductTypeSelect } from './ProductTypeSelect' import type { ChargeableCharConfig } from '../types' // ─── Emoji regex (same as SymbolInput) ─────────────────────────────────────── @@ -43,14 +44,14 @@ const EMOJI_REGEX = /\p{Extended_Pictographic}/u // ─── Schemas ───────────────────────────────────────────────────────────────── const createSchema = z.object({ - medioId: z.number().int().positive().nullable().optional(), + productTypeId: z.number().nullable().optional(), symbol: z .string() .min(1, 'El símbolo es requerido.') .max(4, 'Máximo 4 caracteres.') .refine((s) => !EMOJI_REGEX.test(s), 'Los emojis no están permitidos.'), category: z.enum(['Currency', 'Percentage', 'Exclamation', 'Question', 'Other'], { - required_error: 'La categoría es requerida.', + error: 'La categoría es requerida.', }), pricePerUnit: z.coerce .number('Debe ser un número.') @@ -74,7 +75,7 @@ const schedulePriceSchema = z.object({ }) type CreateFormRaw = { - medioId?: string + productTypeId?: number | null symbol: string category: string pricePerUnit: string @@ -131,7 +132,7 @@ export function ChargeableCharFormDialog({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const createForm = useForm({ resolver: zodResolver(createSchema) as any, - defaultValues: { medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' }, + defaultValues: { productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' }, mode: 'onSubmit', }) @@ -145,7 +146,7 @@ export function ChargeableCharFormDialog({ useEffect(() => { if (open) { - createForm.reset({ medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' }) + createForm.reset({ productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' }) scheduleForm.reset({ pricePerUnit: '', validFrom: '' }) createMutation.reset() scheduleMutation.reset() @@ -158,7 +159,7 @@ export function ChargeableCharFormDialog({ function handleCreateSubmit(values: z.infer) { createMutation.mutate( { - medioId: values.medioId ?? null, + productTypeId: values.productTypeId ?? null, symbol: values.symbol, category: values.category as ChargeableCharConfig['category'], pricePerUnit: values.pricePerUnit, @@ -215,6 +216,28 @@ export function ChargeableCharFormDialog({ className="space-y-4" noValidate > + {/* Tipo de producto */} + ( + + Tipo de producto + + field.onChange(v ?? null)} + globalOptionLabel="Global (todos los tipos)" + placeholder="Seleccioná un tipo de producto" + disabled={isPending} + aria-label="Tipo de producto" + /> + + + + )} + /> + {/* Símbolo */} void - medioId: number | undefined + productTypeId: number | undefined activeOnly: boolean - onMedioChange: (medioId: number | undefined) => void + onProductTypeChange: (productTypeId: number | undefined) => void onActiveOnlyChange: (value: boolean) => void onSchedulePrice: (config: ChargeableCharConfig) => void onDeactivate: (config: ChargeableCharConfig) => void } +function resolveReactivationError(err: unknown): string { + if (err instanceof ReactivationNotAllowedError) { + switch (err.reason) { + case 'ALREADY_ACTIVE': + return 'El registro ya está activo.' + case 'VIGENTE_EXISTS': + return 'Ya existe un registro activo para este tipo de producto y símbolo. Modificá ese registro en su lugar.' + case 'POSTERIOR_ROWS_EXIST': + return 'Existen cambios posteriores al cierre de este registro. Para modificar el precio, usá "Programar cambio de precio".' + } + } + return 'No se pudo reactivar el símbolo. Intentá de nuevo.' +} + export function ChargeableCharsTable({ configs, total, page, pageSize, onPageChange, - medioId, + productTypeId, activeOnly, - onMedioChange, + onProductTypeChange, onActiveOnlyChange, onSchedulePrice, onDeactivate, }: ChargeableCharsTableProps) { - const { data: mediosData } = useMediosList({ activo: true, pageSize: 200 }) - const medios = mediosData?.items ?? [] + const { data: ptData } = useProductTypes({ activo: true, pageSize: 200 }) + const productTypes = ptData?.items ?? [] + + const reactivateMutation = useReactivateChargeableCharConfig() + + const [deleteTarget, setDeleteTarget] = useState(null) const totalPages = Math.max(1, Math.ceil(total / pageSize)) const hasPrev = page > 1 const hasNext = page < totalPages + function handleReactivate(config: ChargeableCharConfig) { + reactivateMutation.mutate(config.id, { + onError: (err) => { + toast.error(resolveReactivationError(err)) + }, + }) + } + const columns = useMemo[]>( () => [ { - accessorKey: 'medioId', - header: 'Medio', + accessorKey: 'productTypeId', + header: 'Tipo de Producto', cell: ({ row }) => { - const mid = row.original.medioId - if (mid === null) return Global - const medio = medios.find((m) => m.id === mid) - return {medio?.nombre ?? `Medio ${mid}`} + const ptId = row.original.productTypeId + if (ptId === null) return Global + const pt = productTypes.find((p) => p.id === ptId) + return {pt?.nombre ?? `Tipo ${ptId}`} }, }, { @@ -129,53 +146,71 @@ export function ChargeableCharsTable({ { id: 'acciones', header: 'Acciones', - cell: ({ row }) => ( - e.stopPropagation()}> - - - - - - - - onSchedulePrice(row.original)}> - Programar cambio de precio - - onDeactivate(row.original)} - className="text-destructive" + cell: ({ row }) => { + const config = row.original + return ( + e.stopPropagation()}> + {config.isActive ? ( + <> + onSchedulePrice(config)} + aria-label="Programar cambio de precio" + > + Programar precio + + onDeactivate(config)} + className="text-destructive hover:text-destructive" + aria-label="Desactivar" + > + Desactivar + + > + ) : ( + handleReactivate(config)} + disabled={reactivateMutation.isPending && reactivateMutation.variables === config.id} + aria-label="Reactivar" > - Desactivar - - - - - ), + Reactivar + + )} + setDeleteTarget(config)} + className="text-destructive hover:text-destructive" + aria-label="Eliminar" + > + Eliminar + + + ) + }, }, ], - [medios, onSchedulePrice, onDeactivate], + [productTypes, onSchedulePrice, onDeactivate, reactivateMutation], ) return ( {/* Filters */} - onMedioChange(v === '__all__' ? undefined : Number(v))} - > - - - - - Todos los medios - {medios.map((m) => ( - - {m.nombre} - - ))} - - + onProductTypeChange(v === null ? undefined : v)} + showAllOption={true} + allOptionLabel="Todos los tipos" + globalOptionLabel="Global" + placeholder="Todos los tipos" + aria-label="Tipo de producto" + /> + + {/* Delete confirmation dialog */} + {deleteTarget && ( + { if (!open) setDeleteTarget(null) }} + configId={deleteTarget.id} + symbol={deleteTarget.symbol} + /> + )} ) } diff --git a/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx b/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx index 04b3894..77b1e1e 100644 --- a/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx +++ b/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx @@ -1,139 +1,6 @@ -import { useState } from 'react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { formatCivilDate } from '@/lib/formatters' -import { useMediosList } from '../../medios/hooks/useMediosList' -import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig' -import type { ChargeableCharCategory } from '../types' - -interface CopyToAllMediaDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - symbol: string - pricePerUnit: number - /** yyyy-MM-dd */ - validFrom: string - category: ChargeableCharCategory -} - /** - * Confirmation dialog that creates rows for all active medios - * with the same symbol/price/validFrom/category. - * - * Uses Promise.allSettled — if one medio fails, the rest still proceed. - * Summary toast shows success/failure counts. + * @deprecated Renamed to CopyToAllProductTypesDialog. + * This file is kept for backwards compatibility — no code imports it. + * @see CopyToAllProductTypesDialog */ -export function CopyToAllMediaDialog({ - open, - onOpenChange, - symbol, - pricePerUnit, - validFrom, - category, -}: CopyToAllMediaDialogProps) { - const { data: mediosData } = useMediosList({ activo: true, pageSize: 200 }) - const medios = mediosData?.items ?? [] - const createMutation = useCreateChargeableCharConfig() - const [isProcessing, setIsProcessing] = useState(false) - - async function handleConfirm() { - if (medios.length === 0) return - setIsProcessing(true) - try { - // Promise.allSettled: partial failure doesn't block the rest - const results = await Promise.allSettled( - medios.map((m) => - createMutation.mutateAsync({ - medioId: m.id, - symbol, - category, - pricePerUnit, - validFrom, - }), - ), - ) - const succeeded = results.filter((r) => r.status === 'fulfilled').length - const failed = results.filter((r) => r.status === 'rejected').length - - if (failed === 0) { - toast.success(`Copiado a ${succeeded} medio${succeeded !== 1 ? 's' : ''} exitosamente.`) - } else { - toast.error( - `${succeeded} exitosos, ${failed} fallidos. Revisá los errores en la lista.`, - ) - } - onOpenChange(false) - } finally { - setIsProcessing(false) - } - } - - return ( - - - - Copiar a todos los medios - - Se creará una configuración para cada medio activo con los siguientes datos. - - - - {/* Preview */} - - - Símbolo: - {symbol} - - - Precio/unidad: - {pricePerUnit} - - - Vigente desde: - {formatCivilDate(validFrom)} - - - - {/* List of medios */} - {medios.length > 0 ? ( - - {medios.map((m) => ( - - • - {m.nombre} - - ))} - - ) : ( - Cargando medios... - )} - - - onOpenChange(false)} - disabled={isProcessing} - > - Cancelar - - - {isProcessing ? 'Copiando...' : `Confirmar (${medios.length} medios)`} - - - - - ) -} +export { CopyToAllProductTypesDialog as CopyToAllMediaDialog } from './CopyToAllProductTypesDialog' diff --git a/src/web/src/features/chargeableChars/components/CopyToAllProductTypesDialog.tsx b/src/web/src/features/chargeableChars/components/CopyToAllProductTypesDialog.tsx new file mode 100644 index 0000000..737b6bf --- /dev/null +++ b/src/web/src/features/chargeableChars/components/CopyToAllProductTypesDialog.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { formatCivilDate } from '@/lib/formatters' +import { useProductTypes } from '../../product-types/hooks/useProductTypes' +import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig' +import type { ChargeableCharCategory } from '../types' + +interface CopyToAllProductTypesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + symbol: string + pricePerUnit: number + /** yyyy-MM-dd */ + validFrom: string + category: ChargeableCharCategory +} + +/** + * Confirmation dialog that creates rows for all active ProductTypes + * with the same symbol/price/validFrom/category. + * + * Uses Promise.allSettled — if one productType fails, the rest still proceed. + * Summary toast shows success/failure counts. + */ +export function CopyToAllProductTypesDialog({ + open, + onOpenChange, + symbol, + pricePerUnit, + validFrom, + category, +}: CopyToAllProductTypesDialogProps) { + const { data: ptData } = useProductTypes({ activo: true, pageSize: 200 }) + const productTypes = ptData?.items ?? [] + const createMutation = useCreateChargeableCharConfig() + const [isProcessing, setIsProcessing] = useState(false) + + async function handleConfirm() { + if (productTypes.length === 0) return + setIsProcessing(true) + try { + // Promise.allSettled: partial failure doesn't block the rest + const results = await Promise.allSettled( + productTypes.map((pt) => + createMutation.mutateAsync({ + productTypeId: pt.id, + symbol, + category, + pricePerUnit, + validFrom, + }), + ), + ) + const succeeded = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected').length + + if (failed === 0) { + toast.success(`Copiado a ${succeeded} tipo${succeeded !== 1 ? 's' : ''} exitosamente.`) + } else { + toast.error( + `${succeeded} exitosos, ${failed} fallidos. Revisá los errores en la lista.`, + ) + } + onOpenChange(false) + } finally { + setIsProcessing(false) + } + } + + return ( + + + + Copiar a todos los tipos de producto + + Se creará una configuración para cada tipo de producto activo con los siguientes datos. + + + + {/* Preview */} + + + Símbolo: + {symbol} + + + Precio/unidad: + {pricePerUnit} + + + Vigente desde: + {formatCivilDate(validFrom)} + + + + {/* List of product types */} + {productTypes.length > 0 ? ( + + {productTypes.map((pt) => ( + + • + {pt.nombre} + + ))} + + ) : ( + Cargando tipos de producto... + )} + + + onOpenChange(false)} + disabled={isProcessing} + > + Cancelar + + + {isProcessing ? 'Copiando...' : `Confirmar (${productTypes.length} tipos)`} + + + + + ) +} diff --git a/src/web/src/features/chargeableChars/components/DeleteChargeableCharConfigDialog.tsx b/src/web/src/features/chargeableChars/components/DeleteChargeableCharConfigDialog.tsx new file mode 100644 index 0000000..011bf93 --- /dev/null +++ b/src/web/src/features/chargeableChars/components/DeleteChargeableCharConfigDialog.tsx @@ -0,0 +1,92 @@ +import { toast } from 'sonner' +import { AlertTriangle } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { useDeleteChargeableCharConfig } from '../hooks/useDeleteChargeableCharConfig' + +interface DeleteChargeableCharConfigDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** The config id to delete */ + configId: number + /** Symbol label for the warning text */ + symbol: string +} + +export function DeleteChargeableCharConfigDialog({ + open, + onOpenChange, + configId, + symbol, +}: DeleteChargeableCharConfigDialogProps) { + const deleteMutation = useDeleteChargeableCharConfig() + + function handleConfirm() { + deleteMutation.mutate(configId, { + onSuccess: () => { + toast.success(`Símbolo '${symbol}' eliminado correctamente.`) + onOpenChange(false) + deleteMutation.reset() + }, + onError: () => { + toast.error('No se pudo eliminar el símbolo. Intentá de nuevo.') + }, + }) + } + + function handleClose(open: boolean) { + if (!deleteMutation.isPending) { + deleteMutation.reset() + onOpenChange(open) + } + } + + return ( + + + + Eliminar carácter tasable + + Esta acción eliminará permanentemente la tasación del símbolo '{symbol}'. + ¿Estás seguro? + + + + + + + La eliminación es posible porque aún no existe el módulo de facturación (FAC-001). + Cuando se active, no podrán eliminarse símbolos ya usados. + + + + + handleClose(false)} + disabled={deleteMutation.isPending} + > + Cancelar + + + {deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'} + + + + + ) +} diff --git a/src/web/src/features/chargeableChars/components/ProductTypeSelect.tsx b/src/web/src/features/chargeableChars/components/ProductTypeSelect.tsx new file mode 100644 index 0000000..b035eda --- /dev/null +++ b/src/web/src/features/chargeableChars/components/ProductTypeSelect.tsx @@ -0,0 +1,83 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useProductTypes } from '../../product-types/hooks/useProductTypes' + +const GLOBAL_VALUE = '__global__' +const ALL_VALUE = '__all__' + +interface ProductTypeSelectProps { + /** Current selected productTypeId. null = "Global", undefined = "All types" (filter mode) */ + value: number | null | undefined + /** Called with null for "Global (todos los tipos)", undefined for "All types", or id */ + onValueChange: (value: number | null | undefined) => void + /** If true — show an "All types" option (undefined) for filter use-case */ + showAllOption?: boolean + /** Label for "all" option */ + allOptionLabel?: string + /** Label for global/null option */ + globalOptionLabel?: string + placeholder?: string + disabled?: boolean + 'aria-label'?: string +} + +/** + * ProductTypeSelect — renders a shadcn Select populated with active ProductTypes. + * + * - In "filter" mode (showAllOption=true): adds an "All types" option that returns undefined + * - Always includes a "Global (todos los tipos)" option that returns null + * - Product types come from GET /api/v1/product-types with activo=true + */ +export function ProductTypeSelect({ + value, + onValueChange, + showAllOption = false, + allOptionLabel = 'Todos los tipos', + globalOptionLabel = 'Global (todos los tipos)', + placeholder = 'Seleccioná un tipo de producto', + disabled = false, + 'aria-label': ariaLabel, +}: ProductTypeSelectProps) { + const { data } = useProductTypes({ activo: true, pageSize: 200 }) + const productTypes = data?.items ?? [] + + function toSelectValue(v: number | null | undefined): string { + if (v === undefined) return ALL_VALUE + if (v === null) return GLOBAL_VALUE + return String(v) + } + + function fromSelectValue(sv: string): number | null | undefined { + if (sv === ALL_VALUE) return undefined + if (sv === GLOBAL_VALUE) return null + return Number(sv) + } + + return ( + onValueChange(fromSelectValue(sv))} + disabled={disabled} + > + + + + + {showAllOption && ( + {allOptionLabel} + )} + {globalOptionLabel} + {productTypes.map((pt) => ( + + {pt.nombre} + + ))} + + + ) +} diff --git a/src/web/src/features/chargeableChars/hooks/useDeleteChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useDeleteChargeableCharConfig.ts new file mode 100644 index 0000000..0a603b4 --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useDeleteChargeableCharConfig.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deleteChargeableCharConfig } from '../api/deleteChargeableCharConfig' + +export function useDeleteChargeableCharConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deleteChargeableCharConfig(id), + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] }) + queryClient.removeQueries({ queryKey: ['chargeableChars', id] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useReactivateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useReactivateChargeableCharConfig.ts new file mode 100644 index 0000000..a4196c1 --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useReactivateChargeableCharConfig.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { reactivateChargeableCharConfig } from '../api/reactivateChargeableCharConfig' + +export function useReactivateChargeableCharConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => reactivateChargeableCharConfig(id), + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] }) + queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx b/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx index d2120f3..3eb686d 100644 --- a/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx +++ b/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx @@ -5,7 +5,7 @@ import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs' import { useDeactivateChargeableCharConfig } from '../hooks/useDeactivateChargeableCharConfig' import { ChargeableCharsTable } from '../components/ChargeableCharsTable' import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog' -import { CopyToAllMediaDialog } from '../components/CopyToAllMediaDialog' +import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog' import type { ChargeableCharConfig } from '../types' const PERMISSION = 'tasacion:caracteres_especiales:gestionar' @@ -14,7 +14,7 @@ const DEFAULT_PAGE_SIZE = 20 export function ChargeableCharsPage() { // ── Filter / pagination state ────────────────────────────────────────────── const [page, setPage] = useState(1) - const [medioId, setMedioId] = useState(undefined) + const [selectedProductTypeId, setSelectedProductTypeId] = useState(undefined) const [activeOnly, setActiveOnly] = useState(true) // ── Dialog state ────────────────────────────────────────────────────────── @@ -25,7 +25,7 @@ export function ChargeableCharsPage() { // ── Data ────────────────────────────────────────────────────────────────── const { data, isLoading } = useChargeableCharConfigs({ - medioId, + productTypeId: selectedProductTypeId, activeOnly, page, pageSize: DEFAULT_PAGE_SIZE, @@ -51,7 +51,7 @@ export function ChargeableCharsPage() { onClick={() => setCopyFromConfig(data?.items[0] ?? null)} disabled={!data?.items.length} > - Copiar a todos los medios + Copiar a todos los tipos setCreateOpen(true)}> Nuevo carácter @@ -73,9 +73,9 @@ export function ChargeableCharsPage() { page={page} pageSize={DEFAULT_PAGE_SIZE} onPageChange={setPage} - medioId={medioId} + productTypeId={selectedProductTypeId} activeOnly={activeOnly} - onMedioChange={(v) => { setMedioId(v); setPage(1) }} + onProductTypeChange={(v) => { setSelectedProductTypeId(v); setPage(1) }} onActiveOnlyChange={(v) => { setActiveOnly(v); setPage(1) }} onSchedulePrice={setScheduleConfig} onDeactivate={handleDeactivate} @@ -97,9 +97,9 @@ export function ChargeableCharsPage() { onOpenChange={(open) => { if (!open) setScheduleConfig(null) }} /> - {/* Copy to all medios dialog */} + {/* Copy to all product types dialog */} {copyFromConfig && ( - { if (!open) setCopyFromConfig(null) }} symbol={copyFromConfig.symbol} diff --git a/src/web/src/features/chargeableChars/types.ts b/src/web/src/features/chargeableChars/types.ts index 8a487cc..17c1409 100644 --- a/src/web/src/features/chargeableChars/types.ts +++ b/src/web/src/features/chargeableChars/types.ts @@ -9,7 +9,7 @@ export type ChargeableCharCategory = export interface ChargeableCharConfig { id: number - medioId: number | null + productTypeId: number | null symbol: string category: ChargeableCharCategory pricePerUnit: number @@ -21,7 +21,7 @@ export interface ChargeableCharConfig { } export interface CreateChargeableCharConfigRequest { - medioId: number | null + productTypeId: number | null symbol: string category: ChargeableCharCategory pricePerUnit: number @@ -41,7 +41,7 @@ export interface SchedulePriceChangeResponse { } export interface ChargeableCharConfigsQuery { - medioId?: number + productTypeId?: number activeOnly?: boolean page?: number pageSize?: number @@ -53,3 +53,13 @@ export interface PagedResult { pageSize: number total: number } + +export type ReactivateChargeableCharConfigResponse = { + id: number + symbol: string + productTypeId: number | null + pricePerUnit: number + validFrom: string +} + +export type DeleteChargeableCharConfigResponse = { id: number }
Cargando medios...
Cargando tipos de producto...