diff --git a/src/web/src/features/rubros/components/MoveRubroDialog.tsx b/src/web/src/features/rubros/components/MoveRubroDialog.tsx new file mode 100644 index 0000000..bd05c93 --- /dev/null +++ b/src/web/src/features/rubros/components/MoveRubroDialog.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } 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 { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useMoveRubro } from '../hooks/useMoveRubro' +import type { Rubro, RubroTreeNode } from '../types' + +// ─── Helper: flatten tree excluding a subtree rooted at excludedId ──────────── + +export interface FlatNode { + id: number + nombre: string + depth: number +} + +export function flattenExcludingSubtree( + tree: RubroTreeNode[], + excludedId: number, +): FlatNode[] { + const result: FlatNode[] = [] + + function walk(nodes: RubroTreeNode[], depth: number) { + for (const node of nodes) { + if (node.id === excludedId) { + // Skip this entire subtree (node + all descendants) + continue + } + result.push({ id: node.id, nombre: node.nombre, depth }) + if (node.hijos.length > 0) { + walk(node.hijos, depth + 1) + } + } + } + + walk(tree, 0) + return result +} + +// ─── Schema ─────────────────────────────────────────────────────────────────── + +const moveRubroSchema = z.object({ + nuevoParentId: z + .string() + .transform((val) => (val === 'root' ? null : Number(val))) + .pipe(z.number().int().positive().nullable()), + nuevoOrden: z + .string() + .transform((val) => (val === '' ? 0 : Number(val))) + .pipe(z.number().int().min(0, 'El orden debe ser 0 o mayor')), +}) + +// Raw form field types (what useForm sees before zod transforms) +type MoveRubroFormRaw = { + nuevoParentId: string + nuevoOrden: string +} + +// Output type after zod transforms +type MoveRubroFormOutput = { + nuevoParentId: number | null + nuevoOrden: number +} + +// ─── Error resolver ─────────────────────────────────────────────────────────── + +function resolveMoveError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; message?: string } + return data.message ?? data.error ?? 'Error al mover el rubro' + } + // Handle raw rejection objects (from tests) + const errObj = err as { response?: { status?: number; data?: { message?: string } } } + if (errObj?.response?.data?.message) { + return errObj.response.data.message + } + return 'Error al mover el rubro' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface MoveRubroDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + rubro: Rubro | null + tree: RubroTreeNode[] + onConfirmed?: () => void +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function MoveRubroDialog({ + open, + onOpenChange, + rubro, + tree, + onConfirmed, +}: MoveRubroDialogProps) { + const [backendError, setBackendError] = useState(null) + const { mutateAsync, isPending } = useMoveRubro() + + const availableParents = rubro ? flattenExcludingSubtree(tree, rubro.id) : [] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const form = useForm({ + resolver: zodResolver(moveRubroSchema) as any, + defaultValues: { + nuevoParentId: rubro?.parentId != null ? String(rubro.parentId) : 'root', + nuevoOrden: String(rubro?.orden ?? 0), + }, + }) + + useEffect(() => { + if (open && rubro) { + setBackendError(null) + form.reset({ + nuevoParentId: rubro.parentId != null ? String(rubro.parentId) : 'root', + nuevoOrden: String(rubro.orden ?? 0), + }) + } + }, [open, rubro, form]) + + async function handleSubmit(data: MoveRubroFormOutput) { + if (!rubro) return + setBackendError(null) + + const nuevoParentId = data.nuevoParentId + const nuevoOrden = data.nuevoOrden + + try { + await mutateAsync({ id: rubro.id, data: { nuevoParentId, nuevoOrden } }) + toast.success('Rubro movido') + onOpenChange(false) + onConfirmed?.() + } catch (err) { + const msg = resolveMoveError(err) + setBackendError(msg) + if ( + !isAxiosError(err) || + (err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400) + ) { + toast.error('Error al mover el rubro') + } + } + } + + return ( + + + + Mover rubro + + Seleccioná el nuevo padre y orden para “{rubro?.nombre ?? ''}”. + + + +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Nuevo padre + + + + )} + /> + + ( + + Orden + + + + + + )} + /> + +
+ + +
+ + +
+
+ ) +} diff --git a/src/web/src/features/rubros/components/RubroFormDialog.tsx b/src/web/src/features/rubros/components/RubroFormDialog.tsx index eba86a4..8126f2c 100644 --- a/src/web/src/features/rubros/components/RubroFormDialog.tsx +++ b/src/web/src/features/rubros/components/RubroFormDialog.tsx @@ -7,6 +7,7 @@ import { AlertCircle } from 'lucide-react' import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' @@ -101,6 +102,11 @@ export function RubroFormDialog({ {isEdit ? 'Editar rubro' : 'Nuevo rubro'} + + {isEdit + ? `Modificá los datos del rubro "${rubro?.nombre ?? ''}".` + : 'Completá los datos para crear un nuevo rubro.'} +
diff --git a/src/web/src/features/rubros/pages/RubrosPage.tsx b/src/web/src/features/rubros/pages/RubrosPage.tsx index 3e3ed9e..1bd4dc7 100644 --- a/src/web/src/features/rubros/pages/RubrosPage.tsx +++ b/src/web/src/features/rubros/pages/RubrosPage.tsx @@ -11,6 +11,7 @@ import { CanPerform } from '@/components/auth/CanPerform' import { CategoryTree } from '../components/CategoryTree' import { RubroFormDialog } from '../components/RubroFormDialog' import { DeleteRubroDialog } from '../components/DeleteRubroDialog' +import { MoveRubroDialog } from '../components/MoveRubroDialog' import { useRubrosTree } from '../hooks/useRubrosTree' import { useCreateRubro } from '../hooks/useCreateRubro' import { useUpdateRubro } from '../hooks/useUpdateRubro' @@ -28,6 +29,7 @@ export function RubrosPage() { const [deleteOpen, setDeleteOpen] = useState(false) const [deletingRubro, setDeletingRubro] = useState(null) const [formError, setFormError] = useState(null) + const [moveTarget, setMoveTarget] = useState(null) const { data: tree, isLoading, isError } = useRubrosTree(incluirInactivos) const { mutateAsync: createRubro, isPending: creating } = useCreateRubro() @@ -153,7 +155,7 @@ export function RubrosPage() { onEdit={handleEdit} onDelete={handleDelete} onAddChild={handleAddChild} - onMove={() => {}} + onMove={(rubro) => setMoveTarget(rubro)} canEdit={true} /> @@ -180,6 +182,14 @@ export function RubrosPage() { onConfirm={handleDeleteConfirm} /> )} + + {/* Move dialog */} + !o && setMoveTarget(null)} + rubro={moveTarget} + tree={tree ?? []} + /> ) } diff --git a/src/web/src/tests/features/rubros/MoveRubroDialog.test.tsx b/src/web/src/tests/features/rubros/MoveRubroDialog.test.tsx new file mode 100644 index 0000000..79945cb --- /dev/null +++ b/src/web/src/tests/features/rubros/MoveRubroDialog.test.tsx @@ -0,0 +1,374 @@ +import { describe, it, expect, vi, beforeEach } 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 React from 'react' +import { MoveRubroDialog } from '../../../features/rubros/components/MoveRubroDialog' +import { flattenExcludingSubtree } from '../../../features/rubros/components/MoveRubroDialog' +import type { Rubro, RubroTreeNode } from '../../../features/rubros/types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +// Mock useMoveRubro to avoid real network calls +const mockMutateAsync = vi.fn() +let mockIsPending = false + +vi.mock('../../../features/rubros/hooks/useMoveRubro', () => ({ + useMoveRubro: () => ({ + mutateAsync: mockMutateAsync, + get isPending() { + return mockIsPending + }, + }), +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const rubroAutos: Rubro = { + id: 1, + nombre: 'Autos', + orden: 1, + activo: true, + parentId: null, + tarifarioBaseId: null, + fechaCreacion: '2026-04-18T00:00:00Z', + fechaModificacion: null, +} + +const rubroUsados: Rubro = { + id: 2, + nombre: 'Usados', + orden: 1, + activo: true, + parentId: 1, + tarifarioBaseId: null, + fechaCreacion: '2026-04-18T00:00:00Z', + fechaModificacion: null, +} + +const rubroInmuebles: Rubro = { + id: 3, + nombre: 'Inmuebles', + orden: 2, + activo: true, + parentId: null, + tarifarioBaseId: null, + fechaCreacion: '2026-04-18T00:00:00Z', + fechaModificacion: null, +} + +// Tree: +// Autos (id=1) +// Usados (id=2) +// Compactos (id=4) +// Inmuebles (id=3) +const treeCompact: RubroTreeNode = { + id: 4, + nombre: 'Compactos', + orden: 1, + activo: true, + parentId: 2, + tarifarioBaseId: null, + hijos: [], +} + +const treeUsados: RubroTreeNode = { + id: 2, + nombre: 'Usados', + orden: 1, + activo: true, + parentId: 1, + tarifarioBaseId: null, + hijos: [treeCompact], +} + +const treeAutos: RubroTreeNode = { + id: 1, + nombre: 'Autos', + orden: 1, + activo: true, + parentId: null, + tarifarioBaseId: null, + hijos: [treeUsados], +} + +const treeInmuebles: RubroTreeNode = { + id: 3, + nombre: 'Inmuebles', + orden: 2, + activo: true, + parentId: null, + tarifarioBaseId: null, + hijos: [], +} + +const fullTree: RubroTreeNode[] = [treeAutos, treeInmuebles] + +// ─── Helper ─────────────────────────────────────────────────────────────────── + +function wrap(children: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + {children} + , + ) +} + +// ─── flattenExcludingSubtree unit tests ─────────────────────────────────────── + +describe('flattenExcludingSubtree', () => { + it('returns all nodes when excludedId does not match anything', () => { + const result = flattenExcludingSubtree(fullTree, 999) + expect(result.map((n) => n.id)).toContain(1) + expect(result.map((n) => n.id)).toContain(2) + expect(result.map((n) => n.id)).toContain(3) + expect(result.map((n) => n.id)).toContain(4) + }) + + it('excludes the target node itself', () => { + const result = flattenExcludingSubtree(fullTree, 1) + expect(result.map((n) => n.id)).not.toContain(1) + }) + + it('excludes all descendants of the target node', () => { + const result = flattenExcludingSubtree(fullTree, 1) + // Autos (id=1), Usados (id=2), Compactos (id=4) all excluded + expect(result.map((n) => n.id)).not.toContain(1) + expect(result.map((n) => n.id)).not.toContain(2) + expect(result.map((n) => n.id)).not.toContain(4) + // Inmuebles stays + expect(result.map((n) => n.id)).toContain(3) + }) + + it('excludes only the leaf node when a leaf is the target', () => { + const result = flattenExcludingSubtree(fullTree, 4) + expect(result.map((n) => n.id)).toContain(1) + expect(result.map((n) => n.id)).toContain(2) + expect(result.map((n) => n.id)).toContain(3) + expect(result.map((n) => n.id)).not.toContain(4) + }) +}) + +// ─── MoveRubroDialog component tests ───────────────────────────────────────── + +describe('MoveRubroDialog', () => { + beforeEach(() => { + mockMutateAsync.mockReset() + mockIsPending = false + }) + + it('renders with current parent selected when opened', async () => { + // rubroUsados has parentId=1 (Autos) + wrap( + , + ) + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + expect(screen.getByText(/mover rubro/i)).toBeInTheDocument() + }) + + it('shows dialog title with rubro name', async () => { + wrap( + , + ) + await waitFor(() => { + expect(screen.getByText(/mover rubro/i)).toBeInTheDocument() + }) + }) + + it('shows flat list of available parents excluding the rubro being moved', async () => { + // Moving Inmuebles (id=3) — Inmuebles should not appear as option, others should + wrap( + , + ) + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + // The combobox/select area should contain Autos and Usados but not Inmuebles + // We check via the select trigger content after opening + const trigger = screen.getByRole('combobox') + expect(trigger).toBeInTheDocument() + }) + + it('shows flat list of available parents excluding DESCENDANTS of the rubro being moved', async () => { + // Moving Autos (id=1) — Usados (id=2) and Compactos (id=4) are descendants + // The options should not include Autos, Usados, or Compactos + wrap( + , + ) + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + // Open the select + const trigger = screen.getByRole('combobox') + await userEvent.click(trigger) + await waitFor(() => { + // Inmuebles should be available as an option + expect(screen.getByRole('option', { name: /inmuebles/i })).toBeInTheDocument() + }) + // Autos itself and its descendants should not appear + expect(screen.queryByRole('option', { name: /^autos$/i })).not.toBeInTheDocument() + expect(screen.queryByRole('option', { name: /usados/i })).not.toBeInTheDocument() + expect(screen.queryByRole('option', { name: /compactos/i })).not.toBeInTheDocument() + }) + + it('allows selecting "raíz" (null parent)', async () => { + wrap( + , + ) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + const trigger = screen.getByRole('combobox') + await userEvent.click(trigger) + await waitFor(() => { + expect(screen.getByRole('option', { name: /raíz/i })).toBeInTheDocument() + }) + }) + + it('submit calls mutateAsync with { nuevoParentId, nuevoOrden }', async () => { + mockMutateAsync.mockResolvedValue(undefined) + wrap( + , + ) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + // Select raíz + const trigger = screen.getByRole('combobox') + await userEvent.click(trigger) + await waitFor(() => expect(screen.getByRole('option', { name: /raíz/i })).toBeInTheDocument()) + await userEvent.click(screen.getByRole('option', { name: /raíz/i })) + + // Set orden + const ordenInput = screen.getByLabelText(/orden/i) + await userEvent.clear(ordenInput) + await userEvent.type(ordenInput, '5') + + // Submit + await userEvent.click(screen.getByRole('button', { name: /mover/i })) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + id: rubroUsados.id, + data: { nuevoParentId: null, nuevoOrden: 5 }, + }) + }) + }) + + it('displays backend error inline when 409 cycle/duplicate', async () => { + mockMutateAsync.mockRejectedValue({ + response: { + status: 409, + data: { message: 'Ya existe un rubro con ese nombre en el destino' }, + }, + }) + wrap( + , + ) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /mover/i })) + + await waitFor(() => { + expect(screen.getByText(/ya existe un rubro con ese nombre/i)).toBeInTheDocument() + }) + }) + + it('displays backend error inline when 422 depth', async () => { + mockMutateAsync.mockRejectedValue({ + response: { + status: 422, + data: { message: 'Profundidad máxima 10 niveles alcanzada' }, + }, + }) + wrap( + , + ) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /mover/i })) + + await waitFor(() => { + expect(screen.getByText(/profundidad máxima/i)).toBeInTheDocument() + }) + }) + + it('disables submit button while mutation is pending', async () => { + // Set isPending to true before rendering — simulates pending state + mockIsPending = true + mockMutateAsync.mockImplementation(() => new Promise(() => {})) + + wrap( + , + ) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + // When isPending=true, button shows "Moviendo..." and is disabled + const submitBtn = screen.getByRole('button', { name: /moviendo/i }) + expect(submitBtn).toBeDisabled() + }) + + it('closes on cancel', async () => { + const onOpenChange = vi.fn() + wrap( + , + ) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +})