diff --git a/README.md b/README.md index a3d946f..db2be5c 100644 --- a/README.md +++ b/README.md @@ -79,15 +79,57 @@ DonMarcoBackend/ ## 5. Ejecución con Docker (recomendado en el servidor LAN) -```bash +### Primera vez o actualización (importante: usar `--no-cache`) + +Si no ve la pestaña **"Cierre de Gestión"**, las imágenes Docker están **viejas**. Debe **reconstruir sin caché**: + +**Windows (PowerShell):** +```powershell cd DonMarcoBackend -docker compose up -d --build +.\docker-update.ps1 ``` -Acceso desde cualquier PC de la red: +**Linux / Git Bash:** +```bash +cd DonMarcoBackend +chmod +x docker-update.sh +./docker-update.sh +``` + +**Manual:** +```bash +docker compose down +docker compose build --no-cache +docker compose up -d +``` + +### Verificar que Docker tiene la versión correcta + +```bash +curl http://localhost:4000/api/health +``` + +Debe responder `"version": "1.2.0"` y `"features": ["historial-gestiones", "cierre-gestion", ...]`. + +En el navegador (`http://IP-DEL-SERVIDOR`): +- Pestaña **Administración** (solo usuario JEFE) → sección **Cierre de gestión** +- Cada planilla tiene **barra lateral** con gestiones anteriores (solo lectura) +- Al pie: **Don Marco v1.2.0 — Historial de gestiones (Docker)** + +### Acceso en la red - **Web:** `http://IP-DEL-SERVIDOR` (puerto 80) -- **API directa (opcional):** `http://IP-DEL-SERVIDOR:4000/api/health` +- **API:** `http://IP-DEL-SERVIDOR:4000/api/health` + +### Cierre de gestión (solo JEFE) + +1. Ingresar como `JEFE` / `MACAN` +2. Clic en **Administración** (menú superior) +3. En **Cierre de gestión**, elegir el **último día del mes** → **Cerrar gestión** +4. La planilla principal muestra solo la **gestión actual**; el **historial** está en la barra lateral (solo lectura) +5. Tras el cierre, los usuarios cargan filas nuevas con el botón **+** + +Los datos en SQLite se conservan en el volumen `donmarco_data` al actualizar Docker. ## 6. Desarrollo local (sin Docker) diff --git a/backend/src/db.js b/backend/src/db.js index dd684dd..9daa1f0 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -52,5 +52,23 @@ export function initDb() { ); CREATE INDEX IF NOT EXISTS idx_registros_sheet ON registros(sheet_type); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_by INTEGER, + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (updated_by) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS gestiones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + etiqueta TEXT NOT NULL, + fecha_inicio TEXT NOT NULL, + fecha_fin TEXT, + cerrada_at TEXT, + cerrada_por INTEGER, + FOREIGN KEY (cerrada_por) REFERENCES users(id) + ); `); } diff --git a/backend/src/index.js b/backend/src/index.js index 36eb4a3..f0ff62e 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -5,6 +5,8 @@ import authRoutes from './routes/auth.js'; import userRoutes from './routes/users.js'; import sheetRoutes from './routes/sheets.js'; import reportRoutes from './routes/reports.js'; +import settingsRoutes from './routes/settings.js'; +import gestionesRoutes from './routes/gestiones.js'; import bcrypt from 'bcryptjs'; import { db, SHEETS } from './db.js'; @@ -31,11 +33,20 @@ const PORT = process.env.PORT || 4000; app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '2mb' })); -app.get('/api/health', (_req, res) => res.json({ ok: true, service: 'DonMarco API' })); +app.get('/api/health', (_req, res) => + res.json({ + ok: true, + service: 'DonMarco API', + version: '1.2.0', + features: ['historial-gestiones', 'cierre-gestion', 'auto-guardado', 'reportes'], + }) +); app.use('/api/auth', authRoutes); app.use('/api/users', userRoutes); app.use('/api/sheets', sheetRoutes); app.use('/api/reports', reportRoutes); +app.use('/api/settings', settingsRoutes); +app.use('/api/gestiones', gestionesRoutes); app.listen(PORT, '0.0.0.0', () => { console.log(`Don Marco API escuchando en http://0.0.0.0:${PORT}`); diff --git a/backend/src/routes/gestiones.js b/backend/src/routes/gestiones.js new file mode 100644 index 0000000..1c00845 --- /dev/null +++ b/backend/src/routes/gestiones.js @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { authRequired } from '../middleware/auth.js'; +import { listGestiones, getGestionActual } from '../utils/gestion.js'; + +const router = Router(); +router.use(authRequired); + +router.get('/', (_req, res) => { + res.json({ + actual: getGestionActual(), + historial: listGestiones().filter((g) => !g.esActual), + todas: listGestiones(), + }); +}); + +export default router; diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..7a537aa --- /dev/null +++ b/backend/src/routes/settings.js @@ -0,0 +1,36 @@ +import { Router } from 'express'; +import { authRequired, adminRequired } from '../middleware/auth.js'; +import { + getGestionStatus, + cerrarGestionActual, + sugerirCierreMensual, +} from '../utils/gestion.js'; + +const router = Router(); + +router.get('/gestion', authRequired, (_req, res) => { + res.json({ ...getGestionStatus(), sugerirCierre: sugerirCierreMensual() }); +}); + +router.put('/gestion', authRequired, adminRequired, (req, res) => { + const { fechaCierreGestion } = req.body; + + if (!fechaCierreGestion) { + return res.status(400).json({ + error: 'Indique la fecha de cierre. Para consultar historial use la barra lateral de cada planilla.', + }); + } + + if (!/^\d{4}-\d{2}-\d{2}$/.test(fechaCierreGestion)) { + return res.status(400).json({ error: 'Fecha inválida. Use formato AAAA-MM-DD' }); + } + + try { + const result = cerrarGestionActual(fechaCierreGestion, req.user.id); + res.json({ ...getGestionStatus(), sugerirCierre: sugerirCierreMensual(), cierre: result }); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}); + +export default router; diff --git a/backend/src/routes/sheets.js b/backend/src/routes/sheets.js index 1a749ed..b439985 100644 --- a/backend/src/routes/sheets.js +++ b/backend/src/routes/sheets.js @@ -2,6 +2,17 @@ import { Router } from 'express'; import { db, SHEETS } from '../db.js'; import { authRequired } from '../middleware/auth.js'; import { sumIngresos, sumGastos, sumPersonal } from '../utils/calculations.js'; +import { + getGestionStatus, + getGestionActual, + getGestionById, + mergeRowsForSave, + mergePersonalForSave, + isFechaEnPeriodoCerrado, + filterRowsByGestion, + filterPersonalByGestion, + mensualBelongsToGestion, +} from '../utils/gestion.js'; const router = Router(); router.use(authRequired); @@ -19,45 +30,112 @@ function checkPermission(req, sheet, needEdit = false) { return !!row.can_view; } -function getSheetPayload(sheetType) { - const rows = db +function resolveGestionView(gestionId) { + if (!gestionId || gestionId === 'actual') { + return { gestion: getGestionActual(), esHistorial: false }; + } + const gestion = getGestionById(Number(gestionId)); + if (!gestion) return null; + return { gestion, esHistorial: !gestion.esActual }; +} + +function loadParsedRows(sheetType) { + return db .prepare('SELECT id, data, updated_at FROM registros WHERE sheet_type = ? ORDER BY id') - .all(sheetType); - const parsed = rows.map((r) => ({ id: r.id, ...JSON.parse(r.data), updated_at: r.updated_at })); + .all(sheetType) + .map((r) => ({ id: r.id, ...JSON.parse(r.data), updated_at: r.updated_at })); +} + +function annotateRows(rows, isAdmin, esHistorial) { + return rows.map((row) => ({ + ...row, + bloqueada: esHistorial || (!isAdmin && isFechaEnPeriodoCerrado(row.fecha)), + })); +} + +function getSheetPayload(sheetType, isAdmin, gestionView) { + const parsed = loadParsedRows(sheetType); + const gestionState = getGestionStatus(); + const { gestion, esHistorial } = gestionView; if (INGRESO_SHEETS.includes(sheetType)) { - const totals = sumIngresos(parsed); - return { rows: parsed, totals }; - } - if (GASTO_SHEETS.includes(sheetType)) { - const total = sumGastos(parsed); - return { rows: parsed, totals: { totalGastos: total } }; - } - if (sheetType === 'personal') { - const mensualizados = parsed.filter((r) => r.tipo === 'mensualizado'); - const eventuales = parsed.filter((r) => r.tipo === 'eventual'); + const filtered = filterRowsByGestion(parsed, gestion); + const rows = annotateRows(filtered, isAdmin, esHistorial); + const totals = sumIngresos(filtered); return { - mensualizados, - eventuales, - totals: { totalPersonal: sumPersonal(mensualizados, eventuales) }, + rows: rows.length ? rows : [], + totals, + gestion: gestionState, + gestionVista: gestion, + esHistorial, + soloLectura: esHistorial, }; } - return { rows: parsed }; + + if (GASTO_SHEETS.includes(sheetType)) { + const filtered = filterRowsByGestion(parsed, gestion); + const rows = annotateRows(filtered, isAdmin, esHistorial); + const total = sumGastos(filtered); + return { + rows: rows.length ? rows : [], + totals: { totalGastos: total }, + gestion: gestionState, + gestionVista: gestion, + esHistorial, + soloLectura: esHistorial, + }; + } + + if (sheetType === 'personal') { + const mensualizadosAll = parsed.filter((r) => r.tipo === 'mensualizado'); + const eventualesAll = parsed.filter((r) => r.tipo === 'eventual'); + const { mensualizados, eventuales } = filterPersonalByGestion( + mensualizadosAll, + eventualesAll, + gestion + ); + const mensualizadosAnn = mensualizados.map((r) => ({ + ...r, + bloqueada: esHistorial || (!isAdmin && !mensualBelongsToGestion(r, getGestionActual()) && r.gestionId), + })); + const eventualesAnn = eventuales.map((r) => ({ + ...r, + bloqueada: esHistorial || (!isAdmin && isFechaEnPeriodoCerrado(r.fecha)), + })); + return { + mensualizados: mensualizadosAnn, + eventuales: eventualesAnn, + totals: { totalPersonal: sumPersonal(mensualizados, eventuales) }, + gestion: gestionState, + gestionVista: gestion, + esHistorial, + soloLectura: esHistorial, + }; + } + + return { rows: parsed, gestion: gestionState, gestionVista: gestion, esHistorial }; } router.get('/:sheetType', (req, res) => { const { sheetType } = req.params; + const { gestionId } = req.query; + if (!SHEETS.includes(sheetType) || sheetType === 'reportes') { return res.status(400).json({ error: 'Planilla inválida' }); } if (!checkPermission(req, sheetType)) { return res.status(403).json({ error: 'Sin permiso para ver esta planilla' }); } - res.json(getSheetPayload(sheetType)); + + const gestionView = resolveGestionView(gestionId); + if (!gestionView) return res.status(404).json({ error: 'Gestión no encontrada' }); + + res.json(getSheetPayload(sheetType, req.user.isAdmin, gestionView)); }); router.put('/:sheetType', (req, res) => { const { sheetType } = req.params; + if (!SHEETS.includes(sheetType) || sheetType === 'reportes') { return res.status(400).json({ error: 'Planilla inválida' }); } @@ -65,7 +143,30 @@ router.put('/:sheetType', (req, res) => { return res.status(403).json({ error: 'Sin permiso para editar' }); } - const { rows, mensualizados, eventuales } = req.body; + const isAdmin = !!req.user.isAdmin; + const gestionActual = getGestionActual(); + const existing = loadParsedRows(sheetType); + let rowsToSave; + let personalMensual; + let personalEventual; + + try { + if (sheetType === 'personal') { + const merged = mergePersonalForSave( + existing, + req.body.mensualizados, + req.body.eventuales, + isAdmin, + gestionActual + ); + personalMensual = merged.mensualizados; + personalEventual = merged.eventuales; + } else { + rowsToSave = mergeRowsForSave(existing, req.body.rows, isAdmin, gestionActual, 'fecha'); + } + } catch (e) { + return res.status(403).json({ error: e.message }); + } db.prepare('DELETE FROM registros WHERE sheet_type = ?').run(sheetType); @@ -75,21 +176,21 @@ router.put('/:sheetType', (req, res) => { if (sheetType === 'personal') { const all = [ - ...(mensualizados || []).map((r) => ({ ...r, tipo: 'mensualizado' })), - ...(eventuales || []).map((r) => ({ ...r, tipo: 'eventual' })), + ...(personalMensual || []).map((r) => ({ ...r, tipo: 'mensualizado' })), + ...(personalEventual || []).map((r) => ({ ...r, tipo: 'eventual' })), ]; for (const row of all) { - const { id: _id, updated_at: _u, ...data } = row; + const { id: _id, updated_at: _u, bloqueada: _b, ...data } = row; insert.run(sheetType, JSON.stringify(data), req.user.id); } } else { - for (const row of rows || []) { - const { id: _id, updated_at: _u, ...data } = row; + for (const row of rowsToSave || []) { + const { id: _id, updated_at: _u, bloqueada: _b, ...data } = row; insert.run(sheetType, JSON.stringify(data), req.user.id); } } - res.json(getSheetPayload(sheetType)); + res.json(getSheetPayload(sheetType, isAdmin, { gestion: gestionActual, esHistorial: false })); }); export default router; diff --git a/backend/src/utils/gestion.js b/backend/src/utils/gestion.js new file mode 100644 index 0000000..03dd064 --- /dev/null +++ b/backend/src/utils/gestion.js @@ -0,0 +1,272 @@ +import { db } from '../db.js'; + +const LEGACY_KEY = 'fecha_cierre_gestion'; + +export function todayStr() { + return new Date().toISOString().slice(0, 10); +} + +function addDays(isoDate, days) { + const d = new Date(isoDate + 'T12:00:00'); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); +} + +function etiquetaFromRange(inicio, fin) { + const ref = fin || inicio; + const [y, m] = ref.split('-'); + const meses = [ + 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', + ]; + return `${meses[Number(m) - 1]} ${y}`; +} + +function mapGestion(row) { + if (!row) return null; + return { + id: row.id, + etiqueta: row.etiqueta, + fechaInicio: row.fecha_inicio, + fechaFin: row.fecha_fin, + esActual: row.fecha_fin === null, + cerradaAt: row.cerrada_at, + }; +} + +export function ensureGestionesMigrated() { + const count = db.prepare('SELECT COUNT(*) AS n FROM gestiones').get().n; + if (count > 0) return; + + const legacy = db.prepare('SELECT value FROM settings WHERE key = ?').get(LEGACY_KEY); + if (legacy?.value) { + const fin = legacy.value; + const inicio = `${fin.slice(0, 7)}-01`; + db.prepare( + `INSERT INTO gestiones (etiqueta, fecha_inicio, fecha_fin, cerrada_at) + VALUES (?, ?, ?, datetime('now'))` + ).run(etiquetaFromRange(inicio, fin), inicio, fin); + const nuevoInicio = addDays(fin, 1); + db.prepare( + `INSERT INTO gestiones (etiqueta, fecha_inicio, fecha_fin) VALUES (?, ?, NULL)` + ).run('Gestión actual', nuevoInicio); + db.prepare(`DELETE FROM settings WHERE key = ?`).run(LEGACY_KEY); + return; + } + + db.prepare( + `INSERT INTO gestiones (etiqueta, fecha_inicio, fecha_fin) VALUES ('Gestión actual', '2020-01-01', NULL)` + ).run(); +} + +export function listGestiones() { + ensureGestionesMigrated(); + return db + .prepare('SELECT * FROM gestiones ORDER BY fecha_inicio DESC') + .all() + .map(mapGestion); +} + +export function getGestionActual() { + ensureGestionesMigrated(); + const row = db + .prepare('SELECT * FROM gestiones WHERE fecha_fin IS NULL ORDER BY id DESC LIMIT 1') + .get(); + return mapGestion(row); +} + +export function getGestionById(id) { + ensureGestionesMigrated(); + const row = db.prepare('SELECT * FROM gestiones WHERE id = ?').get(id); + return mapGestion(row); +} + +export function findGestionForFecha(fecha) { + if (!fecha || String(fecha).trim() === '') return getGestionActual(); + const f = String(fecha).slice(0, 10); + const row = db + .prepare( + `SELECT * FROM gestiones + WHERE fecha_inicio <= ? AND (fecha_fin IS NULL OR fecha_fin >= ?) + ORDER BY id DESC LIMIT 1` + ) + .get(f, f); + return mapGestion(row); +} + +export function rowBelongsToGestion(row, gestion, fechaField = 'fecha') { + if (!gestion) return true; + const fecha = row[fechaField]; + if (!fecha || String(fecha).trim() === '') { + return gestion.esActual; + } + const f = String(fecha).slice(0, 10); + if (f < gestion.fechaInicio) return false; + if (gestion.fechaFin && f > gestion.fechaFin) return false; + return true; +} + +/** Personal mensualizado: por gestion_id en el registro */ +export function mensualBelongsToGestion(row, gestion) { + if (!gestion) return true; + if (gestion.esActual) { + return !row.gestionId || row.gestionId === gestion.id; + } + return row.gestionId === gestion.id; +} + +export function filterRowsByGestion(rows, gestion, fechaField = 'fecha') { + if (!gestion) return rows; + return rows.filter((r) => rowBelongsToGestion(r, gestion, fechaField)); +} + +export function filterPersonalByGestion(mensualizados, eventuales, gestion) { + if (!gestion) return { mensualizados, eventuales }; + return { + mensualizados: mensualizados.filter((r) => mensualBelongsToGestion(r, gestion)), + eventuales: eventuales.filter((r) => rowBelongsToGestion(r, gestion, 'fecha')), + }; +} + +export function isFechaEnPeriodoCerrado(fecha) { + const g = findGestionForFecha(fecha); + return g ? !g.esActual : false; +} + +export function isCierreActivo() { + return db.prepare('SELECT COUNT(*) AS n FROM gestiones WHERE fecha_fin IS NOT NULL').get().n > 0 + || !!getGestionActual()?.fechaInicio; +} + +export function getGestionStatus() { + ensureGestionesMigrated(); + const actual = getGestionActual(); + const historial = listGestiones().filter((g) => !g.esActual); + const ultimaCerrada = historial[0]; + + return { + gestionActual: actual, + historial, + fechaCierreGestion: ultimaCerrada?.fechaFin || null, + gestionCerrada: historial.length > 0, + hoy: todayStr(), + }; +} + +export function cerrarGestionActual(fechaFin, userId) { + ensureGestionesMigrated(); + const actual = db + .prepare('SELECT * FROM gestiones WHERE fecha_fin IS NULL ORDER BY id DESC LIMIT 1') + .get(); + if (!actual) throw new Error('No hay gestión actual abierta'); + + if (fechaFin < actual.fecha_inicio) { + throw new Error('La fecha de cierre no puede ser anterior al inicio de la gestión actual'); + } + + const etiqueta = etiquetaFromRange(actual.fecha_inicio, fechaFin); + db.prepare( + `UPDATE gestiones SET etiqueta = ?, fecha_fin = ?, cerrada_at = datetime('now'), cerrada_por = ? + WHERE id = ?` + ).run(etiqueta, fechaFin, userId, actual.id); + + const personalRows = db.prepare("SELECT id, data FROM registros WHERE sheet_type = 'personal'").all(); + for (const r of personalRows) { + const data = JSON.parse(r.data); + if (data.tipo === 'mensualizado' && !data.gestionId) { + data.gestionId = actual.id; + db.prepare('UPDATE registros SET data = ? WHERE id = ?').run(JSON.stringify(data), r.id); + } + } + + const nuevoInicio = addDays(fechaFin, 1); + const result = db + .prepare( + `INSERT INTO gestiones (etiqueta, fecha_inicio, fecha_fin) VALUES ('Gestión actual', ?, NULL)` + ) + .run(nuevoInicio); + + db.prepare(`DELETE FROM settings WHERE key = ?`).run(LEGACY_KEY); + + return { + cerrada: mapGestion({ ...actual, etiqueta, fecha_fin: fechaFin }), + nueva: mapGestion({ + id: result.lastInsertRowid, + etiqueta: 'Gestión actual', + fecha_inicio: nuevoInicio, + fecha_fin: null, + cerrada_at: null, + cerrada_por: null, + }), + }; +} + +function stripMeta(row) { + const { id, updated_at, bloqueada, gestionId: _g, ...rest } = row; + return rest; +} + +export function mergeRowsForSave(existing, incoming, isAdmin, gestionActual, fechaField = 'fecha') { + const gid = gestionActual?.id; + const historial = existing.filter((r) => { + const f = r[fechaField]; + if (!f || String(f).trim() === '') return false; + return !rowBelongsToGestion(r, gestionActual, fechaField); + }); + + let actuales; + if (isAdmin) { + actuales = (incoming || []).map((r) => ({ ...stripMeta(r), gestionId: gid })); + } else { + const locked = existing.filter( + (r) => rowBelongsToGestion(r, gestionActual, fechaField) && isFechaEnPeriodoCerrado(r[fechaField]) + ); + const unlockedIncoming = (incoming || []).filter((r) => !isFechaEnPeriodoCerrado(r[fechaField])); + actuales = [ + ...locked.map(stripMeta), + ...unlockedIncoming.map((r) => ({ ...stripMeta(r), gestionId: gid })), + ]; + } + + return [...historial.map(stripMeta), ...actuales]; +} + +export function mergePersonalForSave(existing, incomingMensual, incomingEventual, isAdmin, gestionActual) { + const gid = gestionActual?.id; + const existingM = existing.filter((r) => r.tipo === 'mensualizado'); + const existingE = existing.filter((r) => r.tipo === 'eventual'); + + const historialM = existingM.filter((r) => r.gestionId && r.gestionId !== gid); + const historialE = existingE.filter((r) => !rowBelongsToGestion(r, gestionActual, 'fecha')); + + let actualesM; + let actualesE; + + if (isAdmin) { + actualesM = (incomingMensual || []).map((r) => ({ ...stripMeta(r), gestionId: r.gestionId || gid })); + actualesE = (incomingEventual || []).map((r) => ({ ...stripMeta(r), gestionId: gid })); + } else { + const unlockedM = (incomingMensual || []).filter((r) => !r.gestionId || r.gestionId === gid); + const lockedE = existingE.filter( + (r) => rowBelongsToGestion(r, gestionActual, 'fecha') && isFechaEnPeriodoCerrado(r.fecha) + ); + const unlockedE = (incomingEventual || []).filter((r) => !isFechaEnPeriodoCerrado(r.fecha)); + actualesM = unlockedM.map((r) => ({ ...stripMeta(r), gestionId: gid })); + actualesE = [ + ...lockedE.map(stripMeta), + ...unlockedE.map((r) => ({ ...stripMeta(r), gestionId: gid })), + ]; + } + + return { + mensualizados: [...historialM.map(stripMeta), ...actualesM], + eventuales: [...historialE.map(stripMeta), ...actualesE], + }; +} + +/** Etiqueta sugerida: último día del mes actual */ +export function sugerirCierreMensual() { + const today = new Date(); + const last = new Date(today.getFullYear(), today.getMonth() + 1, 0); + return last.toISOString().slice(0, 10); +} diff --git a/docker-compose.yml b/docker-compose.yml index b5fe29e..dc19b19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: ./backend dockerfile: Dockerfile + image: donmarco-backend:1.2.0 container_name: donmarco-api restart: unless-stopped environment: @@ -15,6 +16,18 @@ services: - "4000:4000" networks: - donmarco_net + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:4000/api/health').then(r=>r.json()).then(d=>process.exit(d.version==='1.2.0'?0:1)).catch(()=>process.exit(1))", + ] + interval: 15s + timeout: 10s + retries: 5 + start_period: 20s frontend: build: @@ -22,12 +35,15 @@ services: dockerfile: Dockerfile args: VITE_API_URL: /api + APP_VERSION: "1.2.0" + image: donmarco-frontend:1.2.0 container_name: donmarco-web restart: unless-stopped ports: - "80:80" depends_on: - - backend + backend: + condition: service_healthy networks: - donmarco_net diff --git a/docker-update.ps1 b/docker-update.ps1 new file mode 100644 index 0000000..c0804ac --- /dev/null +++ b/docker-update.ps1 @@ -0,0 +1,35 @@ +# Reconstruye Don Marco con Docker (incluye Historial de Gestiones v1.2.0) +# Ejecutar en PowerShell desde la carpeta del proyecto: +# .\docker-update.ps1 + +Write-Host "=== Don Marco - Actualizacion Docker v1.2.0 ===" -ForegroundColor Cyan + +docker compose down +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "Reconstruyendo imagenes SIN cache..." -ForegroundColor Yellow +docker compose build --no-cache +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "Iniciando contenedores..." -ForegroundColor Yellow +docker compose up -d +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "" +Write-Host "Verificando API..." -ForegroundColor Yellow +Start-Sleep -Seconds 5 +try { + $health = Invoke-RestMethod -Uri "http://localhost:4000/api/health" -Method GET + Write-Host "Backend OK - version: $($health.version)" -ForegroundColor Green + Write-Host "Features: $($health.features -join ', ')" -ForegroundColor Green +} catch { + Write-Host "No se pudo verificar /api/health. Revise: docker compose logs backend" -ForegroundColor Red +} + +Write-Host "" +Write-Host "Listo. Abra en el navegador:" -ForegroundColor Green +Write-Host " http://localhost" -ForegroundColor White +Write-Host " Usuario: JEFE | Contrasena: MACAN" -ForegroundColor White +Write-Host " Menu: pestana 'Administracion' > seccion Cierre de gestion" -ForegroundColor White +Write-Host " Pie de pagina debe decir: v1.2.0 — Historial de gestiones (Docker)" -ForegroundColor White +Write-Host " Cada planilla tiene barra lateral con gestiones anteriores (solo lectura)" -ForegroundColor White diff --git a/docker-update.sh b/docker-update.sh new file mode 100644 index 0000000..cc9d26b --- /dev/null +++ b/docker-update.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Reconstruye Don Marco con Docker (incluye Historial de Gestiones v1.2.0) +set -e + +echo "=== Don Marco - Actualización Docker v1.2.0 ===" + +docker compose down +docker compose build --no-cache +docker compose up -d + +echo "" +echo "Verificando API..." +sleep 5 +curl -s http://localhost:4000/api/health || true +echo "" +echo "" +echo "Listo. Abra: http://localhost" +echo "Usuario: JEFE | Contraseña: MACAN" +echo "Menú: pestaña 'Administración' > sección Cierre de gestión" +echo "Cada planilla tiene barra lateral con gestiones anteriores (solo lectura)" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ac88a5c..60272cc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,7 +5,9 @@ COPY package.json package-lock.json* ./ RUN npm install COPY . . ARG VITE_API_URL=/api +ARG APP_VERSION=1.1.0 ENV VITE_API_URL=$VITE_API_URL +ENV VITE_APP_VERSION=$APP_VERSION RUN npm run build FROM nginx:alpine diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c170215..ff88170 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -44,6 +44,7 @@ export default function App() { } /> } /> } /> + } /> } /> ); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 6d22b0a..db729ca 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -44,7 +44,12 @@ export const usersApi = { }; export const sheetsApi = { - get: (sheet) => api(`/sheets/${sheet}`), + get: (sheet, params = {}) => { + const q = new URLSearchParams(); + if (params.gestionId) q.set('gestionId', params.gestionId); + const qs = q.toString(); + return api(`/sheets/${sheet}${qs ? `?${qs}` : ''}`); + }, save: (sheet, body) => api(`/sheets/${sheet}`, { method: 'PUT', body: JSON.stringify(body) }), /** Guardado al salir de la planilla (navegación entre pestañas) */ @@ -63,3 +68,16 @@ export const reportsApi = { return api(`/reports?${q}`); }, }; + +export const settingsApi = { + getGestion: () => api('/settings/gestion'), + cerrarGestion: (fechaCierreGestion) => + api('/settings/gestion', { + method: 'PUT', + body: JSON.stringify({ fechaCierreGestion }), + }), +}; + +export const gestionesApi = { + list: () => api('/gestiones'), +}; diff --git a/frontend/src/components/GastoSheet.jsx b/frontend/src/components/GastoSheet.jsx index a5f14c1..4992e47 100644 --- a/frontend/src/components/GastoSheet.jsx +++ b/frontend/src/components/GastoSheet.jsx @@ -1,177 +1,468 @@ import { useState, useEffect, useMemo, useRef } from 'react'; + import { sheetsApi } from '../api/client'; + import { useAuth } from '../context/AuthContext'; + import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; + +import { useGestionView } from '../hooks/useGestionView'; + import SaveStatus from './SaveStatus'; + +import GestionBanner from './GestionBanner'; + +import GestionesSidebar from './GestionesSidebar'; + import { emptyGastoRow, formatMoney } from '../utils/calc'; +import { isRowEditable } from '../utils/gestion'; + + + export default function GastoSheet({ sheetType, title, defaultClasificacion }) { - const { canEdit } = useAuth(); + + const { canEdit, user } = useAuth(); + const editable = canEdit(sheetType); + + const isAdmin = !!user?.isAdmin; + + const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView(); + const [rows, setRows] = useState([emptyGastoRow()]); + + const [gestion, setGestion] = useState(null); + + const [gestionVista, setGestionVista] = useState(null); + + const [esHistorial, setEsHistorial] = useState(false); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const skipNotifyRef = useRef(false); + + + const emptyRow = () => ({ ...emptyGastoRow(), clasificacion: defaultClasificacion }); + + const canEditSheet = editable && !esHistorial; + + + const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ - enabled: editable, + + enabled: canEditSheet, + sheetType, + }); + + useEffect(() => { + let active = true; + setLoading(true); + setLoadError(null); + setReady(false); + + (async () => { + try { - const data = await sheetsApi.get(sheetType); + + const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId }); + if (!active) return; - const loaded = data.rows?.length ? data.rows : [emptyGastoRow()]; + + const loaded = data.rows || []; + skipNotifyRef.current = true; - setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion }))); - setReady(true); + + if (loaded.length) { + + setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion }))); + + } else if (!data.esHistorial && canEditSheet) { + + setRows([emptyRow()]); + + } else { + + setRows([]); + + } + + setGestion(data.gestion || null); + + setGestionVista(data.gestionVista || null); + + setEsHistorial(!!data.esHistorial); + + setReady(!data.esHistorial); + } catch (e) { + if (active) setLoadError(e.message); + } finally { + if (active) setLoading(false); + } + })(); + + return () => { + active = false; + }; - }, [sheetType, defaultClasificacion, setReady]); + + }, [sheetType, defaultClasificacion, selectedGestionId, setReady, canEditSheet]); + + useEffect(() => { - if (loading || !editable) return; + + if (loading || !canEditSheet) return; + if (skipNotifyRef.current) { + skipNotifyRef.current = false; + return; + } + notifyChange({ rows }); - }, [rows, loading, editable, notifyChange]); + + }, [rows, loading, canEditSheet, notifyChange]); + + const total = useMemo( + () => rows.reduce((acc, r) => acc + (Number(r.monto) || 0), 0), + [rows] + ); + + const updateRow = (index, field, value) => { + + if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return; + setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r))); + }; - const addRow = () => - setRows((prev) => [...prev, { ...emptyGastoRow(), clasificacion: defaultClasificacion }]); + + + const addRow = () => setRows((prev) => [...prev, emptyRow()]); + + const removeRow = (index) => { + + if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return; + skipNotifyRef.current = true; + const next = rows.filter((_, i) => i !== index); - const result = - next.length > 0 ? next : [{ ...emptyGastoRow(), clasificacion: defaultClasificacion }]; + + const result = next.length > 0 ? next : [emptyRow()]; + setRows(result); + saveNow({ rows: result }); + }; + + const clearSheet = () => { - if (!window.confirm('¿Vaciar toda la planilla? Se eliminarán todas las filas.')) return; - const blank = [{ ...emptyGastoRow(), clasificacion: defaultClasificacion }]; + + const msg = gestion?.fechaCierreGestion && !isAdmin + + ? '¿Eliminar solo las filas editables? Las del período cerrado se conservarán.' + + : '¿Vaciar toda la planilla? Se eliminarán todas las filas de la gestión actual.'; + + if (!window.confirm(msg)) return; + skipNotifyRef.current = true; - setRows(blank); - saveNow({ rows: blank }); + + if (isAdmin || !gestion?.fechaCierreGestion) { + + const blank = [emptyRow()]; + + setRows(blank); + + saveNow({ rows: blank }); + + return; + + } + + const kept = rows.filter((r) => r.bloqueada); + + const result = kept.length ? kept : [emptyRow()]; + + setRows(result); + + saveNow({ rows: result }); + }; + + if (loading) return

