feat(captura): módulo de captura por departamentos

Backend: tabla CapturaDepartamento, 5 departamentos, 7 desarrollos,
service CRUD + importador Excel + 6 endpoints.
Frontend: tabla editable inline, tabs por dept, resumen avance,
paginación, filtro estatus, permisos por rol, dark mode.
746 API tests + 42 landing tests.
This commit is contained in:
2026-09-07 07:59:14 +00:00
parent 7d19e0da38
commit c2a8f4e929
21 changed files with 2496 additions and 7 deletions

View File

@@ -0,0 +1,149 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { CapturaPage } from './CapturaPage'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
function crearStorageStub(): Storage {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => void mapa.set(k, v),
removeItem: (k: string) => void mapa.delete(k),
clear: () => mapa.clear(),
key: () => null,
get length() { return mapa.size },
} as Storage
}
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
const sesionFake = {
token: 'jwt-de-prueba',
usuario: {
nombre: 'Test User',
rol: 'admin',
asignacion: null,
mondayUserId: null,
modulos: ['captura'],
},
}
const UNIDADES = ['409-1', '409-2', '409-3']
const DATOS = {
'409-1': {
certificacion_gas: 'true',
to_c4_fianza: null,
comentarios_obra: 'Listo',
},
'409-2': {},
}
const RESUMEN = [
{ departamento: 'OBRA', camposTotales: 8, camposLlenos: 3, porcentaje: 13 },
{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 8, porcentaje: 40 },
{ departamento: 'ESCRITURACION', camposTotales: 6, camposLlenos: 6, porcentaje: 50 },
{ departamento: 'FINANZAS', camposTotales: 7, camposLlenos: 5, porcentaje: 36 },
{ departamento: 'POSTVENTA', camposTotales: 2, camposLlenos: 1, porcentaje: 25 },
]
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', crearStorageStub())
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('CapturaPage', () => {
it('renderiza el título y tabs de departamento', async () => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES))
if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN))
if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS))
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<CapturaPage onVolver={() => {}} />)
expect(screen.getByText('Captura por Departamentos')).toBeTruthy()
const nav = await screen.findByLabelText('Departamentos')
expect(within(nav).getByText('Obra')).toBeTruthy()
expect(within(nav).getByText('Ventas')).toBeTruthy()
expect(within(nav).getByText('Escrituración')).toBeTruthy()
expect(within(nav).getByText('Finanzas')).toBeTruthy()
expect(within(nav).getByText('Postventa')).toBeTruthy()
})
it('muestra el select de desarrollo', async () => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES))
if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN))
if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS))
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<CapturaPage onVolver={() => {}} />)
const select = await screen.findByLabelText('Desarrollo')
expect(select).toBeTruthy()
expect((select as HTMLSelectElement).value).toBe('SENDA')
})
it('cambia de tab de departamento', async () => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES))
if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN))
if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS))
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<CapturaPage onVolver={() => {}} />)
const nav = await screen.findByLabelText('Departamentos')
fireEvent.click(within(nav).getByText('Ventas'))
// After clicking Ventas, the fetch should be called again for Ventas tab data
await waitFor(() => {
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/captura/unidades'))).toBe(true)
})
})
it('muestra avance por departamento en la barra lateral', async () => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES))
if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN))
if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS))
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<CapturaPage onVolver={() => {}} />)
await waitFor(() => {
expect(screen.getByText('Avance por departamento')).toBeTruthy()
})
expect(screen.getByText('13%')).toBeTruthy()
expect(screen.getByText('3 de 8 campos')).toBeTruthy()
})
it('muestra error si falla la carga', async () => {
fetchMock.mockResolvedValue(new Response('{}', { status: 500 }))
render(<CapturaPage onVolver={() => {}} />)
expect(await screen.findByRole('alert')).toBeTruthy()
})
})

View File

