no se que modificaciones se hizo posible error

This commit is contained in:
unknown
2026-06-13 12:04:04 -04:00
parent ff90340fdb
commit fa05ae3754
24 changed files with 2466 additions and 402 deletions

View File

@@ -79,15 +79,57 @@ DonMarcoBackend/
## 5. Ejecución con Docker (recomendado en el servidor LAN) ## 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 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) - **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) ## 6. Desarrollo local (sin Docker)

View File

@@ -52,5 +52,23 @@ export function initDb() {
); );
CREATE INDEX IF NOT EXISTS idx_registros_sheet ON registros(sheet_type); 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)
);
`); `);
} }

View File

@@ -5,6 +5,8 @@ import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js'; import userRoutes from './routes/users.js';
import sheetRoutes from './routes/sheets.js'; import sheetRoutes from './routes/sheets.js';
import reportRoutes from './routes/reports.js'; import reportRoutes from './routes/reports.js';
import settingsRoutes from './routes/settings.js';
import gestionesRoutes from './routes/gestiones.js';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { db, SHEETS } from './db.js'; 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(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '2mb' })); 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/auth', authRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/sheets', sheetRoutes); app.use('/api/sheets', sheetRoutes);
app.use('/api/reports', reportRoutes); app.use('/api/reports', reportRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/gestiones', gestionesRoutes);
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Don Marco API escuchando en http://0.0.0.0:${PORT}`); console.log(`Don Marco API escuchando en http://0.0.0.0:${PORT}`);

View File

@@ -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;

View File

@@ -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;

View File

@@ -2,6 +2,17 @@ import { Router } from 'express';
import { db, SHEETS } from '../db.js'; import { db, SHEETS } from '../db.js';
import { authRequired } from '../middleware/auth.js'; import { authRequired } from '../middleware/auth.js';
import { sumIngresos, sumGastos, sumPersonal } from '../utils/calculations.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(); const router = Router();
router.use(authRequired); router.use(authRequired);
@@ -19,45 +30,112 @@ function checkPermission(req, sheet, needEdit = false) {
return !!row.can_view; return !!row.can_view;
} }
function getSheetPayload(sheetType) { function resolveGestionView(gestionId) {
const rows = db 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') .prepare('SELECT id, data, updated_at FROM registros WHERE sheet_type = ? ORDER BY id')
.all(sheetType); .all(sheetType)
const parsed = rows.map((r) => ({ id: r.id, ...JSON.parse(r.data), updated_at: r.updated_at })); .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)) { if (INGRESO_SHEETS.includes(sheetType)) {
const totals = sumIngresos(parsed); const filtered = filterRowsByGestion(parsed, gestion);
return { rows: parsed, totals }; const rows = annotateRows(filtered, isAdmin, esHistorial);
} const totals = sumIngresos(filtered);
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');
return { return {
mensualizados, rows: rows.length ? rows : [],
eventuales, totals,
totals: { totalPersonal: sumPersonal(mensualizados, eventuales) }, 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) => { router.get('/:sheetType', (req, res) => {
const { sheetType } = req.params; const { sheetType } = req.params;
const { gestionId } = req.query;
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') { if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
return res.status(400).json({ error: 'Planilla inválida' }); return res.status(400).json({ error: 'Planilla inválida' });
} }
if (!checkPermission(req, sheetType)) { if (!checkPermission(req, sheetType)) {
return res.status(403).json({ error: 'Sin permiso para ver esta planilla' }); 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) => { router.put('/:sheetType', (req, res) => {
const { sheetType } = req.params; const { sheetType } = req.params;
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') { if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
return res.status(400).json({ error: 'Planilla inválida' }); 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' }); 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); db.prepare('DELETE FROM registros WHERE sheet_type = ?').run(sheetType);
@@ -75,21 +176,21 @@ router.put('/:sheetType', (req, res) => {
if (sheetType === 'personal') { if (sheetType === 'personal') {
const all = [ const all = [
...(mensualizados || []).map((r) => ({ ...r, tipo: 'mensualizado' })), ...(personalMensual || []).map((r) => ({ ...r, tipo: 'mensualizado' })),
...(eventuales || []).map((r) => ({ ...r, tipo: 'eventual' })), ...(personalEventual || []).map((r) => ({ ...r, tipo: 'eventual' })),
]; ];
for (const row of all) { 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); insert.run(sheetType, JSON.stringify(data), req.user.id);
} }
} else { } else {
for (const row of rows || []) { for (const row of rowsToSave || []) {
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); 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; export default router;

View File

@@ -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);
}

View File

@@ -3,6 +3,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
image: donmarco-backend:1.2.0
container_name: donmarco-api container_name: donmarco-api
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -15,6 +16,18 @@ services:
- "4000:4000" - "4000:4000"
networks: networks:
- donmarco_net - 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: frontend:
build: build:
@@ -22,12 +35,15 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_URL: /api VITE_API_URL: /api
APP_VERSION: "1.2.0"
image: donmarco-frontend:1.2.0
container_name: donmarco-web container_name: donmarco-web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
depends_on: depends_on:
- backend backend:
condition: service_healthy
networks: networks:
- donmarco_net - donmarco_net

35
docker-update.ps1 Normal file
View File

@@ -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

20
docker-update.sh Normal file
View File

@@ -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)"

View File

@@ -5,7 +5,9 @@ COPY package.json package-lock.json* ./
RUN npm install RUN npm install
COPY . . COPY . .
ARG VITE_API_URL=/api ARG VITE_API_URL=/api
ARG APP_VERSION=1.1.0
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
ENV VITE_APP_VERSION=$APP_VERSION
RUN npm run build RUN npm run build
FROM nginx:alpine FROM nginx:alpine

View File

@@ -44,6 +44,7 @@ export default function App() {
<Route path="/personal" element={<PrivateRoute sheet="personal"><PersonalSheet /></PrivateRoute>} /> <Route path="/personal" element={<PrivateRoute sheet="personal"><PersonalSheet /></PrivateRoute>} />
<Route path="/reportes" element={<PrivateRoute sheet="reportes"><ReportesPage /></PrivateRoute>} /> <Route path="/reportes" element={<PrivateRoute sheet="reportes"><ReportesPage /></PrivateRoute>} />
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} /> <Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
<Route path="/cierre-gestion" element={<Navigate to="/admin#cierre-gestion" replace />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );

View File

@@ -44,7 +44,12 @@ export const usersApi = {
}; };
export const sheetsApi = { 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) => save: (sheet, body) =>
api(`/sheets/${sheet}`, { method: 'PUT', body: JSON.stringify(body) }), api(`/sheets/${sheet}`, { method: 'PUT', body: JSON.stringify(body) }),
/** Guardado al salir de la planilla (navegación entre pestañas) */ /** Guardado al salir de la planilla (navegación entre pestañas) */
@@ -63,3 +68,16 @@ export const reportsApi = {
return api(`/reports?${q}`); 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'),
};

View File

@@ -1,177 +1,468 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { sheetsApi } from '../api/client'; import { sheetsApi } from '../api/client';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
import { useGestionView } from '../hooks/useGestionView';
import SaveStatus from './SaveStatus'; import SaveStatus from './SaveStatus';
import GestionBanner from './GestionBanner';
import GestionesSidebar from './GestionesSidebar';
import { emptyGastoRow, formatMoney } from '../utils/calc'; import { emptyGastoRow, formatMoney } from '../utils/calc';
import { isRowEditable } from '../utils/gestion';
export default function GastoSheet({ sheetType, title, defaultClasificacion }) { export default function GastoSheet({ sheetType, title, defaultClasificacion }) {
const { canEdit } = useAuth();
const { canEdit, user } = useAuth();
const editable = canEdit(sheetType); const editable = canEdit(sheetType);
const isAdmin = !!user?.isAdmin;
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
const [rows, setRows] = useState([emptyGastoRow()]); 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 [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
const skipNotifyRef = useRef(false); const skipNotifyRef = useRef(false);
const emptyRow = () => ({ ...emptyGastoRow(), clasificacion: defaultClasificacion });
const canEditSheet = editable && !esHistorial;
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
enabled: editable,
enabled: canEditSheet,
sheetType, sheetType,
}); });
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
setReady(false); setReady(false);
(async () => { (async () => {
try { try {
const data = await sheetsApi.get(sheetType);
const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId });
if (!active) return; if (!active) return;
const loaded = data.rows?.length ? data.rows : [emptyGastoRow()];
const loaded = data.rows || [];
skipNotifyRef.current = true; skipNotifyRef.current = true;
if (loaded.length) {
setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion }))); setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion })));
setReady(true);
} catch (e) { } else if (!data.esHistorial && canEditSheet) {
if (active) setLoadError(e.message);
} finally { setRows([emptyRow()]);
if (active) setLoading(false);
} 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 () => { return () => {
active = false; active = false;
}; };
}, [sheetType, defaultClasificacion, setReady]);
}, [sheetType, defaultClasificacion, selectedGestionId, setReady, canEditSheet]);
useEffect(() => { useEffect(() => {
if (loading || !editable) return;
if (loading || !canEditSheet) return;
if (skipNotifyRef.current) { if (skipNotifyRef.current) {
skipNotifyRef.current = false; skipNotifyRef.current = false;
return; return;
} }
notifyChange({ rows }); notifyChange({ rows });
}, [rows, loading, editable, notifyChange]);
}, [rows, loading, canEditSheet, notifyChange]);
const total = useMemo( const total = useMemo(
() => rows.reduce((acc, r) => acc + (Number(r.monto) || 0), 0), () => rows.reduce((acc, r) => acc + (Number(r.monto) || 0), 0),
[rows] [rows]
); );
const updateRow = (index, field, value) => { const updateRow = (index, field, value) => {
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r))); 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) => { const removeRow = (index) => {
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
skipNotifyRef.current = true; skipNotifyRef.current = true;
const next = rows.filter((_, i) => i !== index); 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); setRows(result);
saveNow({ rows: result }); saveNow({ rows: result });
}; };
const clearSheet = () => { 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; skipNotifyRef.current = true;
if (isAdmin || !gestion?.fechaCierreGestion) {
const blank = [emptyRow()];
setRows(blank); setRows(blank);
saveNow({ rows: 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 <p className="loading">Cargando planilla</p>; if (loading) return <p className="loading">Cargando planilla</p>;
return ( return (
<div>
<div className="sheet-layout">
<GestionesSidebar
gestiones={sidebarGestiones}
selectedId={selectedGestionId}
onSelect={selectGestion}
/>
<div className="sheet-layout__main">
<h1 className="page-title">{title}</h1> <h1 className="page-title">{title}</h1>
{loadError && <div className="alert alert-error">{loadError}</div>} {loadError && <div className="alert alert-error">{loadError}</div>}
{editable && <SaveStatus status={status} error={saveError} />}
{!editable && <div className="alert alert-info">Solo lectura</div>}
{esHistorial && gestionVista && (
<div className="historial-badge">
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} {' '}
{gestionVista.fechaFin}) solo lectura
</div>
)}
{!esHistorial && <GestionBanner gestion={gestion} isAdmin={isAdmin} />}
{canEditSheet && <SaveStatus status={status} error={saveError} />}
{esHistorial && <div className="alert alert-info">Gestión cerrada solo consulta</div>}
{!editable && !esHistorial && <div className="alert alert-info">Solo lectura</div>}
<div className="toolbar"> <div className="toolbar">
{editable && (
{canEditSheet && (
<> <>
<button type="button" className="btn btn-primary btn-icon" onClick={addRow}> <button type="button" className="btn btn-primary btn-icon" onClick={addRow}>
+ +
</button> </button>
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}> <button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
Guardar ahora Guardar ahora
</button> </button>
<button type="button" className="btn btn-danger" onClick={clearSheet}> <button type="button" className="btn btn-danger" onClick={clearSheet}>
Vaciar planilla Vaciar planilla
</button> </button>
</> </>
)} )}
</div> </div>
{rows.length === 0 ? (
<p className="loading">Sin registros en esta gestión.</p>
) : (
<div className="card sheet-table-wrap"> <div className="card sheet-table-wrap">
<table className="sheet-table"> <table className="sheet-table">
<thead> <thead>
<tr> <tr>
<th>Fecha</th> <th>Fecha</th>
<th>Detalle</th> <th>Detalle</th>
<th>Impuestos</th> <th>Impuestos</th>
<th> Factura</th> <th> Factura</th>
<th>Monto</th> <th>Monto</th>
<th>Clasificación</th> <th>Clasificación</th>
{editable && <th></th>}
{canEditSheet && <th></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => (
<tr key={i}> {rows.map((row, i) => {
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
return (
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
<td> <td>
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
</td> </td>
<td> <td>
<input value={row.detalle || ''} disabled={!editable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
<input value={row.detalle || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" min="0" step="any" value={row.impuestos ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'impuestos', e.target.value)} />
<input type="number" min="0" step="any" value={row.impuestos ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'impuestos', e.target.value)} />
</td> </td>
<td> <td>
<input value={row.numeroFactura || ''} disabled={!editable} onChange={(e) => updateRow(i, 'numeroFactura', e.target.value)} />
<input value={row.numeroFactura || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'numeroFactura', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" min="0" step="any" value={row.monto ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'monto', e.target.value)} />
<input type="number" min="0" step="any" value={row.monto ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'monto', e.target.value)} />
</td> </td>
<td> <td>
<select value={row.clasificacion || 'diario'} disabled={!editable} onChange={(e) => updateRow(i, 'clasificacion', e.target.value)}>
<select value={row.clasificacion || 'diario'} disabled={!rowEditable} onChange={(e) => updateRow(i, 'clasificacion', e.target.value)}>
<option value="diario">Gasto Diario</option> <option value="diario">Gasto Diario</option>
<option value="fijo">Gasto Fijo</option> <option value="fijo">Gasto Fijo</option>
<option value="variable">Gasto Variable</option> <option value="variable">Gasto Variable</option>
</select> </select>
</td> </td>
{editable && (
{canEditSheet && (
<td> <td>
<button <button
type="button" type="button"
className="btn btn-danger btn-icon" className="btn btn-danger btn-icon"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
removeRow(i); removeRow(i);
}} }}
title="Eliminar fila"
disabled={!rowEditable}
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
> >
× ×
</button> </button>
</td> </td>
)} )}
</tr> </tr>
))}
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
)}
<div className="totals-bar"> <div className="totals-bar">
<span> <span>
Total gastos: <strong>{formatMoney(total)}</strong> Total gastos: <strong>{formatMoney(total)}</strong>
</span> </span>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -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 (
<div className={`alert ${activo ? 'alert-error' : 'alert-info'}`} style={{ marginBottom: '0.75rem' }}>
<strong>{activo ? 'Cierre de gestión activo' : 'Período en curso'}:</strong> {msg}
</div>
);
}

View File

@@ -0,0 +1,49 @@
export default function GestionesSidebar({ gestiones, gestionActual, selectedId, onSelect }) {
const historial = gestiones?.historial || [];
const actual = gestiones?.gestionActual || gestionActual;
return (
<aside className="gestiones-sidebar">
<h3 className="gestiones-sidebar__title">Gestiones</h3>
<button
type="button"
className={`gestion-item gestion-item--actual${selectedId === 'actual' ? ' gestion-item--active' : ''}`}
onClick={() => onSelect('actual')}
>
<span className="gestion-item__label">Gestión actual</span>
{actual && (
<span className="gestion-item__range">
desde {actual.fechaInicio}
</span>
)}
</button>
{historial.length > 0 && (
<>
<p className="gestiones-sidebar__subtitle">Historial</p>
<ul className="gestion-list">
{historial.map((g) => (
<li key={g.id}>
<button
type="button"
className={`gestion-item${String(selectedId) === String(g.id) ? ' gestion-item--active' : ''}`}
onClick={() => onSelect(String(g.id))}
>
<span className="gestion-item__label">{g.etiqueta}</span>
<span className="gestion-item__range">
{g.fechaInicio} {g.fechaFin}
</span>
</button>
</li>
))}
</ul>
</>
)}
{historial.length === 0 && (
<p className="gestiones-sidebar__empty">Sin gestiones cerradas aún.</p>
)}
</aside>
);
}

View File

@@ -1,186 +1,490 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { sheetsApi } from '../api/client'; import { sheetsApi } from '../api/client';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
import { useGestionView } from '../hooks/useGestionView';
import SaveStatus from './SaveStatus'; import SaveStatus from './SaveStatus';
import GestionBanner from './GestionBanner';
import GestionesSidebar from './GestionesSidebar';
import { calcIngresoRow, emptyIngresoRow, formatMoney } from '../utils/calc'; import { calcIngresoRow, emptyIngresoRow, formatMoney } from '../utils/calc';
import { isRowEditable } from '../utils/gestion';
export default function IngresoSheet({ sheetType, title }) { export default function IngresoSheet({ sheetType, title }) {
const { canEdit } = useAuth();
const { canEdit, user } = useAuth();
const editable = canEdit(sheetType); const editable = canEdit(sheetType);
const isAdmin = !!user?.isAdmin;
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
const [rows, setRows] = useState([emptyIngresoRow()]); 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 [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
const skipNotifyRef = useRef(false); const skipNotifyRef = useRef(false);
const canEditSheet = editable && !esHistorial;
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
enabled: editable,
enabled: canEditSheet,
sheetType, sheetType,
}); });
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
setReady(false); setReady(false);
(async () => { (async () => {
try { try {
const data = await sheetsApi.get(sheetType);
const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId });
if (!active) return; if (!active) return;
skipNotifyRef.current = true; skipNotifyRef.current = true;
setRows(data.rows?.length ? data.rows : [emptyIngresoRow()]);
setReady(true); const loaded = data.rows || [];
} catch (e) {
if (active) setLoadError(e.message); if (loaded.length) {
} finally {
if (active) setLoading(false); 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 () => { return () => {
active = false; active = false;
}; };
}, [sheetType, setReady]);
}, [sheetType, selectedGestionId, setReady, canEditSheet]);
useEffect(() => { useEffect(() => {
if (loading || !editable) return;
if (loading || !canEditSheet) return;
if (skipNotifyRef.current) { if (skipNotifyRef.current) {
skipNotifyRef.current = false; skipNotifyRef.current = false;
return; return;
} }
notifyChange({ rows }); notifyChange({ rows });
}, [rows, loading, editable, notifyChange]);
}, [rows, loading, canEditSheet, notifyChange]);
const totals = useMemo(() => { const totals = useMemo(() => {
let totalIngresos = 0; let totalIngresos = 0;
let totalUtilidad = 0; let totalUtilidad = 0;
rows.forEach((row) => { rows.forEach((row) => {
const { precioTotal, utilidad } = calcIngresoRow(row); const { precioTotal, utilidad } = calcIngresoRow(row);
totalIngresos += precioTotal; totalIngresos += precioTotal;
totalUtilidad += utilidad; totalUtilidad += utilidad;
}); });
return { totalIngresos, totalUtilidad }; return { totalIngresos, totalUtilidad };
}, [rows]); }, [rows]);
const updateRow = (index, field, value) => { const updateRow = (index, field, value) => {
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r))); setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
}; };
const addRow = () => setRows((prev) => [...prev, emptyIngresoRow()]); const addRow = () => setRows((prev) => [...prev, emptyIngresoRow()]);
const removeRow = (index) => { const removeRow = (index) => {
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
skipNotifyRef.current = true; skipNotifyRef.current = true;
const next = rows.filter((_, i) => i !== index); const next = rows.filter((_, i) => i !== index);
const result = next.length > 0 ? next : [emptyIngresoRow()]; const result = next.length > 0 ? next : [emptyIngresoRow()];
setRows(result); setRows(result);
saveNow({ rows: result }); saveNow({ rows: result });
}; };
const clearSheet = () => { 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; skipNotifyRef.current = true;
if (isAdmin || !gestion?.fechaCierreGestion) {
const blank = [emptyIngresoRow()];
setRows(blank); setRows(blank);
saveNow({ rows: 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 <p className="loading">Cargando planilla</p>; if (loading) return <p className="loading">Cargando planilla</p>;
return ( return (
<div>
<div className="sheet-layout">
<GestionesSidebar
gestiones={sidebarGestiones}
selectedId={selectedGestionId}
onSelect={selectGestion}
/>
<div className="sheet-layout__main">
<h1 className="page-title">{title}</h1> <h1 className="page-title">{title}</h1>
{loadError && <div className="alert alert-error">{loadError}</div>} {loadError && <div className="alert alert-error">{loadError}</div>}
{editable && <SaveStatus status={status} error={saveError} />}
{!editable && <div className="alert alert-info">Solo lectura sin permiso de edición</div>}
{esHistorial && gestionVista && (
<div className="historial-badge">
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} {' '}
{gestionVista.fechaFin}) solo lectura
</div>
)}
{!esHistorial && <GestionBanner gestion={gestion} isAdmin={isAdmin} />}
{canEditSheet && <SaveStatus status={status} error={saveError} />}
{esHistorial && <div className="alert alert-info">Gestión cerrada solo consulta</div>}
{!editable && !esHistorial && (
<div className="alert alert-info">Solo lectura sin permiso de edición</div>
)}
<div className="toolbar"> <div className="toolbar">
{editable && (
{canEditSheet && (
<> <>
<button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea"> <button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea">
+ +
</button> </button>
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}> <button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
Guardar ahora Guardar ahora
</button> </button>
<button type="button" className="btn btn-danger" onClick={clearSheet}> <button type="button" className="btn btn-danger" onClick={clearSheet}>
Vaciar planilla Vaciar planilla
</button> </button>
</> </>
)} )}
</div> </div>
{rows.length === 0 ? (
<p className="loading">Sin registros en esta gestión.</p>
) : (
<div className="card sheet-table-wrap"> <div className="card sheet-table-wrap">
<table className="sheet-table"> <table className="sheet-table">
<thead> <thead>
<tr> <tr>
<th>Fecha</th> <th>Fecha</th>
<th>Cliente</th> <th>Cliente</th>
<th>Detalle</th> <th>Detalle</th>
<th>Cantidad</th> <th>Cantidad</th>
<th>P. Unitario</th> <th>P. Unitario</th>
<th>C. Unitario</th> <th>C. Unitario</th>
<th>Precio Total</th> <th>Precio Total</th>
<th>Utilidad</th> <th>Utilidad</th>
{editable && <th></th>}
{canEditSheet && <th></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => { {rows.map((row, i) => {
const { precioTotal, utilidad } = calcIngresoRow(row); const { precioTotal, utilidad } = calcIngresoRow(row);
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
return ( return (
<tr key={i}>
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
<td> <td>
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
</td> </td>
<td> <td>
<input value={row.cliente || ''} disabled={!editable} onChange={(e) => updateRow(i, 'cliente', e.target.value)} />
<input value={row.cliente || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'cliente', e.target.value)} />
</td> </td>
<td> <td>
<input value={row.detalle || ''} disabled={!editable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
<input value={row.detalle || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" min="0" step="any" value={row.cantidad ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'cantidad', e.target.value)} />
<input type="number" min="0" step="any" value={row.cantidad ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'cantidad', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" min="0" step="any" value={row.precioUnitario ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'precioUnitario', e.target.value)} />
<input type="number" min="0" step="any" value={row.precioUnitario ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'precioUnitario', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" min="0" step="any" value={row.costoUnitario ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'costoUnitario', e.target.value)} />
<input type="number" min="0" step="any" value={row.costoUnitario ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'costoUnitario', e.target.value)} />
</td> </td>
<td className="computed">{formatMoney(precioTotal)}</td> <td className="computed">{formatMoney(precioTotal)}</td>
<td className="computed">{formatMoney(utilidad)}</td> <td className="computed">{formatMoney(utilidad)}</td>
{editable && (
{canEditSheet && (
<td> <td>
<button <button
type="button" type="button"
className="btn btn-danger btn-icon" className="btn btn-danger btn-icon"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
removeRow(i); removeRow(i);
}} }}
title="Quitar fila"
disabled={!rowEditable}
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Quitar fila'}
> >
× ×
</button> </button>
</td> </td>
)} )}
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
)}
<div className="totals-bar"> <div className="totals-bar">
<span> <span>
Sumatoria ingresos: <strong>{formatMoney(totals.totalIngresos)}</strong> Sumatoria ingresos: <strong>{formatMoney(totals.totalIngresos)}</strong>
</span> </span>
<span> <span>
Sumatoria utilidad: <strong>{formatMoney(totals.totalUtilidad)}</strong> Sumatoria utilidad: <strong>{formatMoney(totals.totalUtilidad)}</strong>
</span> </span>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -38,7 +38,10 @@ export default function Layout({ children }) {
</NavLink> </NavLink>
))} ))}
{user?.isAdmin && ( {user?.isAdmin && (
<NavLink to="/admin" className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`}> <NavLink
to="/admin"
className={({ isActive }) => `nav-tab nav-tab--admin${isActive ? ' active' : ''}`}
>
Administración Administración
</NavLink> </NavLink>
)} )}
@@ -46,7 +49,12 @@ export default function Layout({ children }) {
Salir Salir
</button> </button>
</nav> </nav>
<main className="main-content">{children}</main> <main className="main-content">
{children}
<p className="app-version">
Don Marco v{import.meta.env.VITE_APP_VERSION || '1.2.0'} Historial de gestiones (Docker)
</p>
</main>
</div> </div>
); );
} }

View File

@@ -1,236 +1,662 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { sheetsApi } from '../api/client'; import { sheetsApi } from '../api/client';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useSheetAutoSave } from '../hooks/useSheetAutoSave'; import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
import { useGestionView } from '../hooks/useGestionView';
import SaveStatus from './SaveStatus'; import SaveStatus from './SaveStatus';
import GestionBanner from './GestionBanner';
import GestionesSidebar from './GestionesSidebar';
import { emptyMensualizado, emptyEventual, formatMoney } from '../utils/calc'; import { emptyMensualizado, emptyEventual, formatMoney } from '../utils/calc';
import { isRowEditable } from '../utils/gestion';
const MAX_MENSUAL = 10; const MAX_MENSUAL = 10;
const MAX_EVENTUAL = 5; const MAX_EVENTUAL = 5;
function rowCostMensual(r) { function rowCostMensual(r) {
return (Number(r.sueldoBase) || 0) + (Number(r.cargasSociales) || 0) + (Number(r.bonos) || 0); return (Number(r.sueldoBase) || 0) + (Number(r.cargasSociales) || 0) + (Number(r.bonos) || 0);
} }
export default function PersonalSheet() { export default function PersonalSheet() {
const { canEdit } = useAuth();
const { canEdit, user } = useAuth();
const editable = canEdit('personal'); const editable = canEdit('personal');
const isAdmin = !!user?.isAdmin;
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
const [mensualizados, setMensualizados] = useState([emptyMensualizado()]); const [mensualizados, setMensualizados] = useState([emptyMensualizado()]);
const [eventuales, setEventuales] = useState([emptyEventual()]); 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 [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
const skipNotifyRef = useRef(false); const skipNotifyRef = useRef(false);
const canEditSheet = editable && !esHistorial;
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({ const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
enabled: editable,
enabled: canEditSheet,
sheetType: 'personal', sheetType: 'personal',
}); });
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
setReady(false); setReady(false);
(async () => { (async () => {
try { try {
const data = await sheetsApi.get('personal');
const data = await sheetsApi.get('personal', { gestionId: selectedGestionId });
if (!active) return; if (!active) return;
skipNotifyRef.current = true; skipNotifyRef.current = true;
setMensualizados(
data.mensualizados?.length ? data.mensualizados.slice(0, MAX_MENSUAL) : [emptyMensualizado()] const m = data.mensualizados || [];
);
setEventuales(data.eventuales?.length ? data.eventuales : [emptyEventual()]); const e = data.eventuales || [];
setReady(true);
} catch (e) { if (m.length || e.length) {
if (active) setLoadError(e.message);
} finally { setMensualizados(m.length ? m.slice(0, MAX_MENSUAL) : []);
if (active) setLoading(false);
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 () => { return () => {
active = false; active = false;
}; };
}, [setReady]);
}, [selectedGestionId, setReady, canEditSheet]);
useEffect(() => { useEffect(() => {
if (loading || !editable) return;
if (loading || !canEditSheet) return;
if (skipNotifyRef.current) { if (skipNotifyRef.current) {
skipNotifyRef.current = false; skipNotifyRef.current = false;
return; return;
} }
notifyChange({ mensualizados, eventuales }); notifyChange({ mensualizados, eventuales });
}, [mensualizados, eventuales, loading, editable, notifyChange]);
}, [mensualizados, eventuales, loading, canEditSheet, notifyChange]);
const total = useMemo(() => { const total = useMemo(() => {
const m = mensualizados.reduce((a, r) => a + rowCostMensual(r), 0); const m = mensualizados.reduce((a, r) => a + rowCostMensual(r), 0);
const e = eventuales.reduce((a, r) => a + (Number(r.pagoDiario) || 0), 0); const e = eventuales.reduce((a, r) => a + (Number(r.pagoDiario) || 0), 0);
return m + e; return m + e;
}, [mensualizados, eventuales]); }, [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))); 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))); 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) => { const removeEventual = (index) => {
if (!isRowEditable(eventuales[index], canEditSheet, isAdmin)) return;
skipNotifyRef.current = true; skipNotifyRef.current = true;
const next = eventuales.filter((_, i) => i !== index); const next = eventuales.filter((_, i) => i !== index);
const result = next.length > 0 ? next : [emptyEventual()]; const result = next.length > 0 ? next : [emptyEventual()];
setEventuales(result); setEventuales(result);
saveNow({ mensualizados, eventuales: result }); saveNow({ mensualizados, eventuales: result });
}; };
const clearSheet = () => { const clearSheet = () => {
if (!window.confirm('¿Vaciar toda la planilla de personal?')) return;
const m = [emptyMensualizado()]; const msg = gestion?.fechaCierreGestion && !isAdmin
const e = [emptyEventual()];
? '¿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; skipNotifyRef.current = true;
if (isAdmin || !gestion?.fechaCierreGestion) {
const m = [emptyMensualizado()];
const e = [emptyEventual()];
setMensualizados(m); setMensualizados(m);
setEventuales(e); setEventuales(e);
saveNow({ mensualizados: m, eventuales: 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 <p className="loading">Cargando planilla</p>; if (loading) return <p className="loading">Cargando planilla</p>;
return ( return (
<div>
<div className="sheet-layout">
<GestionesSidebar
gestiones={sidebarGestiones}
selectedId={selectedGestionId}
onSelect={selectGestion}
/>
<div className="sheet-layout__main">
<h1 className="page-title">Planilla de Personal</h1> <h1 className="page-title">Planilla de Personal</h1>
{loadError && <div className="alert alert-error">{loadError}</div>} {loadError && <div className="alert alert-error">{loadError}</div>}
{editable && <SaveStatus status={status} error={saveError} />}
{esHistorial && gestionVista && (
<div className="historial-badge">
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} {' '}
{gestionVista.fechaFin}) solo lectura
</div>
)}
{!esHistorial && <GestionBanner gestion={gestion} isAdmin={isAdmin} />}
{canEditSheet && <SaveStatus status={status} error={saveError} />}
{esHistorial && <div className="alert alert-info">Gestión cerrada solo consulta</div>}
<div className="toolbar"> <div className="toolbar">
{editable && (
{canEditSheet && (
<> <>
<button <button
type="button" type="button"
className="btn btn-primary btn-icon" className="btn btn-primary btn-icon"
disabled={mensualizados.length >= MAX_MENSUAL} disabled={mensualizados.length >= MAX_MENSUAL}
onClick={() => setMensualizados((p) => (p.length < MAX_MENSUAL ? [...p, emptyMensualizado()] : p))} onClick={() => setMensualizados((p) => (p.length < MAX_MENSUAL ? [...p, emptyMensualizado()] : p))}
title="Agregar mensualizado" title="Agregar mensualizado"
> >
+ Mensual + Mensual
</button> </button>
<button <button
type="button" type="button"
className="btn btn-secondary btn-icon" className="btn btn-secondary btn-icon"
disabled={eventuales.length >= MAX_EVENTUAL} disabled={eventuales.length >= MAX_EVENTUAL}
onClick={() => setEventuales((p) => (p.length < MAX_EVENTUAL ? [...p, emptyEventual()] : p))} onClick={() => setEventuales((p) => (p.length < MAX_EVENTUAL ? [...p, emptyEventual()] : p))}
title="Agregar eventual" title="Agregar eventual"
> >
+ Eventual + Eventual
</button> </button>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => saveNow({ mensualizados, eventuales })} onClick={() => saveNow({ mensualizados, eventuales })}
disabled={status === 'saving'} disabled={status === 'saving'}
> >
Guardar ahora Guardar ahora
</button> </button>
<button type="button" className="btn btn-danger" onClick={clearSheet}> <button type="button" className="btn btn-danger" onClick={clearSheet}>
Vaciar planilla Vaciar planilla
</button> </button>
</> </>
)} )}
</div> </div>
{sinDatos ? (
<p className="loading">Sin registros en esta gestión.</p>
) : (
<>
<div className="card" style={{ marginBottom: '1rem' }}> <div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Funcionarios mensualizados (máx. {MAX_MENSUAL})</h2> <h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Funcionarios mensualizados (máx. {MAX_MENSUAL})</h2>
{mensualizados.length === 0 ? (
<p style={{ color: 'var(--muted)' }}>Sin mensualizados en esta gestión.</p>
) : (
<div className="sheet-table-wrap"> <div className="sheet-table-wrap">
<table className="sheet-table"> <table className="sheet-table">
<thead> <thead>
<tr> <tr>
<th>Nombre</th> <th>Nombre</th>
<th>Sueldo base</th> <th>Sueldo base</th>
<th>Cargas sociales</th> <th>Cargas sociales</th>
<th>Bonos</th> <th>Bonos</th>
<th>Subtotal</th> <th>Subtotal</th>
{editable && <th></th>}
{canEditSheet && <th></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{mensualizados.map((row, i) => (
<tr key={i}> {mensualizados.map((row, i) => {
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
return (
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
<td> <td>
<input value={row.nombre || ''} disabled={!editable} onChange={(e) => updateMensual(i, 'nombre', e.target.value)} />
<input value={row.nombre || ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'nombre', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" value={row.sueldoBase ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'sueldoBase', e.target.value)} />
<input type="number" value={row.sueldoBase ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'sueldoBase', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" value={row.cargasSociales ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'cargasSociales', e.target.value)} />
<input type="number" value={row.cargasSociales ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'cargasSociales', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" value={row.bonos ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'bonos', e.target.value)} />
<input type="number" value={row.bonos ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'bonos', e.target.value)} />
</td> </td>
<td className="computed">{formatMoney(rowCostMensual(row))}</td> <td className="computed">{formatMoney(rowCostMensual(row))}</td>
{editable && (
{canEditSheet && (
<td> <td>
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeMensual(i)} title="Eliminar fila">
<button
type="button"
className="btn btn-danger btn-icon"
onClick={() => removeMensual(i)}
disabled={!rowEditable}
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
>
× ×
</button> </button>
</td> </td>
)} )}
</tr> </tr>
))}
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div> </div>
<div className="card"> <div className="card">
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)</h2> <h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)</h2>
{eventuales.length === 0 ? (
<p style={{ color: 'var(--muted)' }}>Sin eventuales en esta gestión.</p>
) : (
<div className="sheet-table-wrap"> <div className="sheet-table-wrap">
<table className="sheet-table"> <table className="sheet-table">
<thead> <thead>
<tr> <tr>
<th>Fecha</th> <th>Fecha</th>
<th>Nombre</th> <th>Nombre</th>
<th>Pago diario</th> <th>Pago diario</th>
{editable && <th></th>}
{canEditSheet && <th></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{eventuales.map((row, i) => (
<tr key={i}> {eventuales.map((row, i) => {
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
return (
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
<td> <td>
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateEventual(i, 'fecha', e.target.value)} />
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'fecha', e.target.value)} />
</td> </td>
<td> <td>
<input value={row.nombre || ''} disabled={!editable} onChange={(e) => updateEventual(i, 'nombre', e.target.value)} />
<input value={row.nombre || ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'nombre', e.target.value)} />
</td> </td>
<td> <td>
<input type="number" value={row.pagoDiario ?? ''} disabled={!editable} onChange={(e) => updateEventual(i, 'pagoDiario', e.target.value)} />
<input type="number" value={row.pagoDiario ?? ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'pagoDiario', e.target.value)} />
</td> </td>
{editable && (
{canEditSheet && (
<td> <td>
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeEventual(i)} title="Eliminar fila">
<button
type="button"
className="btn btn-danger btn-icon"
onClick={() => removeEventual(i)}
disabled={!rowEditable}
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
>
× ×
</button> </button>
</td> </td>
)} )}
</tr> </tr>
))}
);
})}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
)}
</div>
</>
)}
<div className="totals-bar" style={{ marginTop: '1rem' }}> <div className="totals-bar" style={{ marginTop: '1rem' }}>
<span> <span>
Sumatoria total personal: <strong>{formatMoney(total)}</strong> Sumatoria total personal: <strong>{formatMoney(total)}</strong>
</span> </span>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -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,
};
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; 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 = { const SHEET_LABELS = {
ventas: 'Ventas', ventas: 'Ventas',
@@ -22,12 +23,30 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [newUser, setNewUser] = useState({ username: '', password: '', permissions: emptyPerms() }); 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 () => { const load = async () => {
setError('');
setGestionError('');
try { try {
setUsers(await usersApi.list()); setUsers(await usersApi.list());
} catch (e) { } catch (e) {
setError(e.message); 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -37,6 +56,12 @@ export default function AdminPage() {
load(); load();
}, []); }, []);
useEffect(() => {
if (location.hash === '#cierre-gestion') {
document.getElementById('cierre-gestion')?.scrollIntoView({ behavior: 'smooth' });
}
}, [location.hash, loading]);
const handleCreate = async (e) => { const handleCreate = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
@@ -69,18 +94,112 @@ export default function AdminPage() {
} }
}; };
if (loading) return <p className="loading">Cargando usuarios</p>; 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 <p className="loading">Cargando administración</p>;
return ( return (
<div> <div>
<h1 className="page-title">Administración de usuarios</h1> <h1 className="page-title">Administración</h1>
<p style={{ color: 'var(--muted)' }}> <p style={{ color: 'var(--muted)' }}>
El superusuario <strong>JEFE</strong> puede crear usuarios, asignar contraseñas y habilitar acceso por planilla. Solo visible para el administrador <strong>JEFE</strong>. Gestione el cierre del período y los usuarios.
</p> </p>
{error && <div className="alert alert-error">{error}</div>}
<div className="card admin-grid" style={{ marginBottom: '1.5rem' }}> <nav className="admin-subnav">
<h2 style={{ marginTop: 0 }}>Nuevo usuario</h2> <a href="#cierre-gestion" className="admin-subnav__link admin-subnav__link--active">
Cierre de gestión
</a>
<a href="#usuarios" className="admin-subnav__link">
Usuarios
</a>
</nav>
{error && <div className="alert alert-error">{error}</div>}
{gestionMsg && <div className="alert alert-success">{gestionMsg}</div>}
<div id="cierre-gestion" className="card card-highlight admin-grid" style={{ marginBottom: '1.5rem' }}>
<h2 style={{ marginTop: 0 }}>Cierre de gestión</h2>
{gestionError && <div className="alert alert-error">{gestionError}</div>}
<div className="alert alert-info" style={{ fontSize: '0.88rem', lineHeight: 1.6 }}>
<strong>¿Cómo funciona?</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
<li>Al <strong>cerrar la gestión</strong> (típicamente fin de mes), el período queda bloqueado de inmediato.</li>
<li>Cada planilla muestra solo la <strong>gestión actual</strong>; el historial está en la <strong>barra lateral</strong> (solo lectura).</li>
<li>La nueva gestión comienza el día siguiente al cierre. Use el botón <strong>+</strong> para cargar datos nuevos.</li>
<li>Filas con fecha del período cerrado no se pueden editar (excepto JEFE).</li>
</ul>
</div>
{gestion?.gestionActual && (
<p style={{ fontSize: '0.85rem', marginBottom: '1rem' }}>
Gestión actual: desde <strong>{gestion.gestionActual.fechaInicio}</strong> Hoy:{' '}
<strong>{gestion.hoy}</strong>
</p>
)}
{gestion?.historial?.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<p style={{ fontSize: '0.85rem', marginBottom: '0.5rem' }}>
<strong>Gestiones cerradas:</strong>
</p>
<ul style={{ margin: 0, paddingLeft: '1.2rem', fontSize: '0.85rem' }}>
{gestion.historial.map((g) => (
<li key={g.id}>
{g.etiqueta} ({g.fechaInicio} {g.fechaFin})
</li>
))}
</ul>
</div>
)}
<form onSubmit={cerrarGestion}>
<div className="form-group">
<label>Último día de la gestión a cerrar</label>
<input type="date" value={fechaCierre} onChange={(e) => setFechaCierre(e.target.value)} required />
{gestion?.sugerirCierre && (
<p style={{ fontSize: '0.8rem', color: 'var(--muted)', marginTop: '0.35rem' }}>
Sugerido (fin de mes): {gestion.sugerirCierre}
</p>
)}
</div>
<div className="toolbar">
<button type="submit" className="btn btn-primary">
Cerrar gestión
</button>
</div>
</form>
</div>
<div id="usuarios" className="card admin-grid" style={{ marginBottom: '1.5rem' }}>
<h2 style={{ marginTop: 0 }}>Usuarios Nuevo usuario</h2>
<form onSubmit={handleCreate}> <form onSubmit={handleCreate}>
<div className="form-group"> <div className="form-group">
<label>Usuario</label> <label>Usuario</label>

View File

@@ -1,9 +1,11 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
export default function HomePage() { export default function HomePage() {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<div>
<div className="card"> <div className="card">
<h1 className="page-title">Menú principal</h1> <h1 className="page-title">Menú principal</h1>
<p> <p>
@@ -13,11 +15,23 @@ export default function HomePage() {
<li>Planillas de ingresos: Ventas, Servicios y Alquileres</li> <li>Planillas de ingresos: Ventas, Servicios y Alquileres</li>
<li>Planillas de egresos: Gastos Diarios y Gastos Fijos</li> <li>Planillas de egresos: Gastos Diarios y Gastos Fijos</li>
<li>Planilla de Personal y Reportes consolidados</li> <li>Planilla de Personal y Reportes consolidados</li>
{user?.isAdmin && <li>Panel de Administración para gestionar usuarios y permisos</li>}
</ul> </ul>
<p style={{ fontSize: '0.85rem', color: 'var(--muted)', marginTop: '1.5rem' }}> <p style={{ fontSize: '0.85rem', color: 'var(--muted)', marginTop: '1.5rem' }}>
Los datos se guardan automáticamente en el servidor al editar y al cambiar de pestaña. Use el botón <strong>+</strong> para agregar filas. Asegúrese de tener el backend en ejecución (puerto 4000). Los datos se guardan automáticamente en el servidor. Use el botón <strong>+</strong> para agregar filas.
</p> </p>
</div> </div>
{user?.isAdmin && (
<div className="card card-highlight" style={{ marginTop: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Administración (JEFE)</h2>
<p style={{ color: 'var(--muted)', marginBottom: '1rem' }}>
Cierre de gestión, usuarios y permisos en un solo lugar.
</p>
<Link to="/admin" className="btn btn-primary">
Ir a Administración
</Link>
</div>
)}
</div>
); );
} }

View File

@@ -382,6 +382,211 @@ textarea {
opacity: 0.85; 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) { @media (max-width: 768px) {
.navbar { .navbar {
padding: 0.5rem; padding: 0.5rem;

View File

@@ -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 +).`;
}