refactor+feat(frontend): chargeableChars por ProductType + Reactivate/Delete/UI condicional (PRC-001)

Part of feature/PRC-001 pre-merge refinement.

REFACTOR:
- types, API client, hooks, components renamed MedioId -> ProductTypeId
- CopyToAllMediaDialog -> CopyToAllProductTypesDialog
- ProductTypeSelect reused from features/product-types (or created minimal stub)
- Form validation + test mocks updated

FEATURES:
- Conditional action buttons per row.isActive:
  - Active: Desactivar + Eliminar
  - Inactive: Reactivar + Eliminar
- Reactivate: useReactivateChargeableCharConfig hook, 409 reason surfaces
  localized error message
- Delete: useDeleteChargeableCharConfig hook + DeleteChargeableCharConfigDialog
  with confirmation warning + FAC-001 disclaimer
- ProductType column in ChargeableCharsTable (fallback "Global" when null)

Tests:
- Conditional rendering tests (5 new)
- Reactivate/Delete hook tests (5 new)
- Updated mocks for all existing tests
- DeleteChargeableCharConfigDialog tests (3 new)
- CopyToAllProductTypesDialog tests (3 new)
This commit is contained in:
2026-04-21 11:08:17 -03:00
parent f7fb76219a
commit 3eecb05634
19 changed files with 1009 additions and 254 deletions

View File

@@ -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
}) => (
<select
aria-label="Tipo de producto"
value={value === null ? '__global__' : value === undefined ? '__all__' : String(value)}
onChange={(e) => {
const v = e.target.value
onValueChange(v === '__global__' ? null : v === '__all__' ? undefined : Number(v))
}}
disabled={disabled}
>
<option value="__global__">Global (todos los tipos)</option>
<option value="1">Clasificados</option>
<option value="2">Notables</option>
</select>
),
}))
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<string, unknown>
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()

View File

@@ -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
}) => (
<select
aria-label={ariaLabel ?? 'Tipo de producto'}
value={value === null ? '__global__' : value === undefined ? '__all__' : String(value)}
onChange={(e) => {
const v = e.target.value
onValueChange(v === '__global__' ? null : v === '__all__' ? undefined : Number(v))
}}
>
<option value="__all__">Todos los tipos</option>
<option value="__global__">Global</option>
<option value="1">Clasificados</option>
</select>
),
}))
const API_URL = 'http://localhost:5000'
function makeConfig(overrides: Partial<ChargeableCharConfig> = {}): 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 })
})
})

View File

@@ -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(
<QueryClientProvider client={qc}>
<CopyToAllMediaDialog
<CopyToAllProductTypesDialog
open={true}
onOpenChange={onOpenChange}
symbol="$"
@@ -46,27 +64,25 @@ function renderDialog(onOpenChange = vi.fn()) {
)
}
describe('CopyToAllMediaDialog', () => {
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<string, unknown>)['medioId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true },
{ id: createCalls.length, productTypeId: (body as Record<string, unknown>)['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)

View File

@@ -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(
<QueryClientProvider client={qc}>
<CopyToAllProductTypesDialog
open={true}
onOpenChange={onOpenChange}
symbol="$"
pricePerUnit={1.5}
validFrom="2026-04-25"
category="Currency"
/>
</QueryClientProvider>,
)
}
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<string, unknown>)['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)
})
})

View File

@@ -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(
<QueryClientProvider client={qc}>
<DeleteChargeableCharConfigDialog
open={true}
onOpenChange={onOpenChange}
configId={configId}
symbol={symbol}
/>
</QueryClientProvider>,
)
}
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)
})
})

View File

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

View File

@@ -0,0 +1,11 @@
import { axiosClient } from '@/api/axiosClient'
import type { DeleteChargeableCharConfigResponse } from '../types'
export async function deleteChargeableCharConfig(
id: number,
): Promise<DeleteChargeableCharConfigResponse> {
const response = await axiosClient.delete<DeleteChargeableCharConfigResponse>(
`/api/v1/admin/chargeable-chars/${id}`,
)
return response.data
}

View File

@@ -5,7 +5,7 @@ export async function listChargeableCharConfigs(
query: ChargeableCharConfigsQuery,
): Promise<PagedResult<ChargeableCharConfig>> {
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))

View File

