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)
|
## 5. Ejecución con Docker (recomendado en el servidor LAN)
|
||||||
|
|
||||||
```bash
|
### Primera vez o actualización (importante: usar `--no-cache`)
|
||||||
|
|
||||||
|
Si no ve la pestaña **"Cierre de Gestión"**, las imágenes Docker están **viejas**. Debe **reconstruir sin caché**:
|
||||||
|
|
||||||
|
**Windows (PowerShell):**
|
||||||
|
```powershell
|
||||||
cd DonMarcoBackend
|
cd DonMarcoBackend
|
||||||
docker compose up -d --build
|
.\docker-update.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
Acceso desde cualquier PC de la red:
|
**Linux / Git Bash:**
|
||||||
|
```bash
|
||||||
|
cd DonMarcoBackend
|
||||||
|
chmod +x docker-update.sh
|
||||||
|
./docker-update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual:**
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificar que Docker tiene la versión correcta
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:4000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Debe responder `"version": "1.2.0"` y `"features": ["historial-gestiones", "cierre-gestion", ...]`.
|
||||||
|
|
||||||
|
En el navegador (`http://IP-DEL-SERVIDOR`):
|
||||||
|
- Pestaña **Administración** (solo usuario JEFE) → sección **Cierre de gestión**
|
||||||
|
- Cada planilla tiene **barra lateral** con gestiones anteriores (solo lectura)
|
||||||
|
- Al pie: **Don Marco v1.2.0 — Historial de gestiones (Docker)**
|
||||||
|
|
||||||
|
### Acceso en la red
|
||||||
|
|
||||||
- **Web:** `http://IP-DEL-SERVIDOR` (puerto 80)
|
- **Web:** `http://IP-DEL-SERVIDOR` (puerto 80)
|
||||||
- **API directa (opcional):** `http://IP-DEL-SERVIDOR:4000/api/health`
|
- **API:** `http://IP-DEL-SERVIDOR:4000/api/health`
|
||||||
|
|
||||||
|
### Cierre de gestión (solo JEFE)
|
||||||
|
|
||||||
|
1. Ingresar como `JEFE` / `MACAN`
|
||||||
|
2. Clic en **Administración** (menú superior)
|
||||||
|
3. En **Cierre de gestión**, elegir el **último día del mes** → **Cerrar gestión**
|
||||||
|
4. La planilla principal muestra solo la **gestión actual**; el **historial** está en la barra lateral (solo lectura)
|
||||||
|
5. Tras el cierre, los usuarios cargan filas nuevas con el botón **+**
|
||||||
|
|
||||||
|
Los datos en SQLite se conservan en el volumen `donmarco_data` al actualizar Docker.
|
||||||
|
|
||||||
## 6. Desarrollo local (sin Docker)
|
## 6. Desarrollo local (sin Docker)
|
||||||
|
|
||||||
|
|||||||
@@ -52,5 +52,23 @@ export function initDb() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_registros_sheet ON registros(sheet_type);
|
CREATE INDEX IF NOT EXISTS idx_registros_sheet ON registros(sheet_type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_by INTEGER,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gestiones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
etiqueta TEXT NOT NULL,
|
||||||
|
fecha_inicio TEXT NOT NULL,
|
||||||
|
fecha_fin TEXT,
|
||||||
|
cerrada_at TEXT,
|
||||||
|
cerrada_por INTEGER,
|
||||||
|
FOREIGN KEY (cerrada_por) REFERENCES users(id)
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import authRoutes from './routes/auth.js';
|
|||||||
import userRoutes from './routes/users.js';
|
import userRoutes from './routes/users.js';
|
||||||
import sheetRoutes from './routes/sheets.js';
|
import sheetRoutes from './routes/sheets.js';
|
||||||
import reportRoutes from './routes/reports.js';
|
import reportRoutes from './routes/reports.js';
|
||||||
|
import settingsRoutes from './routes/settings.js';
|
||||||
|
import gestionesRoutes from './routes/gestiones.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { db, SHEETS } from './db.js';
|
import { db, SHEETS } from './db.js';
|
||||||
|
|
||||||
@@ -31,11 +33,20 @@ const PORT = process.env.PORT || 4000;
|
|||||||
app.use(cors({ origin: true, credentials: true }));
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => res.json({ ok: true, service: 'DonMarco API' }));
|
app.get('/api/health', (_req, res) =>
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
service: 'DonMarco API',
|
||||||
|
version: '1.2.0',
|
||||||
|
features: ['historial-gestiones', 'cierre-gestion', 'auto-guardado', 'reportes'],
|
||||||
|
})
|
||||||
|
);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/sheets', sheetRoutes);
|
app.use('/api/sheets', sheetRoutes);
|
||||||
app.use('/api/reports', reportRoutes);
|
app.use('/api/reports', reportRoutes);
|
||||||
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
app.use('/api/gestiones', gestionesRoutes);
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Don Marco API escuchando en http://0.0.0.0:${PORT}`);
|
console.log(`Don Marco API escuchando en http://0.0.0.0:${PORT}`);
|
||||||
|
|||||||
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 { db, SHEETS } from '../db.js';
|
||||||
import { authRequired } from '../middleware/auth.js';
|
import { authRequired } from '../middleware/auth.js';
|
||||||
import { sumIngresos, sumGastos, sumPersonal } from '../utils/calculations.js';
|
import { sumIngresos, sumGastos, sumPersonal } from '../utils/calculations.js';
|
||||||
|
import {
|
||||||
|
getGestionStatus,
|
||||||
|
getGestionActual,
|
||||||
|
getGestionById,
|
||||||
|
mergeRowsForSave,
|
||||||
|
mergePersonalForSave,
|
||||||
|
isFechaEnPeriodoCerrado,
|
||||||
|
filterRowsByGestion,
|
||||||
|
filterPersonalByGestion,
|
||||||
|
mensualBelongsToGestion,
|
||||||
|
} from '../utils/gestion.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authRequired);
|
router.use(authRequired);
|
||||||
@@ -19,45 +30,112 @@ function checkPermission(req, sheet, needEdit = false) {
|
|||||||
return !!row.can_view;
|
return !!row.can_view;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSheetPayload(sheetType) {
|
function resolveGestionView(gestionId) {
|
||||||
const rows = db
|
if (!gestionId || gestionId === 'actual') {
|
||||||
|
return { gestion: getGestionActual(), esHistorial: false };
|
||||||
|
}
|
||||||
|
const gestion = getGestionById(Number(gestionId));
|
||||||
|
if (!gestion) return null;
|
||||||
|
return { gestion, esHistorial: !gestion.esActual };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadParsedRows(sheetType) {
|
||||||
|
return db
|
||||||
.prepare('SELECT id, data, updated_at FROM registros WHERE sheet_type = ? ORDER BY id')
|
.prepare('SELECT id, data, updated_at FROM registros WHERE sheet_type = ? ORDER BY id')
|
||||||
.all(sheetType);
|
.all(sheetType)
|
||||||
const parsed = rows.map((r) => ({ id: r.id, ...JSON.parse(r.data), updated_at: r.updated_at }));
|
.map((r) => ({ id: r.id, ...JSON.parse(r.data), updated_at: r.updated_at }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateRows(rows, isAdmin, esHistorial) {
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
bloqueada: esHistorial || (!isAdmin && isFechaEnPeriodoCerrado(row.fecha)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSheetPayload(sheetType, isAdmin, gestionView) {
|
||||||
|
const parsed = loadParsedRows(sheetType);
|
||||||
|
const gestionState = getGestionStatus();
|
||||||
|
const { gestion, esHistorial } = gestionView;
|
||||||
|
|
||||||
if (INGRESO_SHEETS.includes(sheetType)) {
|
if (INGRESO_SHEETS.includes(sheetType)) {
|
||||||
const totals = sumIngresos(parsed);
|
const filtered = filterRowsByGestion(parsed, gestion);
|
||||||
return { rows: parsed, totals };
|
const rows = annotateRows(filtered, isAdmin, esHistorial);
|
||||||
}
|
const totals = sumIngresos(filtered);
|
||||||
if (GASTO_SHEETS.includes(sheetType)) {
|
|
||||||
const total = sumGastos(parsed);
|
|
||||||
return { rows: parsed, totals: { totalGastos: total } };
|
|
||||||
}
|
|
||||||
if (sheetType === 'personal') {
|
|
||||||
const mensualizados = parsed.filter((r) => r.tipo === 'mensualizado');
|
|
||||||
const eventuales = parsed.filter((r) => r.tipo === 'eventual');
|
|
||||||
return {
|
return {
|
||||||
mensualizados,
|
rows: rows.length ? rows : [],
|
||||||
eventuales,
|
totals,
|
||||||
totals: { totalPersonal: sumPersonal(mensualizados, eventuales) },
|
gestion: gestionState,
|
||||||
|
gestionVista: gestion,
|
||||||
|
esHistorial,
|
||||||
|
soloLectura: esHistorial,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { rows: parsed };
|
|
||||||
|
if (GASTO_SHEETS.includes(sheetType)) {
|
||||||
|
const filtered = filterRowsByGestion(parsed, gestion);
|
||||||
|
const rows = annotateRows(filtered, isAdmin, esHistorial);
|
||||||
|
const total = sumGastos(filtered);
|
||||||
|
return {
|
||||||
|
rows: rows.length ? rows : [],
|
||||||
|
totals: { totalGastos: total },
|
||||||
|
gestion: gestionState,
|
||||||
|
gestionVista: gestion,
|
||||||
|
esHistorial,
|
||||||
|
soloLectura: esHistorial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sheetType === 'personal') {
|
||||||
|
const mensualizadosAll = parsed.filter((r) => r.tipo === 'mensualizado');
|
||||||
|
const eventualesAll = parsed.filter((r) => r.tipo === 'eventual');
|
||||||
|
const { mensualizados, eventuales } = filterPersonalByGestion(
|
||||||
|
mensualizadosAll,
|
||||||
|
eventualesAll,
|
||||||
|
gestion
|
||||||
|
);
|
||||||
|
const mensualizadosAnn = mensualizados.map((r) => ({
|
||||||
|
...r,
|
||||||
|
bloqueada: esHistorial || (!isAdmin && !mensualBelongsToGestion(r, getGestionActual()) && r.gestionId),
|
||||||
|
}));
|
||||||
|
const eventualesAnn = eventuales.map((r) => ({
|
||||||
|
...r,
|
||||||
|
bloqueada: esHistorial || (!isAdmin && isFechaEnPeriodoCerrado(r.fecha)),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
mensualizados: mensualizadosAnn,
|
||||||
|
eventuales: eventualesAnn,
|
||||||
|
totals: { totalPersonal: sumPersonal(mensualizados, eventuales) },
|
||||||
|
gestion: gestionState,
|
||||||
|
gestionVista: gestion,
|
||||||
|
esHistorial,
|
||||||
|
soloLectura: esHistorial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows: parsed, gestion: gestionState, gestionVista: gestion, esHistorial };
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/:sheetType', (req, res) => {
|
router.get('/:sheetType', (req, res) => {
|
||||||
const { sheetType } = req.params;
|
const { sheetType } = req.params;
|
||||||
|
const { gestionId } = req.query;
|
||||||
|
|
||||||
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
|
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
|
||||||
return res.status(400).json({ error: 'Planilla inválida' });
|
return res.status(400).json({ error: 'Planilla inválida' });
|
||||||
}
|
}
|
||||||
if (!checkPermission(req, sheetType)) {
|
if (!checkPermission(req, sheetType)) {
|
||||||
return res.status(403).json({ error: 'Sin permiso para ver esta planilla' });
|
return res.status(403).json({ error: 'Sin permiso para ver esta planilla' });
|
||||||
}
|
}
|
||||||
res.json(getSheetPayload(sheetType));
|
|
||||||
|
const gestionView = resolveGestionView(gestionId);
|
||||||
|
if (!gestionView) return res.status(404).json({ error: 'Gestión no encontrada' });
|
||||||
|
|
||||||
|
res.json(getSheetPayload(sheetType, req.user.isAdmin, gestionView));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:sheetType', (req, res) => {
|
router.put('/:sheetType', (req, res) => {
|
||||||
const { sheetType } = req.params;
|
const { sheetType } = req.params;
|
||||||
|
|
||||||
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
|
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
|
||||||
return res.status(400).json({ error: 'Planilla inválida' });
|
return res.status(400).json({ error: 'Planilla inválida' });
|
||||||
}
|
}
|
||||||
@@ -65,7 +143,30 @@ router.put('/:sheetType', (req, res) => {
|
|||||||
return res.status(403).json({ error: 'Sin permiso para editar' });
|
return res.status(403).json({ error: 'Sin permiso para editar' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, mensualizados, eventuales } = req.body;
|
const isAdmin = !!req.user.isAdmin;
|
||||||
|
const gestionActual = getGestionActual();
|
||||||
|
const existing = loadParsedRows(sheetType);
|
||||||
|
let rowsToSave;
|
||||||
|
let personalMensual;
|
||||||
|
let personalEventual;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sheetType === 'personal') {
|
||||||
|
const merged = mergePersonalForSave(
|
||||||
|
existing,
|
||||||
|
req.body.mensualizados,
|
||||||
|
req.body.eventuales,
|
||||||
|
isAdmin,
|
||||||
|
gestionActual
|
||||||
|
);
|
||||||
|
personalMensual = merged.mensualizados;
|
||||||
|
personalEventual = merged.eventuales;
|
||||||
|
} else {
|
||||||
|
rowsToSave = mergeRowsForSave(existing, req.body.rows, isAdmin, gestionActual, 'fecha');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(403).json({ error: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM registros WHERE sheet_type = ?').run(sheetType);
|
db.prepare('DELETE FROM registros WHERE sheet_type = ?').run(sheetType);
|
||||||
|
|
||||||
@@ -75,21 +176,21 @@ router.put('/:sheetType', (req, res) => {
|
|||||||
|
|
||||||
if (sheetType === 'personal') {
|
if (sheetType === 'personal') {
|
||||||
const all = [
|
const all = [
|
||||||
...(mensualizados || []).map((r) => ({ ...r, tipo: 'mensualizado' })),
|
...(personalMensual || []).map((r) => ({ ...r, tipo: 'mensualizado' })),
|
||||||
...(eventuales || []).map((r) => ({ ...r, tipo: 'eventual' })),
|
...(personalEventual || []).map((r) => ({ ...r, tipo: 'eventual' })),
|
||||||
];
|
];
|
||||||
for (const row of all) {
|
for (const row of all) {
|
||||||
const { id: _id, updated_at: _u, ...data } = row;
|
const { id: _id, updated_at: _u, bloqueada: _b, ...data } = row;
|
||||||
insert.run(sheetType, JSON.stringify(data), req.user.id);
|
insert.run(sheetType, JSON.stringify(data), req.user.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const row of rows || []) {
|
for (const row of rowsToSave || []) {
|
||||||
const { id: _id, updated_at: _u, ...data } = row;
|
const { id: _id, updated_at: _u, bloqueada: _b, ...data } = row;
|
||||||
insert.run(sheetType, JSON.stringify(data), req.user.id);
|
insert.run(sheetType, JSON.stringify(data), req.user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(getSheetPayload(sheetType));
|
res.json(getSheetPayload(sheetType, isAdmin, { gestion: gestionActual, esHistorial: false }));
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
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:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: donmarco-backend:1.2.0
|
||||||
container_name: donmarco-api
|
container_name: donmarco-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -15,6 +16,18 @@ services:
|
|||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
networks:
|
networks:
|
||||||
- donmarco_net
|
- donmarco_net
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:4000/api/health').then(r=>r.json()).then(d=>process.exit(d.version==='1.2.0'?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -22,12 +35,15 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: /api
|
VITE_API_URL: /api
|
||||||
|
APP_VERSION: "1.2.0"
|
||||||
|
image: donmarco-frontend:1.2.0
|
||||||
container_name: donmarco-web
|
container_name: donmarco-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- donmarco_net
|
- donmarco_net
|
||||||
|
|
||||||
|
|||||||
35
docker-update.ps1
Normal file
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
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_API_URL=/api
|
ARG VITE_API_URL=/api
|
||||||
|
ARG APP_VERSION=1.1.0
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_APP_VERSION=$APP_VERSION
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function App() {
|
|||||||
<Route path="/personal" element={<PrivateRoute sheet="personal"><PersonalSheet /></PrivateRoute>} />
|
<Route path="/personal" element={<PrivateRoute sheet="personal"><PersonalSheet /></PrivateRoute>} />
|
||||||
<Route path="/reportes" element={<PrivateRoute sheet="reportes"><ReportesPage /></PrivateRoute>} />
|
<Route path="/reportes" element={<PrivateRoute sheet="reportes"><ReportesPage /></PrivateRoute>} />
|
||||||
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
||||||
|
<Route path="/cierre-gestion" element={<Navigate to="/admin#cierre-gestion" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ export const usersApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const sheetsApi = {
|
export const sheetsApi = {
|
||||||
get: (sheet) => api(`/sheets/${sheet}`),
|
get: (sheet, params = {}) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.gestionId) q.set('gestionId', params.gestionId);
|
||||||
|
const qs = q.toString();
|
||||||
|
return api(`/sheets/${sheet}${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
save: (sheet, body) =>
|
save: (sheet, body) =>
|
||||||
api(`/sheets/${sheet}`, { method: 'PUT', body: JSON.stringify(body) }),
|
api(`/sheets/${sheet}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
/** Guardado al salir de la planilla (navegación entre pestañas) */
|
/** Guardado al salir de la planilla (navegación entre pestañas) */
|
||||||
@@ -63,3 +68,16 @@ export const reportsApi = {
|
|||||||
return api(`/reports?${q}`);
|
return api(`/reports?${q}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
getGestion: () => api('/settings/gestion'),
|
||||||
|
cerrarGestion: (fechaCierreGestion) =>
|
||||||
|
api('/settings/gestion', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ fechaCierreGestion }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gestionesApi = {
|
||||||
|
list: () => api('/gestiones'),
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,177 +1,468 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { sheetsApi } from '../api/client';
|
import { sheetsApi } from '../api/client';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
|
import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
|
||||||
|
|
||||||
|
import { useGestionView } from '../hooks/useGestionView';
|
||||||
|
|
||||||
import SaveStatus from './SaveStatus';
|
import SaveStatus from './SaveStatus';
|
||||||
|
|
||||||
|
import GestionBanner from './GestionBanner';
|
||||||
|
|
||||||
|
import GestionesSidebar from './GestionesSidebar';
|
||||||
|
|
||||||
import { emptyGastoRow, formatMoney } from '../utils/calc';
|
import { emptyGastoRow, formatMoney } from '../utils/calc';
|
||||||
|
|
||||||
|
import { isRowEditable } from '../utils/gestion';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function GastoSheet({ sheetType, title, defaultClasificacion }) {
|
export default function GastoSheet({ sheetType, title, defaultClasificacion }) {
|
||||||
const { canEdit } = useAuth();
|
|
||||||
|
const { canEdit, user } = useAuth();
|
||||||
|
|
||||||
const editable = canEdit(sheetType);
|
const editable = canEdit(sheetType);
|
||||||
|
|
||||||
|
const isAdmin = !!user?.isAdmin;
|
||||||
|
|
||||||
|
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
|
||||||
|
|
||||||
const [rows, setRows] = useState([emptyGastoRow()]);
|
const [rows, setRows] = useState([emptyGastoRow()]);
|
||||||
|
|
||||||
|
const [gestion, setGestion] = useState(null);
|
||||||
|
|
||||||
|
const [gestionVista, setGestionVista] = useState(null);
|
||||||
|
|
||||||
|
const [esHistorial, setEsHistorial] = useState(false);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [loadError, setLoadError] = useState(null);
|
const [loadError, setLoadError] = useState(null);
|
||||||
|
|
||||||
const skipNotifyRef = useRef(false);
|
const skipNotifyRef = useRef(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const emptyRow = () => ({ ...emptyGastoRow(), clasificacion: defaultClasificacion });
|
||||||
|
|
||||||
|
const canEditSheet = editable && !esHistorial;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
||||||
enabled: editable,
|
|
||||||
|
enabled: canEditSheet,
|
||||||
|
|
||||||
sheetType,
|
sheetType,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
|
|
||||||
setReady(false);
|
setReady(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await sheetsApi.get(sheetType);
|
|
||||||
|
const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId });
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const loaded = data.rows?.length ? data.rows : [emptyGastoRow()];
|
|
||||||
|
const loaded = data.rows || [];
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
|
|
||||||
|
if (loaded.length) {
|
||||||
|
|
||||||
setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion })));
|
setRows(loaded.map((r) => ({ ...r, clasificacion: r.clasificacion || defaultClasificacion })));
|
||||||
setReady(true);
|
|
||||||
} catch (e) {
|
} else if (!data.esHistorial && canEditSheet) {
|
||||||
if (active) setLoadError(e.message);
|
|
||||||
} finally {
|
setRows([emptyRow()]);
|
||||||
if (active) setLoading(false);
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
setRows([]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setGestion(data.gestion || null);
|
||||||
|
|
||||||
|
setGestionVista(data.gestionVista || null);
|
||||||
|
|
||||||
|
setEsHistorial(!!data.esHistorial);
|
||||||
|
|
||||||
|
setReady(!data.esHistorial);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
if (active) setLoadError(e.message);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
if (active) setLoading(false);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
||||||
active = false;
|
active = false;
|
||||||
|
|
||||||
};
|
};
|
||||||
}, [sheetType, defaultClasificacion, setReady]);
|
|
||||||
|
}, [sheetType, defaultClasificacion, selectedGestionId, setReady, canEditSheet]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !editable) return;
|
|
||||||
|
if (loading || !canEditSheet) return;
|
||||||
|
|
||||||
if (skipNotifyRef.current) {
|
if (skipNotifyRef.current) {
|
||||||
|
|
||||||
skipNotifyRef.current = false;
|
skipNotifyRef.current = false;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyChange({ rows });
|
notifyChange({ rows });
|
||||||
}, [rows, loading, editable, notifyChange]);
|
|
||||||
|
}, [rows, loading, canEditSheet, notifyChange]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const total = useMemo(
|
const total = useMemo(
|
||||||
|
|
||||||
() => rows.reduce((acc, r) => acc + (Number(r.monto) || 0), 0),
|
() => rows.reduce((acc, r) => acc + (Number(r.monto) || 0), 0),
|
||||||
|
|
||||||
[rows]
|
[rows]
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updateRow = (index, field, value) => {
|
const updateRow = (index, field, value) => {
|
||||||
|
|
||||||
|
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
|
||||||
|
|
||||||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addRow = () =>
|
|
||||||
setRows((prev) => [...prev, { ...emptyGastoRow(), clasificacion: defaultClasificacion }]);
|
|
||||||
|
const addRow = () => setRows((prev) => [...prev, emptyRow()]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const removeRow = (index) => {
|
const removeRow = (index) => {
|
||||||
|
|
||||||
|
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
|
|
||||||
const next = rows.filter((_, i) => i !== index);
|
const next = rows.filter((_, i) => i !== index);
|
||||||
const result =
|
|
||||||
next.length > 0 ? next : [{ ...emptyGastoRow(), clasificacion: defaultClasificacion }];
|
const result = next.length > 0 ? next : [emptyRow()];
|
||||||
|
|
||||||
setRows(result);
|
setRows(result);
|
||||||
|
|
||||||
saveNow({ rows: result });
|
saveNow({ rows: result });
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const clearSheet = () => {
|
const clearSheet = () => {
|
||||||
if (!window.confirm('¿Vaciar toda la planilla? Se eliminarán todas las filas.')) return;
|
|
||||||
const blank = [{ ...emptyGastoRow(), clasificacion: defaultClasificacion }];
|
const msg = gestion?.fechaCierreGestion && !isAdmin
|
||||||
|
|
||||||
|
? '¿Eliminar solo las filas editables? Las del período cerrado se conservarán.'
|
||||||
|
|
||||||
|
: '¿Vaciar toda la planilla? Se eliminarán todas las filas de la gestión actual.';
|
||||||
|
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
|
|
||||||
|
if (isAdmin || !gestion?.fechaCierreGestion) {
|
||||||
|
|
||||||
|
const blank = [emptyRow()];
|
||||||
|
|
||||||
setRows(blank);
|
setRows(blank);
|
||||||
|
|
||||||
saveNow({ rows: blank });
|
saveNow({ rows: blank });
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = rows.filter((r) => r.bloqueada);
|
||||||
|
|
||||||
|
const result = kept.length ? kept : [emptyRow()];
|
||||||
|
|
||||||
|
setRows(result);
|
||||||
|
|
||||||
|
saveNow({ rows: result });
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (loading) return <p className="loading">Cargando planilla…</p>;
|
if (loading) return <p className="loading">Cargando planilla…</p>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
|
<div className="sheet-layout">
|
||||||
|
|
||||||
|
<GestionesSidebar
|
||||||
|
|
||||||
|
gestiones={sidebarGestiones}
|
||||||
|
|
||||||
|
selectedId={selectedGestionId}
|
||||||
|
|
||||||
|
onSelect={selectGestion}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="sheet-layout__main">
|
||||||
|
|
||||||
<h1 className="page-title">{title}</h1>
|
<h1 className="page-title">{title}</h1>
|
||||||
|
|
||||||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||||||
{editable && <SaveStatus status={status} error={saveError} />}
|
|
||||||
{!editable && <div className="alert alert-info">Solo lectura</div>}
|
|
||||||
|
|
||||||
|
{esHistorial && gestionVista && (
|
||||||
|
|
||||||
|
<div className="historial-badge">
|
||||||
|
|
||||||
|
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} →{' '}
|
||||||
|
|
||||||
|
{gestionVista.fechaFin}) — solo lectura
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{!esHistorial && <GestionBanner gestion={gestion} isAdmin={isAdmin} />}
|
||||||
|
|
||||||
|
{canEditSheet && <SaveStatus status={status} error={saveError} />}
|
||||||
|
|
||||||
|
{esHistorial && <div className="alert alert-info">Gestión cerrada — solo consulta</div>}
|
||||||
|
|
||||||
|
{!editable && !esHistorial && <div className="alert alert-info">Solo lectura</div>}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
{editable && (
|
|
||||||
|
{canEditSheet && (
|
||||||
|
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow}>
|
<button type="button" className="btn btn-primary btn-icon" onClick={addRow}>
|
||||||
|
|
||||||
+
|
+
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
|
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
|
||||||
|
|
||||||
Guardar ahora
|
Guardar ahora
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||||
|
|
||||||
Vaciar planilla
|
Vaciar planilla
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
|
||||||
|
<p className="loading">Sin registros en esta gestión.</p>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
<div className="card sheet-table-wrap">
|
<div className="card sheet-table-wrap">
|
||||||
|
|
||||||
<table className="sheet-table">
|
<table className="sheet-table">
|
||||||
|
|
||||||
<thead>
|
<thead>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
|
||||||
<th>Fecha</th>
|
<th>Fecha</th>
|
||||||
|
|
||||||
<th>Detalle</th>
|
<th>Detalle</th>
|
||||||
|
|
||||||
<th>Impuestos</th>
|
<th>Impuestos</th>
|
||||||
|
|
||||||
<th>Nº Factura</th>
|
<th>Nº Factura</th>
|
||||||
|
|
||||||
<th>Monto</th>
|
<th>Monto</th>
|
||||||
|
|
||||||
<th>Clasificación</th>
|
<th>Clasificación</th>
|
||||||
{editable && <th></th>}
|
|
||||||
|
{canEditSheet && <th></th>}
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row, i) => (
|
|
||||||
<tr key={i}>
|
{rows.map((row, i) => {
|
||||||
|
|
||||||
|
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
|
||||||
|
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input value={row.detalle || ''} disabled={!editable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
|
||||||
|
<input value={row.detalle || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" min="0" step="any" value={row.impuestos ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'impuestos', e.target.value)} />
|
|
||||||
|
<input type="number" min="0" step="any" value={row.impuestos ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'impuestos', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input value={row.numeroFactura || ''} disabled={!editable} onChange={(e) => updateRow(i, 'numeroFactura', e.target.value)} />
|
|
||||||
|
<input value={row.numeroFactura || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'numeroFactura', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" min="0" step="any" value={row.monto ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'monto', e.target.value)} />
|
|
||||||
|
<input type="number" min="0" step="any" value={row.monto ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'monto', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<select value={row.clasificacion || 'diario'} disabled={!editable} onChange={(e) => updateRow(i, 'clasificacion', e.target.value)}>
|
|
||||||
|
<select value={row.clasificacion || 'diario'} disabled={!rowEditable} onChange={(e) => updateRow(i, 'clasificacion', e.target.value)}>
|
||||||
|
|
||||||
<option value="diario">Gasto Diario</option>
|
<option value="diario">Gasto Diario</option>
|
||||||
|
|
||||||
<option value="fijo">Gasto Fijo</option>
|
<option value="fijo">Gasto Fijo</option>
|
||||||
|
|
||||||
<option value="variable">Gasto Variable</option>
|
<option value="variable">Gasto Variable</option>
|
||||||
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
{editable && (
|
|
||||||
|
{canEditSheet && (
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
className="btn btn-danger btn-icon"
|
className="btn btn-danger btn-icon"
|
||||||
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
removeRow(i);
|
removeRow(i);
|
||||||
|
|
||||||
}}
|
}}
|
||||||
title="Eliminar fila"
|
|
||||||
|
disabled={!rowEditable}
|
||||||
|
|
||||||
|
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
×
|
×
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
|
||||||
|
);
|
||||||
|
|
||||||
|
})}
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="totals-bar">
|
<div className="totals-bar">
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
|
|
||||||
Total gastos: <strong>{formatMoney(total)}</strong>
|
Total gastos: <strong>{formatMoney(total)}</strong>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { sheetsApi } from '../api/client';
|
import { sheetsApi } from '../api/client';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
|
import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
|
||||||
|
|
||||||
|
import { useGestionView } from '../hooks/useGestionView';
|
||||||
|
|
||||||
import SaveStatus from './SaveStatus';
|
import SaveStatus from './SaveStatus';
|
||||||
|
|
||||||
|
import GestionBanner from './GestionBanner';
|
||||||
|
|
||||||
|
import GestionesSidebar from './GestionesSidebar';
|
||||||
|
|
||||||
import { calcIngresoRow, emptyIngresoRow, formatMoney } from '../utils/calc';
|
import { calcIngresoRow, emptyIngresoRow, formatMoney } from '../utils/calc';
|
||||||
|
|
||||||
|
import { isRowEditable } from '../utils/gestion';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function IngresoSheet({ sheetType, title }) {
|
export default function IngresoSheet({ sheetType, title }) {
|
||||||
const { canEdit } = useAuth();
|
|
||||||
|
const { canEdit, user } = useAuth();
|
||||||
|
|
||||||
const editable = canEdit(sheetType);
|
const editable = canEdit(sheetType);
|
||||||
|
|
||||||
|
const isAdmin = !!user?.isAdmin;
|
||||||
|
|
||||||
|
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
|
||||||
|
|
||||||
const [rows, setRows] = useState([emptyIngresoRow()]);
|
const [rows, setRows] = useState([emptyIngresoRow()]);
|
||||||
|
|
||||||
|
const [gestion, setGestion] = useState(null);
|
||||||
|
|
||||||
|
const [gestionVista, setGestionVista] = useState(null);
|
||||||
|
|
||||||
|
const [esHistorial, setEsHistorial] = useState(false);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [loadError, setLoadError] = useState(null);
|
const [loadError, setLoadError] = useState(null);
|
||||||
|
|
||||||
const skipNotifyRef = useRef(false);
|
const skipNotifyRef = useRef(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const canEditSheet = editable && !esHistorial;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
||||||
enabled: editable,
|
|
||||||
|
enabled: canEditSheet,
|
||||||
|
|
||||||
sheetType,
|
sheetType,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
|
|
||||||
setReady(false);
|
setReady(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await sheetsApi.get(sheetType);
|
|
||||||
|
const data = await sheetsApi.get(sheetType, { gestionId: selectedGestionId });
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
setRows(data.rows?.length ? data.rows : [emptyIngresoRow()]);
|
|
||||||
setReady(true);
|
const loaded = data.rows || [];
|
||||||
} catch (e) {
|
|
||||||
if (active) setLoadError(e.message);
|
if (loaded.length) {
|
||||||
} finally {
|
|
||||||
if (active) setLoading(false);
|
setRows(loaded);
|
||||||
|
|
||||||
|
} else if (!data.esHistorial && canEditSheet) {
|
||||||
|
|
||||||
|
setRows([emptyIngresoRow()]);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
setRows([]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setGestion(data.gestion || null);
|
||||||
|
|
||||||
|
setGestionVista(data.gestionVista || null);
|
||||||
|
|
||||||
|
setEsHistorial(!!data.esHistorial);
|
||||||
|
|
||||||
|
setReady(!data.esHistorial);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
if (active) setLoadError(e.message);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
if (active) setLoading(false);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
||||||
active = false;
|
active = false;
|
||||||
|
|
||||||
};
|
};
|
||||||
}, [sheetType, setReady]);
|
|
||||||
|
}, [sheetType, selectedGestionId, setReady, canEditSheet]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !editable) return;
|
|
||||||
|
if (loading || !canEditSheet) return;
|
||||||
|
|
||||||
if (skipNotifyRef.current) {
|
if (skipNotifyRef.current) {
|
||||||
|
|
||||||
skipNotifyRef.current = false;
|
skipNotifyRef.current = false;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyChange({ rows });
|
notifyChange({ rows });
|
||||||
}, [rows, loading, editable, notifyChange]);
|
|
||||||
|
}, [rows, loading, canEditSheet, notifyChange]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
|
|
||||||
let totalIngresos = 0;
|
let totalIngresos = 0;
|
||||||
|
|
||||||
let totalUtilidad = 0;
|
let totalUtilidad = 0;
|
||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
|
|
||||||
const { precioTotal, utilidad } = calcIngresoRow(row);
|
const { precioTotal, utilidad } = calcIngresoRow(row);
|
||||||
|
|
||||||
totalIngresos += precioTotal;
|
totalIngresos += precioTotal;
|
||||||
|
|
||||||
totalUtilidad += utilidad;
|
totalUtilidad += utilidad;
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { totalIngresos, totalUtilidad };
|
return { totalIngresos, totalUtilidad };
|
||||||
|
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updateRow = (index, field, value) => {
|
const updateRow = (index, field, value) => {
|
||||||
|
|
||||||
|
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
|
||||||
|
|
||||||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const addRow = () => setRows((prev) => [...prev, emptyIngresoRow()]);
|
const addRow = () => setRows((prev) => [...prev, emptyIngresoRow()]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const removeRow = (index) => {
|
const removeRow = (index) => {
|
||||||
|
|
||||||
|
if (!isRowEditable(rows[index], canEditSheet, isAdmin)) return;
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
|
|
||||||
const next = rows.filter((_, i) => i !== index);
|
const next = rows.filter((_, i) => i !== index);
|
||||||
|
|
||||||
const result = next.length > 0 ? next : [emptyIngresoRow()];
|
const result = next.length > 0 ? next : [emptyIngresoRow()];
|
||||||
|
|
||||||
setRows(result);
|
setRows(result);
|
||||||
|
|
||||||
saveNow({ rows: result });
|
saveNow({ rows: result });
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const clearSheet = () => {
|
const clearSheet = () => {
|
||||||
if (!window.confirm('¿Vaciar toda la planilla? Se eliminarán todas las filas.')) return;
|
|
||||||
const blank = [emptyIngresoRow()];
|
const msg = gestion?.fechaCierreGestion && !isAdmin
|
||||||
|
|
||||||
|
? '¿Eliminar solo las filas editables? Las del período cerrado se conservarán.'
|
||||||
|
|
||||||
|
: '¿Vaciar toda la planilla? Se eliminarán todas las filas de la gestión actual.';
|
||||||
|
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
|
|
||||||
|
if (isAdmin || !gestion?.fechaCierreGestion) {
|
||||||
|
|
||||||
|
const blank = [emptyIngresoRow()];
|
||||||
|
|
||||||
setRows(blank);
|
setRows(blank);
|
||||||
|
|
||||||
saveNow({ rows: blank });
|
saveNow({ rows: blank });
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = rows.filter((r) => r.bloqueada);
|
||||||
|
|
||||||
|
const result = kept.length ? kept : [emptyIngresoRow()];
|
||||||
|
|
||||||
|
setRows(result);
|
||||||
|
|
||||||
|
saveNow({ rows: result });
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (loading) return <p className="loading">Cargando planilla…</p>;
|
if (loading) return <p className="loading">Cargando planilla…</p>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
|
<div className="sheet-layout">
|
||||||
|
|
||||||
|
<GestionesSidebar
|
||||||
|
|
||||||
|
gestiones={sidebarGestiones}
|
||||||
|
|
||||||
|
selectedId={selectedGestionId}
|
||||||
|
|
||||||
|
onSelect={selectGestion}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="sheet-layout__main">
|
||||||
|
|
||||||
<h1 className="page-title">{title}</h1>
|
<h1 className="page-title">{title}</h1>
|
||||||
|
|
||||||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||||||
{editable && <SaveStatus status={status} error={saveError} />}
|
|
||||||
{!editable && <div className="alert alert-info">Solo lectura — sin permiso de edición</div>}
|
|
||||||
|
|
||||||
|
{esHistorial && gestionVista && (
|
||||||
|
|
||||||
|
<div className="historial-badge">
|
||||||
|
|
||||||
|
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} →{' '}
|
||||||
|
|
||||||
|
{gestionVista.fechaFin}) — solo lectura
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{!esHistorial && <GestionBanner gestion={gestion} isAdmin={isAdmin} />}
|
||||||
|
|
||||||
|
{canEditSheet && <SaveStatus status={status} error={saveError} />}
|
||||||
|
|
||||||
|
{esHistorial && <div className="alert alert-info">Gestión cerrada — solo consulta</div>}
|
||||||
|
|
||||||
|
{!editable && !esHistorial && (
|
||||||
|
|
||||||
|
<div className="alert alert-info">Solo lectura — sin permiso de edición</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
{editable && (
|
|
||||||
|
{canEditSheet && (
|
||||||
|
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea">
|
<button type="button" className="btn btn-primary btn-icon" onClick={addRow} title="Agregar línea">
|
||||||
|
|
||||||
+
|
+
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
|
<button type="button" className="btn btn-secondary" onClick={() => saveNow({ rows })} disabled={status === 'saving'}>
|
||||||
|
|
||||||
Guardar ahora
|
Guardar ahora
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||||
|
|
||||||
Vaciar planilla
|
Vaciar planilla
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
|
||||||
|
<p className="loading">Sin registros en esta gestión.</p>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
<div className="card sheet-table-wrap">
|
<div className="card sheet-table-wrap">
|
||||||
|
|
||||||
<table className="sheet-table">
|
<table className="sheet-table">
|
||||||
|
|
||||||
<thead>
|
<thead>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
|
||||||
<th>Fecha</th>
|
<th>Fecha</th>
|
||||||
|
|
||||||
<th>Cliente</th>
|
<th>Cliente</th>
|
||||||
|
|
||||||
<th>Detalle</th>
|
<th>Detalle</th>
|
||||||
|
|
||||||
<th>Cantidad</th>
|
<th>Cantidad</th>
|
||||||
|
|
||||||
<th>P. Unitario</th>
|
<th>P. Unitario</th>
|
||||||
|
|
||||||
<th>C. Unitario</th>
|
<th>C. Unitario</th>
|
||||||
|
|
||||||
<th>Precio Total</th>
|
<th>Precio Total</th>
|
||||||
|
|
||||||
<th>Utilidad</th>
|
<th>Utilidad</th>
|
||||||
{editable && <th></th>}
|
|
||||||
|
{canEditSheet && <th></th>}
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
|
|
||||||
const { precioTotal, utilidad } = calcIngresoRow(row);
|
const { precioTotal, utilidad } = calcIngresoRow(row);
|
||||||
|
|
||||||
|
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={i}>
|
|
||||||
|
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
|
||||||
|
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'fecha', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input value={row.cliente || ''} disabled={!editable} onChange={(e) => updateRow(i, 'cliente', e.target.value)} />
|
|
||||||
|
<input value={row.cliente || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'cliente', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input value={row.detalle || ''} disabled={!editable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
|
||||||
|
<input value={row.detalle || ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'detalle', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" min="0" step="any" value={row.cantidad ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'cantidad', e.target.value)} />
|
|
||||||
|
<input type="number" min="0" step="any" value={row.cantidad ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'cantidad', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" min="0" step="any" value={row.precioUnitario ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'precioUnitario', e.target.value)} />
|
|
||||||
|
<input type="number" min="0" step="any" value={row.precioUnitario ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'precioUnitario', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" min="0" step="any" value={row.costoUnitario ?? ''} disabled={!editable} onChange={(e) => updateRow(i, 'costoUnitario', e.target.value)} />
|
|
||||||
|
<input type="number" min="0" step="any" value={row.costoUnitario ?? ''} disabled={!rowEditable} onChange={(e) => updateRow(i, 'costoUnitario', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="computed">{formatMoney(precioTotal)}</td>
|
<td className="computed">{formatMoney(precioTotal)}</td>
|
||||||
|
|
||||||
<td className="computed">{formatMoney(utilidad)}</td>
|
<td className="computed">{formatMoney(utilidad)}</td>
|
||||||
{editable && (
|
|
||||||
|
{canEditSheet && (
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
className="btn btn-danger btn-icon"
|
className="btn btn-danger btn-icon"
|
||||||
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
removeRow(i);
|
removeRow(i);
|
||||||
|
|
||||||
}}
|
}}
|
||||||
title="Quitar fila"
|
|
||||||
|
disabled={!rowEditable}
|
||||||
|
|
||||||
|
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Quitar fila'}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
×
|
×
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
})}
|
})}
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="totals-bar">
|
<div className="totals-bar">
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
|
|
||||||
Sumatoria ingresos: <strong>{formatMoney(totals.totalIngresos)}</strong>
|
Sumatoria ingresos: <strong>{formatMoney(totals.totalIngresos)}</strong>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
|
|
||||||
Sumatoria utilidad: <strong>{formatMoney(totals.totalUtilidad)}</strong>
|
Sumatoria utilidad: <strong>{formatMoney(totals.totalUtilidad)}</strong>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ export default function Layout({ children }) {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
{user?.isAdmin && (
|
{user?.isAdmin && (
|
||||||
<NavLink to="/admin" className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`}>
|
<NavLink
|
||||||
|
to="/admin"
|
||||||
|
className={({ isActive }) => `nav-tab nav-tab--admin${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
Administración
|
Administración
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
@@ -46,7 +49,12 @@ export default function Layout({ children }) {
|
|||||||
Salir
|
Salir
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<main className="main-content">{children}</main>
|
<main className="main-content">
|
||||||
|
{children}
|
||||||
|
<p className="app-version">
|
||||||
|
Don Marco v{import.meta.env.VITE_APP_VERSION || '1.2.0'} — Historial de gestiones (Docker)
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,236 +1,662 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { sheetsApi } from '../api/client';
|
import { sheetsApi } from '../api/client';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
|
import { useSheetAutoSave } from '../hooks/useSheetAutoSave';
|
||||||
|
|
||||||
|
import { useGestionView } from '../hooks/useGestionView';
|
||||||
|
|
||||||
import SaveStatus from './SaveStatus';
|
import SaveStatus from './SaveStatus';
|
||||||
|
|
||||||
|
import GestionBanner from './GestionBanner';
|
||||||
|
|
||||||
|
import GestionesSidebar from './GestionesSidebar';
|
||||||
|
|
||||||
import { emptyMensualizado, emptyEventual, formatMoney } from '../utils/calc';
|
import { emptyMensualizado, emptyEventual, formatMoney } from '../utils/calc';
|
||||||
|
|
||||||
|
import { isRowEditable } from '../utils/gestion';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const MAX_MENSUAL = 10;
|
const MAX_MENSUAL = 10;
|
||||||
|
|
||||||
const MAX_EVENTUAL = 5;
|
const MAX_EVENTUAL = 5;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function rowCostMensual(r) {
|
function rowCostMensual(r) {
|
||||||
|
|
||||||
return (Number(r.sueldoBase) || 0) + (Number(r.cargasSociales) || 0) + (Number(r.bonos) || 0);
|
return (Number(r.sueldoBase) || 0) + (Number(r.cargasSociales) || 0) + (Number(r.bonos) || 0);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function PersonalSheet() {
|
export default function PersonalSheet() {
|
||||||
const { canEdit } = useAuth();
|
|
||||||
|
const { canEdit, user } = useAuth();
|
||||||
|
|
||||||
const editable = canEdit('personal');
|
const editable = canEdit('personal');
|
||||||
|
|
||||||
|
const isAdmin = !!user?.isAdmin;
|
||||||
|
|
||||||
|
const { selectedGestionId, selectGestion, sidebarGestiones } = useGestionView();
|
||||||
|
|
||||||
const [mensualizados, setMensualizados] = useState([emptyMensualizado()]);
|
const [mensualizados, setMensualizados] = useState([emptyMensualizado()]);
|
||||||
|
|
||||||
const [eventuales, setEventuales] = useState([emptyEventual()]);
|
const [eventuales, setEventuales] = useState([emptyEventual()]);
|
||||||
|
|
||||||
|
const [gestion, setGestion] = useState(null);
|
||||||
|
|
||||||
|
const [gestionVista, setGestionVista] = useState(null);
|
||||||
|
|
||||||
|
const [esHistorial, setEsHistorial] = useState(false);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [loadError, setLoadError] = useState(null);
|
const [loadError, setLoadError] = useState(null);
|
||||||
|
|
||||||
const skipNotifyRef = useRef(false);
|
const skipNotifyRef = useRef(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const canEditSheet = editable && !esHistorial;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
const { status, error: saveError, setReady, notifyChange, saveNow } = useSheetAutoSave({
|
||||||
enabled: editable,
|
|
||||||
|
enabled: canEditSheet,
|
||||||
|
|
||||||
sheetType: 'personal',
|
sheetType: 'personal',
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
|
|
||||||
setReady(false);
|
setReady(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await sheetsApi.get('personal');
|
|
||||||
|
const data = await sheetsApi.get('personal', { gestionId: selectedGestionId });
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
setMensualizados(
|
|
||||||
data.mensualizados?.length ? data.mensualizados.slice(0, MAX_MENSUAL) : [emptyMensualizado()]
|
const m = data.mensualizados || [];
|
||||||
);
|
|
||||||
setEventuales(data.eventuales?.length ? data.eventuales : [emptyEventual()]);
|
const e = data.eventuales || [];
|
||||||
setReady(true);
|
|
||||||
} catch (e) {
|
if (m.length || e.length) {
|
||||||
if (active) setLoadError(e.message);
|
|
||||||
} finally {
|
setMensualizados(m.length ? m.slice(0, MAX_MENSUAL) : []);
|
||||||
if (active) setLoading(false);
|
|
||||||
|
setEventuales(e);
|
||||||
|
|
||||||
|
} else if (!data.esHistorial && canEditSheet) {
|
||||||
|
|
||||||
|
setMensualizados([emptyMensualizado()]);
|
||||||
|
|
||||||
|
setEventuales([emptyEventual()]);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
setMensualizados([]);
|
||||||
|
|
||||||
|
setEventuales([]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setGestion(data.gestion || null);
|
||||||
|
|
||||||
|
setGestionVista(data.gestionVista || null);
|
||||||
|
|
||||||
|
setEsHistorial(!!data.esHistorial);
|
||||||
|
|
||||||
|
setReady(!data.esHistorial);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
if (active) setLoadError(err.message);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
if (active) setLoading(false);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
||||||
active = false;
|
active = false;
|
||||||
|
|
||||||
};
|
};
|
||||||
}, [setReady]);
|
|
||||||
|
}, [selectedGestionId, setReady, canEditSheet]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !editable) return;
|
|
||||||
|
if (loading || !canEditSheet) return;
|
||||||
|
|
||||||
if (skipNotifyRef.current) {
|
if (skipNotifyRef.current) {
|
||||||
|
|
||||||
skipNotifyRef.current = false;
|
skipNotifyRef.current = false;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyChange({ mensualizados, eventuales });
|
notifyChange({ mensualizados, eventuales });
|
||||||
}, [mensualizados, eventuales, loading, editable, notifyChange]);
|
|
||||||
|
}, [mensualizados, eventuales, loading, canEditSheet, notifyChange]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const total = useMemo(() => {
|
const total = useMemo(() => {
|
||||||
|
|
||||||
const m = mensualizados.reduce((a, r) => a + rowCostMensual(r), 0);
|
const m = mensualizados.reduce((a, r) => a + rowCostMensual(r), 0);
|
||||||
|
|
||||||
const e = eventuales.reduce((a, r) => a + (Number(r.pagoDiario) || 0), 0);
|
const e = eventuales.reduce((a, r) => a + (Number(r.pagoDiario) || 0), 0);
|
||||||
|
|
||||||
return m + e;
|
return m + e;
|
||||||
|
|
||||||
}, [mensualizados, eventuales]);
|
}, [mensualizados, eventuales]);
|
||||||
|
|
||||||
const updateMensual = (i, field, value) =>
|
|
||||||
|
|
||||||
|
const updateMensual = (i, field, value) => {
|
||||||
|
|
||||||
|
if (!isRowEditable(mensualizados[i], canEditSheet, isAdmin)) return;
|
||||||
|
|
||||||
setMensualizados((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r)));
|
setMensualizados((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r)));
|
||||||
const updateEventual = (i, field, value) =>
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const updateEventual = (i, field, value) => {
|
||||||
|
|
||||||
|
if (!isRowEditable(eventuales[i], canEditSheet, isAdmin)) return;
|
||||||
|
|
||||||
setEventuales((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r)));
|
setEventuales((p) => p.map((r, idx) => (idx === i ? { ...r, [field]: value } : r)));
|
||||||
|
|
||||||
const removeMensual = (index) => {
|
|
||||||
skipNotifyRef.current = true;
|
|
||||||
const next = mensualizados.filter((_, i) => i !== index);
|
|
||||||
const result = next.length > 0 ? next : [emptyMensualizado()];
|
|
||||||
setMensualizados(result);
|
|
||||||
saveNow({ mensualizados: result, eventuales });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const removeMensual = (index) => {
|
||||||
|
|
||||||
|
if (!isRowEditable(mensualizados[index], canEditSheet, isAdmin)) return;
|
||||||
|
|
||||||
|
skipNotifyRef.current = true;
|
||||||
|
|
||||||
|
const next = mensualizados.filter((_, i) => i !== index);
|
||||||
|
|
||||||
|
const result = next.length > 0 ? next : [emptyMensualizado()];
|
||||||
|
|
||||||
|
setMensualizados(result);
|
||||||
|
|
||||||
|
saveNow({ mensualizados: result, eventuales });
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const removeEventual = (index) => {
|
const removeEventual = (index) => {
|
||||||
|
|
||||||
|
if (!isRowEditable(eventuales[index], canEditSheet, isAdmin)) return;
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
|
|
||||||
const next = eventuales.filter((_, i) => i !== index);
|
const next = eventuales.filter((_, i) => i !== index);
|
||||||
|
|
||||||
const result = next.length > 0 ? next : [emptyEventual()];
|
const result = next.length > 0 ? next : [emptyEventual()];
|
||||||
|
|
||||||
setEventuales(result);
|
setEventuales(result);
|
||||||
|
|
||||||
saveNow({ mensualizados, eventuales: result });
|
saveNow({ mensualizados, eventuales: result });
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const clearSheet = () => {
|
const clearSheet = () => {
|
||||||
if (!window.confirm('¿Vaciar toda la planilla de personal?')) return;
|
|
||||||
const m = [emptyMensualizado()];
|
const msg = gestion?.fechaCierreGestion && !isAdmin
|
||||||
const e = [emptyEventual()];
|
|
||||||
|
? '¿Eliminar solo filas editables? Las del período cerrado se conservarán.'
|
||||||
|
|
||||||
|
: '¿Vaciar toda la planilla de personal de la gestión actual?';
|
||||||
|
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
|
||||||
skipNotifyRef.current = true;
|
skipNotifyRef.current = true;
|
||||||
|
|
||||||
|
if (isAdmin || !gestion?.fechaCierreGestion) {
|
||||||
|
|
||||||
|
const m = [emptyMensualizado()];
|
||||||
|
|
||||||
|
const e = [emptyEventual()];
|
||||||
|
|
||||||
setMensualizados(m);
|
setMensualizados(m);
|
||||||
|
|
||||||
setEventuales(e);
|
setEventuales(e);
|
||||||
|
|
||||||
saveNow({ mensualizados: m, eventuales: e });
|
saveNow({ mensualizados: m, eventuales: e });
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = mensualizados.filter((r) => r.bloqueada);
|
||||||
|
|
||||||
|
const e = eventuales.filter((r) => r.bloqueada);
|
||||||
|
|
||||||
|
setMensualizados(m.length ? m : [emptyMensualizado()]);
|
||||||
|
|
||||||
|
setEventuales(e.length ? e : [emptyEventual()]);
|
||||||
|
|
||||||
|
saveNow({
|
||||||
|
|
||||||
|
mensualizados: m.length ? m : [emptyMensualizado()],
|
||||||
|
|
||||||
|
eventuales: e.length ? e : [emptyEventual()],
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const sinDatos = mensualizados.length === 0 && eventuales.length === 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (loading) return <p className="loading">Cargando planilla…</p>;
|
if (loading) return <p className="loading">Cargando planilla…</p>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
|
<div className="sheet-layout">
|
||||||
|
|
||||||
|
<GestionesSidebar
|
||||||
|
|
||||||
|
gestiones={sidebarGestiones}
|
||||||
|
|
||||||
|
selectedId={selectedGestionId}
|
||||||
|
|
||||||
|
onSelect={selectGestion}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="sheet-layout__main">
|
||||||
|
|
||||||
<h1 className="page-title">Planilla de Personal</h1>
|
<h1 className="page-title">Planilla de Personal</h1>
|
||||||
|
|
||||||
{loadError && <div className="alert alert-error">{loadError}</div>}
|
{loadError && <div className="alert alert-error">{loadError}</div>}
|
||||||
{editable && <SaveStatus status={status} error={saveError} />}
|
|
||||||
|
|
||||||
|
|
||||||
|
{esHistorial && gestionVista && (
|
||||||
|
|
||||||
|
<div className="historial-badge">
|
||||||
|
|
||||||
|
Historial: <strong>{gestionVista.etiqueta}</strong> ({gestionVista.fechaInicio} →{' '}
|
||||||
|
|
||||||
|
{gestionVista.fechaFin}) — solo lectura
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{!esHistorial && <GestionBanner gestion={gestion} isAdmin={isAdmin} />}
|
||||||
|
|
||||||
|
{canEditSheet && <SaveStatus status={status} error={saveError} />}
|
||||||
|
|
||||||
|
{esHistorial && <div className="alert alert-info">Gestión cerrada — solo consulta</div>}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
{editable && (
|
|
||||||
|
{canEditSheet && (
|
||||||
|
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
className="btn btn-primary btn-icon"
|
className="btn btn-primary btn-icon"
|
||||||
|
|
||||||
disabled={mensualizados.length >= MAX_MENSUAL}
|
disabled={mensualizados.length >= MAX_MENSUAL}
|
||||||
|
|
||||||
onClick={() => setMensualizados((p) => (p.length < MAX_MENSUAL ? [...p, emptyMensualizado()] : p))}
|
onClick={() => setMensualizados((p) => (p.length < MAX_MENSUAL ? [...p, emptyMensualizado()] : p))}
|
||||||
|
|
||||||
title="Agregar mensualizado"
|
title="Agregar mensualizado"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
+ Mensual
|
+ Mensual
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
className="btn btn-secondary btn-icon"
|
className="btn btn-secondary btn-icon"
|
||||||
|
|
||||||
disabled={eventuales.length >= MAX_EVENTUAL}
|
disabled={eventuales.length >= MAX_EVENTUAL}
|
||||||
|
|
||||||
onClick={() => setEventuales((p) => (p.length < MAX_EVENTUAL ? [...p, emptyEventual()] : p))}
|
onClick={() => setEventuales((p) => (p.length < MAX_EVENTUAL ? [...p, emptyEventual()] : p))}
|
||||||
|
|
||||||
title="Agregar eventual"
|
title="Agregar eventual"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
+ Eventual
|
+ Eventual
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
||||||
onClick={() => saveNow({ mensualizados, eventuales })}
|
onClick={() => saveNow({ mensualizados, eventuales })}
|
||||||
|
|
||||||
disabled={status === 'saving'}
|
disabled={status === 'saving'}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
Guardar ahora
|
Guardar ahora
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
<button type="button" className="btn btn-danger" onClick={clearSheet}>
|
||||||
|
|
||||||
Vaciar planilla
|
Vaciar planilla
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{sinDatos ? (
|
||||||
|
|
||||||
|
<p className="loading">Sin registros en esta gestión.</p>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
|
<>
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||||
|
|
||||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Funcionarios mensualizados (máx. {MAX_MENSUAL})</h2>
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Funcionarios mensualizados (máx. {MAX_MENSUAL})</h2>
|
||||||
|
|
||||||
|
{mensualizados.length === 0 ? (
|
||||||
|
|
||||||
|
<p style={{ color: 'var(--muted)' }}>Sin mensualizados en esta gestión.</p>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
<div className="sheet-table-wrap">
|
<div className="sheet-table-wrap">
|
||||||
|
|
||||||
<table className="sheet-table">
|
<table className="sheet-table">
|
||||||
|
|
||||||
<thead>
|
<thead>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
|
||||||
<th>Nombre</th>
|
<th>Nombre</th>
|
||||||
|
|
||||||
<th>Sueldo base</th>
|
<th>Sueldo base</th>
|
||||||
|
|
||||||
<th>Cargas sociales</th>
|
<th>Cargas sociales</th>
|
||||||
|
|
||||||
<th>Bonos</th>
|
<th>Bonos</th>
|
||||||
|
|
||||||
<th>Subtotal</th>
|
<th>Subtotal</th>
|
||||||
{editable && <th></th>}
|
|
||||||
|
{canEditSheet && <th></th>}
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{mensualizados.map((row, i) => (
|
|
||||||
<tr key={i}>
|
{mensualizados.map((row, i) => {
|
||||||
|
|
||||||
|
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input value={row.nombre || ''} disabled={!editable} onChange={(e) => updateMensual(i, 'nombre', e.target.value)} />
|
|
||||||
|
<input value={row.nombre || ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'nombre', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" value={row.sueldoBase ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'sueldoBase', e.target.value)} />
|
|
||||||
|
<input type="number" value={row.sueldoBase ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'sueldoBase', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" value={row.cargasSociales ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'cargasSociales', e.target.value)} />
|
|
||||||
|
<input type="number" value={row.cargasSociales ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'cargasSociales', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" value={row.bonos ?? ''} disabled={!editable} onChange={(e) => updateMensual(i, 'bonos', e.target.value)} />
|
|
||||||
|
<input type="number" value={row.bonos ?? ''} disabled={!rowEditable} onChange={(e) => updateMensual(i, 'bonos', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="computed">{formatMoney(rowCostMensual(row))}</td>
|
<td className="computed">{formatMoney(rowCostMensual(row))}</td>
|
||||||
{editable && (
|
|
||||||
|
{canEditSheet && (
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeMensual(i)} title="Eliminar fila">
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
type="button"
|
||||||
|
|
||||||
|
className="btn btn-danger btn-icon"
|
||||||
|
|
||||||
|
onClick={() => removeMensual(i)}
|
||||||
|
|
||||||
|
disabled={!rowEditable}
|
||||||
|
|
||||||
|
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
×
|
×
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
|
||||||
|
);
|
||||||
|
|
||||||
|
})}
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
||||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)</h2>
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Trabajadores eventuales por día (máx. {MAX_EVENTUAL} filas)</h2>
|
||||||
|
|
||||||
|
{eventuales.length === 0 ? (
|
||||||
|
|
||||||
|
<p style={{ color: 'var(--muted)' }}>Sin eventuales en esta gestión.</p>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
<div className="sheet-table-wrap">
|
<div className="sheet-table-wrap">
|
||||||
|
|
||||||
<table className="sheet-table">
|
<table className="sheet-table">
|
||||||
|
|
||||||
<thead>
|
<thead>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
|
||||||
<th>Fecha</th>
|
<th>Fecha</th>
|
||||||
|
|
||||||
<th>Nombre</th>
|
<th>Nombre</th>
|
||||||
|
|
||||||
<th>Pago diario</th>
|
<th>Pago diario</th>
|
||||||
{editable && <th></th>}
|
|
||||||
|
{canEditSheet && <th></th>}
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{eventuales.map((row, i) => (
|
|
||||||
<tr key={i}>
|
{eventuales.map((row, i) => {
|
||||||
|
|
||||||
|
const rowEditable = isRowEditable(row, canEditSheet, isAdmin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<tr key={i} className={row.bloqueada ? 'row-locked' : ''}>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="date" value={row.fecha || ''} disabled={!editable} onChange={(e) => updateEventual(i, 'fecha', e.target.value)} />
|
|
||||||
|
<input type="date" value={row.fecha || ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'fecha', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input value={row.nombre || ''} disabled={!editable} onChange={(e) => updateEventual(i, 'nombre', e.target.value)} />
|
|
||||||
|
<input value={row.nombre || ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'nombre', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input type="number" value={row.pagoDiario ?? ''} disabled={!editable} onChange={(e) => updateEventual(i, 'pagoDiario', e.target.value)} />
|
|
||||||
|
<input type="number" value={row.pagoDiario ?? ''} disabled={!rowEditable} onChange={(e) => updateEventual(i, 'pagoDiario', e.target.value)} />
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
{editable && (
|
|
||||||
|
{canEditSheet && (
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<button type="button" className="btn btn-danger btn-icon" onClick={() => removeEventual(i)} title="Eliminar fila">
|
|
||||||
|
<button
|
||||||
|
|
||||||
|
type="button"
|
||||||
|
|
||||||
|
className="btn btn-danger btn-icon"
|
||||||
|
|
||||||
|
onClick={() => removeEventual(i)}
|
||||||
|
|
||||||
|
disabled={!rowEditable}
|
||||||
|
|
||||||
|
title={row.bloqueada ? 'Bloqueado — período cerrado' : 'Eliminar fila'}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
×
|
×
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
|
||||||
|
);
|
||||||
|
|
||||||
|
})}
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="totals-bar" style={{ marginTop: '1rem' }}>
|
<div className="totals-bar" style={{ marginTop: '1rem' }}>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
|
|
||||||
Sumatoria total personal: <strong>{formatMoney(total)}</strong>
|
Sumatoria total personal: <strong>{formatMoney(total)}</strong>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { useState, useEffect } from 'react';
|
||||||
import { usersApi } from '../api/client';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { usersApi, settingsApi } from '../api/client';
|
||||||
|
|
||||||
const SHEET_LABELS = {
|
const SHEET_LABELS = {
|
||||||
ventas: 'Ventas',
|
ventas: 'Ventas',
|
||||||
@@ -22,12 +23,30 @@ export default function AdminPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [newUser, setNewUser] = useState({ username: '', password: '', permissions: emptyPerms() });
|
const [newUser, setNewUser] = useState({ username: '', password: '', permissions: emptyPerms() });
|
||||||
|
const [gestion, setGestion] = useState(null);
|
||||||
|
const [fechaCierre, setFechaCierre] = useState('');
|
||||||
|
const [gestionMsg, setGestionMsg] = useState('');
|
||||||
|
const [gestionError, setGestionError] = useState('');
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
setError('');
|
||||||
|
setGestionError('');
|
||||||
try {
|
try {
|
||||||
setUsers(await usersApi.list());
|
setUsers(await usersApi.list());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const gestionData = await settingsApi.getGestion();
|
||||||
|
setGestion(gestionData);
|
||||||
|
setFechaCierre(gestionData.sugerirCierre || gestionData.fechaCierreGestion || '');
|
||||||
|
} catch (e) {
|
||||||
|
setGestionError(
|
||||||
|
e.message.includes('404') || e.message.includes('Error en la solicitud')
|
||||||
|
? 'No se pudo cargar el cierre de gestión. Reconstruya Docker: docker compose build --no-cache && docker compose up -d'
|
||||||
|
: e.message
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -37,6 +56,12 @@ export default function AdminPage() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.hash === '#cierre-gestion') {
|
||||||
|
document.getElementById('cierre-gestion')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [location.hash, loading]);
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -69,18 +94,112 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <p className="loading">Cargando usuarios…</p>;
|
const cerrarGestion = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!fechaCierre) {
|
||||||
|
setError('Indique el último día de la gestión a cerrar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const etiqueta = gestion?.gestionActual?.fechaInicio
|
||||||
|
? `del ${gestion.gestionActual.fechaInicio} al ${fechaCierre}`
|
||||||
|
: `hasta el ${fechaCierre}`;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`¿Cerrar la gestión ${etiqueta}?\n\nSe bloqueará el período para todos (excepto JEFE) y comenzará una gestión nueva. El historial quedará en la barra lateral de cada planilla.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGestionMsg('');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await settingsApi.cerrarGestion(fechaCierre);
|
||||||
|
setGestion(data);
|
||||||
|
setFechaCierre(data.sugerirCierre || '');
|
||||||
|
const nombre = data.cierre?.cerrada?.etiqueta || 'gestión cerrada';
|
||||||
|
setGestionMsg(`"${nombre}" cerrada. Nueva gestión desde ${data.cierre?.nueva?.fechaInicio || 'mañana'}.`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="loading">Cargando administración…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Administración de usuarios</h1>
|
<h1 className="page-title">Administración</h1>
|
||||||
<p style={{ color: 'var(--muted)' }}>
|
<p style={{ color: 'var(--muted)' }}>
|
||||||
El superusuario <strong>JEFE</strong> puede crear usuarios, asignar contraseñas y habilitar acceso por planilla.
|
Solo visible para el administrador <strong>JEFE</strong>. Gestione el cierre del período y los usuarios.
|
||||||
</p>
|
</p>
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
|
||||||
|
|
||||||
<div className="card admin-grid" style={{ marginBottom: '1.5rem' }}>
|
<nav className="admin-subnav">
|
||||||
<h2 style={{ marginTop: 0 }}>Nuevo usuario</h2>
|
<a href="#cierre-gestion" className="admin-subnav__link admin-subnav__link--active">
|
||||||
|
Cierre de gestión
|
||||||
|
</a>
|
||||||
|
<a href="#usuarios" className="admin-subnav__link">
|
||||||
|
Usuarios
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
{gestionMsg && <div className="alert alert-success">{gestionMsg}</div>}
|
||||||
|
|
||||||
|
<div id="cierre-gestion" className="card card-highlight admin-grid" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Cierre de gestión</h2>
|
||||||
|
{gestionError && <div className="alert alert-error">{gestionError}</div>}
|
||||||
|
|
||||||
|
<div className="alert alert-info" style={{ fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
<strong>¿Cómo funciona?</strong>
|
||||||
|
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
|
||||||
|
<li>Al <strong>cerrar la gestión</strong> (típicamente fin de mes), el período queda bloqueado de inmediato.</li>
|
||||||
|
<li>Cada planilla muestra solo la <strong>gestión actual</strong>; el historial está en la <strong>barra lateral</strong> (solo lectura).</li>
|
||||||
|
<li>La nueva gestión comienza el día siguiente al cierre. Use el botón <strong>+</strong> para cargar datos nuevos.</li>
|
||||||
|
<li>Filas con fecha del período cerrado no se pueden editar (excepto JEFE).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gestion?.gestionActual && (
|
||||||
|
<p style={{ fontSize: '0.85rem', marginBottom: '1rem' }}>
|
||||||
|
Gestión actual: desde <strong>{gestion.gestionActual.fechaInicio}</strong> — Hoy:{' '}
|
||||||
|
<strong>{gestion.hoy}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gestion?.historial?.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<p style={{ fontSize: '0.85rem', marginBottom: '0.5rem' }}>
|
||||||
|
<strong>Gestiones cerradas:</strong>
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '1.2rem', fontSize: '0.85rem' }}>
|
||||||
|
{gestion.historial.map((g) => (
|
||||||
|
<li key={g.id}>
|
||||||
|
{g.etiqueta} ({g.fechaInicio} → {g.fechaFin})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={cerrarGestion}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Último día de la gestión a cerrar</label>
|
||||||
|
<input type="date" value={fechaCierre} onChange={(e) => setFechaCierre(e.target.value)} required />
|
||||||
|
{gestion?.sugerirCierre && (
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--muted)', marginTop: '0.35rem' }}>
|
||||||
|
Sugerido (fin de mes): {gestion.sugerirCierre}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="toolbar">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Cerrar gestión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="usuarios" className="card admin-grid" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Usuarios — Nuevo usuario</h2>
|
||||||
<form onSubmit={handleCreate}>
|
<form onSubmit={handleCreate}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Usuario</label>
|
<label>Usuario</label>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h1 className="page-title">Menú principal</h1>
|
<h1 className="page-title">Menú principal</h1>
|
||||||
<p>
|
<p>
|
||||||
@@ -13,11 +15,23 @@ export default function HomePage() {
|
|||||||
<li>Planillas de ingresos: Ventas, Servicios y Alquileres</li>
|
<li>Planillas de ingresos: Ventas, Servicios y Alquileres</li>
|
||||||
<li>Planillas de egresos: Gastos Diarios y Gastos Fijos</li>
|
<li>Planillas de egresos: Gastos Diarios y Gastos Fijos</li>
|
||||||
<li>Planilla de Personal y Reportes consolidados</li>
|
<li>Planilla de Personal y Reportes consolidados</li>
|
||||||
{user?.isAdmin && <li>Panel de Administración para gestionar usuarios y permisos</li>}
|
|
||||||
</ul>
|
</ul>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted)', marginTop: '1.5rem' }}>
|
<p style={{ fontSize: '0.85rem', color: 'var(--muted)', marginTop: '1.5rem' }}>
|
||||||
Los datos se guardan automáticamente en el servidor al editar y al cambiar de pestaña. Use el botón <strong>+</strong> para agregar filas. Asegúrese de tener el backend en ejecución (puerto 4000).
|
Los datos se guardan automáticamente en el servidor. Use el botón <strong>+</strong> para agregar filas.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<div className="card card-highlight" style={{ marginTop: '1rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Administración (JEFE)</h2>
|
||||||
|
<p style={{ color: 'var(--muted)', marginBottom: '1rem' }}>
|
||||||
|
Cierre de gestión, usuarios y permisos en un solo lugar.
|
||||||
|
</p>
|
||||||
|
<Link to="/admin" className="btn btn-primary">
|
||||||
|
Ir a Administración
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,6 +382,211 @@ textarea {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sheet-table tr.row-locked {
|
||||||
|
background: rgba(255, 176, 32, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-table tr.row-locked input,
|
||||||
|
.sheet-table tr.row-locked select {
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab--cierre {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
color: #ffb0b0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab--cierre.active {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab--admin {
|
||||||
|
border-color: var(--warning);
|
||||||
|
color: #ffd080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab--admin.active {
|
||||||
|
background: var(--warning);
|
||||||
|
color: #1a1200;
|
||||||
|
border-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-highlight {
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px rgba(61, 156, 245, 0.25), var(--shadow);
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(61, 156, 245, 0.2);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subnav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subnav__link {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-subnav__link--active,
|
||||||
|
.admin-subnav__link:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.btn {
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-layout__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestiones-sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 4.5rem;
|
||||||
|
max-height: calc(100vh - 6rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestiones-sidebar__title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestiones-sidebar__subtitle {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestiones-sidebar__empty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestion-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestion-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestion-item--actual {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestion-item--active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestion-item__label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestion-item__range {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historial-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 176, 32, 0.15);
|
||||||
|
border: 1px solid var(--warning);
|
||||||
|
color: #ffd080;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.sheet-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.gestiones-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.gestion-list {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.gestion-item {
|
||||||
|
width: auto;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.navbar {
|
.navbar {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
|||||||
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