Cargando planilla…

; + + return ( -
-

{title}

- {loadError &&
{loadError}
} - {editable && } - {!editable &&
Solo lectura
} -
- {editable && ( - <> - - - - +
+ + + + + +
+ +

{title}

+ + {loadError &&
{loadError}
} + + + + {esHistorial && gestionVista && ( + +
+ + Historial: {gestionVista.etiqueta} ({gestionVista.fechaInicio} →{' '} + + {gestionVista.fechaFin}) — solo lectura + +
+ )} + + + + {!esHistorial && } + + {canEditSheet && } + + {esHistorial &&
Gestión cerrada — solo consulta
} + + {!editable && !esHistorial &&
Solo lectura
} + + + +
+ + {canEditSheet && ( + + <> + + + + + + + + + + )} + +
+ + + + {rows.length === 0 ? ( + +

Sin registros en esta gestión.

+ + ) : ( + +
+ + + + + + + + + + + + + + + + + + + + {canEditSheet && } + + + + + + + + {rows.map((row, i) => { + + const rowEditable = isRowEditable(row, canEditSheet, isAdmin); + + return ( + + + + + + + + + + + + + + + + {canEditSheet && ( + + + + )} + + + + ); + + })} + + + +
FechaDetalleImpuestosNº FacturaMontoClasificación
+ + updateRow(i, 'fecha', e.target.value)} /> + + + + updateRow(i, 'detalle', e.target.value)} /> + + + + updateRow(i, 'impuestos', e.target.value)} /> + + + + updateRow(i, 'numeroFactura', e.target.value)} /> + + + + updateRow(i, 'monto', e.target.value)} /> + + + + + + + + + +
+ +
+ + )} + + + +
+ + + + Total gastos: {formatMoney(total)} + + + +
+
-
- - - - - - - - - - {editable && } - - - - {rows.map((row, i) => ( - - - - - - - - {editable && ( - - )} - - ))} - -
FechaDetalleImpuestosNº FacturaMontoClasificación
- updateRow(i, 'fecha', e.target.value)} /> - - updateRow(i, 'detalle', e.target.value)} /> - - updateRow(i, 'impuestos', e.target.value)} /> - - updateRow(i, 'numeroFactura', e.target.value)} /> - - updateRow(i, 'monto', e.target.value)} /> - - - - -
-
- -
- - Total gastos: {formatMoney(total)} - -
+ ); + } + diff --git a/frontend/src/components/GestionBanner.jsx b/frontend/src/components/GestionBanner.jsx new file mode 100644 index 0000000..0d131f3 --- /dev/null +++ b/frontend/src/components/GestionBanner.jsx @@ -0,0 +1,14 @@ +import { gestionMensaje } from '../utils/gestion'; + +export default function GestionBanner({ gestion, isAdmin }) { + const msg = gestionMensaje(gestion, isAdmin); + if (!msg) return null; + + const activo = gestion?.gestionCerrada; + + return ( +
+ {activo ? 'Cierre de gestión activo' : 'Período en curso'}: {msg} +
+ ); +} diff --git a/frontend/src/components/GestionesSidebar.jsx b/frontend/src/components/GestionesSidebar.jsx new file mode 100644 index 0000000..69ba82c --- /dev/null +++ b/frontend/src/components/GestionesSidebar.jsx @@ -0,0 +1,49 @@ +export default function GestionesSidebar({ gestiones, gestionActual, selectedId, onSelect }) { + const historial = gestiones?.historial || []; + const actual = gestiones?.gestionActual || gestionActual; + + return ( + + ); +} diff --git a/frontend/src/components/IngresoSheet.jsx b/frontend/src/components/IngresoSheet.jsx index 7007d7e..aacfae3 100644 --- a/frontend/src/components/IngresoSheet.jsx +++ b/frontend/src/components/IngresoSheet.jsx @@ -1,186 +1,490 @@ import { useState, useEffect, useMemo, useRef } from 'react'; + import { sheetsApi } from '../api/client'; + import { useAuth } from '../context/AuthContext'; + import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; + +import { useGestionView } from '../hooks/useGestionView'; + import SaveStatus from './SaveStatus'; + +import GestionBanner from './GestionBanner'; + +import GestionesSidebar from './GestionesSidebar'; + import { calcIngresoRow, emptyIngresoRow, formatMoney } from '../utils/calc'; +import { isRowEditable } from '../utils/gestion'; + + + export default function IngresoSheet({ sheetType, title }) { - const { canEdit } = useAuth(); + + const { canEdit, user } = useAuth(); + const editable = canEdit(sheetType); + + const isAdmin = !!user?.isAdmin; + + const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView(); + const [rows, setRows] = useState([emptyIngresoRow()]); + + const [gestion, setGestion] = useState(null); + + const [gestionVista, setGestionVista] = useState(null); + + const [esHistorial, setEsHistorial] = useState(false); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const skipNotifyRef = useRef(false); + + + const canEditSheet = editable && !esHistorial; + + + const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ - enabled: editable, + + enabled: canEditSheet, + sheetType, + }); + + useEffect(() => { + let active = true; + setLoading(true); + setLoadError(null); + setReady(false); + + (async () => { + try { - const data = await sheetsApi.get(sheetType); + + const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId }); + if (!active) return; + skipNotifyRef.current = true; - setRows(data.rows?.length ? data.rows : [emptyIngresoRow()]); - setReady(true); + + const loaded = data.rows || []; + + if (loaded.length) { + + setRows(loaded); + + } else if (!data.esHistorial && canEditSheet) { + + setRows([emptyIngresoRow()]); + + } else { + + setRows([]); + + } + + setGestion(data.gestion || null); + + setGestionVista(data.gestionVista || null); + + setEsHistorial(!!data.esHistorial); + + setReady(!data.esHistorial); + } catch (e) { + if (active) setLoadError(e.message); + } finally { + if (active) setLoading(false); + } + })(); + + return () => { + active = false; + }; - }, [sheetType, setReady]); + + }, [sheetType, selectedGestionId, setReady, canEditSheet]); + + useEffect(() => { - if (loading || !editable) return; + + if (loading || !canEditSheet) return; + if (skipNotifyRef.current) { + skipNotifyRef.current = false; + return; + } + notifyChange({ rows }); - }, [rows, loading, editable, notifyChange]); + + }, [rows, loading, canEditSheet, notifyChange]); + + const totals = useMemo(() => { + let totalIngresos = 0; + let totalUtilidad = 0; + rows.forEach((row) => { + const { precioTotal, utilidad } = calcIngresoRow(row); + totalIngresos += precioTotal; + totalUtilidad += utilidad; + }); + return { totalIngresos, totalUtilidad }; + }, [rows]); + + const updateRow = (index, field, value) => { + + if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return; + setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r))); + }; + + const addRow = () => setRows((prev) => [...prev, emptyIngresoRow()]); + + const removeRow = (index) => { + + if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return; + skipNotifyRef.current = true; + const next = rows.filter((_, i) => i !== index); + const result = next.length > 0 ? next : [emptyIngresoRow()]; + setRows(result); + saveNow({ rows: result }); + }; + + const clearSheet = () => { - if (!window.confirm('¿Vaciar toda la planilla? Se eliminarán todas las filas.')) return; - const blank = [emptyIngresoRow()]; + + const msg = gestion?.fechaCierreGestion && !isAdmin + + ? '¿Eliminar solo las filas editables? Las del período cerrado se conservarán.' + + : '¿Vaciar toda la planilla? Se eliminarán todas las filas de la gestión actual.'; + + if (!window.confirm(msg)) return; + skipNotifyRef.current = true; - setRows(blank); - saveNow({ rows: blank }); + + if (isAdmin || !gestion?.fechaCierreGestion) { + + const blank = [emptyIngresoRow()]; + + setRows(blank); + + saveNow({ rows: blank }); + + return; + + } + + const kept = rows.filter((r) => r.bloqueada); + + const result = kept.length ? kept : [emptyIngresoRow()]; + + setRows(result); + + saveNow({ rows: result }); + }; + + if (loading) return

