From a3a15a411851e5e7ea41b8e9d96a4ee1debc44ef Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 18:55:57 -0300 Subject: [PATCH] test+feat(web/adm-009): iibb subfeature mirror de iva Types (ProvinciaArgentina 24 valores + PROVINCIA_DISPLAY), iibbApi.ts, useIngresosBrutos hooks, tabla con columna Provincia, FormModal sin Alicuota en edit [REQ-UI-007], NuevaVigenciaIibbModal con preview, TiposDeIibbPage con banner. 8 tests RTL pasan (iibb). Total fiscal: 47/47 tests. --- .../src/features/fiscal/iibb/api/iibbApi.ts | 73 ++++ .../components/HistorialCadenaIibbTooltip.tsx | 65 ++++ .../components/IngresosBrutosFormModal.tsx | 311 ++++++++++++++++++ .../iibb/components/IngresosBrutosTable.tsx | 191 +++++++++++ .../components/NuevaVigenciaIibbModal.tsx | 246 ++++++++++++++ .../fiscal/iibb/hooks/useIngresosBrutos.ts | 121 +++++++ .../fiscal/iibb/pages/TiposDeIibbPage.tsx | 203 ++++++++++++ .../fiscal/iibb/types/ingresosBrutos.types.ts | 127 +++++++ .../iibb/IngresosBrutosFormModal.test.tsx | 93 ++++++ .../fiscal/iibb/TiposDeIibbPage.test.tsx | 123 +++++++ 10 files changed, 1553 insertions(+) create mode 100644 src/web/src/features/fiscal/iibb/api/iibbApi.ts create mode 100644 src/web/src/features/fiscal/iibb/components/HistorialCadenaIibbTooltip.tsx create mode 100644 src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx create mode 100644 src/web/src/features/fiscal/iibb/components/IngresosBrutosTable.tsx create mode 100644 src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx create mode 100644 src/web/src/features/fiscal/iibb/hooks/useIngresosBrutos.ts create mode 100644 src/web/src/features/fiscal/iibb/pages/TiposDeIibbPage.tsx create mode 100644 src/web/src/features/fiscal/iibb/types/ingresosBrutos.types.ts create mode 100644 src/web/src/tests/features/fiscal/iibb/IngresosBrutosFormModal.test.tsx create mode 100644 src/web/src/tests/features/fiscal/iibb/TiposDeIibbPage.test.tsx diff --git a/src/web/src/features/fiscal/iibb/api/iibbApi.ts b/src/web/src/features/fiscal/iibb/api/iibbApi.ts new file mode 100644 index 0000000..129f07b --- /dev/null +++ b/src/web/src/features/fiscal/iibb/api/iibbApi.ts @@ -0,0 +1,73 @@ +// ADM-009 — API client tipado para fiscal/iibb +import { axiosClient } from '@/api/axiosClient' +import type { + IngresosBrutos, + CreateIngresosBrutosRequest, + UpdateIngresosBrutosRequest, + NuevaVersionIngresosBrutosRequest, + NuevaVersionIibbResponse, + HistorialCadenaIibbEntry, + IngresosBrutosFilter, + PagedResponse, +} from '../types/ingresosBrutos.types' + +const BASE = '/api/v1/admin/fiscal/iibb' + +export async function listIngresosBrutos( + params: IngresosBrutosFilter, +): Promise> { + const p = new URLSearchParams() + if (params.page !== undefined) p.set('page', String(params.page)) + if (params.pageSize !== undefined) p.set('pageSize', String(params.pageSize)) + if (params.provincia !== undefined) p.set('provincia', params.provincia) + if (params.activo !== undefined) p.set('activo', String(params.activo)) + + const res = await axiosClient.get>(BASE, { params: p }) + return res.data +} + +export async function getIngresosBrutosById(id: number): Promise { + const res = await axiosClient.get(`${BASE}/${id}`) + return res.data +} + +export async function getHistorialIngresosBrutos(id: number): Promise { + const res = await axiosClient.get(`${BASE}/${id}/historial`) + return res.data +} + +export async function createIngresosBrutos( + body: CreateIngresosBrutosRequest, +): Promise { + const res = await axiosClient.post(BASE, body) + return res.data +} + +export async function updateIngresosBrutos( + id: number, + body: UpdateIngresosBrutosRequest, +): Promise { + const res = await axiosClient.patch(`${BASE}/${id}`, body) + return res.data +} + +export async function nuevaVersionIngresosBrutos( + id: number, + body: NuevaVersionIngresosBrutosRequest, +): Promise { + const res = await axiosClient.post( + `${BASE}/${id}/nueva-version`, + body, + ) + return res.data +} + +export async function deactivateIngresosBrutos(id: number): Promise { + const res = await axiosClient.post(`${BASE}/${id}/deactivate`) + return res.data +} + +export async function reactivateIngresosBrutos(id: number): Promise { + const res = await axiosClient.post(`${BASE}/${id}/reactivate`) + return res.data +} diff --git a/src/web/src/features/fiscal/iibb/components/HistorialCadenaIibbTooltip.tsx b/src/web/src/features/fiscal/iibb/components/HistorialCadenaIibbTooltip.tsx new file mode 100644 index 0000000..da8d29e --- /dev/null +++ b/src/web/src/features/fiscal/iibb/components/HistorialCadenaIibbTooltip.tsx @@ -0,0 +1,65 @@ +// T600.27 (IIBB) — HistorialCadenaIibbTooltip +// Espejo de HistorialCadenaTooltip para IIBB +import { useState } from 'react' +import { History } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { useHistorialIngresosBrutos } from '../hooks/useIngresosBrutos' + +interface HistorialCadenaIibbTooltipProps { + id: number +} + +function formatVigencia(desde: string, hasta: string | null): string { + return hasta ? `${desde} → ${hasta}` : `${desde} → ahora` +} + +export function HistorialCadenaIibbTooltip({ id }: HistorialCadenaIibbTooltipProps) { + const [enabled, setEnabled] = useState(false) + + const { data: historial, isLoading } = useHistorialIngresosBrutos(id, enabled) + + return ( + + + + + + {!enabled || isLoading ? ( + Cargando historial... + ) : !historial || historial.length === 0 ? ( + Sin historial + ) : ( +
+

