diff --git a/api-gateway/src/controllers/captura-departamento.controller.test.ts b/api-gateway/src/controllers/captura-departamento.controller.test.ts index 2dddf5f..86f3771 100644 --- a/api-gateway/src/controllers/captura-departamento.controller.test.ts +++ b/api-gateway/src/controllers/captura-departamento.controller.test.ts @@ -148,7 +148,7 @@ describe('guardarEndpoint (POST /api/captura)', () => { res, ) expect(status).toHaveBeenCalledWith(204) - expect(capturaService.guardarCampo).toHaveBeenCalledWith('SENDA', 'U-101', 'VENTAS', 'estatus', 'VENDIDO', 'u1') + expect(capturaService.guardarCampo).toHaveBeenCalledWith('SENDA', 'U-101', 'VENTAS', 'estatus', 'VENDIDO', 'u1', 'admin') }) it('rechaza departamento inválido', async () => { @@ -190,7 +190,7 @@ describe('bulkEndpoint (POST /api/captura/bulk)', () => { expect(capturaService.guardarBulk).toHaveBeenCalledWith('SENDA', 'VENTAS', [ { unidad: 'U-101', campo: 'estatus', valor: 'VENDIDO' }, { unidad: 'U-102', campo: 'estatus', valor: 'DISPONIBLE' }, - ], 'u1') + ], 'u1', 'admin') }) }) diff --git a/api-gateway/src/controllers/captura-departamento.controller.ts b/api-gateway/src/controllers/captura-departamento.controller.ts index 3ea4a21..a831d4a 100644 --- a/api-gateway/src/controllers/captura-departamento.controller.ts +++ b/api-gateway/src/controllers/captura-departamento.controller.ts @@ -75,6 +75,7 @@ export async function guardarEndpoint(req: Request, res: Response): Promise { body.departamento, body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })), usuarioId, + req.usuarioToken?.rol ?? '', ) res.status(204).end() } diff --git a/api-gateway/src/services/captura-departamento.service.test.ts b/api-gateway/src/services/captura-departamento.service.test.ts index cfcaf20..41cc41c 100644 --- a/api-gateway/src/services/captura-departamento.service.test.ts +++ b/api-gateway/src/services/captura-departamento.service.test.ts @@ -97,7 +97,7 @@ describe('obtenerCapturaUnidad', () => { describe('guardarCampo', () => { it('hace upsert del campo', async () => { prismaMock.capturaDepartamento.upsert.mockResolvedValue({}) - await guardarCampo('SENDA', 'U-101', 'VENTAS', 'estatus', 'VENDIDO', 'user-1') + await guardarCampo('SENDA', 'U-101', 'VENTAS', 'estatus', 'VENDIDO', 'user-1', 'admin') expect(prismaMock.capturaDepartamento.upsert).toHaveBeenCalledWith({ where: { desarrollo_unidadCodigo_departamento_campo: { @@ -122,7 +122,7 @@ describe('guardarCampo', () => { it('permite valor null', async () => { prismaMock.capturaDepartamento.upsert.mockResolvedValue({}) - await guardarCampo('SENDA', 'U-101', 'OBRA', 'comentarios_obra', null, 'user-1') + await guardarCampo('SENDA', 'U-101', 'OBRA', 'comentarios_obra', null, 'user-1', 'admin') expect(prismaMock.capturaDepartamento.upsert).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ valor: null }), @@ -137,7 +137,7 @@ describe('guardarBulk', () => { await guardarBulk('SENDA', 'VENTAS', [ { unidad: 'U-101', campo: 'estatus', valor: 'VENDIDO' }, { unidad: 'U-101', campo: 'modelo', valor: 'Casa A' }, - ], 'user-1') + ], 'user-1', 'admin') expect(prismaMock.$transaction).toHaveBeenCalledTimes(1) expect(prismaMock.$transaction).toHaveBeenCalledWith(expect.arrayContaining([ expect.any(Promise), @@ -145,6 +145,55 @@ describe('guardarBulk', () => { }) }) +describe('fechas de Obra bloqueadas', () => { + const CAMPO_FECHA = 'certificacion_gas_fecha' + + it('admin puede modificar una fecha ya establecida', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-101', campo: CAMPO_FECHA, valor: '2026-01-01' }, + ]) + prismaMock.capturaDepartamento.upsert.mockResolvedValue({}) + await guardarCampo('SENDA', 'U-101', 'OBRA', CAMPO_FECHA, '2026-02-02', 'user-1', 'admin') + expect(prismaMock.capturaDepartamento.upsert).toHaveBeenCalled() + }) + + it('no-admin no puede modificar una fecha ya establecida', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-101', campo: CAMPO_FECHA, valor: '2026-01-01' }, + ]) + await expect( + guardarCampo('SENDA', 'U-101', 'OBRA', CAMPO_FECHA, '2026-02-02', 'user-1', 'obra'), + ).rejects.toThrow('solo puede modificarlas un administrador') + expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled() + }) + + it('no-admin puede establecer una fecha por primera vez', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([]) + prismaMock.capturaDepartamento.upsert.mockResolvedValue({}) + await guardarCampo('SENDA', 'U-101', 'OBRA', CAMPO_FECHA, '2026-03-03', 'user-1', 'obra') + expect(prismaMock.capturaDepartamento.upsert).toHaveBeenCalled() + }) + + it('bulk: rechaza completo si algún registro cambia una fecha establecida (no-admin)', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-101', campo: CAMPO_FECHA, valor: '2026-01-01' }, + ]) + await expect( + guardarBulk( + 'SENDA', + 'OBRA', + [ + { unidad: 'U-101', campo: 'comentarios_obra', valor: 'ok' }, + { unidad: 'U-101', campo: CAMPO_FECHA, valor: '2026-05-05' }, + ], + 'user-1', + 'obra', + ), + ).rejects.toThrow('solo puede modificarlas un administrador') + expect(prismaMock.$transaction).not.toHaveBeenCalled() + }) +}) + describe('resumenAvance', () => { it('calcula porcentaje de avance por departamento', async () => { // Mock para count (campos llenos) diff --git a/api-gateway/src/services/captura-departamento.service.ts b/api-gateway/src/services/captura-departamento.service.ts index c8d1124..87379f4 100644 --- a/api-gateway/src/services/captura-departamento.service.ts +++ b/api-gateway/src/services/captura-departamento.service.ts @@ -1,5 +1,42 @@ import { prisma } from './prisma.js' import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js' +import { AppError } from '../utils/errors.js' + +/** Fechas de Obra emparejadas a checkboxes: tras establecerse solo las cambia un admin */ +const CAMPOS_FECHA_OBRA = new Set( + (CAMPOS_POR_DEPARTAMENTO.OBRA ?? []) + .filter((c) => c.conFecha) + .map((c) => c.conFecha as string), +) + +/** Bloquear modificación de fechas de Obra ya establecidas (solo admin) */ +async function validarFechasObra( + desarrollo: string, + departamento: string, + registros: { unidad: string; campo: string; valor: string | null }[], + rol: string, +): Promise { + if (rol === 'admin' || departamento !== 'OBRA') return + const candidatos = registros.filter((r) => CAMPOS_FECHA_OBRA.has(r.campo)) + if (candidatos.length === 0) return + + const existentes = await prisma.capturaDepartamento.findMany({ + where: { + desarrollo, + departamento, + unidadCodigo: { in: [...new Set(candidatos.map((r) => r.unidad))] }, + campo: { in: candidatos.map((r) => r.campo) }, + }, + }) + const previos = new Map(existentes.map((e) => [`${e.unidadCodigo}|${e.campo}`, e.valor])) + + for (const r of candidatos) { + const previo = previos.get(`${r.unidad}|${r.campo}`) + if (previo != null && previo !== r.valor) { + throw new AppError(403, 'Las fechas ya establecidas solo puede modificarlas un administrador') + } + } +} // ── Obtener unidades únicas de un desarrollo ────────────────────────── @@ -67,7 +104,10 @@ export async function guardarCampo( campo: string, valor: string | null, usuarioId: string, + rol: string, ): Promise { + await validarFechasObra(desarrollo, departamento, [{ unidad: unidadCodigo, campo, valor }], rol) + const tipo = CAMPOS_POR_DEPARTAMENTO[departamento]?.find((c) => c.campo === campo)?.tipo ?? 'text' await prisma.capturaDepartamento.upsert({ @@ -91,7 +131,10 @@ export async function guardarBulk( departamento: string, registros: { unidad: string; campo: string; valor: string | null }[], usuarioId: string, + rol: string, ): Promise { + await validarFechasObra(desarrollo, departamento, registros, rol) + const tipoPorCampo = new Map( (CAMPOS_POR_DEPARTAMENTO[departamento] ?? []).map((c) => [c.campo, c.tipo]), ) diff --git a/apps/landing/src/components/TablaCaptura.test.tsx b/apps/landing/src/components/TablaCaptura.test.tsx index 3d977d6..48a1c3a 100644 --- a/apps/landing/src/components/TablaCaptura.test.tsx +++ b/apps/landing/src/components/TablaCaptura.test.tsx @@ -277,4 +277,46 @@ describe('TablaCaptura', () => { expect(onGuardar).toHaveBeenCalledTimes(1) expect(onGuardar.mock.calls[0][0]['409-1']).toEqual({ comentarios_obra: 'Nota X' }) }) + + it('fechas de Obra guardadas: disabled para no-admin, editables para admin', () => { + 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 datosConFecha: DatosCaptura = { + '409-1': { certificacion_gas: null, certificacion_gas_fecha: '2026-01-01' }, + '409-2': { certificacion_gas: null, certificacion_gas_fecha: null }, + } + + // No-admin: la fecha guardada se deshabilita, la vacía sigue editable + const r1 = render( + , + ) + const fechas1 = r1.container.querySelectorAll('input[type="date"]') + expect((fechas1[0] as HTMLInputElement).disabled).toBe(true) + expect((fechas1[1] as HTMLInputElement).disabled).toBe(false) + r1.unmount() + + // Admin: todas editables + const r2 = render( + , + ) + const fechas2 = r2.container.querySelectorAll('input[type="date"]') + expect((fechas2[0] as HTMLInputElement).disabled).toBe(false) + expect((fechas2[1] as HTMLInputElement).disabled).toBe(false) + }) }) diff --git a/apps/landing/src/components/TablaCaptura.tsx b/apps/landing/src/components/TablaCaptura.tsx index aa2bf6b..7b8109c 100644 --- a/apps/landing/src/components/TablaCaptura.tsx +++ b/apps/landing/src/components/TablaCaptura.tsx @@ -13,6 +13,8 @@ interface Props { guardando: boolean /** Filtro de estatus de unidad (columna estestatus en VENTAS) */ filtroEstatus?: string + /** Si es false, las fechas de Obra ya guardadas no se pueden editar (solo admin) */ + puedeEditarFechas?: boolean } const POR_PAGINA = 50 @@ -57,6 +59,8 @@ interface CeldaProps { valorFecha?: string | null dirtyFecha?: boolean onFechaChange?: (nuevo: string | null) => void + /** Fecha ya establecida y usuario sin permiso de edición */ + fechaBloqueada?: boolean } function CeldaEditable({ @@ -69,6 +73,7 @@ function CeldaEditable({ valorFecha, dirtyFecha, onFechaChange, + fechaBloqueada, }: CeldaProps) { const bordeDirty = dirty ? 'ring-2 ring-accent/40' : '' @@ -88,8 +93,14 @@ function CeldaEditable({ 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}`} + title={ + fechaBloqueada + ? 'Fecha ya establecida — solo un administrador puede modificarla' + : undefined + } + className={`w-28 border-0 bg-transparent px-1 py-0.5 text-xs focus:outline-none [color-scheme:light] dark:[color-scheme:dark] disabled:cursor-not-allowed ${colorFecha} ${bordeFecha}`} /> )} ({}) const [pagina, setPagina] = useState(0) @@ -314,6 +326,12 @@ export function TablaCaptura({ campoFecha ? valorEfectivo(dirty, datos, unidad, campoFecha) : undefined } dirtyFecha={campoFecha ? dirty[unidad]?.[campoFecha] !== undefined : false} + fechaBloqueada={ + campoFecha + ? !puedeEditarFechas && + valorOriginal(datos, unidad, campoFecha) != null + : false + } onFechaChange={ campoFecha ? (nuevo) => handleChange(unidad, campoFecha, nuevo) diff --git a/apps/landing/src/pages/CapturaPage.tsx b/apps/landing/src/pages/CapturaPage.tsx index 572f7ec..c88816e 100644 --- a/apps/landing/src/pages/CapturaPage.tsx +++ b/apps/landing/src/pages/CapturaPage.tsx @@ -87,8 +87,12 @@ export function CapturaPage({ onVolver }: Props) { await guardarBulk({ desarrollo, departamento, registros }) toast.success('Cambios guardados') await cargar() - } catch { - toast.error('No se pudieron guardar los cambios') + } catch (e) { + toast.error( + e instanceof Error && e.message && !e.message.startsWith('HTTP') + ? e.message + : 'No se pudieron guardar los cambios', + ) } finally { setGuardando(false) } @@ -215,6 +219,7 @@ export function CapturaPage({ onVolver }: Props) { onGuardar={handleGuardar} guardando={guardando} filtroEstatus={filtroEstatus} + puedeEditarFechas={rol === 'admin'} /> )} diff --git a/apps/landing/src/services/captura.ts b/apps/landing/src/services/captura.ts index 1a80a87..ce544c1 100644 --- a/apps/landing/src/services/captura.ts +++ b/apps/landing/src/services/captura.ts @@ -161,7 +161,10 @@ export async function guardarCampo(data: { }), ) manejar401(resp) - if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + if (!resp.ok) { + const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null + throw new Error(cuerpo?.error ?? `HTTP ${resp.status}`) + } } /** POST /api/captura/bulk — guardar múltiples campos */ @@ -183,5 +186,8 @@ export async function guardarBulk(data: { }), ) manejar401(resp) - if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + if (!resp.ok) { + const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null + throw new Error(cuerpo?.error ?? `HTTP ${resp.status}`) + } }