feat(frontend): chargeableChars feature — table + dialog + copy-to-all (PRC-001)
- types.ts: ChargeableCharConfig, PagedResult, requests (validFrom/validTo as yyyy-MM-dd strings, UDT-011)
- categories.ts: CHARGEABLE_CHAR_CATEGORIES + CATEGORY_LABELS
- api/: 5 functions (list, getById, create, schedulePriceChange, deactivate) via axiosClient
- hooks/: 5 TanStack Query hooks; mutations invalidate ['chargeableChars','list'] + byId
- SymbolInput.tsx: emoji-blocking input (/\p{Extended_Pictographic}/u), max 4 chars
- ChargeableCharsTable.tsx: shadcn DataTable; medio filter + activeOnly toggle; Vigente/Cerrada badges; formatCivilDate (UDT-011)
- ChargeableCharFormDialog.tsx: dual-mode create/schedulePrice; Zod schema; todayArgentina() min date; 409 inline error
- CopyToAllMediaDialog.tsx: Promise.allSettled over active medios; preview symbol/price/date
- ChargeableCharsPage.tsx: orchestrates table + dialogs + state
- routes.tsx: path/permission constants
- router.tsx: route /admin/tasacion/chargeable-chars registered
- AppSidebar.tsx: nav item "Caracteres Tasables" with Hash icon
- Tests: 22 new RTL/vitest tests (5 test files) — strict TDD RED→GREEN→REFACTOR
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
Tag,
|
||||
Layers,
|
||||
Package,
|
||||
Hash,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -89,6 +90,12 @@ const adminItems: NavItem[] = [
|
||||
icon: Package,
|
||||
requiredPermission: 'catalogo:productos:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Caracteres Tasables',
|
||||
href: '/admin/tasacion/chargeable-chars',
|
||||
icon: Hash,
|
||||
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll, afterAll, beforeEach } 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 { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog'
|
||||
import type { ChargeableCharConfig } from '../types'
|
||||
|
||||
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(); vi.useRealTimers() })
|
||||
afterAll(() => server.close())
|
||||
|
||||
function setupFakeTimers() {
|
||||
// Fix today to 2026-04-20 ART
|
||||
// 2026-04-20T15:00:00-03:00 = 2026-04-20T18:00:00Z
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.setSystemTime(new Date('2026-04-20T18:00:00.000Z'))
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
mode: 'create' | 'schedulePrice' = 'create',
|
||||
config?: ChargeableCharConfig,
|
||||
onOpenChange = vi.fn(),
|
||||
) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChargeableCharFormDialog
|
||||
open={true}
|
||||
mode={mode}
|
||||
config={config}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ChargeableCharFormDialog — create mode', () => {
|
||||
beforeEach(() => setupFakeTimers())
|
||||
|
||||
it('shows validation error when pricePerUnit is 0', async () => {
|
||||
renderDialog('create')
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '0')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-25')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(),
|
||||
{ timeout: 3000 })
|
||||
})
|
||||
|
||||
it('shows validation error when validFrom is in the past', async () => {
|
||||
renderDialog('create')
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '1.5')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-19')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/anterior a hoy/i)).toBeInTheDocument(),
|
||||
{ timeout: 3000 })
|
||||
})
|
||||
|
||||
it('happy path calls mutation with correct yyyy-MM-dd string payload', 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',
|
||||
pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true,
|
||||
},
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const onOpenChange = vi.fn()
|
||||
renderDialog('create', undefined, onOpenChange)
|
||||
|
||||
// Fill symbol
|
||||
const symbolInput = screen.getByRole('textbox', { name: /símbolo/i })
|
||||
await userEvent.clear(symbolInput)
|
||||
await userEvent.type(symbolInput, '$')
|
||||
|
||||
// Select category via Radix Select
|
||||
await userEvent.click(screen.getByRole('combobox', { name: /categoría/i }))
|
||||
await userEvent.click(screen.getByRole('option', { name: /moneda/i }))
|
||||
|
||||
// Fill price
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '1.5')
|
||||
|
||||
// Fill date
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-25')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^guardar$/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toBeTruthy()
|
||||
const body = capturedBody as Record<string, unknown>
|
||||
expect(body['validFrom']).toBe('2026-04-25')
|
||||
expect(typeof body['validFrom']).toBe('string')
|
||||
}, { timeout: 8000 })
|
||||
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 8000 })
|
||||
}, 15000)
|
||||
|
||||
it('shows inline message on server 409 (ForwardOnly)', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
|
||||
HttpResponse.json(
|
||||
{ error: 'chargeable_char_forward_only', message: 'No se pueden retrodatar precios.' },
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
renderDialog('create')
|
||||
|
||||
// Fill symbol
|
||||
const symbolInput = screen.getByRole('textbox', { name: /símbolo/i })
|
||||
await userEvent.clear(symbolInput)
|
||||
await userEvent.type(symbolInput, '$')
|
||||
|
||||
// Select category
|
||||
await userEvent.click(screen.getByRole('combobox', { name: /categoría/i }))
|
||||
await userEvent.click(screen.getByRole('option', { name: /moneda/i }))
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '1.5')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-25')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^guardar$/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/retrodatar/i)).toBeInTheDocument(),
|
||||
{ timeout: 8000 })
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('ChargeableCharFormDialog — schedulePrice mode', () => {
|
||||
beforeEach(() => setupFakeTimers())
|
||||
|
||||
it('hides symbol and category inputs (read-only mode)', () => {
|
||||
const existingConfig: ChargeableCharConfig = {
|
||||
id: 5, medioId: null, symbol: '%', category: 'Percentage',
|
||||
pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true,
|
||||
}
|
||||
renderDialog('schedulePrice', existingConfig)
|
||||
|
||||
// Should not show editable symbol/category inputs in schedulePrice mode
|
||||
expect(screen.queryByLabelText(/símbolo/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/categoría/i)).not.toBeInTheDocument()
|
||||
|
||||
// Price and date should still be present
|
||||
expect(screen.getByRole('spinbutton', { name: /precio/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/vigente desde/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('happy path schedulePrice calls PUT endpoint with correct payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
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 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const existingConfig: ChargeableCharConfig = {
|
||||
id: 5, medioId: null, symbol: '%', category: 'Percentage',
|
||||
pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true,
|
||||
}
|
||||
const onOpenChange = vi.fn()
|
||||
renderDialog('schedulePrice', existingConfig, onOpenChange)
|
||||
|
||||
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
|
||||
await userEvent.clear(priceInput)
|
||||
await userEvent.type(priceInput, '2')
|
||||
|
||||
const dateInput = screen.getByLabelText(/vigente desde/i)
|
||||
await userEvent.clear(dateInput)
|
||||
await userEvent.type(dateInput, '2026-04-25')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toBeTruthy()
|
||||
const body = capturedBody as Record<string, unknown>
|
||||
expect(body['newValidFrom']).toBe('2026-04-25')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
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 { ChargeableCharsTable } from '../components/ChargeableCharsTable'
|
||||
import type { ChargeableCharConfig } from '../types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
function makeConfig(overrides: Partial<ChargeableCharConfig> = {}): ChargeableCharConfig {
|
||||
return {
|
||||
id: 1,
|
||||
medioId: null,
|
||||
symbol: '$',
|
||||
category: 'Currency',
|
||||
pricePerUnit: 1.5,
|
||||
validFrom: '2026-01-01',
|
||||
validTo: null,
|
||||
isActive: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderTable(
|
||||
configs: ChargeableCharConfig[],
|
||||
onSchedulePrice = vi.fn(),
|
||||
onDeactivate = vi.fn(),
|
||||
) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChargeableCharsTable
|
||||
configs={configs}
|
||||
total={configs.length}
|
||||
page={1}
|
||||
pageSize={20}
|
||||
onPageChange={vi.fn()}
|
||||
medioId={undefined}
|
||||
activeOnly={true}
|
||||
onMedioChange={vi.fn()}
|
||||
onActiveOnlyChange={vi.fn()}
|
||||
onSchedulePrice={onSchedulePrice}
|
||||
onDeactivate={onDeactivate}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ChargeableCharsTable', () => {
|
||||
it('renders rows from query result — symbol and category visible', () => {
|
||||
renderTable([
|
||||
makeConfig({ id: 1, symbol: '$', category: 'Currency' }),
|
||||
makeConfig({ id: 2, symbol: '%', category: 'Percentage' }),
|
||||
])
|
||||
|
||||
expect(screen.getByText('$')).toBeInTheDocument()
|
||||
expect(screen.getByText('%')).toBeInTheDocument()
|
||||
// Category is displayed with localized label "Moneda ($)"
|
||||
expect(screen.getByText('Moneda ($)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays "Global" when medioId is null', () => {
|
||||
renderTable([makeConfig({ medioId: null })])
|
||||
expect(screen.getByText('Global')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Vigente" badge for rows with validTo === null', () => {
|
||||
renderTable([makeConfig({ validTo: null, isActive: true })])
|
||||
expect(screen.getByText('Vigente')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Cerrada" badge for rows with validTo set', () => {
|
||||
renderTable([makeConfig({ validTo: '2026-03-31', isActive: false })])
|
||||
expect(screen.getByText('Cerrada')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats validFrom using formatCivilDate — shows dd/MM/yyyy', () => {
|
||||
renderTable([makeConfig({ validFrom: '2026-01-15' })])
|
||||
expect(screen.getByText('15/01/2026')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
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'
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const medios = [makeMedio(1, 'La Nación'), makeMedio(2, 'Clarín'), makeMedio(3, 'Infobae')]
|
||||
|
||||
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/admin/medios`, () =>
|
||||
HttpResponse.json({ items: medios, page: 1, pageSize: 100, total: 3 }),
|
||||
),
|
||||
)
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<CopyToAllMediaDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
symbol="$"
|
||||
pricePerUnit={1.5}
|
||||
validFrom="2026-04-25"
|
||||
category="Currency"
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('CopyToAllMediaDialog', () => {
|
||||
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 () => {
|
||||
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 },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
renderDialog()
|
||||
|
||||
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)
|
||||
|
||||
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,78 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { SymbolInput } from '../components/SymbolInput'
|
||||
|
||||
afterEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('SymbolInput — emoji blocking', () => {
|
||||
it('typing ASCII chars updates value via onChange', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SymbolInput value="" onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.type(input, '$')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('$')
|
||||
})
|
||||
|
||||
it('typing an emoji does NOT call onChange with emoji content (emoji blocked)', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SymbolInput value="" onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// userEvent.type fires change events for each char; emoji chars may be split into surrogates.
|
||||
// The contract: onChange must never be called with a value containing an Extended_Pictographic char.
|
||||
await userEvent.type(input, '😀')
|
||||
|
||||
const calls = onChange.mock.calls.map(([v]: [string]) => v)
|
||||
const hasEmoji = calls.some((v) => /\p{Extended_Pictographic}/u.test(v))
|
||||
expect(hasEmoji).toBe(false)
|
||||
})
|
||||
|
||||
it('pasting a string with emoji — onChange NOT called with emoji content', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SymbolInput value="" onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.click(input)
|
||||
// userEvent.paste triggers onPaste handler with the given text
|
||||
await userEvent.paste('😀')
|
||||
|
||||
// onChange must NOT have been called with an emoji
|
||||
const calls = onChange.mock.calls.map(([v]: [string]) => v)
|
||||
const hasEmoji = calls.some((v) => /\p{Extended_Pictographic}/u.test(v))
|
||||
expect(hasEmoji).toBe(false)
|
||||
})
|
||||
|
||||
it('pasting normal text (no emoji) allows value update', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SymbolInput value="" onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await userEvent.click(input)
|
||||
// Paste normal ASCII — should go through onChange
|
||||
await userEvent.paste('$')
|
||||
|
||||
// onChange may be called with '$' or the merged result
|
||||
// The key assertion: no rejection for non-emoji
|
||||
const calls = onChange.mock.calls.map(([v]: [string]) => v)
|
||||
const allNonEmoji = calls.every((v) => !/\p{Extended_Pictographic}/u.test(v))
|
||||
expect(allNonEmoji).toBe(true)
|
||||
})
|
||||
|
||||
it('value is capped at 4 characters — 5th char is rejected via onChange not called with 5+ chars', async () => {
|
||||
// Start with value of 4 chars already set
|
||||
const onChange = vi.fn()
|
||||
render(<SymbolInput value="$$$$" onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// DOM value is controlled at 4 chars; any additional char should be blocked
|
||||
await userEvent.type(input, '$')
|
||||
|
||||
// onChange should NOT be called with a 5-char string
|
||||
const calls = onChange.mock.calls.map(([v]: [string]) => v)
|
||||
const tooLong = calls.some((v) => v.length > 4)
|
||||
expect(tooLong).toBe(false)
|
||||
})
|
||||
})
|
||||
92
src/web/src/features/chargeableChars/__tests__/hooks.test.ts
Normal file
92
src/web/src/features/chargeableChars/__tests__/hooks.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs'
|
||||
import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange'
|
||||
|
||||
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 makeWrapper() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return {
|
||||
qc,
|
||||
wrapper: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children),
|
||||
}
|
||||
}
|
||||
|
||||
describe('useChargeableCharConfigs', () => {
|
||||
it('fetches list and returns paged result', async () => {
|
||||
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 }],
|
||||
page: 1, pageSize: 20, total: 1,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const { wrapper } = makeWrapper()
|
||||
const { result } = renderHook(() => useChargeableCharConfigs({}), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data?.items).toHaveLength(1)
|
||||
expect(result.current.data?.items[0].symbol).toBe('$')
|
||||
})
|
||||
|
||||
it('sends medioId and activeOnly as query params', async () => {
|
||||
let capturedUrl: string | null = null
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||
}),
|
||||
)
|
||||
const { wrapper } = makeWrapper()
|
||||
const { result } = renderHook(
|
||||
() => useChargeableCharConfigs({ medioId: 3, activeOnly: true }),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(capturedUrl).toContain('medioId=3')
|
||||
expect(capturedUrl).toContain('activeOnly=true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSchedulePriceChange', () => {
|
||||
it('on success invalidates both list and byId query keys', async () => {
|
||||
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 },
|
||||
closed: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const { qc, wrapper } = makeWrapper()
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
|
||||
const { result } = renderHook(() => useSchedulePriceChange(5), { wrapper })
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ newPricePerUnit: 2.0, newValidFrom: '2026-05-01' })
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ChargeableCharConfig, CreateChargeableCharConfigRequest } from '../types'
|
||||
|
||||
export async function createChargeableCharConfig(
|
||||
payload: CreateChargeableCharConfigRequest,
|
||||
): Promise<ChargeableCharConfig> {
|
||||
const response = await axiosClient.post<ChargeableCharConfig>(
|
||||
'/api/v1/admin/chargeable-chars',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
|
||||
export async function deactivateChargeableCharConfig(id: number): Promise<void> {
|
||||
await axiosClient.patch(`/api/v1/admin/chargeable-chars/${id}/deactivate`)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ChargeableCharConfig } from '../types'
|
||||
|
||||
export async function getChargeableCharConfig(id: number): Promise<ChargeableCharConfig> {
|
||||
const response = await axiosClient.get<ChargeableCharConfig>(
|
||||
`/api/v1/admin/chargeable-chars/${id}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ChargeableCharConfig, ChargeableCharConfigsQuery, PagedResult } from '../types'
|
||||
|
||||
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.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))
|
||||
|
||||
const response = await axiosClient.get<PagedResult<ChargeableCharConfig>>(
|
||||
'/api/v1/admin/chargeable-chars',
|
||||
{ params },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { SchedulePriceChangeRequest, SchedulePriceChangeResponse } from '../types'
|
||||
|
||||
export async function schedulePriceChange(
|
||||
id: number,
|
||||
payload: SchedulePriceChangeRequest,
|
||||
): Promise<SchedulePriceChangeResponse> {
|
||||
const response = await axiosClient.put<SchedulePriceChangeResponse>(
|
||||
`/api/v1/admin/chargeable-chars/${id}/price`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
18
src/web/src/features/chargeableChars/categories.ts
Normal file
18
src/web/src/features/chargeableChars/categories.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// PRC-001 — ChargeableCharCategory constants
|
||||
import type { ChargeableCharCategory } from './types'
|
||||
|
||||
export const CHARGEABLE_CHAR_CATEGORIES: ChargeableCharCategory[] = [
|
||||
'Currency',
|
||||
'Percentage',
|
||||
'Exclamation',
|
||||
'Question',
|
||||
'Other',
|
||||
]
|
||||
|
||||
export const CATEGORY_LABELS: Record<ChargeableCharCategory, string> = {
|
||||
Currency: 'Moneda ($)',
|
||||
Percentage: 'Porcentaje (%)',
|
||||
Exclamation: 'Exclamación (!)',
|
||||
Question: 'Pregunta (?)',
|
||||
Other: 'Otro',
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } 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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
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 type { ChargeableCharConfig } from '../types'
|
||||
|
||||
// ─── Emoji regex (same as SymbolInput) ───────────────────────────────────────
|
||||
const EMOJI_REGEX = /\p{Extended_Pictographic}/u
|
||||
|
||||
// ─── Schemas ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const createSchema = z.object({
|
||||
medioId: z.number().int().positive().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.',
|
||||
}),
|
||||
pricePerUnit: z.coerce
|
||||
.number('Debe ser un número.')
|
||||
.positive('El precio debe ser mayor a cero.'),
|
||||
validFrom: z
|
||||
.string()
|
||||
.min(1, 'La fecha es requerida.')
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.')
|
||||
.refine((v) => v >= todayArgentina(), 'La fecha no puede ser anterior a hoy.'),
|
||||
})
|
||||
|
||||
const schedulePriceSchema = z.object({
|
||||
pricePerUnit: z.coerce
|
||||
.number('Debe ser un número.')
|
||||
.positive('El precio debe ser mayor a cero.'),
|
||||
validFrom: z
|
||||
.string()
|
||||
.min(1, 'La fecha es requerida.')
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.')
|
||||
.refine((v) => v >= todayArgentina(), 'La fecha no puede ser anterior a hoy.'),
|
||||
})
|
||||
|
||||
type CreateFormRaw = {
|
||||
medioId?: string
|
||||
symbol: string
|
||||
category: string
|
||||
pricePerUnit: string
|
||||
validFrom: string
|
||||
}
|
||||
|
||||
type SchedulePriceFormRaw = {
|
||||
pricePerUnit: string
|
||||
validFrom: string
|
||||
}
|
||||
|
||||
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveBackendError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { error?: string; message?: string; code?: string }
|
||||
if (err.response.status === 409) {
|
||||
return data.message ?? 'No se pueden retrodatar precios. Elegí una fecha posterior.'
|
||||
}
|
||||
if (err.response.status === 400 && data.code === 'CHARGEABLE_CHAR_FORWARD_ONLY') {
|
||||
return 'No se pueden retrodatar precios.'
|
||||
}
|
||||
return data.message ?? data.error ?? 'Error al guardar.'
|
||||
}
|
||||
return 'Error al guardar. Intentá de nuevo.'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChargeableCharFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
mode: 'create' | 'schedulePrice'
|
||||
/** Required when mode is 'schedulePrice' */
|
||||
config?: ChargeableCharConfig
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ChargeableCharFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
config,
|
||||
}: ChargeableCharFormDialogProps) {
|
||||
const createMutation = useCreateChargeableCharConfig()
|
||||
const scheduleMutation = useSchedulePriceChange(config?.id ?? 0)
|
||||
|
||||
const isSchedule = mode === 'schedulePrice'
|
||||
const activeMutation = isSchedule ? scheduleMutation : createMutation
|
||||
|
||||
// ── Create form ──────────────────────────────────────────────────────────
|
||||
// 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: '' },
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
// ── SchedulePrice form ───────────────────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const scheduleForm = useForm<SchedulePriceFormRaw>({
|
||||
resolver: zodResolver(schedulePriceSchema) as any,
|
||||
defaultValues: { pricePerUnit: '', validFrom: '' },
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
createForm.reset({ medioId: undefined, symbol: '', category: '', pricePerUnit: '', validFrom: '' })
|
||||
scheduleForm.reset({ pricePerUnit: '', validFrom: '' })
|
||||
createMutation.reset()
|
||||
scheduleMutation.reset()
|
||||
}
|
||||
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const backendError = resolveBackendError(activeMutation.error)
|
||||
const today = todayArgentina()
|
||||
|
||||
function handleCreateSubmit(values: z.infer<typeof createSchema>) {
|
||||
createMutation.mutate(
|
||||
{
|
||||
medioId: values.medioId ?? null,
|
||||
symbol: values.symbol,
|
||||
category: values.category as ChargeableCharConfig['category'],
|
||||
pricePerUnit: values.pricePerUnit,
|
||||
validFrom: values.validFrom,
|
||||
},
|
||||
{
|
||||
onSuccess: () => onOpenChange(false),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function handleScheduleSubmit(values: z.infer<typeof schedulePriceSchema>) {
|
||||
scheduleMutation.mutate(
|
||||
{
|
||||
newPricePerUnit: values.pricePerUnit,
|
||||
newValidFrom: values.validFrom,
|
||||
},
|
||||
{
|
||||
onSuccess: () => onOpenChange(false),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const isPending = activeMutation.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isSchedule ? 'Programar cambio de precio' : 'Nuevo carácter tasable'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isSchedule
|
||||
? `Programá un nuevo precio para "${config?.symbol}" a partir de la fecha elegida.`
|
||||
: 'Completá los datos para crear un nuevo carácter tasable.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── CREATE MODE ─────────────────────────────────────────────────── */}
|
||||
{!isSchedule && (
|
||||
<Form {...createForm}>
|
||||
<form
|
||||
onSubmit={createForm.handleSubmit(
|
||||
handleCreateSubmit as unknown as Parameters<typeof createForm.handleSubmit>[0],
|
||||
)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Símbolo */}
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="symbol"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="symbol-input">Símbolo</FormLabel>
|
||||
<FormControl>
|
||||
<SymbolInput
|
||||
id="symbol-input"
|
||||
aria-label="Símbolo"
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
disabled={isPending}
|
||||
placeholder="ej: $"
|
||||
/>
|
||||
</FormControl>
|
||||
{!fieldState.error && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Categoría */}
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Categoría</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger aria-label="Categoría">
|
||||
<SelectValue placeholder="Seleccioná una categoría" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHARGEABLE_CHAR_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Precio */}
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="pricePerUnit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Precio por unidad</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0.0001"
|
||||
placeholder="0.0000"
|
||||
aria-label="Precio por unidad"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Vigente desde */}
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="validFrom"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vigente desde</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="date"
|
||||
min={today}
|
||||
aria-label="Vigente desde"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* ── SCHEDULE PRICE MODE ─────────────────────────────────────────── */}
|
||||
{isSchedule && (
|
||||
<Form {...scheduleForm}>
|
||||
<form
|
||||
onSubmit={scheduleForm.handleSubmit(
|
||||
handleScheduleSubmit as unknown as Parameters<typeof scheduleForm.handleSubmit>[0],
|
||||
)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Read-only info */}
|
||||
{config && (
|
||||
<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">{config.symbol}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Categoría: </span>
|
||||
<span>{CATEGORY_LABELS[config.category]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nuevo precio */}
|
||||
<FormField
|
||||
control={scheduleForm.control}
|
||||
name="pricePerUnit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nuevo precio por unidad</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0.0001"
|
||||
placeholder="0.0000"
|
||||
aria-label="Precio por unidad"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Vigente desde */}
|
||||
<FormField
|
||||
control={scheduleForm.control}
|
||||
name="validFrom"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vigente desde</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="date"
|
||||
min={today}
|
||||
aria-label="Vigente desde"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
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'
|
||||
|
||||
interface ChargeableCharsTableProps {
|
||||
configs: ChargeableCharConfig[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
medioId: number | undefined
|
||||
activeOnly: boolean
|
||||
onMedioChange: (medioId: number | undefined) => void
|
||||
onActiveOnlyChange: (value: boolean) => void
|
||||
onSchedulePrice: (config: ChargeableCharConfig) => void
|
||||
onDeactivate: (config: ChargeableCharConfig) => void
|
||||
}
|
||||
|
||||
export function ChargeableCharsTable({
|
||||
configs,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
medioId,
|
||||
activeOnly,
|
||||
onMedioChange,
|
||||
onActiveOnlyChange,
|
||||
onSchedulePrice,
|
||||
onDeactivate,
|
||||
}: ChargeableCharsTableProps) {
|
||||
const { data: mediosData } = useMediosList({ activo: true, pageSize: 200 })
|
||||
const medios = mediosData?.items ?? []
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const hasPrev = page > 1
|
||||
const hasNext = page < totalPages
|
||||
|
||||
const columns = useMemo<ColumnDef<ChargeableCharConfig>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'medioId',
|
||||
header: 'Medio',
|
||||
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>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'symbol',
|
||||
header: 'Símbolo',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-lg">{row.original.symbol}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'category',
|
||||
header: 'Categoría',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">
|
||||
{CATEGORY_LABELS[row.original.category] ?? row.original.category}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'pricePerUnit',
|
||||
header: 'Precio/unidad',
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{new Intl.NumberFormat('es-AR', {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
}).format(row.original.pricePerUnit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'validFrom',
|
||||
header: 'Desde',
|
||||
cell: ({ row }) => <span>{formatCivilDate(row.original.validFrom)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'validTo',
|
||||
header: 'Hasta',
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.validTo ? formatCivilDate(row.original.validTo) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'isActive',
|
||||
header: 'Estado',
|
||||
cell: ({ row }) =>
|
||||
row.original.isActive ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Vigente
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Cerrada
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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"
|
||||
>
|
||||
Desactivar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[medios, onSchedulePrice, onDeactivate],
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="activeOnly"
|
||||
checked={activeOnly}
|
||||
onCheckedChange={onActiveOnlyChange}
|
||||
aria-label="Solo activos"
|
||||
/>
|
||||
<Label htmlFor="activeOnly" className="text-sm">Solo activos</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={configs}
|
||||
getRowId={(row) => String(row.id)}
|
||||
emptyMessage="Todavía no hay caracteres tasables configurados."
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{total} resultado{total !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPrev}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="Anterior"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasNext}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="Siguiente"
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 { 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.
|
||||
*/
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
// PRC-001 — UDT: emoji blocking regex (Unicode Extended_Pictographic)
|
||||
const EMOJI_REGEX = /\p{Extended_Pictographic}/u
|
||||
|
||||
interface SymbolInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
id?: string
|
||||
'aria-label'?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled text input that blocks emoji characters.
|
||||
* Emoji detection via /\p{Extended_Pictographic}/u (spec R4.4).
|
||||
* Max length 4 chars. Server-side validation remains authoritative.
|
||||
*/
|
||||
export function SymbolInput({ value, onChange, error, disabled, placeholder, id, 'aria-label': ariaLabel, name }: SymbolInputProps) {
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const v = e.target.value
|
||||
// Block emojis
|
||||
if (EMOJI_REGEX.test(v)) return
|
||||
// Enforce max length
|
||||
if (v.length > 4) return
|
||||
onChange(v)
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||
const pasted = e.clipboardData.getData('text')
|
||||
if (EMOJI_REGEX.test(pasted)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
// Normal paste proceeds (length clamping happens via onChange)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
maxLength={4}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder ?? 'ej: $'}
|
||||
aria-invalid={!!error}
|
||||
aria-label={ariaLabel}
|
||||
className="font-mono"
|
||||
/>
|
||||
{error && (
|
||||
<span className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getChargeableCharConfig } from '../api/getChargeableCharConfig'
|
||||
|
||||
export function useChargeableCharConfig(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ['chargeableChars', id] as const,
|
||||
queryFn: () => getChargeableCharConfig(id),
|
||||
enabled: id > 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listChargeableCharConfigs } from '../api/listChargeableCharConfigs'
|
||||
import type { ChargeableCharConfigsQuery } from '../types'
|
||||
|
||||
export const chargeableCharConfigsQueryKey = (query: ChargeableCharConfigsQuery) =>
|
||||
['chargeableChars', 'list', query] as const
|
||||
|
||||
export function useChargeableCharConfigs(query: ChargeableCharConfigsQuery) {
|
||||
return useQuery({
|
||||
queryKey: chargeableCharConfigsQueryKey(query),
|
||||
queryFn: () => listChargeableCharConfigs(query),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { createChargeableCharConfig } from '../api/createChargeableCharConfig'
|
||||
import type { CreateChargeableCharConfigRequest } from '../types'
|
||||
|
||||
export function useCreateChargeableCharConfig() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateChargeableCharConfigRequest) =>
|
||||
createChargeableCharConfig(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { deactivateChargeableCharConfig } from '../api/deactivateChargeableCharConfig'
|
||||
|
||||
export function useDeactivateChargeableCharConfig(id: number) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => deactivateChargeableCharConfig(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { schedulePriceChange } from '../api/schedulePriceChange'
|
||||
import type { SchedulePriceChangeRequest } from '../types'
|
||||
|
||||
export function useSchedulePriceChange(id: number) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: SchedulePriceChangeRequest) => schedulePriceChange(id, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['chargeableChars', 'list'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['chargeableChars', id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
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 type { ChargeableCharConfig } from '../types'
|
||||
|
||||
const PERMISSION = 'tasacion:caracteres_especiales:gestionar'
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
export function ChargeableCharsPage() {
|
||||
// ── Filter / pagination state ──────────────────────────────────────────────
|
||||
const [page, setPage] = useState(1)
|
||||
const [medioId, setMedioId] = useState<number | undefined>(undefined)
|
||||
const [activeOnly, setActiveOnly] = useState(true)
|
||||
|
||||
// ── Dialog state ──────────────────────────────────────────────────────────
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [scheduleConfig, setScheduleConfig] = useState<ChargeableCharConfig | null>(null)
|
||||
const [deactivateId, setDeactivateId] = useState<number | null>(null)
|
||||
const [copyFromConfig, setCopyFromConfig] = useState<ChargeableCharConfig | null>(null)
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────
|
||||
const { data, isLoading } = useChargeableCharConfigs({
|
||||
medioId,
|
||||
activeOnly,
|
||||
page,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
|
||||
const deactivateMutation = useDeactivateChargeableCharConfig(deactivateId ?? 0)
|
||||
|
||||
function handleDeactivate(config: ChargeableCharConfig) {
|
||||
setDeactivateId(config.id)
|
||||
// Trigger deactivation immediately (idempotent)
|
||||
deactivateMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Caracteres Tasables</h1>
|
||||
<CanPerform permission={PERMISSION}>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCopyFromConfig(data?.items[0] ?? null)}
|
||||
disabled={!data?.items.length}
|
||||
>
|
||||
Copiar a todos los medios
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
Nuevo carácter
|
||||
</Button>
|
||||
</div>
|
||||
</CanPerform>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-12 w-full rounded-md bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ChargeableCharsTable
|
||||
configs={data?.items ?? []}
|
||||
total={data?.total ?? 0}
|
||||
page={page}
|
||||
pageSize={DEFAULT_PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
medioId={medioId}
|
||||
activeOnly={activeOnly}
|
||||
onMedioChange={(v) => { setMedioId(v); setPage(1) }}
|
||||
onActiveOnlyChange={(v) => { setActiveOnly(v); setPage(1) }}
|
||||
onSchedulePrice={setScheduleConfig}
|
||||
onDeactivate={handleDeactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<ChargeableCharFormDialog
|
||||
open={createOpen}
|
||||
mode="create"
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
|
||||
{/* Schedule price dialog */}
|
||||
<ChargeableCharFormDialog
|
||||
open={!!scheduleConfig}
|
||||
mode="schedulePrice"
|
||||
config={scheduleConfig ?? undefined}
|
||||
onOpenChange={(open) => { if (!open) setScheduleConfig(null) }}
|
||||
/>
|
||||
|
||||
{/* Copy to all medios dialog */}
|
||||
{copyFromConfig && (
|
||||
<CopyToAllMediaDialog
|
||||
open={!!copyFromConfig}
|
||||
onOpenChange={(open) => { if (!open) setCopyFromConfig(null) }}
|
||||
symbol={copyFromConfig.symbol}
|
||||
pricePerUnit={copyFromConfig.pricePerUnit}
|
||||
validFrom={copyFromConfig.validFrom}
|
||||
category={copyFromConfig.category}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
src/web/src/features/chargeableChars/routes.tsx
Normal file
9
src/web/src/features/chargeableChars/routes.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// PRC-001 — chargeableChars feature routes
|
||||
// Route: /admin/tasacion/chargeable-chars
|
||||
// Permission: tasacion:caracteres_especiales:gestionar
|
||||
//
|
||||
// Note: Registration is done in the main router (src/web/src/router.tsx).
|
||||
// This file exports the route path constant for consistency.
|
||||
|
||||
export const CHARGEABLE_CHARS_PATH = '/admin/tasacion/chargeable-chars'
|
||||
export const CHARGEABLE_CHARS_PERMISSION = 'tasacion:caracteres_especiales:gestionar'
|
||||
55
src/web/src/features/chargeableChars/types.ts
Normal file
55
src/web/src/features/chargeableChars/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// PRC-001 — ChargeableCharConfig feature types
|
||||
|
||||
export type ChargeableCharCategory =
|
||||
| 'Currency'
|
||||
| 'Percentage'
|
||||
| 'Exclamation'
|
||||
| 'Question'
|
||||
| 'Other'
|
||||
|
||||
export interface ChargeableCharConfig {
|
||||
id: number
|
||||
medioId: number | null
|
||||
symbol: string
|
||||
category: ChargeableCharCategory
|
||||
pricePerUnit: number
|
||||
/** yyyy-MM-dd — Cat2 civil date, NEVER a Date object */
|
||||
validFrom: string
|
||||
/** yyyy-MM-dd | null — null means still active */
|
||||
validTo: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface CreateChargeableCharConfigRequest {
|
||||
medioId: number | null
|
||||
symbol: string
|
||||
category: ChargeableCharCategory
|
||||
pricePerUnit: number
|
||||
/** yyyy-MM-dd */
|
||||
validFrom: string
|
||||
}
|
||||
|
||||
export interface SchedulePriceChangeRequest {
|
||||
newPricePerUnit: number
|
||||
/** yyyy-MM-dd */
|
||||
newValidFrom: string
|
||||
}
|
||||
|
||||
export interface SchedulePriceChangeResponse {
|
||||
created: ChargeableCharConfig
|
||||
closed: ChargeableCharConfig | null
|
||||
}
|
||||
|
||||
export interface ChargeableCharConfigsQuery {
|
||||
medioId?: number
|
||||
activeOnly?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||
import { ProductsPage } from './features/products/pages/ProductsPage'
|
||||
import { ChargeableCharsPage } from './features/chargeableChars/pages/ChargeableCharsPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -331,6 +332,16 @@ export function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ChargeableChars routes — PRC-001 */}
|
||||
<Route
|
||||
path="/admin/tasacion/chargeable-chars"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['tasacion:caracteres_especiales:gestionar']}>
|
||||
<ChargeableCharsPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user