feat(captura): Escrituracion con 3 pares fecha+checkbox y columna Desviacion en dias
This commit is contained in:
@@ -30,6 +30,12 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
|
|||||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||||
],
|
],
|
||||||
ESCRITURACION: [
|
ESCRITURACION: [
|
||||||
|
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
|
||||||
|
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
|
||||||
|
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
||||||
|
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
||||||
|
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
||||||
|
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
||||||
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
||||||
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
||||||
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
||||||
|
|||||||
@@ -192,6 +192,24 @@ describe('fechas de Obra bloqueadas', () => {
|
|||||||
).rejects.toThrow('solo puede modificarlas un administrador')
|
).rejects.toThrow('solo puede modificarlas un administrador')
|
||||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('ESCRITURACION: no-admin no puede modificar una fecha ya establecida', async () => {
|
||||||
|
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||||
|
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
|
||||||
|
])
|
||||||
|
await expect(
|
||||||
|
guardarCampo(
|
||||||
|
'SENDA',
|
||||||
|
'U-101',
|
||||||
|
'ESCRITURACION',
|
||||||
|
'entrega_programada_fecha',
|
||||||
|
'2026-10-15',
|
||||||
|
'user-1',
|
||||||
|
'escrituracion',
|
||||||
|
),
|
||||||
|
).rejects.toThrow('solo puede modificarlas un administrador')
|
||||||
|
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('resumenAvance', () => {
|
describe('resumenAvance', () => {
|
||||||
|
|||||||
@@ -2,22 +2,24 @@ import { prisma } from './prisma.js'
|
|||||||
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js'
|
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js'
|
||||||
import { AppError } from '../utils/errors.js'
|
import { AppError } from '../utils/errors.js'
|
||||||
|
|
||||||
/** Fechas de Obra emparejadas a checkboxes: tras establecerse solo las cambia un admin */
|
/** Fechas emparejadas a checkboxes por departamento: tras establecerse solo las cambia un admin */
|
||||||
const CAMPOS_FECHA_OBRA = new Set(
|
const CAMPOS_FECHA_BLOQUEADOS: Record<string, Set<string>> = {}
|
||||||
(CAMPOS_POR_DEPARTAMENTO.OBRA ?? [])
|
for (const [dept, campos] of Object.entries(CAMPOS_POR_DEPARTAMENTO)) {
|
||||||
.filter((c) => c.conFecha)
|
const fechas = campos.filter((c) => c.conFecha).map((c) => c.conFecha as string)
|
||||||
.map((c) => c.conFecha as string),
|
if (fechas.length > 0) CAMPOS_FECHA_BLOQUEADOS[dept] = new Set(fechas)
|
||||||
)
|
}
|
||||||
|
|
||||||
/** Bloquear modificación de fechas de Obra ya establecidas (solo admin) */
|
/** Bloquear modificación de fechas ya establecidas (solo admin) */
|
||||||
async function validarFechasObra(
|
async function validarFechasBloqueadas(
|
||||||
desarrollo: string,
|
desarrollo: string,
|
||||||
departamento: string,
|
departamento: string,
|
||||||
registros: { unidad: string; campo: string; valor: string | null }[],
|
registros: { unidad: string; campo: string; valor: string | null }[],
|
||||||
rol: string,
|
rol: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (rol === 'admin' || departamento !== 'OBRA') return
|
if (rol === 'admin') return
|
||||||
const candidatos = registros.filter((r) => CAMPOS_FECHA_OBRA.has(r.campo))
|
const bloqueados = CAMPOS_FECHA_BLOQUEADOS[departamento]
|
||||||
|
if (!bloqueados) return
|
||||||
|
const candidatos = registros.filter((r) => bloqueados.has(r.campo))
|
||||||
if (candidatos.length === 0) return
|
if (candidatos.length === 0) return
|
||||||
|
|
||||||
const existentes = await prisma.capturaDepartamento.findMany({
|
const existentes = await prisma.capturaDepartamento.findMany({
|
||||||
@@ -106,7 +108,7 @@ export async function guardarCampo(
|
|||||||
usuarioId: string,
|
usuarioId: string,
|
||||||
rol: string,
|
rol: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await validarFechasObra(desarrollo, departamento, [{ unidad: unidadCodigo, campo, valor }], rol)
|
await validarFechasBloqueadas(desarrollo, departamento, [{ unidad: unidadCodigo, campo, valor }], rol)
|
||||||
|
|
||||||
const tipo = CAMPOS_POR_DEPARTAMENTO[departamento]?.find((c) => c.campo === campo)?.tipo ?? 'text'
|
const tipo = CAMPOS_POR_DEPARTAMENTO[departamento]?.find((c) => c.campo === campo)?.tipo ?? 'text'
|
||||||
|
|
||||||
@@ -133,7 +135,7 @@ export async function guardarBulk(
|
|||||||
usuarioId: string,
|
usuarioId: string,
|
||||||
rol: string,
|
rol: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await validarFechasObra(desarrollo, departamento, registros, rol)
|
await validarFechasBloqueadas(desarrollo, departamento, registros, rol)
|
||||||
|
|
||||||
const tipoPorCampo = new Map(
|
const tipoPorCampo = new Map(
|
||||||
(CAMPOS_POR_DEPARTAMENTO[departamento] ?? []).map((c) => [c.campo, c.tipo]),
|
(CAMPOS_POR_DEPARTAMENTO[departamento] ?? []).map((c) => [c.campo, c.tipo]),
|
||||||
|
|||||||
@@ -319,4 +319,47 @@ describe('TablaCaptura', () => {
|
|||||||
expect((fechas2[0] as HTMLInputElement).disabled).toBe(false)
|
expect((fechas2[0] as HTMLInputElement).disabled).toBe(false)
|
||||||
expect((fechas2[1] as HTMLInputElement).disabled).toBe(false)
|
expect((fechas2[1] as HTMLInputElement).disabled).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('ESCRITURACION: pares fecha+checkbox y columna Desviación calculada', () => {
|
||||||
|
const onGuardar = vi.fn()
|
||||||
|
const camposEscrituracion: CampoDef[] = [
|
||||||
|
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
||||||
|
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
||||||
|
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
||||||
|
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
||||||
|
]
|
||||||
|
const datosEscrituracion: DatosCaptura = {
|
||||||
|
// 15 días de desviación
|
||||||
|
'409-1': { entrega_programada: 'true', entrega_programada_fecha: '2026-01-01', entrega_reprogramada: 'true', entrega_reprogramada_fecha: '2026-01-16' },
|
||||||
|
// Falta reprogramada → desviación '—'
|
||||||
|
'409-2': { entrega_programada: null, entrega_programada_fecha: '2026-02-01', entrega_reprogramada: null, entrega_reprogramada_fecha: null },
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={UNIDADES}
|
||||||
|
campos={camposEscrituracion}
|
||||||
|
datos={datosEscrituracion}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Columnas: Unidad + 2 checkboxes emparejados + Desviación (fechas ocultas)
|
||||||
|
expect(screen.getAllByRole('columnheader')).toHaveLength(4)
|
||||||
|
expect(screen.getByText('Desviación')).toBeTruthy()
|
||||||
|
|
||||||
|
// Desviación: 15 días en la primera unidad, '—' en la segunda
|
||||||
|
const celdas1 = container.querySelectorAll('tbody tr')[0]?.querySelectorAll('td')
|
||||||
|
expect(celdas1?.[celdas1.length - 1]?.textContent).toBe('15')
|
||||||
|
const celdas2 = container.querySelectorAll('tbody tr')[1]?.querySelectorAll('td')
|
||||||
|
expect(celdas2?.[celdas2.length - 1]?.textContent).toBe('—')
|
||||||
|
|
||||||
|
// Editar la reprogramada recalcula la desviación al vuelo
|
||||||
|
const fechas = container.querySelectorAll('input[type="date"]')
|
||||||
|
expect(fechas).toHaveLength(4) // 2 fechas por unidad
|
||||||
|
fireEvent.change(fechas[3], { target: { value: '2026-02-21' } })
|
||||||
|
const celdas2b = container.querySelectorAll('tbody tr')[1]?.querySelectorAll('td')
|
||||||
|
expect(celdas2b?.[celdas2b.length - 1]?.textContent).toBe('20')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, useCallback } from 'react'
|
import { Fragment, useMemo, useState, useCallback } from 'react'
|
||||||
import type { CampoDef, DatosCaptura } from '../services/captura'
|
import type { CampoDef, DatosCaptura } from '../services/captura'
|
||||||
|
|
||||||
// ─── Tipos ──────────────────────────────────────────────────────────
|
// ─── Tipos ──────────────────────────────────────────────────────────
|
||||||
@@ -46,6 +46,16 @@ function hoyISO(): string {
|
|||||||
return `${d.getFullYear()}-${mes}-${dia}`
|
return `${d.getFullYear()}-${mes}-${dia}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Días de desviación entre entrega programada y reprogramada (null si falta una fecha) */
|
||||||
|
function diasDesviacion(dirty: DirtyMap, datos: DatosCaptura, unidad: string): number | null {
|
||||||
|
const prog = valorEfectivo(dirty, datos, unidad, 'entrega_programada_fecha')
|
||||||
|
const reprog = valorEfectivo(dirty, datos, unidad, 'entrega_reprogramada_fecha')
|
||||||
|
if (!prog || !reprog) return null
|
||||||
|
return Math.round(
|
||||||
|
(new Date(reprog).getTime() - new Date(prog).getTime()) / 86_400_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Componente de celda ─────────────────────────────────────────────
|
// ─── Componente de celda ─────────────────────────────────────────────
|
||||||
|
|
||||||
interface CeldaProps {
|
interface CeldaProps {
|
||||||
@@ -203,6 +213,14 @@ export function TablaCaptura({
|
|||||||
[campos],
|
[campos],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Columna virtual Desviación: activa si el depto tiene las dos fechas de entrega */
|
||||||
|
const tieneDesviacion = useMemo(
|
||||||
|
() =>
|
||||||
|
campos.some((c) => c.campo === 'entrega_programada_fecha') &&
|
||||||
|
campos.some((c) => c.campo === 'entrega_reprogramada_fecha'),
|
||||||
|
[campos],
|
||||||
|
)
|
||||||
|
|
||||||
/** ¿Hay cambios pendientes? */
|
/** ¿Hay cambios pendientes? */
|
||||||
const tieneDirty = useMemo(() => {
|
const tieneDirty = useMemo(() => {
|
||||||
for (const u of Object.values(dirty)) {
|
for (const u of Object.values(dirty)) {
|
||||||
@@ -283,13 +301,22 @@ export function TablaCaptura({
|
|||||||
Unidad
|
Unidad
|
||||||
</th>
|
</th>
|
||||||
{camposVisibles.map((c) => (
|
{camposVisibles.map((c) => (
|
||||||
<th
|
<Fragment key={c.campo}>
|
||||||
key={c.campo}
|
<th
|
||||||
className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
||||||
title={c.label}
|
title={c.label}
|
||||||
>
|
>
|
||||||
{c.label}
|
{c.label}
|
||||||
</th>
|
</th>
|
||||||
|
{tieneDesviacion && c.campo === 'entrega_reprogramada' && (
|
||||||
|
<th
|
||||||
|
className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
||||||
|
title="Días entre la entrega programada y la reprogramada"
|
||||||
|
>
|
||||||
|
Desviación
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -310,35 +337,40 @@ export function TablaCaptura({
|
|||||||
const val = valorEfectivo(dirty, datos, unidad, c.campo)
|
const val = valorEfectivo(dirty, datos, unidad, c.campo)
|
||||||
const isDirty = dirty[unidad]?.[c.campo] !== undefined
|
const isDirty = dirty[unidad]?.[c.campo] !== undefined
|
||||||
const campoFecha = c.conFecha
|
const campoFecha = c.conFecha
|
||||||
|
const esReprogramada = tieneDesviacion && c.campo === 'entrega_reprogramada'
|
||||||
return (
|
return (
|
||||||
<td
|
<Fragment key={`${unidad}-${c.campo}`}>
|
||||||
key={`${unidad}-${c.campo}`}
|
<td className="px-1 py-0.5">
|
||||||
className="px-1 py-0.5"
|
<CeldaEditable
|
||||||
>
|
tipo={c.tipo}
|
||||||
<CeldaEditable
|
valor={val}
|
||||||
tipo={c.tipo}
|
options={c.options}
|
||||||
valor={val}
|
onChange={(nuevo) => handleChange(unidad, c.campo, nuevo)}
|
||||||
options={c.options}
|
dirty={isDirty}
|
||||||
onChange={(nuevo) => handleChange(unidad, c.campo, nuevo)}
|
conFecha={campoFecha}
|
||||||
dirty={isDirty}
|
valorFecha={
|
||||||
conFecha={campoFecha}
|
campoFecha ? valorEfectivo(dirty, datos, unidad, campoFecha) : undefined
|
||||||
valorFecha={
|
}
|
||||||
campoFecha ? valorEfectivo(dirty, datos, unidad, campoFecha) : undefined
|
dirtyFecha={campoFecha ? dirty[unidad]?.[campoFecha] !== undefined : false}
|
||||||
}
|
fechaBloqueada={
|
||||||
dirtyFecha={campoFecha ? dirty[unidad]?.[campoFecha] !== undefined : false}
|
campoFecha
|
||||||
fechaBloqueada={
|
? !puedeEditarFechas &&
|
||||||
campoFecha
|
valorOriginal(datos, unidad, campoFecha) != null
|
||||||
? !puedeEditarFechas &&
|
: false
|
||||||
valorOriginal(datos, unidad, campoFecha) != null
|
}
|
||||||
: false
|
onFechaChange={
|
||||||
}
|
campoFecha
|
||||||
onFechaChange={
|
? (nuevo) => handleChange(unidad, campoFecha, nuevo)
|
||||||
campoFecha
|
: undefined
|
||||||
? (nuevo) => handleChange(unidad, campoFecha, nuevo)
|
}
|
||||||
: undefined
|
/>
|
||||||
}
|
</td>
|
||||||
/>
|
{esReprogramada && (
|
||||||
</td>
|
<td className="px-1 py-0.5 text-center text-xs text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{diasDesviacion(dirty, datos, unidad) ?? '—'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
|
|||||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||||
],
|
],
|
||||||
ESCRITURACION: [
|
ESCRITURACION: [
|
||||||
|
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
|
||||||
|
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
|
||||||
|
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
||||||
|
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
||||||
|
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
||||||
|
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
||||||
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
||||||
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
||||||
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
||||||
|
|||||||
Reference in New Issue
Block a user