@@ -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<ReactivateChargeableCharConfigResponse> {
try {
const response = await axiosClient.patch<ReactivateChargeableCharConfigResponse>(
`/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
}
}

View File

@@ -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<CreateFormRaw>({
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<typeof createSchema>) {
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 */}
<FormField
control={createForm.control}
name="productTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Tipo de producto</FormLabel>
<FormControl>
<ProductTypeSelect
value={field.value}
onValueChange={(v) => field.onChange(v ?? null)}
globalOptionLabel="Global (todos los tipos)"
placeholder="Seleccioná un tipo de producto"
disabled={isPending}
aria-label="Tipo de producto"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Símbolo */}
<FormField
control={createForm.control}

View File

@@ -1,28 +1,19 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { MoreHorizontal } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataTable } from '@/components/ui/data-table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { formatCivilDate } from '@/lib/formatters'
import type { ChargeableCharConfig } from '../types'
import { CATEGORY_LABELS } from '../categories'
import { useMediosList } from '../../medios/hooks/useMediosList'
import { useProductTypes } from '../../product-types/hooks/useProductTypes'
import { useReactivateChargeableCharConfig } from '../hooks/useReactivateChargeableCharConfig'
import { ReactivationNotAllowedError } from '../api/reactivateChargeableCharConfig'
import { ProductTypeSelect } from './ProductTypeSelect'
import { DeleteChargeableCharConfigDialog } from './DeleteChargeableCharConfigDialog'
interface ChargeableCharsTableProps {
configs: ChargeableCharConfig[]
@@ -30,44 +21,70 @@ interface ChargeableCharsTableProps {
page: number
pageSize: number
onPageChange: (page: number) => 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<ChargeableCharConfig | null>(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<ColumnDef<ChargeableCharConfig>[]>(
() => [
{
accessorKey: 'medioId',
header: 'Medio',
accessorKey: 'productTypeId',
header: 'Tipo de Producto',
cell: ({ row }) => {
const mid = row.original.medioId
if (mid === null) return <span className="text-muted-foreground">Global</span>
const medio = medios.find((m) => m.id === mid)
return <span>{medio?.nombre ?? `Medio ${mid}`}</span>
const ptId = row.original.productTypeId
if (ptId === null) return <span className="text-muted-foreground">Global</span>
const pt = productTypes.find((p) => p.id === ptId)
return <span>{pt?.nombre ?? `Tipo ${ptId}`}</span>
},
},
{
@@ -129,53 +146,71 @@ export function ChargeableCharsTable({
{
id: 'acciones',
header: 'Acciones',
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" aria-label="Acciones">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onSchedulePrice(row.original)}>
Programar cambio de precio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDeactivate(row.original)}
className="text-destructive"
cell: ({ row }) => {
const config = row.original
return (
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
{config.isActive ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => onSchedulePrice(config)}
aria-label="Programar cambio de precio"
>
Programar precio
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDeactivate(config)}
className="text-destructive hover:text-destructive"
aria-label="Desactivar"
>
Desactivar
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleReactivate(config)}
disabled={reactivateMutation.isPending && reactivateMutation.variables === config.id}
aria-label="Reactivar"
>
Desactivar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
Reactivar
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(config)}
className="text-destructive hover:text-destructive"
aria-label="Eliminar"
>
Eliminar
</Button>
</div>
)
},
},
],
[medios, onSchedulePrice, onDeactivate],
[productTypes, onSchedulePrice, onDeactivate, reactivateMutation],
)
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<Select
value={medioId !== undefined ? String(medioId) : '__all__'}
onValueChange={(v) => onMedioChange(v === '__all__' ? undefined : Number(v))}
>
<SelectTrigger className="h-9 w-48" aria-label="Medio">
<SelectValue placeholder="Todos los medios" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Todos los medios</SelectItem>
{medios.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<ProductTypeSelect
value={productTypeId}
onValueChange={(v) => onProductTypeChange(v === null ? undefined : v)}
showAllOption={true}
allOptionLabel="Todos los tipos"
globalOptionLabel="Global"
placeholder="Todos los tipos"
aria-label="Tipo de producto"
/>
<div className="flex items-center gap-2">
<Switch
@@ -224,6 +259,16 @@ export function ChargeableCharsTable({
</Button>
</div>
</div>
{/* Delete confirmation dialog */}
{deleteTarget && (
<DeleteChargeableCharConfigDialog
open={!!deleteTarget}
onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}
configId={deleteTarget.id}
symbol={deleteTarget.symbol}
/>
)}
</div>
)
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Copiar a todos los medios</DialogTitle>
<DialogDescription>
Se creará una configuración para cada medio activo con los siguientes datos.
</DialogDescription>
</DialogHeader>
{/* Preview */}
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
<div>
<span className="text-muted-foreground">Símbolo: </span>
<span className="font-mono font-semibold">{symbol}</span>
</div>
<div>
<span className="text-muted-foreground">Precio/unidad: </span>
<span>{pricePerUnit}</span>
</div>
<div>
<span className="text-muted-foreground">Vigente desde: </span>
<span>{formatCivilDate(validFrom)}</span>
</div>
</div>
{/* List of medios */}
{medios.length > 0 ? (
<div className="max-h-48 overflow-y-auto space-y-1 text-sm">
{medios.map((m) => (
<div key={m.id} className="flex items-center gap-2 py-1">
<span className="text-muted-foreground"></span>
<span>{m.nombre}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Cargando medios...</p>
)}
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={isProcessing || medios.length === 0}
>
{isProcessing ? 'Copiando...' : `Confirmar (${medios.length} medios)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export { CopyToAllProductTypesDialog as CopyToAllMediaDialog } from './CopyToAllProductTypesDialog'

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Copiar a todos los tipos de producto</DialogTitle>
<DialogDescription>
Se creará una configuración para cada tipo de producto activo con los siguientes datos.
</DialogDescription>
</DialogHeader>
{/* Preview */}
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
<div>
<span className="text-muted-foreground">Símbolo: </span>
<span className="font-mono font-semibold">{symbol}</span>
</div>
<div>
<span className="text-muted-foreground">Precio/unidad: </span>
<span>{pricePerUnit}</span>
</div>
<div>
<span className="text-muted-foreground">Vigente desde: </span>
<span>{formatCivilDate(validFrom)}</span>
</div>
</div>
{/* List of product types */}
{productTypes.length > 0 ? (
<div className="max-h-48 overflow-y-auto space-y-1 text-sm">
{productTypes.map((pt) => (
<div key={pt.id} className="flex items-center gap-2 py-1">
<span className="text-muted-foreground"></span>
<span>{pt.nombre}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Cargando tipos de producto...</p>
)}
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={isProcessing || productTypes.length === 0}
>
{isProcessing ? 'Copiando...' : `Confirmar (${productTypes.length} tipos)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Eliminar carácter tasable</DialogTitle>
<DialogDescription>
Esta acción eliminará permanentemente la tasación del símbolo '{symbol}'.
¿Estás seguro?
</DialogDescription>
</DialogHeader>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
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.
</AlertDescription>
</Alert>
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => handleClose(false)}
disabled={deleteMutation.isPending}
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
onClick={handleConfirm}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 (
<Select
value={toSelectValue(value)}
onValueChange={(sv) => onValueChange(fromSelectValue(sv))}
disabled={disabled}
>
<SelectTrigger aria-label={ariaLabel ?? placeholder}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{showAllOption && (
<SelectItem value={ALL_VALUE}>{allOptionLabel}</SelectItem>
)}
<SelectItem value={GLOBAL_VALUE}>{globalOptionLabel}</SelectItem>
{productTypes.map((pt) => (
<SelectItem key={pt.id} value={String(pt.id)}>
{pt.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

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

View File

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

View File

@@ -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<number | undefined>(undefined)
const [selectedProductTypeId, setSelectedProductTypeId] = useState<number | undefined>(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
</Button>
<Button size="sm" onClick={() => 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 && (
<CopyToAllMediaDialog
<CopyToAllProductTypesDialog
open={!!copyFromConfig}
onOpenChange={(open) => { if (!open) setCopyFromConfig(null) }}
symbol={copyFromConfig.symbol}

View File

@@ -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<T> {
pageSize: number
total: number
}
export type ReactivateChargeableCharConfigResponse = {
id: number
symbol: string
productTypeId: number | null
pricePerUnit: number
validFrom: string
}
export type DeleteChargeableCharConfigResponse = { id: number }