@@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import {
DESARROLLOS,
DEPARTAMENTOS,
CAMPOS_POR_DEPARTAMENTO,
DEPARTAMENTOS_POR_ROL,
DEPARTAMENTO_LABELS,
obtenerCaptura,
obtenerUnidadesCaptura,
obtenerResumenCaptura,
guardarBulk,
type DatosCaptura,
type DepartamentoId,
type ResumenDepartamento,
} from '../services/captura'
import { TablaCaptura, type DirtyMap } from '../components/TablaCaptura'
import { ResumenCaptura } from '../components/ResumenCaptura'
import { getSesion } from '../services/auth'
interface Props {
onVolver: () => void
}
export function CapturaPage({ onVolver }: Props) {
// ─── Estado ───────────────────────────────────────────────────────
const [desarrollo, setDesarrollo] = useState<string>(String(DESARROLLOS[0]))
const [departamento, setDepartamento] = useState<DepartamentoId>(DEPARTAMENTOS[0])
const [unidades, setUnidades] = useState<string[]>([])
const [datos, setDatos] = useState<DatosCaptura>({})
const [resumen, setResumen] = useState<ResumenDepartamento[]>([])
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
const [guardando, setGuardando] = useState(false)
const [filtroEstatus, setFiltroEstatus] = useState('')
// ─── Permisos ─────────────────────────────────────────────────────
const rol = getSesion()?.usuario.rol ?? 'admin'
const departamentosPermitidos = useMemo(() => {
return (DEPARTAMENTOS_POR_ROL[rol] ?? DEPARTAMENTOS_POR_ROL['admin']) as DepartamentoId[]
}, [rol])
// Si el departamento activo no está permitido, cambiar al primero permitido
useEffect(() => {
if (departamentosPermitidos.length > 0 && !departamentosPermitidos.includes(departamento)) {
setDepartamento(departamentosPermitidos[0])
}
}, [departamentosPermitidos, departamento])
// ─── Carga de datos ───────────────────────────────────────────────
const cargar = useCallback(async () => {
setCargando(true)
setError('')
try {
const [u, d, r] = await Promise.all([
obtenerUnidadesCaptura(desarrollo),
obtenerCaptura(desarrollo),
obtenerResumenCaptura(desarrollo),
])
setUnidades(u)
setDatos(d)
setResumen(r)
} catch {
setError('No se pudieron cargar los datos de captura.')
} finally {
setCargando(false)
}
}, [desarrollo])
useEffect(() => { void cargar() }, [cargar])
// ─── Guardar cambios ──────────────────────────────────────────────
const handleGuardar = useCallback(
async (dirty: DirtyMap) => {
setGuardando(true)
try {
const registros: { unidad: string; campo: string; valor: string | null }[] = []
for (const [unidad, campos] of Object.entries(dirty)) {
for (const [campo, valor] of Object.entries(campos)) {
registros.push({ unidad, campo, valor })
}
}
await guardarBulk({ desarrollo, departamento, registros })
toast.success('Cambios guardados')
await cargar()
} catch {
toast.error('No se pudieron guardar los cambios')
} finally {
setGuardando(false)
}
},
[desarrollo, departamento, cargar],
)
// ─── Render ───────────────────────────────────────────────────────
const campos = CAMPOS_POR_DEPARTAMENTO[departamento] ?? []
return (
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-12 sm:px-6 sm:py-16">
{/* Header */}
<div className="flex flex-wrap items-center gap-4">
<button
type="button"
onClick={onVolver}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
&larr; Volver
</button>
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">
Captura por Departamentos
</h1>
</div>
{/* Filtros */}
<div className="mt-6 flex flex-wrap items-end gap-4">
{/* Desarrollo */}
<div>
<label
htmlFor="captura-desarrollo"
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Desarrollo
</label>
<select
id="captura-desarrollo"
value={desarrollo}
onChange={(e) => setDesarrollo(e.target.value)}
className="mt-1.5 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
{DESARROLLOS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
{/* Filtro de estatus */}
<div>
<label
htmlFor="captura-estatus"
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Filtrar por estatus
</label>
<select
id="captura-estatus"
value={filtroEstatus}
onChange={(e) => setFiltroEstatus(e.target.value)}
className="mt-1.5 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Todos</option>
<option value="DISPONIBLE">Disponible</option>
<option value="APARTADO">Apartado</option>
<option value="VENDIDO">Vendido</option>
<option value="ESCRITURADO">Escriturado</option>
</select>
</div>
</div>
{/* Tabs de departamento */}
<nav aria-label="Departamentos" className="mt-6">
<ul className="flex gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1 dark:border-[#1E293B] dark:bg-[#1E293B]">
{DEPARTAMENTOS.filter((d) =>
departamentosPermitidos.includes(d as DepartamentoId),
).map((d) => {
const dept = d as DepartamentoId
const activo = dept === departamento
return (
<li key={d}>
<button
type="button"
onClick={() => setDepartamento(dept)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activo
? 'bg-white text-[#111827] shadow-sm dark:bg-[#1E293B] dark:text-[#E2E8F0]'
: 'text-[#64748B] hover:text-[#374151] dark:text-[#94A3B8] dark:hover:text-[#CBD5E1]'
}`}
>
{DEPARTAMENTO_LABELS[dept]}
</button>
</li>
)
})}
</ul>
</nav>
{/* Contenido: tabla + resumen */}
<div className="mt-6 grid gap-6 lg:grid-cols-[1fr_240px]">
{/* Tabla */}
<section>
{cargando ? (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando captura…</p>
</div>
) : error ? (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p role="alert" className="text-sm text-red-600">{error}</p>
<button
type="button"
onClick={() => void cargar()}
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
Reintentar
</button>
</div>
) : (
<TablaCaptura
unidades={unidades}
campos={campos}
datos={datos}
onGuardar={handleGuardar}
guardando={guardando}
filtroEstatus={filtroEstatus}
/>
)}
</section>
{/* Resumen lateral */}
<ResumenCaptura
resumen={resumen}
departamentoActivo={departamento}
departamentosPermitidos={departamentosPermitidos}
onSeleccionar={setDepartamento}
/>
</div>
</main>
)
}