Cargando planilla…

; + + return ( -
-

{title}

- {loadError &&
{loadError}
} - {editable && } - {!editable &&
Solo lectura — sin permiso de edición
} -
- {editable && ( - <> - - - - +
+ + + + + +
+ +

{title}

+ + {loadError &&
{loadError}
} + + + + {esHistorial && gestionVista && ( + +
+ + Historial: {gestionVista.etiqueta} ({gestionVista.fechaInicio} →{' '} + + {gestionVista.fechaFin}) — solo lectura + +
+ )} -
-
- - - - - - - - - - - - {editable && } - - - - {rows.map((row, i) => { - const { precioTotal, utilidad } = calcIngresoRow(row); - return ( - - - - - - - - - - {editable && ( - - )} + + + {!esHistorial && } + + {canEditSheet && } + + {esHistorial &&
Gestión cerrada — solo consulta
} + + {!editable && !esHistorial && ( + +
Solo lectura — sin permiso de edición
+ + )} + + + +
+ + {canEditSheet && ( + + <> + + + + + + + + + + )} + +
+ + + + {rows.length === 0 ? ( + +

Sin registros en esta gestión.

+ + ) : ( + +
+ +
FechaClienteDetalleCantidadP. UnitarioC. UnitarioPrecio TotalUtilidad
- updateRow(i, 'fecha', e.target.value)} /> - - updateRow(i, 'cliente', e.target.value)} /> - - updateRow(i, 'detalle', e.target.value)} /> - - updateRow(i, 'cantidad', e.target.value)} /> - - updateRow(i, 'precioUnitario', e.target.value)} /> - - updateRow(i, 'costoUnitario', e.target.value)} /> - {formatMoney(precioTotal)}{formatMoney(utilidad)} - -
+ + + + + + + + + + + + + + + + + + + + + + {canEditSheet && } + - ); - })} - -
FechaClienteDetalleCantidadP. UnitarioC. UnitarioPrecio TotalUtilidad
+ + + + + + {rows.map((row, i) => { + + const { precioTotal, utilidad } = calcIngresoRow(row); + + const rowEditable = isRowEditable(row, canEditSheet, isAdmin); + + return ( + + + + + + updateRow(i, 'fecha', e.target.value)} /> + + + + + + updateRow(i, 'cliente', e.target.value)} /> + + + + + + updateRow(i, 'detalle', e.target.value)} /> + + + + + + updateRow(i, 'cantidad', e.target.value)} /> + + + + + + updateRow(i, 'precioUnitario', e.target.value)} /> + + + + + + updateRow(i, 'costoUnitario', e.target.value)} /> + + + + {formatMoney(precioTotal)} + + {formatMoney(utilidad)} + + {canEditSheet && ( + + + + + + + + )} + + + + ); + + })} + + + + + +
+ + )} + + + +
+ + + + Sumatoria ingresos: {formatMoney(totals.totalIngresos)} + + + + + + Sumatoria utilidad: {formatMoney(totals.totalUtilidad)} + + + +
+
-
- - Sumatoria ingresos: {formatMoney(totals.totalIngresos)} - - - Sumatoria utilidad: {formatMoney(totals.totalUtilidad)} - -
+ ); + } + diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index ecbff1a..2ca9503 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -38,7 +38,10 @@ export default function Layout({ children }) { ))} {user?.isAdmin && ( - `nav-tab${isActive ? ' active' : ''}`}> + `nav-tab nav-tab--admin${isActive ? ' active' : ''}`} + > Administración )} @@ -46,7 +49,12 @@ export default function Layout({ children }) { Salir -
{children}
+
+ {children} +

+ Don Marco v{import.meta.env.VITE_APP_VERSION || '1.2.0'} — Historial de gestiones (Docker) +

+
); } diff --git a/frontend/src/components/PersonalSheet.jsx b/frontend/src/components/PersonalSheet.jsx index 704e698..fbf4396 100644 --- a/frontend/src/components/PersonalSheet.jsx +++ b/frontend/src/components/PersonalSheet.jsx @@ -1,236 +1,662 @@ import { useState, useEffect, useMemo, useRef } from 'react'; + import { sheetsApi } from '../api/client'; + import { useAuth } from '../context/AuthContext'; + import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; + +import { useGestionView } from '../hooks/useGestionView'; + import SaveStatus from './SaveStatus'; + +import GestionBanner from './GestionBanner'; + +import GestionesSidebar from './GestionesSidebar'; + import { emptyMensualizado, emptyEventual, formatMoney } from '../utils/calc'; +import { isRowEditable } from '../utils/gestion'; + + + const MAX_MENSUAL = 10; + const MAX_EVENTUAL = 5; + + function rowCostMensual(r) { + return (Number(r.sueldoBase) || 0) + (Number(r.cargasSociales) || 0) + (Number(r.bonos) || 0); + } + + export default function PersonalSheet() { - const { canEdit } = useAuth(); + + const { canEdit, user } = useAuth(); + const editable = canEdit('personal'); + + const isAdmin = !!user?.isAdmin; + + const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView(); + const [mensualizados, setMensualizados] = useState([emptyMensualizado()]); + const [eventuales, setEventuales] = useState([emptyEventual()]); + + const [gestion, setGestion] = useState(null); + + const [gestionVista, setGestionVista] = useState(null); + + const [esHistorial, setEsHistorial] = useState(false); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const skipNotifyRef = useRef(false); + + + const canEditSheet = editable && !esHistorial; + + + const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ - enabled: editable, + + enabled: canEditSheet, + sheetType: 'personal', + }); + + useEffect(() => { + let active = true; + setLoading(true); + setLoadError(null); + setReady(false); + + (async () => { + try { - const data = await sheetsApi.get('personal'); + + const data = await sheetsApi.get('personal', { gestionId: selectedGestionId }); + if (!active) return; + skipNotifyRef.current = true; - setMensualizados( - data.mensualizados?.length ? data.mensualizados.slice(0, MAX_MENSUAL) : [emptyMensualizado()] - ); - setEventuales(data.eventuales?.length ? data.eventuales : [emptyEventual()]); - setReady(true); - } catch (e) { - if (active) setLoadError(e.message); + + const m = data.mensualizados || []; + + const e = data.eventuales || []; + + if (m.length || e.length) { + + setMensualizados(m.length ? m.slice(0, MAX_MENSUAL) : []); + + setEventuales(e); + + } else if (!data.esHistorial && canEditSheet) { + + setMensualizados([emptyMensualizado()]); + + setEventuales([emptyEventual()]); + + } else { + + setMensualizados([]); + + setEventuales([]); + + } + + setGestion(data.gestion || null); + + setGestionVista(data.gestionVista || null); + + setEsHistorial(!!data.esHistorial); + + setReady(!data.esHistorial); + + } catch (err) { + + if (active) setLoadError(err.message); + } finally { + if (active) setLoading(false); + } + })(); + + return () => { + active = false; + }; - }, [setReady]); + + }, [selectedGestionId, setReady, canEditSheet]); + + useEffect(() => { - if (loading || !editable) return; + + if (loading || !canEditSheet) return; + if (skipNotifyRef.current) { + skipNotifyRef.current = false; + return; + } + notifyChange({ mensualizados, eventuales }); - }, [mensualizados, eventuales, loading, editable, notifyChange]); + + }, [mensualizados, eventuales, loading, canEditSheet, notifyChange]); + + const total = useMemo(() => { + const m = mensualizados.reduce((a, r) => a + rowCostMensual(r), 0); + const e = eventuales.reduce((a, r) => a + (Number(r.pagoDiario) || 0), 0); + return m + e; + }, [mensualizados, eventuales]); - const updateMensual = (i, field, value) => + + + const updateMensual = (i, field, value) => { + + if (!isRowEditable(mensualizados[i], canEditSheet, isAdmin)) return; + setMensualizados((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r))); - const updateEventual = (i, field, value) => + + }; + + + + const updateEventual = (i, field, value) => { + + if (!isRowEditable(eventuales[i], canEditSheet, isAdmin)) return; + setEventuales((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r))); - const removeMensual = (index) => { - skipNotifyRef.current = true; - const next = mensualizados.filter((_, i) => i !== index); - const result = next.length > 0 ? next : [emptyMensualizado()]; - setMensualizados(result); - saveNow({ mensualizados: result, eventuales }); }; + + + const removeMensual = (index) => { + + if (!isRowEditable(mensualizados[index], canEditSheet, isAdmin)) return; + + skipNotifyRef.current = true; + + const next = mensualizados.filter((_, i) => i !== index); + + const result = next.length > 0 ? next : [emptyMensualizado()]; + + setMensualizados(result); + + saveNow({ mensualizados: result, eventuales }); + + }; + + + const removeEventual = (index) => { + + if (!isRowEditable(eventuales[index], canEditSheet, isAdmin)) return; + skipNotifyRef.current = true; + const next = eventuales.filter((_, i) => i !== index); + const result = next.length > 0 ? next : [emptyEventual()]; + setEventuales(result); + saveNow({ mensualizados, eventuales: result }); + }; + + const clearSheet = () => { - if (!window.confirm('¿Vaciar toda la planilla de personal?')) return; - const m = [emptyMensualizado()]; - const e = [emptyEventual()]; + + const msg = gestion?.fechaCierreGestion && !isAdmin + + ? '¿Eliminar solo filas editables? Las del período cerrado se conservarán.' + + : '¿Vaciar toda la planilla de personal de la gestión actual?'; + + if (!window.confirm(msg)) return; + skipNotifyRef.current = true; - setMensualizados(m); - setEventuales(e); - saveNow({ mensualizados: m, eventuales: e }); + + if (isAdmin || !gestion?.fechaCierreGestion) { + + const m = [emptyMensualizado()]; + + const e = [emptyEventual()]; + + setMensualizados(m); + + setEventuales(e); + + saveNow({ mensualizados: m, eventuales: e }); + + return; + + } + + const m = mensualizados.filter((r) => r.bloqueada); + + const e = eventuales.filter((r) => r.bloqueada); + + setMensualizados(m.length ? m : [emptyMensualizado()]); + + setEventuales(e.length ? e : [emptyEventual()]); + + saveNow({ + + mensualizados: m.length ? m : [emptyMensualizado()], + + eventuales: e.length ? e : [emptyEventual()], + + }); + }; + + + const sinDatos = mensualizados.length === 0 && eventuales.length === 0; + + + if (loading) return