Historial de versiones

+ {historial.map((entry, idx) => ( +
+ + v{idx + 1} ({entry.alicuota}%) + + + [{formatVigencia(entry.vigenciaDesde, entry.vigenciaHasta)}] + + {idx < historial.length - 1 && ( + + )} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx b/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx new file mode 100644 index 0000000..c1b75ae --- /dev/null +++ b/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx @@ -0,0 +1,311 @@ +// T600.25 (IIBB) — IngresosBrutosFormModal +// Modal de edición / creación de IngresosBrutos +// CRÍTICO: NO incluye campo Alícuota en modo edit (inmutable, cambiar via NuevaVersion) +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 { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} 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 { useCreateIngresosBrutos, useUpdateIngresosBrutos } from '../hooks/useIngresosBrutos' +import { PROVINCIAS, PROVINCIA_DISPLAY } from '../types/ingresosBrutos.types' +import type { IngresosBrutos, ProvinciaArgentina } from '../types/ingresosBrutos.types' +import { toast } from 'sonner' + +const formSchema = z.object({ + provincia: z.string().min(1, 'La provincia es requerida'), + descripcion: z + .string() + .min(1, 'La descripción es requerida') + .max(200, 'Máximo 200 caracteres'), + activo: z.boolean(), + alicuotaCreate: z.coerce + .number({ invalid_type_error: 'Debe ser un número' }) + .min(0, 'Mínimo 0%') + .max(100, 'Máximo 100%') + .optional(), + vigenciaDesde: z.string().optional(), +}) + +type FormValues = z.infer + +interface IngresosBrutosFormModalProps { + open: boolean + item: IngresosBrutos | null // null = modo create + onClose: () => void + onSuccess: () => void +} + +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 } + if (data.error === 'inmutable_usar_nueva_version') { + return 'Para cambiar la alícuota usá el botón "Nueva vigencia" en lugar de "Editar".' + } + if (data.error === 'duplicate_provincia') { + return data.message ?? 'Ya existe un registro para esa provincia' + } + return data.message ?? data.error ?? 'Error al guardar' + } + return 'Error al guardar. Intentá de nuevo.' +} + +export function IngresosBrutosFormModal({ + open, + item, + onClose, + onSuccess, +}: IngresosBrutosFormModalProps) { + const isEdit = item != null + const createMutation = useCreateIngresosBrutos() + const updateMutation = useUpdateIngresosBrutos() + + const isPending = createMutation.isPending || updateMutation.isPending + const error = createMutation.error ?? updateMutation.error + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provincia: '', + descripcion: '', + activo: true, + alicuotaCreate: undefined, + vigenciaDesde: '', + }, + }) + + useEffect(() => { + if (item) { + form.reset({ + provincia: item.provincia, + descripcion: item.descripcion, + activo: item.activo, + alicuotaCreate: undefined, + vigenciaDesde: '', + }) + } else { + form.reset({ + provincia: '', + descripcion: '', + activo: true, + alicuotaCreate: undefined, + vigenciaDesde: '', + }) + } + createMutation.reset() + updateMutation.reset() + }, [item, open]) // eslint-disable-line react-hooks/exhaustive-deps + + const backendError = resolveBackendError(error) + + function handleSubmit(values: FormValues) { + if (isEdit) { + updateMutation.mutate( + { + id: item.id, + body: { + descripcion: values.descripcion, + activo: values.activo, + }, + }, + { + onSuccess: () => { + toast.success('Ingresos Brutos actualizado') + onSuccess() + onClose() + }, + }, + ) + } else { + if (values.alicuotaCreate === undefined) { + form.setError('alicuotaCreate', { message: 'La alícuota es requerida' }) + return + } + createMutation.mutate( + { + provincia: values.provincia as ProvinciaArgentina, + descripcion: values.descripcion, + alicuota: values.alicuotaCreate, + vigenciaDesde: values.vigenciaDesde ?? new Date().toISOString().slice(0, 10), + }, + { + onSuccess: () => { + toast.success('Ingresos Brutos creado') + onSuccess() + onClose() + }, + }, + ) + } + } + + return ( + { if (!v) onClose() }}> + + + + {isEdit ? 'Editar Ingresos Brutos' : 'Crear Ingresos Brutos'} + + + +
+ + {backendError && ( + + + {backendError} + + )} + + {/* Provincia — solo en create */} + {!isEdit && ( + ( + + Provincia + + + + )} + /> + )} + + {/* Descripción */} + ( + + Descripción + + + + + + )} + /> + + {/* Solo en create: alícuota y vigenciaDesde */} + {!isEdit && ( + <> + ( + + Alícuota inicial (%) + + + + + + )} + /> + + ( + + Vigencia desde + + + + + + )} + /> + + )} + + {/* Nota informativa en modo EDIT */} + {isEdit && ( +

+ 💡 Para cambiar la alícuota usá el botón Nueva vigencia en la tabla. +

+ )} + + + + + + + +
+
+ ) +} diff --git a/src/web/src/features/fiscal/iibb/components/IngresosBrutosTable.tsx b/src/web/src/features/fiscal/iibb/components/IngresosBrutosTable.tsx new file mode 100644 index 0000000..6764d08 --- /dev/null +++ b/src/web/src/features/fiscal/iibb/components/IngresosBrutosTable.tsx @@ -0,0 +1,191 @@ +// T600.24 (IIBB) — IngresosBrutosTable +// Tabla principal para Ingresos Brutos con acciones por fila +import { useMemo } from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import { Pencil, CalendarPlus, PowerOff, Power } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { HistorialCadenaIibbTooltip } from './HistorialCadenaIibbTooltip' +import { useDeactivateIngresosBrutos, useReactivateIngresosBrutos } from '../hooks/useIngresosBrutos' +import type { IngresosBrutos } from '../types/ingresosBrutos.types' +import { toast } from 'sonner' + +interface IngresosBrutosTableProps { + rows: IngresosBrutos[] + onEdit: (row: IngresosBrutos) => void + onNuevaVersion: (row: IngresosBrutos) => void + isLoading?: boolean +} + +export function IngresosBrutosTable({ + rows, + onEdit, + onNuevaVersion, + isLoading, +}: IngresosBrutosTableProps) { + const deactivate = useDeactivateIngresosBrutos() + const reactivate = useReactivateIngresosBrutos() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'provinciaDisplay', + header: 'Provincia', + cell: ({ row }) => ( + {row.original.provinciaDisplay} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'descripcion', + header: 'Descripción', + meta: { priority: 'high' }, + }, + { + accessorKey: 'alicuota', + header: 'Alícuota', + cell: ({ row }) => ( + {row.original.alicuota}% + ), + meta: { priority: 'high' }, + }, + { + id: 'vigencia', + header: 'Vigencia', + cell: ({ row }) => { + const { vigenciaDesde, vigenciaHasta } = row.original + return ( + + {vigenciaDesde} → {vigenciaHasta ?? abierta} + + ) + }, + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'version', + header: 'Versión', + cell: ({ row }) => ( +
+ + {row.original.predecesorId ? '# en cadena' : 'raíz'} + + +
+ ), + meta: { priority: 'low' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => { + const item = row.original + const isPending = deactivate.isPending || reactivate.isPending + + return ( +
e.stopPropagation()} + > + + + + + {item.activo ? ( + + ) : ( + + )} +
+ ) + }, + meta: { priority: 'high' }, + }, + ], + [onEdit, onNuevaVersion, deactivate, reactivate], + ) + + return ( + String(row.id)} + isLoading={isLoading} + emptyMessage="Sin resultados — no se encontraron registros de Ingresos Brutos con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx b/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx new file mode 100644 index 0000000..9434968 --- /dev/null +++ b/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx @@ -0,0 +1,246 @@ +// T600.26 (IIBB) — NuevaVigenciaIibbModal +// Modal para crear una nueva vigencia/versión de IngresosBrutos +import { useEffect } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle, TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { useNuevaVersionIngresosBrutos } from '../hooks/useIngresosBrutos' +import type { IngresosBrutos } from '../types/ingresosBrutos.types' +import { toast } from 'sonner' + +const formSchema = z.object({ + alicuota: z.coerce + .number({ invalid_type_error: 'Debe ser un número' }) + .min(0, 'Mínimo 0%') + .max(100, 'Máximo 100%'), + vigenciaDesde: z + .string() + .min(1, 'La vigencia desde es requerida') + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato: YYYY-MM-DD'), +}) + +type FormValues = z.infer + +interface NuevaVigenciaIibbModalProps { + open: boolean + item: IngresosBrutos | null + onClose: () => void + onSuccess: () => void +} + +function fechaCierre(vigenciaDesde: string): string { + if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—' + const d = new Date(vigenciaDesde + 'T00:00:00') + d.setDate(d.getDate() - 1) + return d.toISOString().slice(0, 10) +} + +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 } + if (data.error === 'predecesora_ya_cerrada') { + return 'La versión actual ya fue cerrada. No se puede crear una nueva versión sobre ella.' + } + if (data.error === 'vigencia_desde_invalida') { + return data.message ?? 'La fecha de vigencia debe ser posterior a la versión actual.' + } + return data.message ?? data.error ?? 'Error al crear versión' + } + return 'Error al crear versión. Intentá de nuevo.' +} + +export function NuevaVigenciaIibbModal({ + open, + item, + onClose, + onSuccess, +}: NuevaVigenciaIibbModalProps) { + const mutation = useNuevaVersionIngresosBrutos() + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + alicuota: '' as unknown as number, + vigenciaDesde: '', + }, + mode: 'onChange', + }) + + const watchedAlicuota = useWatch({ control: form.control, name: 'alicuota' }) + const watchedVigencia = useWatch({ control: form.control, name: 'vigenciaDesde' }) + + const formState = form.formState + const isFormValid = formState.isValid && !formState.isValidating + + useEffect(() => { + if (open) { + form.reset({ + alicuota: '' as unknown as number, + vigenciaDesde: '', + }) + mutation.reset() + } + }, [open]) // eslint-disable-line react-hooks/exhaustive-deps + + const backendError = resolveBackendError(mutation.error) + const showPreview = + isFormValid && + watchedAlicuota !== undefined && + watchedVigencia?.match(/^\d{4}-\d{2}-\d{2}$/) + + function handleSubmit(values: FormValues) { + if (!item) return + mutation.mutate( + { + id: item.id, + body: { + alicuota: values.alicuota, + vigenciaDesde: values.vigenciaDesde, + }, + }, + { + onSuccess: () => { + toast.success(`Nueva versión de ${item.provinciaDisplay} creada`) + onSuccess() + onClose() + }, + }, + ) + } + + return ( + { if (!v) onClose() }}> + + + + + Nueva vigencia — {item?.provinciaDisplay} + + + +
+ Esta acción crea una nueva versión de IIBB para esta provincia. La versión actual + quedará cerrada con la fecha anterior a la nueva vigencia. +
+ +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Alícuota nueva (%) + + + + + + )} + /> + + ( + + Vigencia desde + + + + + + )} + /> + + {showPreview && item && ( +
+

Vista previa:

+

+ Nueva versión {item.provinciaDisplay} con alícuota{' '} + {watchedAlicuota}% vigente desde{' '} + {watchedVigencia}. +

+

+ Versión actual ({item.alicuota}%) quedará cerrada el{' '} + {fechaCierre(watchedVigencia)}. +

+

+ Esta acción no se puede deshacer. +

+
+ )} + + + + + + + +
+
+ ) +} diff --git a/src/web/src/features/fiscal/iibb/hooks/useIngresosBrutos.ts b/src/web/src/features/fiscal/iibb/hooks/useIngresosBrutos.ts new file mode 100644 index 0000000..e8d9df3 --- /dev/null +++ b/src/web/src/features/fiscal/iibb/hooks/useIngresosBrutos.ts @@ -0,0 +1,121 @@ +// ADM-009 — TanStack Query hooks para fiscal/iibb +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + listIngresosBrutos, + getIngresosBrutosById, + getHistorialIngresosBrutos, + createIngresosBrutos, + updateIngresosBrutos, + nuevaVersionIngresosBrutos, + deactivateIngresosBrutos, + reactivateIngresosBrutos, +} from '../api/iibbApi' +import type { + IngresosBrutosFilter, + CreateIngresosBrutosRequest, + UpdateIngresosBrutosRequest, + NuevaVersionIngresosBrutosRequest, +} from '../types/ingresosBrutos.types' + +// ─── Query keys estables ────────────────────────────────────────────────────── + +export const iibbListQueryKey = (filters: IngresosBrutosFilter) => + ['fiscal', 'iibb', 'list', filters] as const + +export const iibbDetailQueryKey = (id: number) => + ['fiscal', 'iibb', id] as const + +export const iibbHistorialQueryKey = (id: number) => + ['fiscal', 'iibb', id, 'historial'] as const + +// ─── List ───────────────────────────────────────────────────────────────────── + +export function useIngresosBrutosList(filters: IngresosBrutosFilter) { + return useQuery({ + queryKey: iibbListQueryKey(filters), + queryFn: () => listIngresosBrutos(filters), + staleTime: 15_000, + }) +} + +// ─── Detail ────────────────────────────────────────────────────────────────── + +export function useIngresosBrutos(id: number | null) { + return useQuery({ + queryKey: iibbDetailQueryKey(id ?? 0), + queryFn: () => getIngresosBrutosById(id!), + enabled: id != null, + staleTime: 15_000, + }) +} + +// ─── Historial (lazy — solo cuando el tooltip está abierto) ─────────────────── + +export function useHistorialIngresosBrutos(id: number | null, enabled = false) { + return useQuery({ + queryKey: iibbHistorialQueryKey(id ?? 0), + queryFn: () => getHistorialIngresosBrutos(id!), + enabled: id != null && enabled, + staleTime: 15_000, + }) +} + +// ─── Create ────────────────────────────────────────────────────────────────── + +export function useCreateIngresosBrutos() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateIngresosBrutosRequest) => createIngresosBrutos(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] }) + }, + }) +} + +// ─── Update ────────────────────────────────────────────────────────────────── + +export function useUpdateIngresosBrutos() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: UpdateIngresosBrutosRequest }) => + updateIngresosBrutos(id, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] }) + }, + }) +} + +// ─── Nueva versión ─────────────────────────────────────────────────────────── + +export function useNuevaVersionIngresosBrutos() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: NuevaVersionIngresosBrutosRequest }) => + nuevaVersionIngresosBrutos(id, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] }) + }, + }) +} + +// ─── Deactivate / Reactivate ───────────────────────────────────────────────── + +export function useDeactivateIngresosBrutos() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivateIngresosBrutos(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] }) + }, + }) +} + +export function useReactivateIngresosBrutos() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => reactivateIngresosBrutos(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] }) + }, + }) +} diff --git a/src/web/src/features/fiscal/iibb/pages/TiposDeIibbPage.tsx b/src/web/src/features/fiscal/iibb/pages/TiposDeIibbPage.tsx new file mode 100644 index 0000000..86a28dd --- /dev/null +++ b/src/web/src/features/fiscal/iibb/pages/TiposDeIibbPage.tsx @@ -0,0 +1,203 @@ +// T600.28 (IIBB) — TiposDeIibbPage +// Página principal de gestión de Ingresos Brutos +import { useState, useCallback } from 'react' +import { TriangleAlert, PlusCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { IngresosBrutosTable } from '../components/IngresosBrutosTable' +import { IngresosBrutosFormModal } from '../components/IngresosBrutosFormModal' +import { NuevaVigenciaIibbModal } from '../components/NuevaVigenciaIibbModal' +import { useIngresosBrutosList } from '../hooks/useIngresosBrutos' +import { + PROVINCIAS, + PROVINCIA_DISPLAY, +} from '../types/ingresosBrutos.types' +import type { IngresosBrutos, IngresosBrutosFilter, ProvinciaArgentina } from '../types/ingresosBrutos.types' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +export function TiposDeIibbPage() { + const [page, setPage] = useState(1) + const [provinciaFilter, setProvinciaFilter] = useState(undefined) + const [activoFilter, setActivoFilter] = useState(undefined) + + // Estado de modales + const [editItem, setEditItem] = useState(null) + const [editOpen, setEditOpen] = useState(false) + const [nuevaVigenciaItem, setNuevaVigenciaItem] = useState(null) + const [nuevaVigenciaOpen, setNuevaVigenciaOpen] = useState(false) + + const filters: IngresosBrutosFilter = { + page, + pageSize: 20, + ...(provinciaFilter !== undefined ? { provincia: provinciaFilter } : {}), + ...(activoFilter !== undefined ? { activo: activoFilter } : {}), + } + + const { data, isLoading } = useIngresosBrutosList(filters) + + const handleEdit = useCallback((row: IngresosBrutos) => { + setEditItem(row) + setEditOpen(true) + }, []) + + const handleNuevaVersion = useCallback((row: IngresosBrutos) => { + setNuevaVigenciaItem(row) + setNuevaVigenciaOpen(true) + }, []) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+ {/* Banner de advertencia global */} +
+ + + Los cambios de alícuota afectan presupuestos en curso. Usá{' '} + Nueva vigencia para versionar cambios de alícuota. + +
+ + {/* Header */} +
+

