diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index aa89dcf..785aeb7 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -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 { diff --git a/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx new file mode 100644 index 0000000..9f23d6c --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/ChargeableCharFormDialog.test.tsx @@ -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( + + + , + ) +} + +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 + 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 + expect(body['newValidFrom']).toBe('2026-04-25') + }, { timeout: 5000 }) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 }) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx b/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx new file mode 100644 index 0000000..1147d5e --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/ChargeableCharsTable.test.tsx @@ -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 { + 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( + + + , + ) +} + +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() + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx b/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx new file mode 100644 index 0000000..33a9231 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/CopyToAllMediaDialog.test.tsx @@ -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( + + + , + ) +} + +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)['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) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/SymbolInput.test.tsx b/src/web/src/features/chargeableChars/__tests__/SymbolInput.test.tsx new file mode 100644 index 0000000..36c0531 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/SymbolInput.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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) + }) +}) diff --git a/src/web/src/features/chargeableChars/__tests__/hooks.test.ts b/src/web/src/features/chargeableChars/__tests__/hooks.test.ts new file mode 100644 index 0000000..0e38ec3 --- /dev/null +++ b/src/web/src/features/chargeableChars/__tests__/hooks.test.ts @@ -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) + }) +}) diff --git a/src/web/src/features/chargeableChars/api/createChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/createChargeableCharConfig.ts new file mode 100644 index 0000000..36f2167 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/createChargeableCharConfig.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ChargeableCharConfig, CreateChargeableCharConfigRequest } from '../types' + +export async function createChargeableCharConfig( + payload: CreateChargeableCharConfigRequest, +): Promise { + const response = await axiosClient.post( + '/api/v1/admin/chargeable-chars', + payload, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/api/deactivateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/deactivateChargeableCharConfig.ts new file mode 100644 index 0000000..5b41c47 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/deactivateChargeableCharConfig.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateChargeableCharConfig(id: number): Promise { + await axiosClient.patch(`/api/v1/admin/chargeable-chars/${id}/deactivate`) +} diff --git a/src/web/src/features/chargeableChars/api/getChargeableCharConfig.ts b/src/web/src/features/chargeableChars/api/getChargeableCharConfig.ts new file mode 100644 index 0000000..23fdb11 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/getChargeableCharConfig.ts @@ -0,0 +1,9 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ChargeableCharConfig } from '../types' + +export async function getChargeableCharConfig(id: number): Promise { + const response = await axiosClient.get( + `/api/v1/admin/chargeable-chars/${id}`, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts b/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts new file mode 100644 index 0000000..cd1e0b3 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/listChargeableCharConfigs.ts @@ -0,0 +1,18 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ChargeableCharConfig, ChargeableCharConfigsQuery, PagedResult } from '../types' + +export async function listChargeableCharConfigs( + query: ChargeableCharConfigsQuery, +): Promise> { + 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>( + '/api/v1/admin/chargeable-chars', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/api/schedulePriceChange.ts b/src/web/src/features/chargeableChars/api/schedulePriceChange.ts new file mode 100644 index 0000000..0bc4250 --- /dev/null +++ b/src/web/src/features/chargeableChars/api/schedulePriceChange.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SchedulePriceChangeRequest, SchedulePriceChangeResponse } from '../types' + +export async function schedulePriceChange( + id: number, + payload: SchedulePriceChangeRequest, +): Promise { + const response = await axiosClient.put( + `/api/v1/admin/chargeable-chars/${id}/price`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/chargeableChars/categories.ts b/src/web/src/features/chargeableChars/categories.ts new file mode 100644 index 0000000..498aaa0 --- /dev/null +++ b/src/web/src/features/chargeableChars/categories.ts @@ -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 = { + Currency: 'Moneda ($)', + Percentage: 'Porcentaje (%)', + Exclamation: 'Exclamación (!)', + Question: 'Pregunta (?)', + Other: 'Otro', +} diff --git a/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx b/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx new file mode 100644 index 0000000..1d650ca --- /dev/null +++ b/src/web/src/features/chargeableChars/components/ChargeableCharFormDialog.tsx @@ -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({ + 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({ + 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) { + 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) { + scheduleMutation.mutate( + { + newPricePerUnit: values.pricePerUnit, + newValidFrom: values.validFrom, + }, + { + onSuccess: () => onOpenChange(false), + }, + ) + } + + const isPending = activeMutation.isPending + + return ( + + + + + {isSchedule ? 'Programar cambio de precio' : 'Nuevo carácter tasable'} + + + {isSchedule + ? `Programá un nuevo precio para "${config?.symbol}" a partir de la fecha elegida.` + : 'Completá los datos para crear un nuevo carácter tasable.'} + + + + {backendError && ( + + + {backendError} + + )} + + {/* ── CREATE MODE ─────────────────────────────────────────────────── */} + {!isSchedule && ( +
+ [0], + )} + className="space-y-4" + noValidate + > + {/* Símbolo */} + ( + + Símbolo + + + + {!fieldState.error && } + + )} + /> + + {/* Categoría */} + ( + + Categoría + + + + + + )} + /> + + {/* Precio */} + ( + + Precio por unidad + + + + + + )} + /> + + {/* Vigente desde */} + ( + + Vigente desde + + + + + + )} + /> + + + + + + + + )} + + {/* ── SCHEDULE PRICE MODE ─────────────────────────────────────────── */} + {isSchedule && ( +
+ [0], + )} + className="space-y-4" + noValidate + > + {/* Read-only info */} + {config && ( +
+
+ Símbolo: + {config.symbol} +
+
+ Categoría: + {CATEGORY_LABELS[config.category]} +
+
+ )} + + {/* Nuevo precio */} + ( + + Nuevo precio por unidad + + + + + + )} + /> + + {/* Vigente desde */} + ( + + Vigente desde + + + + + + )} + /> + + + + + + + + )} +
+
+ ) +} diff --git a/src/web/src/features/chargeableChars/components/ChargeableCharsTable.tsx b/src/web/src/features/chargeableChars/components/ChargeableCharsTable.tsx new file mode 100644 index 0000000..e05abb3 --- /dev/null +++ b/src/web/src/features/chargeableChars/components/ChargeableCharsTable.tsx @@ -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[]>( + () => [ + { + accessorKey: 'medioId', + header: 'Medio', + cell: ({ row }) => { + const mid = row.original.medioId + if (mid === null) return Global + const medio = medios.find((m) => m.id === mid) + return {medio?.nombre ?? `Medio ${mid}`} + }, + }, + { + accessorKey: 'symbol', + header: 'Símbolo', + cell: ({ row }) => ( + {row.original.symbol} + ), + }, + { + accessorKey: 'category', + header: 'Categoría', + cell: ({ row }) => ( + + {CATEGORY_LABELS[row.original.category] ?? row.original.category} + + ), + }, + { + accessorKey: 'pricePerUnit', + header: 'Precio/unidad', + cell: ({ row }) => ( + + {new Intl.NumberFormat('es-AR', { + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }).format(row.original.pricePerUnit)} + + ), + }, + { + accessorKey: 'validFrom', + header: 'Desde', + cell: ({ row }) => {formatCivilDate(row.original.validFrom)}, + }, + { + accessorKey: 'validTo', + header: 'Hasta', + cell: ({ row }) => ( + + {row.original.validTo ? formatCivilDate(row.original.validTo) : '—'} + + ), + }, + { + accessorKey: 'isActive', + header: 'Estado', + cell: ({ row }) => + row.original.isActive ? ( + + Vigente + + ) : ( + + Cerrada + + ), + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + + + onSchedulePrice(row.original)}> + Programar cambio de precio + + onDeactivate(row.original)} + className="text-destructive" + > + Desactivar + + + +
+ ), + }, + ], + [medios, onSchedulePrice, onDeactivate], + ) + + return ( +
+ {/* Filters */} +
+ + +
+ + +
+
+ + String(row.id)} + emptyMessage="Todavía no hay caracteres tasables configurados." + /> + + {/* Pagination */} +
+ + {total} resultado{total !== 1 ? 's' : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx b/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx new file mode 100644 index 0000000..04b3894 --- /dev/null +++ b/src/web/src/features/chargeableChars/components/CopyToAllMediaDialog.tsx @@ -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 ( + + + + Copiar a todos los medios + + Se creará una configuración para cada medio activo con los siguientes datos. + + + + {/* Preview */} +
+
+ Símbolo: + {symbol} +
+
+ Precio/unidad: + {pricePerUnit} +
+
+ Vigente desde: + {formatCivilDate(validFrom)} +
+
+ + {/* List of medios */} + {medios.length > 0 ? ( +
+ {medios.map((m) => ( +
+ + {m.nombre} +
+ ))} +
+ ) : ( +

Cargando medios...

+ )} + + + + + +
+
+ ) +} diff --git a/src/web/src/features/chargeableChars/components/SymbolInput.tsx b/src/web/src/features/chargeableChars/components/SymbolInput.tsx new file mode 100644 index 0000000..585000a --- /dev/null +++ b/src/web/src/features/chargeableChars/components/SymbolInput.tsx @@ -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) { + 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) { + const pasted = e.clipboardData.getData('text') + if (EMOJI_REGEX.test(pasted)) { + e.preventDefault() + } + // Normal paste proceeds (length clamping happens via onChange) + } + + return ( +
+ + {error && ( + + {error} + + )} +
+ ) +} diff --git a/src/web/src/features/chargeableChars/hooks/useChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useChargeableCharConfig.ts new file mode 100644 index 0000000..802f92f --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useChargeableCharConfig.ts @@ -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, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useChargeableCharConfigs.ts b/src/web/src/features/chargeableChars/hooks/useChargeableCharConfigs.ts new file mode 100644 index 0000000..0ada98e --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useChargeableCharConfigs.ts @@ -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, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useCreateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useCreateChargeableCharConfig.ts new file mode 100644 index 0000000..c21897a --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useCreateChargeableCharConfig.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useDeactivateChargeableCharConfig.ts b/src/web/src/features/chargeableChars/hooks/useDeactivateChargeableCharConfig.ts new file mode 100644 index 0000000..48c4e71 --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useDeactivateChargeableCharConfig.ts @@ -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] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/hooks/useSchedulePriceChange.ts b/src/web/src/features/chargeableChars/hooks/useSchedulePriceChange.ts new file mode 100644 index 0000000..d35673e --- /dev/null +++ b/src/web/src/features/chargeableChars/hooks/useSchedulePriceChange.ts @@ -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] }) + }, + }) +} diff --git a/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx b/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx new file mode 100644 index 0000000..d2120f3 --- /dev/null +++ b/src/web/src/features/chargeableChars/pages/ChargeableCharsPage.tsx @@ -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(undefined) + const [activeOnly, setActiveOnly] = useState(true) + + // ── Dialog state ────────────────────────────────────────────────────────── + const [createOpen, setCreateOpen] = useState(false) + const [scheduleConfig, setScheduleConfig] = useState(null) + const [deactivateId, setDeactivateId] = useState(null) + const [copyFromConfig, setCopyFromConfig] = useState(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 ( +
+
+