Cargando planilla…

; + + return ( -
-

Planilla de Personal

- {loadError &&
{loadError}
} - {editable && } -
- {editable && ( - <> - - - - - +
+ + + + + +
+ +

Planilla de Personal

+ + {loadError &&
{loadError}
} + + + + {esHistorial && gestionVista && ( + +
+ + Historial: {gestionVista.etiqueta} ({gestionVista.fechaInicio} →{' '} + + {gestionVista.fechaFin}) — solo lectura + +
+ )} -
-
-

Funcionarios mensualizados (máx. {MAX_MENSUAL})

-
- - - - - - - - - {editable && } - - - - {mensualizados.map((row, i) => ( - - - - - - - {editable && ( - - )} - - ))} - -
NombreSueldo baseCargas socialesBonosSubtotal
- updateMensual(i, 'nombre', e.target.value)} /> - - updateMensual(i, 'sueldoBase', e.target.value)} /> - - updateMensual(i, 'cargasSociales', e.target.value)} /> - - updateMensual(i, 'bonos', e.target.value)} /> - {formatMoney(rowCostMensual(row))} - -
+ + + {!esHistorial && } + + {canEditSheet && } + + {esHistorial &&
Gestión cerrada — solo consulta
} + + + +
+ + {canEditSheet && ( + + <> + + + + + + + + + + + + )} +
-
-
-

Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)

