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.
This commit is contained in:
73
src/web/src/features/fiscal/iibb/api/iibbApi.ts
Normal file
73
src/web/src/features/fiscal/iibb/api/iibbApi.ts
Normal file
@@ -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<PagedResponse<IngresosBrutos>> {
|
||||
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<PagedResponse<IngresosBrutos>>(BASE, { params: p })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getIngresosBrutosById(id: number): Promise<IngresosBrutos> {
|
||||
const res = await axiosClient.get<IngresosBrutos>(`${BASE}/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getHistorialIngresosBrutos(id: number): Promise<HistorialCadenaIibbEntry[]> {
|
||||
const res = await axiosClient.get<HistorialCadenaIibbEntry[]>(`${BASE}/${id}/historial`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createIngresosBrutos(
|
||||
body: CreateIngresosBrutosRequest,
|
||||
): Promise<IngresosBrutos> {
|
||||
const res = await axiosClient.post<IngresosBrutos>(BASE, body)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateIngresosBrutos(
|
||||
id: number,
|
||||
body: UpdateIngresosBrutosRequest,
|
||||
): Promise<IngresosBrutos> {
|
||||
const res = await axiosClient.patch<IngresosBrutos>(`${BASE}/${id}`, body)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function nuevaVersionIngresosBrutos(
|
||||
id: number,
|
||||
body: NuevaVersionIngresosBrutosRequest,
|
||||
): Promise<NuevaVersionIibbResponse> {
|
||||
const res = await axiosClient.post<NuevaVersionIibbResponse>(
|
||||
`${BASE}/${id}/nueva-version`,
|
||||
body,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deactivateIngresosBrutos(id: number): Promise<IngresosBrutos> {
|
||||
const res = await axiosClient.post<IngresosBrutos>(`${BASE}/${id}/deactivate`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function reactivateIngresosBrutos(id: number): Promise<IngresosBrutos> {
|
||||
const res = await axiosClient.post<IngresosBrutos>(`${BASE}/${id}/reactivate`)
|
||||
return res.data
|
||||
}
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="historial"
|
||||
onMouseEnter={() => setEnabled(true)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-sm">
|
||||
{!enabled || isLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Cargando historial...</span>
|
||||
) : !historial || historial.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">Sin historial</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium mb-1">Historial de versiones</p>
|
||||
{historial.map((entry, idx) => (
|
||||
<div key={entry.id} className="text-xs">
|
||||
<span className="font-mono font-medium">
|
||||
v{idx + 1} ({entry.alicuota}%)
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
[{formatVigencia(entry.vigenciaDesde, entry.vigenciaHasta)}]
|
||||
</span>
|
||||
{idx < historial.length - 1 && (
|
||||
<span className="text-muted-foreground"> →</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof formSchema>
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Editar Ingresos Brutos' : 'Crear Ingresos Brutos'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Provincia — solo en create */}
|
||||
{!isEdit && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provincia"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provincia</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isPending}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger aria-label="Provincia">
|
||||
<SelectValue placeholder="Seleccioná una provincia" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{PROVINCIAS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{PROVINCIA_DISPLAY[p]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Descripción */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="descripcion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Descripción</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Descripción del registro"
|
||||
aria-label="Descripción"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Solo en create: alícuota y vigenciaDesde */}
|
||||
{!isEdit && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="alicuotaCreate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alícuota inicial (%)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
placeholder="Ej: 2.5"
|
||||
aria-label="Alícuota inicial"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vigenciaDesde"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vigencia desde</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="date"
|
||||
aria-label="Vigencia desde"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Nota informativa en modo EDIT */}
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground rounded-md border border-border bg-muted/50 p-3">
|
||||
💡 Para cambiar la alícuota usá el botón <strong>Nueva vigencia</strong> en la tabla.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
aria-label="cancelar"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<ColumnDef<IngresosBrutos>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'provinciaDisplay',
|
||||
header: 'Provincia',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.provinciaDisplay}</span>
|
||||
),
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'descripcion',
|
||||
header: 'Descripción',
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'alicuota',
|
||||
header: 'Alícuota',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.alicuota}%</span>
|
||||
),
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'vigencia',
|
||||
header: 'Vigencia',
|
||||
cell: ({ row }) => {
|
||||
const { vigenciaDesde, vigenciaHasta } = row.original
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{vigenciaDesde} → {vigenciaHasta ?? <em>abierta</em>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { priority: 'medium' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'activo',
|
||||
header: 'Estado',
|
||||
cell: ({ row }) =>
|
||||
row.original.activo ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
>
|
||||
Activo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
>
|
||||
Inactivo
|
||||
</Badge>
|
||||
),
|
||||
meta: { priority: 'medium' },
|
||||
},
|
||||
{
|
||||
id: 'version',
|
||||
header: 'Versión',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.predecesorId ? '# en cadena' : 'raíz'}
|
||||
</span>
|
||||
<HistorialCadenaIibbTooltip id={row.original.id} />
|
||||
</div>
|
||||
),
|
||||
meta: { priority: 'low' },
|
||||
},
|
||||
{
|
||||
id: 'acciones',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
const isPending = deactivate.isPending || reactivate.isPending
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="editar"
|
||||
title="Editar"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onEdit(item)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="nueva vigencia"
|
||||
title="Nueva vigencia"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onNuevaVersion(item)}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{item.activo ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="desactivar"
|
||||
title="Desactivar"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
deactivate.mutate(item.id, {
|
||||
onSuccess: () =>
|
||||
toast.success(`${item.provinciaDisplay} desactivado`),
|
||||
onError: () =>
|
||||
toast.error('Error al desactivar. Intentá de nuevo.'),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<PowerOff className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="reactivar"
|
||||
title="Reactivar"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
reactivate.mutate(item.id, {
|
||||
onSuccess: () =>
|
||||
toast.success(`${item.provinciaDisplay} reactivado`),
|
||||
onError: () =>
|
||||
toast.error('Error al reactivar. Intentá de nuevo.'),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
],
|
||||
[onEdit, onNuevaVersion, deactivate, reactivate],
|
||||
)
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
getRowId={(row) => String(row.id)}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Sin resultados — no se encontraron registros de Ingresos Brutos con los filtros seleccionados."
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof formSchema>
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-warning" />
|
||||
Nueva vigencia — {item?.provinciaDisplay}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="rounded-md border px-4 py-3 text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-foreground)',
|
||||
}}
|
||||
>
|
||||
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.
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="alicuota"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alícuota nueva (%)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
placeholder="Ej: 3.0"
|
||||
aria-label="Alícuota nueva"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vigenciaDesde"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vigencia desde</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="date"
|
||||
aria-label="Vigencia desde"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{showPreview && item && (
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3 space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">Vista previa:</p>
|
||||
<p>
|
||||
Nueva versión <strong>{item.provinciaDisplay}</strong> con alícuota{' '}
|
||||
<strong>{watchedAlicuota}%</strong> vigente desde{' '}
|
||||
<strong>{watchedVigencia}</strong>.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Versión actual ({item.alicuota}%) quedará cerrada el{' '}
|
||||
<strong>{fechaCierre(watchedVigencia)}</strong>.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
Esta acción no se puede deshacer.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={mutation.isPending}
|
||||
aria-label="cancelar"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isFormValid || mutation.isPending}
|
||||
aria-label="confirmar"
|
||||
>
|
||||
{mutation.isPending ? 'Creando versión...' : 'Confirmar creación de versión'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
121
src/web/src/features/fiscal/iibb/hooks/useIngresosBrutos.ts
Normal file
121
src/web/src/features/fiscal/iibb/hooks/useIngresosBrutos.ts
Normal file
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
203
src/web/src/features/fiscal/iibb/pages/TiposDeIibbPage.tsx
Normal file
203
src/web/src/features/fiscal/iibb/pages/TiposDeIibbPage.tsx
Normal file
@@ -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<ProvinciaArgentina | undefined>(undefined)
|
||||
const [activoFilter, setActivoFilter] = useState<boolean | undefined>(undefined)
|
||||
|
||||
// Estado de modales
|
||||
const [editItem, setEditItem] = useState<IngresosBrutos | null>(null)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [nuevaVigenciaItem, setNuevaVigenciaItem] = useState<IngresosBrutos | null>(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 (
|
||||
<div className="space-y-4">
|
||||
{/* Banner de advertencia global */}
|
||||
<div
|
||||
className="flex items-start gap-3 rounded-md border px-4 py-3 text-sm"
|
||||
role="alert"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-foreground)',
|
||||
}}
|
||||
>
|
||||
<TriangleAlert className="h-4 w-4 mt-0.5 shrink-0" style={{ color: 'var(--warning)' }} />
|
||||
<span>
|
||||
Los cambios de alícuota afectan presupuestos en curso. Usá{' '}
|
||||
<strong>Nueva vigencia</strong> para versionar cambios de alícuota.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Ingresos Brutos</h1>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditItem(null)
|
||||
setEditOpen(true)
|
||||
}}
|
||||
aria-label="crear nuevo"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Crear nuevo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select
|
||||
value={provinciaFilter ?? '__all__'}
|
||||
onValueChange={(v) => {
|
||||
setProvinciaFilter(v === '__all__' ? undefined : (v as ProvinciaArgentina))
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="max-w-xs" aria-label="Filtrar por provincia">
|
||||
<SelectValue placeholder="Filtrar por provincia..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">Todas las provincias</SelectItem>
|
||||
{PROVINCIAS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{PROVINCIA_DISPLAY[p]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Estado:</span>
|
||||
<Button
|
||||
variant={activoFilter === undefined ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => { setActivoFilter(undefined); setPage(1) }}
|
||||
>
|
||||
Todos
|
||||
</Button>
|
||||
<Button
|
||||
variant={activoFilter === true ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => { setActivoFilter(true); setPage(1) }}
|
||||
>
|
||||
Activos
|
||||
</Button>
|
||||
<Button
|
||||
variant={activoFilter === false ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => { setActivoFilter(false); setPage(1) }}
|
||||
>
|
||||
Inactivos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<IngresosBrutosTable
|
||||
rows={data?.items ?? []}
|
||||
onEdit={handleEdit}
|
||||
onNuevaVersion={handleNuevaVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Paginación */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{data ? `${data.total} registro${data.total !== 1 ? 's' : ''}` : ''}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPrev}
|
||||
onClick={() => setPage((p) => p - 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={() => setPage((p) => p + 1)}
|
||||
aria-label="Siguiente"
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Crear/Editar */}
|
||||
<IngresosBrutosFormModal
|
||||
open={editOpen}
|
||||
item={editItem}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSuccess={() => setEditOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Modal Nueva Vigencia */}
|
||||
<NuevaVigenciaIibbModal
|
||||
open={nuevaVigenciaOpen}
|
||||
item={nuevaVigenciaItem}
|
||||
onClose={() => setNuevaVigenciaOpen(false)}
|
||||
onSuccess={() => setNuevaVigenciaOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
src/web/src/features/fiscal/iibb/types/ingresosBrutos.types.ts
Normal file
127
src/web/src/features/fiscal/iibb/types/ingresosBrutos.types.ts
Normal file
@@ -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<ProvinciaArgentina, string> = {
|
||||
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<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
message: string
|
||||
}
|
||||
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<IngresosBrutosFormModal
|
||||
open={opts.open ?? true}
|
||||
item={opts.item ?? null}
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
123
src/web/src/tests/features/fiscal/iibb/TiposDeIibbPage.test.tsx
Normal file
123
src/web/src/tests/features/fiscal/iibb/TiposDeIibbPage.test.tsx
Normal file
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/admin/fiscal/iibb']}>
|
||||
<TooltipProvider>
|
||||
<Routes>
|
||||
<Route path="/admin/fiscal/iibb" element={<TiposDeIibbPage />} />
|
||||
</Routes>
|
||||
</TooltipProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user