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,97 @@
import { DEPARTAMENTO_LABELS, type ResumenDepartamento, type DepartamentoId } from '../services/captura'
interface Props {
resumen: ResumenDepartamento[]
departamentoActivo: DepartamentoId | null
departamentosPermitidos: DepartamentoId[]
onSeleccionar: (dept: DepartamentoId) => void
}
/** Color de la barra según porcentaje */
function colorBarra(pct: number): string {
if (pct >= 80) return 'bg-green-500'
if (pct >= 50) return 'bg-yellow-500'
return 'bg-red-500'
}
function colorTexto(pct: number): string {
if (pct >= 80) return 'text-green-600 dark:text-green-400'
if (pct >= 50) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400'
}
/** Barra lateral de resumen de avance por departamento */
export function ResumenCaptura({
resumen,
departamentoActivo,
departamentosPermitidos,
onSeleccionar,
}: Props) {
/** Filtrar solo departamentos permitidos */
const filtrado = resumen.filter((r) =>
departamentosPermitidos.includes(r.departamento as DepartamentoId),
)
if (filtrado.length === 0) {
return (
<aside className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Avance por departamento
</h3>
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Sin datos disponibles.</p>
</aside>
)
}
return (
<aside className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Avance por departamento
</h3>
<ul className="mt-3 space-y-3">
{filtrado.map((r) => {
const dept = r.departamento as DepartamentoId
const activo = dept === departamentoActivo
const label = DEPARTAMENTO_LABELS[dept]
return (
<li key={dept}>
<button
type="button"
onClick={() => onSeleccionar(dept)}
className={`w-full rounded-lg p-2 text-left transition-colors ${
activo
? 'bg-blue-50 dark:bg-blue-900/30'
: 'hover:bg-[#F8FAFC] dark:hover:bg-[#334155]'
}`}
>
<div className="flex items-center justify-between">
<span
className={`text-sm font-medium ${
activo
? 'text-accent'
: 'text-[#111827] dark:text-[#E2E8F0]'
}`}
>
{label}
</span>
<span className={`text-xs font-semibold ${colorTexto(r.porcentaje)}`}>
{r.porcentaje}%
</span>
</div>
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-[#E5E7EB] dark:bg-[#334155]">
<div
className={`h-full rounded-full transition-all ${colorBarra(r.porcentaje)}`}
style={{ width: `${r.porcentaje}%` }}
/>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{r.camposLlenos} de {r.camposTotales} campos
</p>
</button>
</li>
)
})}
</ul>
</aside>
)
}

View File

@@ -0,0 +1,209 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { TablaCaptura } from './TablaCaptura'
import type { CampoDef, DatosCaptura } from '../services/captura'
const CAMPOS_OBRA: CampoDef[] = [
{ campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas' },
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
]
const CAMPOS_VENTAS: CampoDef[] = [
{ campo: 'estatus', tipo: 'select', label: 'Estatus', options: ['DISPONIBLE', 'APARTADO', 'VENDIDO'] },
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
]
const DATOS: DatosCaptura = {
'409-1': { certificacion_gas: 'true', comentarios_obra: 'Listo' },
'409-2': { certificacion_gas: null, comentarios_obra: null },
}
const UNIDADES = ['409-1', '409-2']
afterEach(() => { cleanup() })
describe('TablaCaptura', () => {
it('renderiza la tabla con unidades y campos', () => {
const onGuardar = vi.fn()
render(
<TablaCaptura
unidades={UNIDADES}
campos={CAMPOS_OBRA}
datos={DATOS}
onGuardar={onGuardar}
guardando={false}
/>,
)
expect(screen.getByText('409-1')).toBeTruthy()
expect(screen.getByText('409-2')).toBeTruthy()
expect(screen.getByText('Certificación Gas')).toBeTruthy()
expect(screen.getByText('Comentarios')).toBeTruthy()
})
it('muestra checkboxes para campos boolean', () => {
const onGuardar = vi.fn()
render(
<TablaCaptura
unidades={UNIDADES}
campos={CAMPOS_OBRA}
datos={DATOS}
onGuardar={onGuardar}
guardando={false}
/>,
)
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes.length).toBe(2) // 2 unidades * 1 boolean campo
// Primera unidad tiene certificacion_gas = 'true'
expect((checkboxes[0] as HTMLInputElement).checked).toBe(true)
// Segunda unidad tiene null
expect((checkboxes[1] as HTMLInputElement).checked).toBe(false)
})
it('muestra select para campos select', () => {
const onGuardar = vi.fn()
render(
<TablaCaptura
unidades={UNIDADES}
campos={CAMPOS_VENTAS}
datos={{}}
onGuardar={onGuardar}
guardando={false}
/>,
)
const selects = screen.getAllByRole('combobox')
expect(selects.length).toBe(2) // 2 unidades * 1 select campo
})
it('muestra "sin unidades" cuando la lista está vacía', () => {
const onGuardar = vi.fn()
render(
<TablaCaptura
unidades={[]}
campos={CAMPOS_OBRA}
datos={{}}
onGuardar={onGuardar}
guardando={false}
/>,
)
expect(screen.getByText(/No hay unidades/)).toBeTruthy()
})
it('edita una celda y muestra el botón de guardar', () => {
const onGuardar = vi.fn()
render(
<TablaCaptura
unidades={UNIDADES}
campos={CAMPOS_OBRA}
datos={DATOS}
onGuardar={onGuardar}
guardando={false}
/>,
)
// Click en el checkbox de la segunda unidad (cambiar de unchecked a checked)
const checkboxes = screen.getAllByRole('checkbox')
fireEvent.click(checkboxes[1])
// Debería aparecer "Hay cambios sin guardar"
expect(screen.getByText('Hay cambios sin guardar')).toBeTruthy()
expect(screen.getByText('Guardar cambios')).toBeTruthy()
expect(screen.getByText('Descartar')).toBeTruthy()
})
it('click en Guardar llama a onGuardar con los cambios dirty', () => {
const onGuardar = vi.fn()
render(
<TablaCaptura
unidades={UNIDADES}
campos={CAMPOS_OBRA}
datos={DATOS}
onGuardar={onGuardar}
guardando={false}
/>,
)
// Cambiar checkbox de la segunda unidad
const checkboxes = screen.getAllByRole('checkbox')
fireEvent.click(checkboxes[1])
// Click en Guardar
fireEvent.click(screen.getByText('Guardar cambios'))
expect(onGuardar).toHaveBeenCalledTimes(1)
const dirty = onGuardar.mock.calls[0][0]
expect(dirty['409-2']).toEqual({ certificacion_gas: 'true' })
})
it('click en Descartar elimina los cambios', () => {
const onGuardar = vi.fn()
render(
<TablaCaptura
unidades={UNIDADES}
campos={CAMPOS_OBRA}
datos={DATOS}
onGuardar={onGuardar}
guardando={false}
/>,
)
// Cambiar checkbox
const checkboxes = screen.getAllByRole('checkbox')
fireEvent.click(checkboxes[1])
// Descartar
fireEvent.click(screen.getByText('Descartar'))
// Ya no debería mostrar "Hay cambios sin guardar"
expect(screen.queryByText('Hay cambios sin guardar')).toBeNull()
})
it('filtra por estatus', () => {
const onGuardar = vi.fn()
const datosConEstatus: DatosCaptura = {
'409-1': { estatus: 'VENDIDO' },
'409-2': { estatus: 'DISPONIBLE' },
}
render(
<TablaCaptura
unidades={UNIDADES}
campos={CAMPOS_VENTAS}
datos={datosConEstatus}
onGuardar={onGuardar}
guardando={false}
filtroEstatus="VENDIDO"
/>,
)
expect(screen.getByText('409-1')).toBeTruthy()
expect(screen.queryByText('409-2')).toBeNull()
})
it('paginación: muestra paginación cuando hay >50 unidades', () => {
const onGuardar = vi.fn()
const unidadesGrandes = Array.from({ length: 60 }, (_, i) => `U-${String(i + 1).padStart(3, '0')}`)
render(
<TablaCaptura
unidades={unidadesGrandes}
campos={CAMPOS_OBRA}
datos={{}}
onGuardar={onGuardar}
guardando={false}
/>,
)
expect(screen.getByText(/Mostrando 1–50 de 60/)).toBeTruthy()
expect(screen.getByText('Anterior')).toBeTruthy()
expect(screen.getByText('Siguiente')).toBeTruthy()
// Navigate to page 2
fireEvent.click(screen.getByText('Siguiente'))
expect(screen.getByText(/Mostrando 51–60 de 60/)).toBeTruthy()
})
})

View File

@@ -0,0 +1,296 @@
import { useMemo, useState, useCallback } from 'react'
import type { CampoDef, DatosCaptura } from '../services/captura'
// ─── Tipos ──────────────────────────────────────────────────────────
export type DirtyMap = Record<string, Record<string, string | null>>
interface Props {
unidades: string[]
campos: CampoDef[]
datos: DatosCaptura
onGuardar: (dirty: DirtyMap) => void
guardando: boolean
/** Filtro de estatus de unidad (columna estestatus en VENTAS) */
filtroEstatus?: string
}
const POR_PAGINA = 50
// ─── Utilidades ─────────────────────────────────────────────────────
/** Obtener valor original de una celda */
function valorOriginal(datos: DatosCaptura, unidad: string, campo: string): string | null {
return datos[unidad]?.[campo] ?? null
}
// ─── Componente de celda ─────────────────────────────────────────────
interface CeldaProps {
tipo: string
valor: string | null
options?: string[]
onChange: (nuevo: string | null) => void
dirty: boolean
}
function CeldaEditable({ tipo, valor, options, onChange, dirty }: CeldaProps) {
const bordeDirty = dirty ? 'ring-2 ring-accent/40' : ''
if (tipo === 'boolean') {
return (
<label className="flex items-center justify-center">
<input
type="checkbox"
checked={valor === 'true'}
onChange={(e) => onChange(e.target.checked ? 'true' : null)}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20 dark:border-[#334155]"
/>
</label>
)
}
if (tipo === 'select' && options) {
return (
<select
value={valor ?? ''}
onChange={(e) => onChange(e.target.value || null)}
className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`}
>
<option value="">—</option>
{options.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
)
}
if (tipo === 'date') {
return (
<input
type="date"
value={valor ?? ''}
onChange={(e) => onChange(e.target.value || null)}
className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`}
/>
)
}
if (tipo === 'number') {
return (
<input
type="number"
value={valor ?? ''}
onChange={(e) => onChange(e.target.value || null)}
className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`}
/>
)
}
// text default
return (
<input
type="text"
value={valor ?? ''}
onChange={(e) => onChange(e.target.value || null)}
className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`}
/>
)
}
// ─── Tabla principal ─────────────────────────────────────────────────
export function TablaCaptura({
unidades,
campos,
datos,
onGuardar,
guardando,
filtroEstatus,
}: Props) {
const [dirty, setDirty] = useState<DirtyMap>({})
const [pagina, setPagina] = useState(0)
/** Obtener estatus de una unidad (campo estatus en dept VENTAS, siempre disponible) */
const estatusUnidad = useCallback(
(unidad: string): string => {
return datos[unidad]?.['estatus'] ?? ''
},
[datos],
)
/** Unidades filtradas por estatus */
const unidadesFiltradas = useMemo(() => {
if (!filtroEstatus) return unidades
return unidades.filter((u) => estatusUnidad(u) === filtroEstatus)
}, [unidades, filtroEstatus, estatusUnidad])
/** Paginación */
const totalPaginas = Math.max(1, Math.ceil(unidadesFiltradas.length / POR_PAGINA))
const paginaActual = Math.min(pagina, totalPaginas - 1)
const unidadesPagina = unidadesFiltradas.slice(
paginaActual * POR_PAGINA,
(paginaActual + 1) * POR_PAGINA,
)
/** ¿Hay cambios pendientes? */
const tieneDirty = useMemo(() => {
for (const u of Object.values(dirty)) {
if (Object.keys(u).length > 0) return true
}
return false
}, [dirty])
/** Handler de cambio de celda */
const handleChange = useCallback(
(unidad: string, campo: string, nuevo: string | null) => {
const original = valorOriginal(datos, unidad, campo)
setDirty((prev) => {
const unidadDirty = { ...(prev[unidad] ?? {}) }
if (nuevo === original) {
delete unidadDirty[campo]
} else {
unidadDirty[campo] = nuevo
}
const next = { ...prev }
if (Object.keys(unidadDirty).length === 0) {
delete next[unidad]
} else {
next[unidad] = unidadDirty
}
return next
})
},
[datos],
)
/** Enviar cambios al padre */
const handleSubmit = useCallback(() => {
onGuardar(dirty)
setDirty({})
}, [dirty, onGuardar])
if (unidades.length === 0) {
return (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
No hay unidades registradas para este desarrollo.
</p>
)
}
return (
<div className="space-y-3">
{/* Barra de guardado */}
{tieneDirty && (
<div className="flex items-center gap-3 rounded-lg bg-amber-50 px-4 py-2 dark:bg-amber-900/20">
<span className="text-sm text-amber-700 dark:text-amber-300">
Hay cambios sin guardar
</span>
<button
type="button"
onClick={handleSubmit}
disabled={guardando}
className="rounded-lg bg-accent px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{guardando ? 'Guardando…' : 'Guardar cambios'}
</button>
<button
type="button"
onClick={() => setDirty({})}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-sm font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
Descartar
</button>
</div>
)}
{/* Tabla scrollable */}
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#1E293B]">
<th className="sticky left-0 z-10 border-r border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#94A3B8]">
Unidad
</th>
{campos.map((c) => (
<th
key={c.campo}
className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
title={c.label}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{unidadesPagina.map((unidad, idx) => (
<tr
key={unidad}
className={`border-b border-[#E5E7EB] dark:border-[#334155] last:border-0 ${
idx % 2 === 0
? 'bg-white dark:bg-[#1E293B]'
: 'bg-[#F9FAFB] dark:bg-[#0F172A]'
}`}
>
<td className="sticky left-0 z-10 border-r border-[#E5E7EB] bg-inherit px-3 py-2 text-xs font-semibold text-[#111827] dark:border-[#334155] dark:text-[#E2E8F0]">
{unidad}
</td>
{campos.map((c) => {
const val = valorOriginal(datos, unidad, c.campo)
const isDirty = dirty[unidad]?.[c.campo] !== undefined
return (
<td
key={`${unidad}-${c.campo}`}
className="px-1 py-0.5"
>
<CeldaEditable
tipo={c.tipo}
valor={val}
options={c.options}
onChange={(nuevo) => handleChange(unidad, c.campo, nuevo)}
dirty={isDirty}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Paginación */}
{totalPaginas > 1 && (
<div className="flex items-center justify-between text-sm text-[#64748B] dark:text-[#94A3B8]">
<span>
Mostrando {paginaActual * POR_PAGINA + 1}–
{Math.min((paginaActual + 1) * POR_PAGINA, unidadesFiltradas.length)} de{' '}
{unidadesFiltradas.length} unidades
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPagina((p) => Math.max(0, p - 1))}
disabled={paginaActual === 0}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1 text-xs font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
Anterior
</button>
<span className="flex items-center px-2 text-xs">
{paginaActual + 1} / {totalPaginas}
</span>
<button
type="button"
onClick={() => setPagina((p) => Math.min(totalPaginas - 1, p + 1))}
disabled={paginaActual >= totalPaginas - 1}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1 text-xs font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
Siguiente
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -15,6 +15,7 @@ const INICIALES: Record<string, string> = {
tableros: 'TB',
'mi-dia': 'MD',
contactos: 'CT',
captura: 'CA',
}
/** Tarjeta de acceso a un módulo del ecosistema */