-
- - - - - - - {editable && } - - - - {eventuales.map((row, i) => ( - - - - - {editable && ( - - )} - - ))} - -
FechaNombrePago diario
- updateEventual(i, 'fecha', e.target.value)} /> - - updateEventual(i, 'nombre', e.target.value)} /> - - updateEventual(i, 'pagoDiario', e.target.value)} /> - - -
+ + + {sinDatos ? ( + +

Sin registros en esta gestión.

+ + ) : ( + + <> + +
+ +

Funcionarios mensualizados (máx. {MAX_MENSUAL})

+ + {mensualizados.length === 0 ? ( + +

Sin mensualizados en esta gestión.

+ + ) : ( + +
+ + + + + + + + + + + + + + + + + + {canEditSheet && } + + + + + + + + {mensualizados.map((row, i) => { + + const rowEditable = isRowEditable(row, canEditSheet, isAdmin); + + return ( + + + + + + + + + + + + + + {canEditSheet && ( + + + + )} + + + + ); + + })} + + + +
NombreSueldo baseCargas socialesBonosSubtotal
+ + updateMensual(i, 'nombre', e.target.value)} /> + + + + updateMensual(i, 'sueldoBase', e.target.value)} /> + + + + updateMensual(i, 'cargasSociales', e.target.value)} /> + + + + updateMensual(i, 'bonos', e.target.value)} /> + + {formatMoney(rowCostMensual(row))} + + + +
+ +
+ + )} + +
+ + + +
+ +

Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)

