feat(frontend): tieneAvisos en RubroTreeNode + disable btn subrubro (CAT-002)

This commit is contained in:
2026-04-19 08:35:42 -03:00
parent bb5dde6e24
commit 4f25233bab
4 changed files with 90 additions and 0 deletions

View File

@@ -101,11 +101,14 @@ export function CategoryTreeNode({
{/* Action buttons — only if canEdit */}
{canEdit && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{/* CAT-002: disabled when leaf-with-avisos; PRD-002 activates the real data path */}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
aria-label={`Agregar subrubro en ${node.nombre}`}
disabled={node.tieneAvisos === true}
title={node.tieneAvisos === true ? 'El rubro contiene avisos asignados. Muévalos antes de agregar sub-rubros.' : undefined}
onClick={() => onAddChild(node.id)}
>
<Plus className="h-3.5 w-3.5" />

View File

@@ -7,6 +7,8 @@ export interface RubroTreeNode {
activo: boolean
parentId: number | null
tarifarioBaseId: number | null
// CAT-002: additive field — optional for backward-compat (PRD-002 always sends it)
tieneAvisos?: boolean
hijos: RubroTreeNode[]
}

View File

@@ -110,6 +110,62 @@ describe('CategoryTree', () => {
})
})
// ── CAT-002: tieneAvisos disables "Agregar subrubro" button ──────────────────
describe('CategoryTreeNode tieneAvisos (CAT-002)', () => {
it('BotonAgregarSubrubro_Disabled_CuandoTieneAvisosTrue', () => {
const nodeConAvisos: RubroTreeNode = {
id: 10,
nombre: 'ConAvisos',
orden: 1,
activo: true,
parentId: null,
tarifarioBaseId: null,
tieneAvisos: true,
hijos: [],
}
render(
<CategoryTree
nodes={[nodeConAvisos]}
onEdit={noop}
onDelete={noop}
onAddChild={noop}
onMove={noop}
canEdit={true}
/>,
)
const addBtn = screen.getByRole('button', { name: /agregar subrubro en conavisos/i })
expect(addBtn).toBeDisabled()
expect(addBtn).toHaveAttribute('title')
expect(addBtn.getAttribute('title')).toMatch(/El rubro contiene avisos/i)
})
it('BotonAgregarSubrubro_Enabled_CuandoTieneAvisosFalse', () => {
const nodeSinAvisos: RubroTreeNode = {
id: 11,
nombre: 'SinAvisos',
orden: 1,
activo: true,
parentId: null,
tarifarioBaseId: null,
tieneAvisos: false,
hijos: [],
}
render(
<CategoryTree
nodes={[nodeSinAvisos]}
onEdit={noop}
onDelete={noop}
onAddChild={noop}
onMove={noop}
canEdit={true}
/>,
)
const addBtn = screen.getByRole('button', { name: /agregar subrubro en sinavisos/i })
expect(addBtn).not.toBeDisabled()
})
})
describe('CategoryTreeNode depth guard', () => {
it('renders depth warning when depth exceeds 10', () => {
// Build a deeply nested node at depth 11

View File

@@ -313,6 +313,35 @@ describe('MoveRubroDialog', () => {
})
})
it('displays backend error inline when 409 rubro_padre_es_hoja_con_avisos (CAT-002)', async () => {
mockMutateAsync.mockRejectedValue({
response: {
status: 409,
data: {
error: 'rubro_padre_es_hoja_con_avisos',
message: 'El rubro padre contiene 3 avisos. Muévalos antes de crear sub-rubros.',
},
},
})
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroUsados}
tree={fullTree}
/>,
)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /mover/i }))
await waitFor(() => {
expect(
screen.getByText(/el rubro padre contiene 3 avisos/i),
).toBeInTheDocument()
})
})
it('displays backend error inline when 422 depth', async () => {
mockMutateAsync.mockRejectedValue({
response: {