238 lines
9.3 KiB
TypeScript
238 lines
9.3 KiB
TypeScript
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 (e) {
|
|
toast.error(
|
|
e instanceof Error && e.message && !e.message.startsWith('HTTP')
|
|
? e.message
|
|
: 'No se pudieron guardar los cambios',
|
|
)
|
|
} finally {
|
|
setGuardando(false)
|
|
}
|
|
},
|
|
[desarrollo, departamento, cargar],
|
|
)
|
|
|
|
// ─── Render ───────────────────────────────────────────────────────
|
|
|
|
const campos = CAMPOS_POR_DEPARTAMENTO[departamento] ?? []
|
|
|
|
return (
|
|
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 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}
|
|
puedeEditarFechas={rol === 'admin'}
|
|
/>
|
|
)}
|
|
</section>
|
|
|
|
{/* Resumen lateral */}
|
|
<ResumenCaptura
|
|
resumen={resumen}
|
|
departamentoActivo={departamento}
|
|
departamentosPermitidos={departamentosPermitidos}
|
|
onSeleccionar={setDepartamento}
|
|
/>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|