feat(captura): fecha junto a checkboxes de Obra con semaforo verde/rojo

This commit is contained in:
2026-09-12 07:30:23 +00:00
parent 852182fabc
commit 1791dd69ba
4 changed files with 135 additions and 19 deletions

View File

@@ -206,4 +206,49 @@ describe('TablaCaptura', () => {
fireEvent.click(screen.getByText('Siguiente'))
expect(screen.getByText(/Mostrando 51–60 de 60/)).toBeTruthy()
})
it('boolean con conFecha: fecha + checkbox en una celda, semáforo y dirty del campo fecha', () => {
const onGuardar = vi.fn()
const camposEmparejados: CampoDef[] = [
{ campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas', conFecha: 'certificacion_gas_fecha' },
{ campo: 'certificacion_gas_fecha', tipo: 'date', label: 'Fecha Certificación Gas' },
]
const datosEmparejados: DatosCaptura = {
// Check activo → fecha en verde
'409-1': { certificacion_gas: 'true', certificacion_gas_fecha: '2026-01-01' },
// Fecha pasada sin check → fecha en rojo
'409-2': { certificacion_gas: null, certificacion_gas_fecha: '2026-01-01' },
}
const { container } = render(
<TablaCaptura
unidades={UNIDADES}
campos={camposEmparejados}
datos={datosEmparejados}
onGuardar={onGuardar}
guardando={false}
/>,
)
// La columna standalone de fecha se oculta: solo Unidad + Certificación Gas
expect(screen.getAllByRole('columnheader')).toHaveLength(2)
expect(screen.queryByText('Fecha Certificación Gas')).toBeNull()
// Un date input y un checkbox por unidad
const fechas = container.querySelectorAll('input[type="date"]')
expect(fechas).toHaveLength(2)
expect((fechas[0] as HTMLInputElement).value).toBe('2026-01-01')
// Semáforo: check activo → verde; fecha pasada sin check → rojo
expect(fechas[0].className).toContain('text-green-600')
expect(fechas[1].className).toContain('text-red-600')
// Cambiar la fecha marca dirty en el campo _fecha y se guarda en el bulk
fireEvent.change(fechas[1], { target: { value: '2026-12-01' } })
fireEvent.click(screen.getByText('Guardar cambios'))
expect(onGuardar).toHaveBeenCalledTimes(1)
const dirty = onGuardar.mock.calls[0][0]
expect(dirty['409-2']).toEqual({ certificacion_gas_fecha: '2026-12-01' })
})
})

View File