+ + {eventuales.length === 0 ? ( + +

Sin eventuales en esta gestión.

+ + ) : ( + +
+ + + + + + + + + + + + + + {canEditSheet && } + + + + + + + + {eventuales.map((row, i) => { + + const rowEditable = isRowEditable(row, canEditSheet, isAdmin); + + return ( + + + + + + + + + + {canEditSheet && ( + + + + )} + + + + ); + + })} + + + +
FechaNombrePago diario
+ + updateEventual(i, 'fecha', e.target.value)} /> + + + + updateEventual(i, 'nombre', e.target.value)} /> + + + + updateEventual(i, 'pagoDiario', e.target.value)} /> + + + + + +
+ +
+ + )} + +
+ + + + )} + + + +
+ + + + Sumatoria total personal: {formatMoney(total)} + + +
+
-
- - Sumatoria total personal: {formatMoney(total)} - -
+ ); + } + diff --git a/frontend/src/hooks/useGestionView.js b/frontend/src/hooks/useGestionView.js new file mode 100644 index 0000000..4d304b5 --- /dev/null +++ b/frontend/src/hooks/useGestionView.js @@ -0,0 +1,29 @@ +import { useState, useEffect } from 'react'; +import { gestionesApi } from '../api/client'; + +export function useGestionView() { + const [selectedGestionId, setSelectedGestionId] = useState('actual'); + const [gestionesList, setGestionesList] = useState(null); + + useEffect(() => { + let active = true; + gestionesApi + .list() + .then((data) => { + if (active) setGestionesList(data); + }) + .catch(() => {}); + return () => { + active = false; + }; + }, []); + + return { + selectedGestionId, + selectGestion: setSelectedGestionId, + gestionesList, + sidebarGestiones: gestionesList + ? { gestionActual: gestionesList.actual, historial: gestionesList.historial } + : null, + }; +} diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index 6015518..e178964 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; -import { usersApi } from '../api/client'; +import { useLocation } from 'react-router-dom'; +import { usersApi, settingsApi } from '../api/client'; const SHEET_LABELS = { ventas: 'Ventas', @@ -22,12 +23,30 @@ export default function AdminPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [newUser, setNewUser] = useState({ username: '', password: '', permissions: emptyPerms() }); + const [gestion, setGestion] = useState(null); + const [fechaCierre, setFechaCierre] = useState(''); + const [gestionMsg, setGestionMsg] = useState(''); + const [gestionError, setGestionError] = useState(''); + const location = useLocation(); const load = async () => { + setError(''); + setGestionError(''); try { setUsers(await usersApi.list()); } catch (e) { setError(e.message); + } + try { + const gestionData = await settingsApi.getGestion(); + setGestion(gestionData); + setFechaCierre(gestionData.sugerirCierre || gestionData.fechaCierreGestion || ''); + } catch (e) { + setGestionError( + e.message.includes('404') || e.message.includes('Error en la solicitud') + ? 'No se pudo cargar el cierre de gestión. Reconstruya Docker: docker compose build --no-cache && docker compose up -d' + : e.message + ); } finally { setLoading(false); } @@ -37,6 +56,12 @@ export default function AdminPage() { load(); }, []); + useEffect(() => { + if (location.hash === '#cierre-gestion') { + document.getElementById('cierre-gestion')?.scrollIntoView({ behavior: 'smooth' }); + } + }, [location.hash, loading]); + const handleCreate = async (e) => { e.preventDefault(); setError(''); @@ -69,18 +94,112 @@ export default function AdminPage() { } }; - if (loading) return

Cargando usuarios…

; + const cerrarGestion = async (e) => { + e.preventDefault(); + if (!fechaCierre) { + setError('Indique el último día de la gestión a cerrar.'); + return; + } + const etiqueta = gestion?.gestionActual?.fechaInicio + ? `del ${gestion.gestionActual.fechaInicio} al ${fechaCierre}` + : `hasta el ${fechaCierre}`; + if ( + !window.confirm( + `¿Cerrar la gestión ${etiqueta}?\n\nSe bloqueará el período para todos (excepto JEFE) y comenzará una gestión nueva. El historial quedará en la barra lateral de cada planilla.` + ) + ) { + return; + } + setGestionMsg(''); + setError(''); + try { + const data = await settingsApi.cerrarGestion(fechaCierre); + setGestion(data); + setFechaCierre(data.sugerirCierre || ''); + const nombre = data.cierre?.cerrada?.etiqueta || 'gestión cerrada'; + setGestionMsg(`"${nombre}" cerrada. Nueva gestión desde ${data.cierre?.nueva?.fechaInicio || 'mañana'}.`); + } catch (err) { + setError(err.message); + } + }; + + if (loading) return

Cargando administración…

; return (
-

Administración de usuarios

+

Administración

- El superusuario JEFE puede crear usuarios, asignar contraseñas y habilitar acceso por planilla. + Solo visible para el administrador JEFE. Gestione el cierre del período y los usuarios.

- {error &&
{error}
} -
-

Nuevo usuario

+ + + {error &&
{error}
} + {gestionMsg &&
{gestionMsg}
} + +
+

Cierre de gestión

+ {gestionError &&
{gestionError}
} + +
+ ¿Cómo funciona? +
    +
  • Al cerrar la gestión (típicamente fin de mes), el período queda bloqueado de inmediato.
  • +
  • Cada planilla muestra solo la gestión actual; el historial está en la barra lateral (solo lectura).
  • +
  • La nueva gestión comienza el día siguiente al cierre. Use el botón + para cargar datos nuevos.
  • +
  • Filas con fecha del período cerrado no se pueden editar (excepto JEFE).
  • +
+
+ + {gestion?.gestionActual && ( +

+ Gestión actual: desde {gestion.gestionActual.fechaInicio} — Hoy:{' '} + {gestion.hoy} +

+ )} + + {gestion?.historial?.length > 0 && ( +
+

+ Gestiones cerradas: +

+
    + {gestion.historial.map((g) => ( +
  • + {g.etiqueta} ({g.fechaInicio} → {g.fechaFin}) +
  • + ))} +
+
+ )} + +
+
+ + setFechaCierre(e.target.value)} required /> + {gestion?.sugerirCierre && ( +

+ Sugerido (fin de mes): {gestion.sugerirCierre} +

+ )} +
+
+ +
+
+
+ +
+

Usuarios — Nuevo usuario

diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 10da21b..6a0166e 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -1,23 +1,37 @@ +import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; export default function HomePage() { const { user } = useAuth(); return ( -
-

Menú principal

-

- Bienvenido, {user?.username}. Utilice la barra superior para acceder a cada planilla. -

-
    -
  • Planillas de ingresos: Ventas, Servicios y Alquileres
  • -
  • Planillas de egresos: Gastos Diarios y Gastos Fijos
  • -
  • Planilla de Personal y Reportes consolidados
  • - {user?.isAdmin &&
  • Panel de Administración para gestionar usuarios y permisos
  • } -
-

- Los datos se guardan automáticamente en el servidor al editar y al cambiar de pestaña. Use el botón + para agregar filas. Asegúrese de tener el backend en ejecución (puerto 4000). -

+
+
+

Menú principal

+

+ Bienvenido, {user?.username}. Utilice la barra superior para acceder a cada planilla. +

+
    +
  • Planillas de ingresos: Ventas, Servicios y Alquileres
  • +
  • Planillas de egresos: Gastos Diarios y Gastos Fijos
  • +
  • Planilla de Personal y Reportes consolidados
  • +
+

+ Los datos se guardan automáticamente en el servidor. Use el botón + para agregar filas. +

+
+ + {user?.isAdmin && ( +
+

Administración (JEFE)

+

+ Cierre de gestión, usuarios y permisos en un solo lugar. +

+ + Ir a Administración + +
+ )}
); } diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index c51e44c..415ac98 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -382,6 +382,211 @@ textarea { opacity: 0.85; } +.sheet-table tr.row-locked { + background: rgba(255, 176, 32, 0.08); +} + +.sheet-table tr.row-locked input, +.sheet-table tr.row-locked select { + opacity: 0.75; + cursor: not-allowed; +} + +.nav-tab--cierre { + border-color: #ff6b6b; + color: #ffb0b0; + font-weight: 700; +} + +.nav-tab--cierre.active { + background: #ff6b6b; + color: #fff; + border-color: #ff6b6b; +} + +.nav-tab--admin { + border-color: var(--warning); + color: #ffd080; +} + +.nav-tab--admin.active { + background: var(--warning); + color: #1a1200; + border-color: var(--warning); +} + +.card-highlight { + border: 2px solid var(--accent); + box-shadow: 0 0 0 1px rgba(61, 156, 245, 0.25), var(--shadow); + scroll-margin-top: 5rem; +} + +.section-badge { + display: inline-block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.25rem 0.5rem; + border-radius: 6px; + background: rgba(61, 156, 245, 0.2); + color: var(--accent); +} + +.admin-subnav { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.admin-subnav__link { + padding: 0.5rem 1rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-weight: 600; + font-size: 0.9rem; +} + +.admin-subnav__link--active, +.admin-subnav__link:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +a.btn { + text-decoration: none; + display: inline-flex; +} + +.app-version { + margin-top: 2rem; + text-align: center; + font-size: 0.75rem; + color: var(--muted); + opacity: 0.7; +} + +.sheet-layout { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.sheet-layout__main { + flex: 1; + min-width: 0; +} + +.gestiones-sidebar { + width: 220px; + flex-shrink: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + position: sticky; + top: 4.5rem; + max-height: calc(100vh - 6rem); + overflow-y: auto; +} + +.gestiones-sidebar__title { + margin: 0 0 0.75rem; + font-size: 0.95rem; + font-weight: 700; +} + +.gestiones-sidebar__subtitle { + margin: 1rem 0 0.5rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.gestiones-sidebar__empty { + font-size: 0.8rem; + color: var(--muted); + margin: 0.5rem 0 0; +} + +.gestion-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.gestion-item { + width: 100%; + text-align: left; + padding: 0.6rem 0.65rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.gestion-item--actual { + border-color: var(--accent); +} + +.gestion-item--active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.gestion-item__label { + font-weight: 600; + font-size: 0.85rem; +} + +.gestion-item__range { + font-size: 0.72rem; + opacity: 0.85; +} + +.historial-badge { + display: inline-block; + margin-bottom: 0.75rem; + padding: 0.35rem 0.65rem; + border-radius: 6px; + background: rgba(255, 176, 32, 0.15); + border: 1px solid var(--warning); + color: #ffd080; + font-size: 0.82rem; +} + +@media (max-width: 900px) { + .sheet-layout { + flex-direction: column; + } + .gestiones-sidebar { + width: 100%; + position: static; + max-height: none; + } + .gestion-list { + flex-direction: row; + flex-wrap: wrap; + } + .gestion-item { + width: auto; + min-width: 140px; + } +} + @media (max-width: 768px) { .navbar { padding: 0.5rem; diff --git a/frontend/src/utils/gestion.js b/frontend/src/utils/gestion.js new file mode 100644 index 0000000..af854fe --- /dev/null +++ b/frontend/src/utils/gestion.js @@ -0,0 +1,17 @@ +export function isRowEditable(row, editable, isAdmin) { + if (!editable) return false; + if (isAdmin) return true; + return !row?.bloqueada; +} + +export function gestionMensaje(gestion, isAdmin) { + if (!gestion?.fechaCierreGestion) return null; + + const cierre = gestion.fechaCierreGestion; + + if (isAdmin) { + return `Cierre de gestión activo (${cierre}). Las filas con fecha ≤ ${cierre} están bloqueadas para otros usuarios. Como administrador usted puede editarlas.`; + } + + return `Cierre de gestión activo (${cierre}). No puede editar filas con fecha ≤ ${cierre}. Solo puede cargar filas nuevas con fecha posterior al cierre (botón +).`; +}