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,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>
)
}