Caracteres Tasables

+ +
+ + +
+
+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : ( + { setMedioId(v); setPage(1) }} + onActiveOnlyChange={(v) => { setActiveOnly(v); setPage(1) }} + onSchedulePrice={setScheduleConfig} + onDeactivate={handleDeactivate} + /> + )} + + {/* Create dialog */} + + + {/* Schedule price dialog */} + { if (!open) setScheduleConfig(null) }} + /> + + {/* Copy to all medios dialog */} + {copyFromConfig && ( + { if (!open) setCopyFromConfig(null) }} + symbol={copyFromConfig.symbol} + pricePerUnit={copyFromConfig.pricePerUnit} + validFrom={copyFromConfig.validFrom} + category={copyFromConfig.category} + /> + )} +
+ ) +} diff --git a/src/web/src/features/chargeableChars/routes.tsx b/src/web/src/features/chargeableChars/routes.tsx new file mode 100644 index 0000000..6ecbf92 --- /dev/null +++ b/src/web/src/features/chargeableChars/routes.tsx @@ -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' diff --git a/src/web/src/features/chargeableChars/types.ts b/src/web/src/features/chargeableChars/types.ts new file mode 100644 index 0000000..8a487cc --- /dev/null +++ b/src/web/src/features/chargeableChars/types.ts @@ -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 { + items: T[] + page: number + pageSize: number + total: number +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 6ce3ea5..d6e0b2b 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -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 */} + + + + } + /> + } /> )