@@ -24,6 +24,14 @@ function valorOriginal(datos: DatosCaptura, unidad: string, campo: string): stri
return datos[unidad]?.[campo] ?? null
}
/** Fecha de hoy en formato ISO local (YYYY-MM-DD) */
function hoyISO(): string {
const d = new Date()
const mes = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${mes}-${dia}`
}
// ─── Componente de celda ─────────────────────────────────────────────
interface CeldaProps {
@@ -32,21 +40,53 @@ interface CeldaProps {
options?: string[]
onChange: (nuevo: string | null) => void
dirty: boolean
/** Campo de fecha emparejado (booleanos de Obra) */
conFecha?: string
valorFecha?: string | null
dirtyFecha?: boolean
onFechaChange?: (nuevo: string | null) => void
}
function CeldaEditable({ tipo, valor, options, onChange, dirty }: CeldaProps) {
function CeldaEditable({
tipo,
valor,
options,
onChange,
dirty,
conFecha,
valorFecha,
dirtyFecha,
onFechaChange,
}: CeldaProps) {
const bordeDirty = dirty ? 'ring-2 ring-accent/40' : ''
if (tipo === 'boolean') {
const checked = valor === 'true'
// Semáforo del campo fecha emparejado (Obra): verde si el check está activo,
// rojo si la fecha ya pasó sin activarse, neutro mientras no llegue la fecha.
const colorFecha = checked
? 'text-green-600 dark:text-green-400'
: valorFecha && valorFecha < hoyISO()
? 'text-red-600 dark:text-red-400'
: 'text-[#111827] dark:text-[#E2E8F0]'
const bordeFecha = dirtyFecha ? 'ring-2 ring-accent/40' : ''
return (
<label className="flex items-center justify-center">
<div className="flex items-center justify-center gap-1">
{conFecha && (
<input
type="date"
value={valorFecha ?? ''}
onChange={(e) => onFechaChange?.(e.target.value || null)}
className={`w-28 border-0 bg-transparent px-1 py-0.5 text-xs focus:outline-none [color-scheme:light] dark:[color-scheme:dark] ${colorFecha} ${bordeFecha}`}
/>
)}
<input
type="checkbox"
checked={valor === 'true'}
checked={checked}
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>
</div>
)
}
@@ -133,6 +173,12 @@ export function TablaCaptura({
(paginaActual + 1) * POR_PAGINA,
)
/** Campos a renderizar: ocultar fechas emparejadas (viven dentro de su checkbox) */
const camposVisibles = useMemo(
() => campos.filter((c) => !campos.some((o) => o.conFecha === c.campo)),
[campos],
)
/** ¿Hay cambios pendientes? */
const tieneDirty = useMemo(() => {
for (const u of Object.values(dirty)) {
@@ -212,7 +258,7 @@ export function TablaCaptura({
<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) => (
{camposVisibles.map((c) => (
<th
key={c.campo}
className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
@@ -236,9 +282,10 @@ export function TablaCaptura({
<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) => {
{camposVisibles.map((c) => {
const val = valorOriginal(datos, unidad, c.campo)
const isDirty = dirty[unidad]?.[c.campo] !== undefined
const campoFecha = c.conFecha
return (
<td
key={`${unidad}-${c.campo}`}
@@ -250,6 +297,16 @@ export function TablaCaptura({
options={c.options}
onChange={(nuevo) => handleChange(unidad, c.campo, nuevo)}
dirty={isDirty}
conFecha={campoFecha}
valorFecha={
campoFecha ? valorOriginal(datos, unidad, campoFecha) : undefined
}
dirtyFecha={campoFecha ? dirty[unidad]?.[campoFecha] !== undefined : false}
onFechaChange={
campoFecha
? (nuevo) => handleChange(unidad, campoFecha, nuevo)
: undefined
}
/>
</td>
)

View File

@@ -7,6 +7,8 @@ export interface CampoDef {
tipo: string
label: string
options?: string[]
/** Campo de fecha emparejado (solo booleanos de Obra) */
conFecha?: string
}
export interface ResumenDepartamento {
@@ -25,12 +27,18 @@ export type DepartamentoId = (typeof DEPARTAMENTOS)[number]
export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
OBRA: [
{ campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas' },
{ campo: 'to_c4_fianza', tipo: 'boolean', label: 'TO y C4-Fianza' },
{ campo: 'regimen_inscrito', tipo: 'boolean', label: 'Régimen Inscrito' },
{ campo: 'no_oficial', tipo: 'boolean', label: 'No. Oficial' },
{ campo: 'clave_catastral', tipo: 'boolean', label: 'Clave Catastral' },
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo' },
{ campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas', conFecha: 'certificacion_gas_fecha' },
{ campo: 'certificacion_gas_fecha', tipo: 'date', label: 'Fecha Certificación Gas' },
{ campo: 'to_c4_fianza', tipo: 'boolean', label: 'TO y C4-Fianza', conFecha: 'to_c4_fianza_fecha' },
{ campo: 'to_c4_fianza_fecha', tipo: 'date', label: 'Fecha TO y C4-Fianza' },
{ campo: 'regimen_inscrito', tipo: 'boolean', label: 'Régimen Inscrito', conFecha: 'regimen_inscrito_fecha' },
{ campo: 'regimen_inscrito_fecha', tipo: 'date', label: 'Fecha Régimen Inscrito' },
{ campo: 'no_oficial', tipo: 'boolean', label: 'No. Oficial', conFecha: 'no_oficial_fecha' },
{ campo: 'no_oficial_fecha', tipo: 'date', label: 'Fecha No. Oficial' },
{ campo: 'clave_catastral', tipo: 'boolean', label: 'Clave Catastral', conFecha: 'clave_catastral_fecha' },
{ campo: 'clave_catastral_fecha', tipo: 'date', label: 'Fecha Clave Catastral' },
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
],