Ingresos Brutos

+ +
+ + {/* Filtros */} +
+ + +
+ Estado: + + + +
+
+ + {/* Tabla */} + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Paginación */} +
+ + {data ? `${data.total} registro${data.total !== 1 ? 's' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+ + {/* Modal Crear/Editar */} + setEditOpen(false)} + onSuccess={() => setEditOpen(false)} + /> + + {/* Modal Nueva Vigencia */} + setNuevaVigenciaOpen(false)} + onSuccess={() => setNuevaVigenciaOpen(false)} + /> +
+ ) +} diff --git a/src/web/src/features/fiscal/iibb/types/ingresosBrutos.types.ts b/src/web/src/features/fiscal/iibb/types/ingresosBrutos.types.ts new file mode 100644 index 0000000..971d35e --- /dev/null +++ b/src/web/src/features/fiscal/iibb/types/ingresosBrutos.types.ts @@ -0,0 +1,127 @@ +// ADM-009 — Tipos TS para feature fiscal/iibb +// Alineados con IngresosBrutosDto / FiscalContracts.cs del backend + +// Provincias argentinas — 24 jurisdicciones (23 INDEC + CABA) +export type ProvinciaArgentina = + | 'BuenosAires' + | 'Catamarca' + | 'Chaco' + | 'Chubut' + | 'CiudadAutonomaDeBuenosAires' + | 'Corrientes' + | 'Cordoba' + | 'EntreRios' + | 'Formosa' + | 'Jujuy' + | 'LaPampa' + | 'LaRioja' + | 'Mendoza' + | 'Misiones' + | 'Neuquen' + | 'RioNegro' + | 'Salta' + | 'SanJuan' + | 'SanLuis' + | 'SantaCruz' + | 'SantaFe' + | 'SantiagoDelEstero' + | 'TierraDelFuego' + | 'Tucuman' + +export const PROVINCIA_DISPLAY: Record = { + BuenosAires: 'Buenos Aires', + Catamarca: 'Catamarca', + Chaco: 'Chaco', + Chubut: 'Chubut', + CiudadAutonomaDeBuenosAires: 'Ciudad Autónoma de Buenos Aires', + Corrientes: 'Corrientes', + Cordoba: 'Córdoba', + EntreRios: 'Entre Ríos', + Formosa: 'Formosa', + Jujuy: 'Jujuy', + LaPampa: 'La Pampa', + LaRioja: 'La Rioja', + Mendoza: 'Mendoza', + Misiones: 'Misiones', + Neuquen: 'Neuquén', + RioNegro: 'Río Negro', + Salta: 'Salta', + SanJuan: 'San Juan', + SanLuis: 'San Luis', + SantaCruz: 'Santa Cruz', + SantaFe: 'Santa Fe', + SantiagoDelEstero: 'Santiago del Estero', + TierraDelFuego: 'Tierra del Fuego', + Tucuman: 'Tucumán', +} + +export const PROVINCIAS: ProvinciaArgentina[] = Object.keys(PROVINCIA_DISPLAY) as ProvinciaArgentina[] + +export interface IngresosBrutos { + id: number + provincia: ProvinciaArgentina + provinciaDisplay: string + descripcion: string + alicuota: number + vigenciaDesde: string // ISO date "yyyy-MM-dd" + vigenciaHasta: string | null + activo: boolean + predecesorId: number | null +} + +export interface CreateIngresosBrutosRequest { + provincia: ProvinciaArgentina + descripcion: string + alicuota: number + vigenciaDesde: string +} + +// UpdateIngresosBrutosRequest — SIN alicuota (inmutable, usar NuevaVersion para cambiar) +export interface UpdateIngresosBrutosRequest { + descripcion: string + activo: boolean +} + +export interface NuevaVersionIngresosBrutosRequest { + alicuota: number + vigenciaDesde: string // "yyyy-MM-dd" +} + +export interface NuevaVersionIibbResponse { + predecesorId: number + nuevaId: number + nuevaAlicuota: number + vigenciaDesde: string + predecesorVigenciaHasta: string +} + +export interface HistorialCadenaIibbEntry { + id: number + provincia: ProvinciaArgentina + provinciaDisplay: string + alicuota: number + vigenciaDesde: string + vigenciaHasta: string | null + activo: boolean + predecesorId: number | null + depth: number +} + +export interface IngresosBrutosFilter { + page?: number + pageSize?: number + provincia?: ProvinciaArgentina + activo?: boolean +} + +export interface PagedResponse { + items: T[] + page: number + pageSize: number + total: number +} + +export interface ApiError { + error: string + message: string +} diff --git a/src/web/src/tests/features/fiscal/iibb/IngresosBrutosFormModal.test.tsx b/src/web/src/tests/features/fiscal/iibb/IngresosBrutosFormModal.test.tsx new file mode 100644 index 0000000..f1ed0c0 --- /dev/null +++ b/src/web/src/tests/features/fiscal/iibb/IngresosBrutosFormModal.test.tsx @@ -0,0 +1,93 @@ +// T600.20-T600.29 (IIBB) — TDD: IngresosBrutosFormModal +// CRÍTICO: verifica que el modal de Editar NO tiene campo Alícuota [REQ-UI-007] +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { IngresosBrutosFormModal } from '../../../../features/fiscal/iibb/components/IngresosBrutosFormModal' +import type { IngresosBrutos } from '../../../../features/fiscal/iibb/types/ingresosBrutos.types' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const sampleIibb: IngresosBrutos = { + id: 1, + provincia: 'Cordoba', + provinciaDisplay: 'Córdoba', + descripcion: 'IIBB Córdoba', + alicuota: 2.5, + vigenciaDesde: '2020-01-01', + vigenciaHasta: null, + activo: true, + predecesorId: null, +} + +function renderModal(opts: { + item?: IngresosBrutos | null + open?: boolean + onClose?: () => void + onSuccess?: () => void +} = {}) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onClose = opts.onClose ?? vi.fn() + const onSuccess = opts.onSuccess ?? vi.fn() + + render( + + + + + , + ) + return { onClose, onSuccess } +} + +describe('IngresosBrutosFormModal — CRÍTICO: sin campo Alícuota en modo EDIT [REQ-UI-007]', () => { + it('NO renderiza label exacto "Alícuota" en modo edit', () => { + renderModal({ item: sampleIibb }) + expect(screen.queryByText(/^alícuota$/i)).toBeNull() + }) + + it('muestra nota informativa sobre NuevaVersion en modo edit', () => { + renderModal({ item: sampleIibb }) + expect(screen.getByText(/para cambiar la alícuota/i)).toBeInTheDocument() + }) + + it('modo edit: muestra campo Descripción', () => { + renderModal({ item: sampleIibb }) + expect(screen.getByLabelText(/descripción/i)).toBeInTheDocument() + }) + + it('modo edit: pre-rellena con la descripción del item', async () => { + renderModal({ item: sampleIibb }) + await waitFor(() => { + const desc = screen.getByLabelText(/descripción/i) as HTMLInputElement + expect(desc.value).toBe('IIBB Córdoba') + }) + }) + + it('modo edit: title es "Editar Ingresos Brutos"', () => { + renderModal({ item: sampleIibb }) + expect(screen.getByText(/editar ingresos brutos/i)).toBeInTheDocument() + }) + + it('modo create: title es "Crear Ingresos Brutos"', () => { + renderModal({ item: null }) + expect(screen.getByText(/crear ingresos brutos/i)).toBeInTheDocument() + }) + + it('botón Cancelar llama onClose', async () => { + const { onClose } = renderModal({ item: null }) + await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/web/src/tests/features/fiscal/iibb/TiposDeIibbPage.test.tsx b/src/web/src/tests/features/fiscal/iibb/TiposDeIibbPage.test.tsx new file mode 100644 index 0000000..8a62a06 --- /dev/null +++ b/src/web/src/tests/features/fiscal/iibb/TiposDeIibbPage.test.tsx @@ -0,0 +1,123 @@ +// T600.20-T600.29 (IIBB) — TDD: TiposDeIibbPage +// Tests: banner + tabla + modales +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { TooltipProvider } from '@/components/ui/tooltip' +import { TiposDeIibbPage } from '../../../../features/fiscal/iibb/pages/TiposDeIibbPage' +import { useAuthStore } from '../../../../stores/authStore' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, + Toaster: () => null, +})) + +const API_URL = 'http://localhost:5000' + +const adminUser = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:fiscal:gestionar'], + mustChangePassword: false, +} + +function makeIibbItems() { + return [ + { + id: 1, + provincia: 'Cordoba', + provinciaDisplay: 'Córdoba', + descripcion: 'IIBB Córdoba', + alicuota: 2.5, + vigenciaDesde: '2020-01-01', + vigenciaHasta: null, + activo: true, + predecesorId: null, + }, + { + id: 2, + provincia: 'BuenosAires', + provinciaDisplay: 'Buenos Aires', + descripcion: 'IIBB Buenos Aires', + alicuota: 3.0, + vigenciaDesde: '2020-01-01', + vigenciaHasta: null, + activo: true, + predecesorId: null, + }, + ] +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminUser) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + + server.use( + http.get(`${API_URL}/api/v1/admin/fiscal/iibb`, () => + HttpResponse.json({ + items: makeIibbItems(), + page: 1, + pageSize: 20, + total: 2, + }), + ), + ) + + render( + + + + + } /> + + + + , + ) +} + +describe('TiposDeIibbPage — banner visible al montar', () => { + it('muestra el banner de advertencia inmediatamente', () => { + renderPage() + expect( + screen.getByText(/cambios de alícuota afectan presupuestos/i), + ).toBeInTheDocument() + }) +}) + +describe('TiposDeIibbPage — tabla y contenido', () => { + it('muestra título "Ingresos Brutos"', () => { + renderPage() + expect(screen.getByText('Ingresos Brutos')).toBeInTheDocument() + }) + + it('muestra botón "Crear nuevo"', () => { + renderPage() + expect(screen.getByRole('button', { name: /crear nuevo/i })).toBeInTheDocument() + }) + + it('renderiza filas con provincias al cargar datos', async () => { + renderPage() + await waitFor(() => + expect(screen.getByText('Córdoba')).toBeInTheDocument(), + ) + expect(screen.getByText('Buenos Aires')).toBeInTheDocument() + }) +})