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:
@@ -10,6 +10,30 @@ import type { ChargeableCharConfig } from '../types'
|
|||||||
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
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 API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
const server = setupServer()
|
const server = setupServer()
|
||||||
@@ -82,14 +106,14 @@ describe('ChargeableCharFormDialog — create mode', () => {
|
|||||||
{ timeout: 3000 })
|
{ 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
|
let capturedBody: unknown = null
|
||||||
server.use(
|
server.use(
|
||||||
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
||||||
capturedBody = await request.json()
|
capturedBody = await request.json()
|
||||||
return HttpResponse.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,
|
pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true,
|
||||||
},
|
},
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
@@ -126,6 +150,8 @@ describe('ChargeableCharFormDialog — create mode', () => {
|
|||||||
const body = capturedBody as Record<string, unknown>
|
const body = capturedBody as Record<string, unknown>
|
||||||
expect(body['validFrom']).toBe('2026-04-25')
|
expect(body['validFrom']).toBe('2026-04-25')
|
||||||
expect(typeof body['validFrom']).toBe('string')
|
expect(typeof body['validFrom']).toBe('string')
|
||||||
|
// productTypeId defaults to null (Global)
|
||||||
|
expect(body['productTypeId']).toBeNull()
|
||||||
}, { timeout: 8000 })
|
}, { timeout: 8000 })
|
||||||
|
|
||||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { 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)', () => {
|
it('hides symbol and category inputs (read-only mode)', () => {
|
||||||
const existingConfig: ChargeableCharConfig = {
|
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,
|
pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true,
|
||||||
}
|
}
|
||||||
renderDialog('schedulePrice', existingConfig)
|
renderDialog('schedulePrice', existingConfig)
|
||||||
@@ -193,13 +219,13 @@ describe('ChargeableCharFormDialog — schedulePrice mode', () => {
|
|||||||
http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, async ({ request }) => {
|
http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, async ({ request }) => {
|
||||||
capturedBody = await request.json()
|
capturedBody = await request.json()
|
||||||
return HttpResponse.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 = {
|
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,
|
pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true,
|
||||||
}
|
}
|
||||||
const onOpenChange = vi.fn()
|
const onOpenChange = vi.fn()
|
||||||
|
|||||||
@@ -10,12 +10,34 @@ import type { ChargeableCharConfig } from '../types'
|
|||||||
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
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'
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
function makeConfig(overrides: Partial<ChargeableCharConfig> = {}): ChargeableCharConfig {
|
function makeConfig(overrides: Partial<ChargeableCharConfig> = {}): ChargeableCharConfig {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
medioId: null,
|
productTypeId: null,
|
||||||
symbol: '$',
|
symbol: '$',
|
||||||
category: 'Currency',
|
category: 'Currency',
|
||||||
pricePerUnit: 1.5,
|
pricePerUnit: 1.5,
|
||||||
@@ -45,9 +67,9 @@ function renderTable(
|
|||||||
page={1}
|
page={1}
|
||||||
pageSize={20}
|
pageSize={20}
|
||||||
onPageChange={vi.fn()}
|
onPageChange={vi.fn()}
|
||||||
medioId={undefined}
|
productTypeId={undefined}
|
||||||
activeOnly={true}
|
activeOnly={true}
|
||||||
onMedioChange={vi.fn()}
|
onProductTypeChange={vi.fn()}
|
||||||
onActiveOnlyChange={vi.fn()}
|
onActiveOnlyChange={vi.fn()}
|
||||||
onSchedulePrice={onSchedulePrice}
|
onSchedulePrice={onSchedulePrice}
|
||||||
onDeactivate={onDeactivate}
|
onDeactivate={onDeactivate}
|
||||||
@@ -69,9 +91,10 @@ describe('ChargeableCharsTable', () => {
|
|||||||
expect(screen.getByText('Moneda ($)')).toBeInTheDocument()
|
expect(screen.getByText('Moneda ($)')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays "Global" when medioId is null', () => {
|
it('displays "Global" when productTypeId is null', () => {
|
||||||
renderTable([makeConfig({ medioId: null })])
|
renderTable([makeConfig({ productTypeId: null })])
|
||||||
expect(screen.getByText('Global')).toBeInTheDocument()
|
// 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', () => {
|
it('shows "Vigente" badge for rows with validTo === null', () => {
|
||||||
@@ -88,4 +111,58 @@ describe('ChargeableCharsTable', () => {
|
|||||||
renderTable([makeConfig({ validFrom: '2026-01-15' })])
|
renderTable([makeConfig({ validFrom: '2026-01-15' })])
|
||||||
expect(screen.getByText('15/01/2026')).toBeInTheDocument()
|
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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
import { setupServer } from 'msw/node'
|
import { setupServer } from 'msw/node'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import React from 'react'
|
import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog'
|
||||||
import { CopyToAllMediaDialog } from '../components/CopyToAllMediaDialog'
|
import type { ProductTypeListItem } from '../../product-types/types'
|
||||||
import type { MedioListItem } from '../../medios/types'
|
|
||||||
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
const API_URL = 'http://localhost:5000'
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
function makeMedio(id: number, nombre: string): MedioListItem {
|
function makeProductType(id: number, nombre: string): ProductTypeListItem {
|
||||||
return { id, codigo: `M${id}`, nombre, tipo: 1, plataformaEmpresaId: null, activo: true }
|
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()
|
const server = setupServer()
|
||||||
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
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 } } })
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
http.get(`${API_URL}/api/v1/product-types`, () =>
|
||||||
HttpResponse.json({ items: medios, page: 1, pageSize: 100, total: 3 }),
|
HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<CopyToAllMediaDialog
|
<CopyToAllProductTypesDialog
|
||||||
open={true}
|
open={true}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
symbol="$"
|
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 () => {
|
it('shows preview of symbol, price, and validFrom', async () => {
|
||||||
renderDialog()
|
renderDialog()
|
||||||
|
|
||||||
// Wait for dialog to render
|
|
||||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||||
|
|
||||||
// Preview info
|
|
||||||
expect(screen.getByText('$')).toBeInTheDocument()
|
expect(screen.getByText('$')).toBeInTheDocument()
|
||||||
expect(screen.getByText('1.5')).toBeInTheDocument()
|
expect(screen.getByText('1.5')).toBeInTheDocument()
|
||||||
expect(screen.getByText('25/04/2026')).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[] = []
|
const createCalls: unknown[] = []
|
||||||
server.use(
|
server.use(
|
||||||
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
createCalls.push(body)
|
createCalls.push(body)
|
||||||
return HttpResponse.json(
|
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 },
|
{ status: 201 },
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -76,7 +92,6 @@ describe('CopyToAllMediaDialog', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('La Nación')).toBeInTheDocument())
|
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 })
|
const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i })
|
||||||
await userEvent.click(confirmBtn)
|
await userEvent.click(confirmBtn)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs'
|
import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs'
|
||||||
import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange'
|
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() } }))
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
@@ -30,7 +33,7 @@ describe('useChargeableCharConfigs', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
|
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
|
||||||
HttpResponse.json({
|
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,
|
page: 1, pageSize: 20, total: 1,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -43,7 +46,7 @@ describe('useChargeableCharConfigs', () => {
|
|||||||
expect(result.current.data?.items[0].symbol).toBe('$')
|
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
|
let capturedUrl: string | null = null
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, ({ request }) => {
|
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, ({ request }) => {
|
||||||
@@ -53,12 +56,12 @@ describe('useChargeableCharConfigs', () => {
|
|||||||
)
|
)
|
||||||
const { wrapper } = makeWrapper()
|
const { wrapper } = makeWrapper()
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useChargeableCharConfigs({ medioId: 3, activeOnly: true }),
|
() => useChargeableCharConfigs({ productTypeId: 3, activeOnly: true }),
|
||||||
{ wrapper },
|
{ wrapper },
|
||||||
)
|
)
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(capturedUrl).toContain('medioId=3')
|
expect(capturedUrl).toContain('productTypeId=3')
|
||||||
expect(capturedUrl).toContain('activeOnly=true')
|
expect(capturedUrl).toContain('activeOnly=true')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -68,7 +71,7 @@ describe('useSchedulePriceChange', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, () =>
|
http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, () =>
|
||||||
HttpResponse.json({
|
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,
|
closed: null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -83,10 +86,108 @@ describe('useSchedulePriceChange', () => {
|
|||||||
})
|
})
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
// Should invalidate the list query and the byId query
|
|
||||||
const listInvalidated = invalidateSpy.mock.calls.some(
|
const listInvalidated = invalidateSpy.mock.calls.some(
|
||||||
([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('chargeableChars'),
|
([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('chargeableChars'),
|
||||||
)
|
)
|
||||||
expect(listInvalidated).toBe(true)
|
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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export async function listChargeableCharConfigs(
|
|||||||
query: ChargeableCharConfigsQuery,
|
query: ChargeableCharConfigsQuery,
|
||||||
): Promise<PagedResult<ChargeableCharConfig>> {
|
): Promise<PagedResult<ChargeableCharConfig>> {
|
||||||
const params = new URLSearchParams()
|
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.activeOnly !== undefined) params.set('activeOnly', String(query.activeOnly))
|
||||||
if (query.page !== undefined) params.set('page', String(query.page))
|
if (query.page !== undefined) params.set('page', String(query.page))
|
||||||
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -29,12 +30,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { todayArgentina } from '@/lib/formatters'
|
import { todayArgentina } from '@/lib/formatters'
|
||||||
import { CHARGEABLE_CHAR_CATEGORIES, CATEGORY_LABELS } from '../categories'
|
import { CHARGEABLE_CHAR_CATEGORIES, CATEGORY_LABELS } from '../categories'
|
||||||
import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig'
|
import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig'
|
||||||
import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange'
|
import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange'
|
||||||
import { SymbolInput } from './SymbolInput'
|
import { SymbolInput } from './SymbolInput'
|
||||||
|
import { ProductTypeSelect } from './ProductTypeSelect'
|
||||||
import type { ChargeableCharConfig } from '../types'
|
import type { ChargeableCharConfig } from '../types'
|
||||||
|
|
||||||
// ─── Emoji regex (same as SymbolInput) ───────────────────────────────────────
|
// ─── Emoji regex (same as SymbolInput) ───────────────────────────────────────
|
||||||
@@ -43,14 +44,14 @@ const EMOJI_REGEX = /\p{Extended_Pictographic}/u
|
|||||||
// ─── Schemas ─────────────────────────────────────────────────────────────────
|
// ─── Schemas ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
medioId: z.number().int().positive().nullable().optional(),
|
productTypeId: z.number().nullable().optional(),
|
||||||
symbol: z
|
symbol: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'El símbolo es requerido.')
|
.min(1, 'El símbolo es requerido.')
|
||||||
.max(4, 'Máximo 4 caracteres.')
|
.max(4, 'Máximo 4 caracteres.')
|
||||||
.refine((s) => !EMOJI_REGEX.test(s), 'Los emojis no están permitidos.'),
|
.refine((s) => !EMOJI_REGEX.test(s), 'Los emojis no están permitidos.'),
|
||||||
category: z.enum(['Currency', 'Percentage', 'Exclamation', 'Question', 'Other'], {
|
category: z.enum(['Currency', 'Percentage', 'Exclamation', 'Question', 'Other'], {
|
||||||
required_error: 'La categoría es requerida.',
|
error: 'La categoría es requerida.',
|
||||||
}),
|
}),
|
||||||
pricePerUnit: z.coerce
|
pricePerUnit: z.coerce
|
||||||
.number('Debe ser un número.')
|
.number('Debe ser un número.')
|
||||||
@@ -74,7 +75,7 @@ const schedulePriceSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
type CreateFormRaw = {
|
type CreateFormRaw = {
|
||||||
medioId?: string
|
productTypeId?: number | null
|
||||||
symbol: string
|
symbol: string
|
||||||
category: string
|
category: string
|
||||||
pricePerUnit: string
|
pricePerUnit: string
|
||||||
@@ -131,7 +132,7 @@ export function ChargeableCharFormDialog({
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const createForm = useForm<CreateFormRaw>({
|
const createForm = useForm<CreateFormRaw>({
|
||||||
resolver: zodResolver(createSchema) as any,
|
resolver: zodResolver(createSchema) as any,
|
||||||
defaultValues: { medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' },
|
defaultValues: { productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' },
|
||||||
mode: 'onSubmit',
|
mode: 'onSubmit',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ export function ChargeableCharFormDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
createForm.reset({ medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' })
|
createForm.reset({ productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' })
|
||||||
scheduleForm.reset({ pricePerUnit: '', validFrom: '' })
|
scheduleForm.reset({ pricePerUnit: '', validFrom: '' })
|
||||||
createMutation.reset()
|
createMutation.reset()
|
||||||
scheduleMutation.reset()
|
scheduleMutation.reset()
|
||||||
@@ -158,7 +159,7 @@ export function ChargeableCharFormDialog({
|
|||||||
function handleCreateSubmit(values: z.infer<typeof createSchema>) {
|
function handleCreateSubmit(values: z.infer<typeof createSchema>) {
|
||||||
createMutation.mutate(
|
createMutation.mutate(
|
||||||
{
|
{
|
||||||
medioId: values.medioId ?? null,
|
productTypeId: values.productTypeId ?? null,
|
||||||
symbol: values.symbol,
|
symbol: values.symbol,
|
||||||
category: values.category as ChargeableCharConfig['category'],
|
category: values.category as ChargeableCharConfig['category'],
|
||||||
pricePerUnit: values.pricePerUnit,
|
pricePerUnit: values.pricePerUnit,
|
||||||
@@ -215,6 +216,28 @@ export function ChargeableCharFormDialog({
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
noValidate
|
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 */}
|
{/* Símbolo */}
|
||||||
<FormField
|
<FormField
|
||||||
control={createForm.control}
|
control={createForm.control}
|
||||||
|
|||||||
@@ -1,28 +1,19 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { MoreHorizontal } from 'lucide-react'
|
import { toast } from 'sonner'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { DataTable } from '@/components/ui/data-table'
|
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 { Switch } from '@/components/ui/switch'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { formatCivilDate } from '@/lib/formatters'
|
import { formatCivilDate } from '@/lib/formatters'
|
||||||
import type { ChargeableCharConfig } from '../types'
|
import type { ChargeableCharConfig } from '../types'
|
||||||
import { CATEGORY_LABELS } from '../categories'
|
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 {
|
interface ChargeableCharsTableProps {
|
||||||
configs: ChargeableCharConfig[]
|
configs: ChargeableCharConfig[]
|
||||||
@@ -30,44 +21,70 @@ interface ChargeableCharsTableProps {
|
|||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
onPageChange: (page: number) => void
|
onPageChange: (page: number) => void
|
||||||
medioId: number | undefined
|
productTypeId: number | undefined
|
||||||
activeOnly: boolean
|
activeOnly: boolean
|
||||||
onMedioChange: (medioId: number | undefined) => void
|
onProductTypeChange: (productTypeId: number | undefined) => void
|
||||||
onActiveOnlyChange: (value: boolean) => void
|
onActiveOnlyChange: (value: boolean) => void
|
||||||
onSchedulePrice: (config: ChargeableCharConfig) => void
|
onSchedulePrice: (config: ChargeableCharConfig) => void
|
||||||
onDeactivate: (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({
|
export function ChargeableCharsTable({
|
||||||
configs,
|
configs,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
medioId,
|
productTypeId,
|
||||||
activeOnly,
|
activeOnly,
|
||||||
onMedioChange,
|
onProductTypeChange,
|
||||||
onActiveOnlyChange,
|
onActiveOnlyChange,
|
||||||
onSchedulePrice,
|
onSchedulePrice,
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
}: ChargeableCharsTableProps) {
|
}: ChargeableCharsTableProps) {
|
||||||
const { data: mediosData } = useMediosList({ activo: true, pageSize: 200 })
|
const { data: ptData } = useProductTypes({ activo: true, pageSize: 200 })
|
||||||
const medios = mediosData?.items ?? []
|
const productTypes = ptData?.items ?? []
|
||||||
|
|
||||||
|
const reactivateMutation = useReactivateChargeableCharConfig()
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ChargeableCharConfig | null>(null)
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const hasPrev = page > 1
|
const hasPrev = page > 1
|
||||||
const hasNext = page < totalPages
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
function handleReactivate(config: ChargeableCharConfig) {
|
||||||
|
reactivateMutation.mutate(config.id, {
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(resolveReactivationError(err))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ChargeableCharConfig>[]>(
|
const columns = useMemo<ColumnDef<ChargeableCharConfig>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'medioId',
|
accessorKey: 'productTypeId',
|
||||||
header: 'Medio',
|
header: 'Tipo de Producto',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const mid = row.original.medioId
|
const ptId = row.original.productTypeId
|
||||||
if (mid === null) return <span className="text-muted-foreground">Global</span>
|
if (ptId === null) return <span className="text-muted-foreground">Global</span>
|
||||||
const medio = medios.find((m) => m.id === mid)
|
const pt = productTypes.find((p) => p.id === ptId)
|
||||||
return <span>{medio?.nombre ?? `Medio ${mid}`}</span>
|
return <span>{pt?.nombre ?? `Tipo ${ptId}`}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -129,53 +146,71 @@ export function ChargeableCharsTable({
|
|||||||
{
|
{
|
||||||
id: 'acciones',
|
id: 'acciones',
|
||||||
header: 'Acciones',
|
header: 'Acciones',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
const config = row.original
|
||||||
<DropdownMenu>
|
return (
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
<Button variant="ghost" size="sm" aria-label="Acciones">
|
{config.isActive ? (
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<>
|
||||||
</Button>
|
<Button
|
||||||
</DropdownMenuTrigger>
|
variant="outline"
|
||||||
<DropdownMenuContent align="end">
|
size="sm"
|
||||||
<DropdownMenuItem onClick={() => onSchedulePrice(row.original)}>
|
onClick={() => onSchedulePrice(config)}
|
||||||
Programar cambio de precio
|
aria-label="Programar cambio de precio"
|
||||||
</DropdownMenuItem>
|
>
|
||||||
<DropdownMenuItem
|
Programar precio
|
||||||
onClick={() => onDeactivate(row.original)}
|
</Button>
|
||||||
className="text-destructive"
|
<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
|
Reactivar
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
</DropdownMenuContent>
|
)}
|
||||||
</DropdownMenu>
|
<Button
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Select
|
<ProductTypeSelect
|
||||||
value={medioId !== undefined ? String(medioId) : '__all__'}
|
value={productTypeId}
|
||||||
onValueChange={(v) => onMedioChange(v === '__all__' ? undefined : Number(v))}
|
onValueChange={(v) => onProductTypeChange(v === null ? undefined : v)}
|
||||||
>
|
showAllOption={true}
|
||||||
<SelectTrigger className="h-9 w-48" aria-label="Medio">
|
allOptionLabel="Todos los tipos"
|
||||||
<SelectValue placeholder="Todos los medios" />
|
globalOptionLabel="Global"
|
||||||
</SelectTrigger>
|
placeholder="Todos los tipos"
|
||||||
<SelectContent>
|
aria-label="Tipo de producto"
|
||||||
<SelectItem value="__all__">Todos los medios</SelectItem>
|
/>
|
||||||
{medios.map((m) => (
|
|
||||||
<SelectItem key={m.id} value={String(m.id)}>
|
|
||||||
{m.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -224,6 +259,16 @@ export function ChargeableCharsTable({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteChargeableCharConfigDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}
|
||||||
|
configId={deleteTarget.id}
|
||||||
|
symbol={deleteTarget.symbol}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* @deprecated Renamed to CopyToAllProductTypesDialog.
|
||||||
* with the same symbol/price/validFrom/category.
|
* This file is kept for backwards compatibility — no code imports it.
|
||||||
*
|
* @see CopyToAllProductTypesDialog
|
||||||
* Uses Promise.allSettled — if one medio fails, the rest still proceed.
|
|
||||||
* Summary toast shows success/failure counts.
|
|
||||||
*/
|
*/
|
||||||
export function CopyToAllMediaDialog({
|
export { CopyToAllProductTypesDialog as CopyToAllMediaDialog } from './CopyToAllProductTypesDialog'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs'
|
|||||||
import { useDeactivateChargeableCharConfig } from '../hooks/useDeactivateChargeableCharConfig'
|
import { useDeactivateChargeableCharConfig } from '../hooks/useDeactivateChargeableCharConfig'
|
||||||
import { ChargeableCharsTable } from '../components/ChargeableCharsTable'
|
import { ChargeableCharsTable } from '../components/ChargeableCharsTable'
|
||||||
import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog'
|
import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog'
|
||||||
import { CopyToAllMediaDialog } from '../components/CopyToAllMediaDialog'
|
import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog'
|
||||||
import type { ChargeableCharConfig } from '../types'
|
import type { ChargeableCharConfig } from '../types'
|
||||||
|
|
||||||
const PERMISSION = 'tasacion:caracteres_especiales:gestionar'
|
const PERMISSION = 'tasacion:caracteres_especiales:gestionar'
|
||||||
@@ -14,7 +14,7 @@ const DEFAULT_PAGE_SIZE = 20
|
|||||||
export function ChargeableCharsPage() {
|
export function ChargeableCharsPage() {
|
||||||
// ── Filter / pagination state ──────────────────────────────────────────────
|
// ── Filter / pagination state ──────────────────────────────────────────────
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [medioId, setMedioId] = useState<number | undefined>(undefined)
|
const [selectedProductTypeId, setSelectedProductTypeId] = useState<number | undefined>(undefined)
|
||||||
const [activeOnly, setActiveOnly] = useState(true)
|
const [activeOnly, setActiveOnly] = useState(true)
|
||||||
|
|
||||||
// ── Dialog state ──────────────────────────────────────────────────────────
|
// ── Dialog state ──────────────────────────────────────────────────────────
|
||||||
@@ -25,7 +25,7 @@ export function ChargeableCharsPage() {
|
|||||||
|
|
||||||
// ── Data ──────────────────────────────────────────────────────────────────
|
// ── Data ──────────────────────────────────────────────────────────────────
|
||||||
const { data, isLoading } = useChargeableCharConfigs({
|
const { data, isLoading } = useChargeableCharConfigs({
|
||||||
medioId,
|
productTypeId: selectedProductTypeId,
|
||||||
activeOnly,
|
activeOnly,
|
||||||
page,
|
page,
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
@@ -51,7 +51,7 @@ export function ChargeableCharsPage() {
|
|||||||
onClick={() => setCopyFromConfig(data?.items[0] ?? null)}
|
onClick={() => setCopyFromConfig(data?.items[0] ?? null)}
|
||||||
disabled={!data?.items.length}
|
disabled={!data?.items.length}
|
||||||
>
|
>
|
||||||
Copiar a todos los medios
|
Copiar a todos los tipos
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
Nuevo carácter
|
Nuevo carácter
|
||||||
@@ -73,9 +73,9 @@ export function ChargeableCharsPage() {
|
|||||||
page={page}
|
page={page}
|
||||||
pageSize={DEFAULT_PAGE_SIZE}
|
pageSize={DEFAULT_PAGE_SIZE}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
medioId={medioId}
|
productTypeId={selectedProductTypeId}
|
||||||
activeOnly={activeOnly}
|
activeOnly={activeOnly}
|
||||||
onMedioChange={(v) => { setMedioId(v); setPage(1) }}
|
onProductTypeChange={(v) => { setSelectedProductTypeId(v); setPage(1) }}
|
||||||
onActiveOnlyChange={(v) => { setActiveOnly(v); setPage(1) }}
|
onActiveOnlyChange={(v) => { setActiveOnly(v); setPage(1) }}
|
||||||
onSchedulePrice={setScheduleConfig}
|
onSchedulePrice={setScheduleConfig}
|
||||||
onDeactivate={handleDeactivate}
|
onDeactivate={handleDeactivate}
|
||||||
@@ -97,9 +97,9 @@ export function ChargeableCharsPage() {
|
|||||||
onOpenChange={(open) => { if (!open) setScheduleConfig(null) }}
|
onOpenChange={(open) => { if (!open) setScheduleConfig(null) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Copy to all medios dialog */}
|
{/* Copy to all product types dialog */}
|
||||||
{copyFromConfig && (
|
{copyFromConfig && (
|
||||||
<CopyToAllMediaDialog
|
<CopyToAllProductTypesDialog
|
||||||
open={!!copyFromConfig}
|
open={!!copyFromConfig}
|
||||||
onOpenChange={(open) => { if (!open) setCopyFromConfig(null) }}
|
onOpenChange={(open) => { if (!open) setCopyFromConfig(null) }}
|
||||||
symbol={copyFromConfig.symbol}
|
symbol={copyFromConfig.symbol}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type ChargeableCharCategory =
|
|||||||
|
|
||||||
export interface ChargeableCharConfig {
|
export interface ChargeableCharConfig {
|
||||||
id: number
|
id: number
|
||||||
medioId: number | null
|
productTypeId: number | null
|
||||||
symbol: string
|
symbol: string
|
||||||
category: ChargeableCharCategory
|
category: ChargeableCharCategory
|
||||||
pricePerUnit: number
|
pricePerUnit: number
|
||||||
@@ -21,7 +21,7 @@ export interface ChargeableCharConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateChargeableCharConfigRequest {
|
export interface CreateChargeableCharConfigRequest {
|
||||||
medioId: number | null
|
productTypeId: number | null
|
||||||
symbol: string
|
symbol: string
|
||||||
category: ChargeableCharCategory
|
category: ChargeableCharCategory
|
||||||
pricePerUnit: number
|
pricePerUnit: number
|
||||||
@@ -41,7 +41,7 @@ export interface SchedulePriceChangeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChargeableCharConfigsQuery {
|
export interface ChargeableCharConfigsQuery {
|
||||||
medioId?: number
|
productTypeId?: number
|
||||||
activeOnly?: boolean
|
activeOnly?: boolean
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
@@ -53,3 +53,13 @@ export interface PagedResult<T> {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReactivateChargeableCharConfigResponse = {
|
||||||
|
id: number
|
||||||
|
symbol: string
|
||||||
|
productTypeId: number | null
|
||||||
|
pricePerUnit: number
|
||||||
|
validFrom: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteChargeableCharConfigResponse = { id: number }
|
||||||
|
|||||||
Reference in New Issue
Block a user