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:
232
apps/landing/src/pages/CapturaPage.tsx
Normal file
232
apps/landing/src/pages/CapturaPage.tsx
Normal 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]"
|
||||
>
|
||||
← 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user