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:
2026-04-20 12:59:27 -03:00
parent 8fc7b363d5
commit c2a0612a70
25 changed files with 1779 additions and 0 deletions

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
}

View File

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

View File

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

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { formatCivilDate } from '@/lib/formatters'
import { 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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'

View 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
}

View File

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