no se que modificaciones se hizo posible error
This commit is contained in:
50
README.md
50
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
16
backend/src/routes/gestiones.js
Normal file
16
backend/src/routes/gestiones.js
Normal 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;
|
||||
36
backend/src/routes/settings.js
Normal file
36
backend/src/routes/settings.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
272
backend/src/utils/gestion.js
Normal file
272
backend/src/utils/gestion.js
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
35
docker-update.ps1
Normal file
35
docker-update.ps1
Normal 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
20
docker-update.sh
Normal 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)"
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function App() {
|
||||
<Route path="/personal" element={<PrivateRoute sheet="personal"><PersonalSheet /></PrivateRoute>} />
|
||||
<Route path="/reportes" element={<PrivateRoute sheet="reportes"><ReportesPage /></PrivateRoute>} />
|
||||
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
||||
<Route path="/cierre-gestion" element={<Navigate to="/admin#cierre-gestion" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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 <p className="loading">Cargando planilla…</p>;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||||
{editable && <SaveStatus status={status} error={saveError} />}
|
||||
{!editable && <div className="alert alert-info">Solo lectura</div>}
|
||||
|
||||
<div className="toolbar">
|
||||
{editable && (
|
||||
<>
|
||||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow}>
|
||||
+
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
|
||||
Guardar ahora
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||
Vaciar planilla
|
||||
</button>
|
||||
</>
|
||||
<div className="sheet-layout">
|
||||
|
||||
<GestionesSidebar
|
||||
|
||||
gestiones={sidebarGestiones}
|
||||
|
||||
selectedId={selectedGestionId}
|
||||
|
||||
onSelect={selectGestion}
|
||||
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div className="sheet-layout__main">
|
||||
|
||||
<h1 className="page-title">{title}</h1>
|
||||
|
||||
{loadError && <div className="alert alert-error">{loadError}</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">
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<>
|
||||
|
||||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow}>
|
||||
|
||||
+
|
||||
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
|
||||
|
||||
Guardar ahora
|
||||
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||
|
||||
Vaciar planilla
|
||||
|
||||
</button>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{rows.length === 0 ? (
|
||||
|
||||
<p className="loading">Sin registros en esta gestión.</p>
|
||||
|
||||
) : (
|
||||
|
||||
<div className="card sheet-table-wrap">
|
||||
|
||||
<table className="sheet-table">
|
||||
|
||||
<thead>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Fecha</th>
|
||||
|
||||
<th>Detalle</th>
|
||||
|
||||
<th>Impuestos</th>
|
||||
|
||||
<th>Nº Factura</th>
|
||||
|
||||
<th>Monto</th>
|
||||
|
||||
<th>Clasificación</th>
|
||||
|
||||
{canEditSheet && <th></th>}
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{rows.map((row, i) => {
|
||||
|
||||
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||
|
||||
return (
|
||||
|
||||
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input value={row.detalle || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" min="0" step="any" value={row.impuestos ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'impuestos', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input value={row.numeroFactura || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'numeroFactura', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" min="0" step="any" value={row.monto ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'monto', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<select value={row.clasificacion || 'diario'} disabled={!rowEditable} onChange={(e) => updateRow(i, 'clasificacion', e.target.value)}>
|
||||
|
||||
<option value="diario">Gasto Diario</option>
|
||||
|
||||
<option value="fijo">Gasto Fijo</option>
|
||||
|
||||
<option value="variable">Gasto Variable</option>
|
||||
|
||||
</select>
|
||||
|
||||
</td>
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<td>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-danger btn-icon"
|
||||
|
||||
onClick={(e) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
removeRow(i);
|
||||
|
||||
}}
|
||||
|
||||
disabled={!rowEditable}
|
||||
|
||||
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||||
|
||||
>
|
||||
|
||||
×
|
||||
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
)}
|
||||
|
||||
</tr>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="totals-bar">
|
||||
|
||||
<span>
|
||||
|
||||
Total gastos: <strong>{formatMoney(total)}</strong>
|
||||
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="card sheet-table-wrap">
|
||||
<table className="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Detalle</th>
|
||||
<th>Impuestos</th>
|
||||
<th>Nº Factura</th>
|
||||
<th>Monto</th>
|
||||
<th>Clasificación</th>
|
||||
{editable && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.detalle || ''} disabled={!editable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.impuestos ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'impuestos', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.numeroFactura || ''} disabled={!editable} onChange={(e) => updateRow(i, 'numeroFactura', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.monto ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'monto', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<select value={row.clasificacion || 'diario'} disabled={!editable} onChange={(e) => updateRow(i, 'clasificacion', e.target.value)}>
|
||||
<option value="diario">Gasto Diario</option>
|
||||
<option value="fijo">Gasto Fijo</option>
|
||||
<option value="variable">Gasto Variable</option>
|
||||
</select>
|
||||
</td>
|
||||
{editable && (
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeRow(i);
|
||||
}}
|
||||
title="Eliminar fila"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="totals-bar">
|
||||
<span>
|
||||
Total gastos: <strong>{formatMoney(total)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
14
frontend/src/components/GestionBanner.jsx
Normal file
14
frontend/src/components/GestionBanner.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/GestionesSidebar.jsx
Normal file
49
frontend/src/components/GestionesSidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 <p className="loading">Cargando planilla…</p>;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{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>}
|
||||
|
||||
<div className="toolbar">
|
||||
{editable && (
|
||||
<>
|
||||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea">
|
||||
+
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
|
||||
Guardar ahora
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||
Vaciar planilla
|
||||
</button>
|
||||
</>
|
||||
<div className="sheet-layout">
|
||||
|
||||
<GestionesSidebar
|
||||
|
||||
gestiones={sidebarGestiones}
|
||||
|
||||
selectedId={selectedGestionId}
|
||||
|
||||
onSelect={selectGestion}
|
||||
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div className="sheet-layout__main">
|
||||
|
||||
<h1 className="page-title">{title}</h1>
|
||||
|
||||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||||
|
||||
|
||||
|
||||
{esHistorial && gestionVista && (
|
||||
|
||||
<div className="historial-badge">
|
||||
|
||||
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} →{' '}
|
||||
|
||||
{gestionVista.fechaFin}) — solo lectura
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card sheet-table-wrap">
|
||||
<table className="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Cliente</th>
|
||||
<th>Detalle</th>
|
||||
<th>Cantidad</th>
|
||||
<th>P. Unitario</th>
|
||||
<th>C. Unitario</th>
|
||||
<th>Precio Total</th>
|
||||
<th>Utilidad</th>
|
||||
{editable && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const { precioTotal, utilidad } = calcIngresoRow(row);
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.cliente || ''} disabled={!editable} onChange={(e) => updateRow(i, 'cliente', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.detalle || ''} disabled={!editable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.cantidad ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'cantidad', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.precioUnitario ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'precioUnitario', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="any" value={row.costoUnitario ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'costoUnitario', e.target.value)} />
|
||||
</td>
|
||||
<td className="computed">{formatMoney(precioTotal)}</td>
|
||||
<td className="computed">{formatMoney(utilidad)}</td>
|
||||
{editable && (
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeRow(i);
|
||||
}}
|
||||
title="Quitar fila"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
|
||||
|
||||
{!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">
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<>
|
||||
|
||||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea">
|
||||
|
||||
+
|
||||
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
|
||||
|
||||
Guardar ahora
|
||||
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||
|
||||
Vaciar planilla
|
||||
|
||||
</button>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{rows.length === 0 ? (
|
||||
|
||||
<p className="loading">Sin registros en esta gestión.</p>
|
||||
|
||||
) : (
|
||||
|
||||
<div className="card sheet-table-wrap">
|
||||
|
||||
<table className="sheet-table">
|
||||
|
||||
<thead>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Fecha</th>
|
||||
|
||||
<th>Cliente</th>
|
||||
|
||||
<th>Detalle</th>
|
||||
|
||||
<th>Cantidad</th>
|
||||
|
||||
<th>P. Unitario</th>
|
||||
|
||||
<th>C. Unitario</th>
|
||||
|
||||
<th>Precio Total</th>
|
||||
|
||||
<th>Utilidad</th>
|
||||
|
||||
{canEditSheet && <th></th>}
|
||||
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{rows.map((row, i) => {
|
||||
|
||||
const { precioTotal, utilidad } = calcIngresoRow(row);
|
||||
|
||||
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||
|
||||
return (
|
||||
|
||||
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input value={row.cliente || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'cliente', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input value={row.detalle || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" min="0" step="any" value={row.cantidad ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'cantidad', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" min="0" step="any" value={row.precioUnitario ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'precioUnitario', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" min="0" step="any" value={row.costoUnitario ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'costoUnitario', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td className="computed">{formatMoney(precioTotal)}</td>
|
||||
|
||||
<td className="computed">{formatMoney(utilidad)}</td>
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<td>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-danger btn-icon"
|
||||
|
||||
onClick={(e) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
removeRow(i);
|
||||
|
||||
}}
|
||||
|
||||
disabled={!rowEditable}
|
||||
|
||||
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Quitar fila'}
|
||||
|
||||
>
|
||||
|
||||
×
|
||||
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
)}
|
||||
|
||||
</tr>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="totals-bar">
|
||||
|
||||
<span>
|
||||
|
||||
Sumatoria ingresos: <strong>{formatMoney(totals.totalIngresos)}</strong>
|
||||
|
||||
</span>
|
||||
|
||||
<span>
|
||||
|
||||
Sumatoria utilidad: <strong>{formatMoney(totals.totalUtilidad)}</strong>
|
||||
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="totals-bar">
|
||||
<span>
|
||||
Sumatoria ingresos: <strong>{formatMoney(totals.totalIngresos)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Sumatoria utilidad: <strong>{formatMoney(totals.totalUtilidad)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ export default function Layout({ children }) {
|
||||
</NavLink>
|
||||
))}
|
||||
{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
|
||||
</NavLink>
|
||||
)}
|
||||
@@ -46,7 +49,12 @@ export default function Layout({ children }) {
|
||||
Salir
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <p className="loading">Cargando planilla…</p>;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Planilla de Personal</h1>
|
||||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||||
{editable && <SaveStatus status={status} error={saveError} />}
|
||||
|
||||
<div className="toolbar">
|
||||
{editable && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-icon"
|
||||
disabled={mensualizados.length >= MAX_MENSUAL}
|
||||
onClick={() => setMensualizados((p) => (p.length < MAX_MENSUAL ? [...p, emptyMensualizado()] : p))}
|
||||
title="Agregar mensualizado"
|
||||
>
|
||||
+ Mensual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-icon"
|
||||
disabled={eventuales.length >= MAX_EVENTUAL}
|
||||
onClick={() => setEventuales((p) => (p.length < MAX_EVENTUAL ? [...p, emptyEventual()] : p))}
|
||||
title="Agregar eventual"
|
||||
>
|
||||
+ Eventual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => saveNow({ mensualizados, eventuales })}
|
||||
disabled={status === 'saving'}
|
||||
>
|
||||
Guardar ahora
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||
Vaciar planilla
|
||||
</button>
|
||||
</>
|
||||
<div className="sheet-layout">
|
||||
|
||||
<GestionesSidebar
|
||||
|
||||
gestiones={sidebarGestiones}
|
||||
|
||||
selectedId={selectedGestionId}
|
||||
|
||||
onSelect={selectGestion}
|
||||
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div className="sheet-layout__main">
|
||||
|
||||
<h1 className="page-title">Planilla de Personal</h1>
|
||||
|
||||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||||
|
||||
|
||||
|
||||
{esHistorial && gestionVista && (
|
||||
|
||||
<div className="historial-badge">
|
||||
|
||||
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} →{' '}
|
||||
|
||||
{gestionVista.fechaFin}) — solo lectura
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Funcionarios mensualizados (máx. {MAX_MENSUAL})</h2>
|
||||
<div className="sheet-table-wrap">
|
||||
<table className="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Sueldo base</th>
|
||||
<th>Cargas sociales</th>
|
||||
<th>Bonos</th>
|
||||
<th>Subtotal</th>
|
||||
{editable && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mensualizados.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input value={row.nombre || ''} disabled={!editable} onChange={(e) => updateMensual(i, 'nombre', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={row.sueldoBase ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'sueldoBase', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={row.cargasSociales ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'cargasSociales', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={row.bonos ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'bonos', e.target.value)} />
|
||||
</td>
|
||||
<td className="computed">{formatMoney(rowCostMensual(row))}</td>
|
||||
{editable && (
|
||||
<td>
|
||||
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeMensual(i)} title="Eliminar fila">
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
{!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">
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-primary btn-icon"
|
||||
|
||||
disabled={mensualizados.length >= MAX_MENSUAL}
|
||||
|
||||
onClick={() => setMensualizados((p) => (p.length < MAX_MENSUAL ? [...p, emptyMensualizado()] : p))}
|
||||
|
||||
title="Agregar mensualizado"
|
||||
|
||||
>
|
||||
|
||||
+ Mensual
|
||||
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-secondary btn-icon"
|
||||
|
||||
disabled={eventuales.length >= MAX_EVENTUAL}
|
||||
|
||||
onClick={() => setEventuales((p) => (p.length < MAX_EVENTUAL ? [...p, emptyEventual()] : p))}
|
||||
|
||||
title="Agregar eventual"
|
||||
|
||||
>
|
||||
|
||||
+ Eventual
|
||||
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-secondary"
|
||||
|
||||
onClick={() => saveNow({ mensualizados, eventuales })}
|
||||
|
||||
disabled={status === 'saving'}
|
||||
|
||||
>
|
||||
|
||||
Guardar ahora
|
||||
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||
|
||||
Vaciar planilla
|
||||
|
||||
</button>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)</h2>
|
||||
<div className="sheet-table-wrap">
|
||||
<table className="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Nombre</th>
|
||||
<th>Pago diario</th>
|
||||
{editable && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eventuales.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateEventual(i, 'fecha', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input value={row.nombre || ''} disabled={!editable} onChange={(e) => updateEventual(i, 'nombre', e.target.value)} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={row.pagoDiario ?? ''} disabled={!editable} onChange={(e) => updateEventual(i, 'pagoDiario', e.target.value)} />
|
||||
</td>
|
||||
{editable && (
|
||||
<td>
|
||||
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeEventual(i)} title="Eliminar fila">
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
{sinDatos ? (
|
||||
|
||||
<p className="loading">Sin registros en esta gestión.</p>
|
||||
|
||||
) : (
|
||||
|
||||
<>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
|
||||
<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">
|
||||
|
||||
<table className="sheet-table">
|
||||
|
||||
<thead>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Nombre</th>
|
||||
|
||||
<th>Sueldo base</th>
|
||||
|
||||
<th>Cargas sociales</th>
|
||||
|
||||
<th>Bonos</th>
|
||||
|
||||
<th>Subtotal</th>
|
||||
|
||||
{canEditSheet && <th></th>}
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{mensualizados.map((row, i) => {
|
||||
|
||||
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||
|
||||
return (
|
||||
|
||||
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||
|
||||
<td>
|
||||
|
||||
<input value={row.nombre || ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'nombre', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" value={row.sueldoBase ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'sueldoBase', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" value={row.cargasSociales ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'cargasSociales', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" value={row.bonos ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'bonos', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td className="computed">{formatMoney(rowCostMensual(row))}</td>
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<td>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-danger btn-icon"
|
||||
|
||||
onClick={() => removeMensual(i)}
|
||||
|
||||
disabled={!rowEditable}
|
||||
|
||||
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||||
|
||||
>
|
||||
|
||||
×
|
||||
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
)}
|
||||
|
||||
</tr>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="card">
|
||||
|
||||
<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">
|
||||
|
||||
<table className="sheet-table">
|
||||
|
||||
<thead>
|
||||
|
||||
<tr>
|
||||
|
||||
<th>Fecha</th>
|
||||
|
||||
<th>Nombre</th>
|
||||
|
||||
<th>Pago diario</th>
|
||||
|
||||
{canEditSheet && <th></th>}
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{eventuales.map((row, i) => {
|
||||
|
||||
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||
|
||||
return (
|
||||
|
||||
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'fecha', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input value={row.nombre || ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'nombre', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<input type="number" value={row.pagoDiario ?? ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'pagoDiario', e.target.value)} />
|
||||
|
||||
</td>
|
||||
|
||||
{canEditSheet && (
|
||||
|
||||
<td>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="btn btn-danger btn-icon"
|
||||
|
||||
onClick={() => removeEventual(i)}
|
||||
|
||||
disabled={!rowEditable}
|
||||
|
||||
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||||
|
||||
>
|
||||
|
||||
×
|
||||
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
)}
|
||||
|
||||
</tr>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="totals-bar" style={{ marginTop: '1rem' }}>
|
||||
|
||||
<span>
|
||||
|
||||
Sumatoria total personal: <strong>{formatMoney(total)}</strong>
|
||||
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="totals-bar" style={{ marginTop: '1rem' }}>
|
||||
<span>
|
||||
Sumatoria total personal: <strong>{formatMoney(total)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
29
frontend/src/hooks/useGestionView.js
Normal file
29
frontend/src/hooks/useGestionView.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 <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 (
|
||||
<div>
|
||||
<h1 className="page-title">Administración de usuarios</h1>
|
||||
<h1 className="page-title">Administración</h1>
|
||||
<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>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="card admin-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Nuevo usuario</h2>
|
||||
<nav className="admin-subnav">
|
||||
<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}>
|
||||
<div className="form-group">
|
||||
<label>Usuario</label>
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function HomePage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h1 className="page-title">Menú principal</h1>
|
||||
<p>
|
||||
Bienvenido, <strong>{user?.username}</strong>. Utilice la barra superior para acceder a cada planilla.
|
||||
</p>
|
||||
<ul style={{ color: 'var(--muted)', lineHeight: 1.8 }}>
|
||||
<li>Planillas de ingresos: Ventas, Servicios y Alquileres</li>
|
||||
<li>Planillas de egresos: Gastos Diarios y Gastos Fijos</li>
|
||||
<li>Planilla de Personal y Reportes consolidados</li>
|
||||
{user?.isAdmin && <li>Panel de Administración para gestionar usuarios y permisos</li>}
|
||||
</ul>
|
||||
<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).
|
||||
</p>
|
||||
<div>
|
||||
<div className="card">
|
||||
<h1 className="page-title">Menú principal</h1>
|
||||
<p>
|
||||
Bienvenido, <strong>{user?.username}</strong>. Utilice la barra superior para acceder a cada planilla.
|
||||
</p>
|
||||
<ul style={{ color: 'var(--muted)', lineHeight: 1.8 }}>
|
||||
<li>Planillas de ingresos: Ventas, Servicios y Alquileres</li>
|
||||
<li>Planillas de egresos: Gastos Diarios y Gastos Fijos</li>
|
||||
<li>Planilla de Personal y Reportes consolidados</li>
|
||||
</ul>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted)', marginTop: '1.5rem' }}>
|
||||
Los datos se guardan automáticamente en el servidor. Use el botón <strong>+</strong> para agregar filas.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
frontend/src/utils/gestion.js
Normal file
17
frontend/src/utils/gestion.js
Normal 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 +).`;
|
||||
}
|
||||
Reference in New Issue
Block a user