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,
|
Tag,
|
||||||
Layers,
|
Layers,
|
||||||
Package,
|
Package,
|
||||||
|
Hash,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -89,6 +90,12 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
requiredPermission: 'catalogo:productos:gestionar',
|
requiredPermission: 'catalogo:productos:gestionar',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Caracteres Tasables',
|
||||||
|
href: '/admin/tasacion/chargeable-chars',
|
||||||
|
icon: Hash,
|
||||||
|
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
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 { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||||
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||||
import { ProductsPage } from './features/products/pages/ProductsPage'
|
import { ProductsPage } from './features/products/pages/ProductsPage'
|
||||||
|
import { ChargeableCharsPage } from './features/chargeableChars/pages/ChargeableCharsPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user