no se que modificaciones se hizo posible error

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

View File

@@ -52,5 +52,23 @@ export function initDb() {
);
CREATE INDEX IF NOT EXISTS idx_registros_sheet ON registros(sheet_type);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_by INTEGER,
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (updated_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS gestiones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
etiqueta TEXT NOT NULL,
fecha_inicio TEXT NOT NULL,
fecha_fin TEXT,
cerrada_at TEXT,
cerrada_por INTEGER,
FOREIGN KEY (cerrada_por) REFERENCES users(id)
);
`);
}

View File

@@ -5,6 +5,8 @@ import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import sheetRoutes from './routes/sheets.js';
import reportRoutes from './routes/reports.js';
import settingsRoutes from './routes/settings.js';
import gestionesRoutes from './routes/gestiones.js';
import bcrypt from 'bcryptjs';
import { db, SHEETS } from './db.js';
@@ -31,11 +33,20 @@ const PORT = process.env.PORT || 4000;
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '2mb' }));
app.get('/api/health', (_req, res) => res.json({ ok: true, service: 'DonMarco API' }));
app.get('/api/health', (_req, res) =>
res.json({
ok: true,
service: 'DonMarco API',
version: '1.2.0',
features: ['historial-gestiones', 'cierre-gestion', 'auto-guardado', 'reportes'],
})
);
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/sheets', sheetRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/gestiones', gestionesRoutes);
app.listen(PORT, '0.0.0.0', () => {
console.log(`Don Marco API escuchando en http://0.0.0.0:${PORT}`);

View File

@@ -0,0 +1,16 @@
import { Router } from 'express';
import { authRequired } from '../middleware/auth.js';
import { listGestiones, getGestionActual } from '../utils/gestion.js';
const router = Router();
router.use(authRequired);
router.get('/', (_req, res) => {
res.json({
actual: getGestionActual(),
historial: listGestiones().filter((g) => !g.esActual),
todas: listGestiones(),
});
});
export default router;

View File

@@ -0,0 +1,36 @@
import { Router } from 'express';
import { authRequired, adminRequired } from '../middleware/auth.js';
import {
getGestionStatus,
cerrarGestionActual,
sugerirCierreMensual,
} from '../utils/gestion.js';
const router = Router();
router.get('/gestion', authRequired, (_req, res) => {
res.json({ ...getGestionStatus(), sugerirCierre: sugerirCierreMensual() });
});
router.put('/gestion', authRequired, adminRequired, (req, res) => {
const { fechaCierreGestion } = req.body;
if (!fechaCierreGestion) {
return res.status(400).json({
error: 'Indique la fecha de cierre. Para consultar historial use la barra lateral de cada planilla.',
});
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(fechaCierreGestion)) {
return res.status(400).json({ error: 'Fecha inválida. Use formato AAAA-MM-DD' });
}
try {
const result = cerrarGestionActual(fechaCierreGestion, req.user.id);
res.json({ ...getGestionStatus(), sugerirCierre: sugerirCierreMensual(), cierre: result });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
export default router;

View File

@@ -2,6 +2,17 @@ import { Router } from 'express';
import { db, SHEETS } from '../db.js';
import { authRequired } from '../middleware/auth.js';
import { sumIngresos, sumGastos, sumPersonal } from '../utils/calculations.js';
import {
getGestionStatus,
getGestionActual,
getGestionById,
mergeRowsForSave,
mergePersonalForSave,
isFechaEnPeriodoCerrado,
filterRowsByGestion,
filterPersonalByGestion,
mensualBelongsToGestion,
} from '../utils/gestion.js';
const router = Router();
router.use(authRequired);
@@ -19,45 +30,112 @@ function checkPermission(req, sheet, needEdit = false) {
return !!row.can_view;
}
function getSheetPayload(sheetType) {
const rows = db
function resolveGestionView(gestionId) {
if (!gestionId || gestionId === 'actual') {
return { gestion: getGestionActual(), esHistorial: false };
}
const gestion = getGestionById(Number(gestionId));
if (!gestion) return null;
return { gestion, esHistorial: !gestion.esActual };
}
function loadParsedRows(sheetType) {
return db
.prepare('SELECT id, data, updated_at FROM registros WHERE sheet_type = ? ORDER BY id')
.all(sheetType);
const parsed = rows.map((r) => ({ id: r.id, ...JSON.parse(r.data), updated_at: r.updated_at }));
.all(sheetType)
.map((r) => ({ id: r.id, ...JSON.parse(r.data), updated_at: r.updated_at }));
}
function annotateRows(rows, isAdmin, esHistorial) {
return rows.map((row) => ({
...row,
bloqueada: esHistorial || (!isAdmin && isFechaEnPeriodoCerrado(row.fecha)),
}));
}
function getSheetPayload(sheetType, isAdmin, gestionView) {
const parsed = loadParsedRows(sheetType);
const gestionState = getGestionStatus();
const { gestion, esHistorial } = gestionView;
if (INGRESO_SHEETS.includes(sheetType)) {
const totals = sumIngresos(parsed);
return { rows: parsed, totals };
}
if (GASTO_SHEETS.includes(sheetType)) {
const total = sumGastos(parsed);
return { rows: parsed, totals: { totalGastos: total } };
}
if (sheetType === 'personal') {
const mensualizados = parsed.filter((r) => r.tipo === 'mensualizado');
const eventuales = parsed.filter((r) => r.tipo === 'eventual');
const filtered = filterRowsByGestion(parsed, gestion);
const rows = annotateRows(filtered, isAdmin, esHistorial);
const totals = sumIngresos(filtered);
return {
mensualizados,
eventuales,
totals: { totalPersonal: sumPersonal(mensualizados, eventuales) },
rows: rows.length ? rows : [],
totals,
gestion: gestionState,
gestionVista: gestion,
esHistorial,
soloLectura: esHistorial,
};
}
return { rows: parsed };
if (GASTO_SHEETS.includes(sheetType)) {
const filtered = filterRowsByGestion(parsed, gestion);
const rows = annotateRows(filtered, isAdmin, esHistorial);
const total = sumGastos(filtered);
return {
rows: rows.length ? rows : [],
totals: { totalGastos: total },
gestion: gestionState,
gestionVista: gestion,
esHistorial,
soloLectura: esHistorial,
};
}
if (sheetType === 'personal') {
const mensualizadosAll = parsed.filter((r) => r.tipo === 'mensualizado');
const eventualesAll = parsed.filter((r) => r.tipo === 'eventual');
const { mensualizados, eventuales } = filterPersonalByGestion(
mensualizadosAll,
eventualesAll,
gestion
);
const mensualizadosAnn = mensualizados.map((r) => ({
...r,
bloqueada: esHistorial || (!isAdmin && !mensualBelongsToGestion(r, getGestionActual()) && r.gestionId),
}));
const eventualesAnn = eventuales.map((r) => ({
...r,
bloqueada: esHistorial || (!isAdmin && isFechaEnPeriodoCerrado(r.fecha)),
}));
return {
mensualizados: mensualizadosAnn,
eventuales: eventualesAnn,
totals: { totalPersonal: sumPersonal(mensualizados, eventuales) },
gestion: gestionState,
gestionVista: gestion,
esHistorial,
soloLectura: esHistorial,
};
}
return { rows: parsed, gestion: gestionState, gestionVista: gestion, esHistorial };
}
router.get('/:sheetType', (req, res) => {
const { sheetType } = req.params;
const { gestionId } = req.query;
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
return res.status(400).json({ error: 'Planilla inválida' });
}
if (!checkPermission(req, sheetType)) {
return res.status(403).json({ error: 'Sin permiso para ver esta planilla' });
}
res.json(getSheetPayload(sheetType));
const gestionView = resolveGestionView(gestionId);
if (!gestionView) return res.status(404).json({ error: 'Gestión no encontrada' });
res.json(getSheetPayload(sheetType, req.user.isAdmin, gestionView));
});
router.put('/:sheetType', (req, res) => {
const { sheetType } = req.params;
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
return res.status(400).json({ error: 'Planilla inválida' });
}
@@ -65,7 +143,30 @@ router.put('/:sheetType', (req, res) => {
return res.status(403).json({ error: 'Sin permiso para editar' });
}
const { rows, mensualizados, eventuales } = req.body;
const isAdmin = !!req.user.isAdmin;
const gestionActual = getGestionActual();
const existing = loadParsedRows(sheetType);
let rowsToSave;
let personalMensual;
let personalEventual;
try {
if (sheetType === 'personal') {
const merged = mergePersonalForSave(
existing,
req.body.mensualizados,
req.body.eventuales,
isAdmin,
gestionActual
);
personalMensual = merged.mensualizados;
personalEventual = merged.eventuales;
} else {
rowsToSave = mergeRowsForSave(existing, req.body.rows, isAdmin, gestionActual, 'fecha');
}
} catch (e) {
return res.status(403).json({ error: e.message });
}
db.prepare('DELETE FROM registros WHERE sheet_type = ?').run(sheetType);
@@ -75,21 +176,21 @@ router.put('/:sheetType', (req, res) => {
if (sheetType === 'personal') {
const all = [
...(mensualizados || []).map((r) => ({ ...r, tipo: 'mensualizado' })),
...(eventuales || []).map((r) => ({ ...r, tipo: 'eventual' })),
...(personalMensual || []).map((r) => ({ ...r, tipo: 'mensualizado' })),
...(personalEventual || []).map((r) => ({ ...r, tipo: 'eventual' })),
];
for (const row of all) {
const { id: _id, updated_at: _u, ...data } = row;
const { id: _id, updated_at: _u, bloqueada: _b, ...data } = row;
insert.run(sheetType, JSON.stringify(data), req.user.id);
}
} else {
for (const row of rows || []) {
const { id: _id, updated_at: _u, ...data } = row;
for (const row of rowsToSave || []) {
const { id: _id, updated_at: _u, bloqueada: _b, ...data } = row;
insert.run(sheetType, JSON.stringify(data), req.user.id);
}
}
res.json(getSheetPayload(sheetType));
res.json(getSheetPayload(sheetType, isAdmin, { gestion: gestionActual, esHistorial: false }));
});
export default router;

View File

@@ -0,0 +1,272 @@
import { db } from '../db.js';
const LEGACY_KEY = 'fecha_cierre_gestion';
export function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function addDays(isoDate, days) {
const d = new Date(isoDate + 'T12:00:00');
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function etiquetaFromRange(inicio, fin) {
const ref = fin || inicio;
const [y, m] = ref.split('-');
const meses = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
return `${meses[Number(m) - 1]} ${y}`;
}
function mapGestion(row) {
if (!row) return null;
return {
id: row.id,
etiqueta: row.etiqueta,
fechaInicio: row.fecha_inicio,
fechaFin: row.fecha_fin,
esActual: row.fecha_fin === null,
cerradaAt: row.cerrada_at,
};
}
export function ensureGestionesMigrated() {
const count = db.prepare('SELECT COUNT(*) AS n FROM gestiones').get().n;
if (count > 0) return;
const legacy = db.prepare('SELECT value FROM settings WHERE key = ?').get(LEGACY_KEY);
if (legacy?.value) {
const fin = legacy.value;
const inicio = `${fin.slice(0, 7)}-01`;
db.prepare(
`INSERT INTO gestiones (etiqueta, fecha_inicio, fecha_fin, cerrada_at)
VALUES (?, ?, ?, datetime('now'))`
).run(etiquetaFromRange(inicio, fin), inicio, fin);
const nuevoInicio = addDays(fin, 1);
db.prepare(
`INSERT INTO gestiones (etiqueta, fecha_inicio, fecha_fin) VALUES (?, ?, NULL)`
).run('Gestión actual', nuevoInicio);
db.prepare(`DELETE FROM settings WHERE key = ?`).run(LEGACY_KEY);
return;
}
db.prepare(
`INSERT INTO gestiones (etiqueta, fecha_inicio, fecha_fin) VALUES ('Gestión actual', '2020-01-01', NULL)`
).run();
}
export function listGestiones() {
ensureGestionesMigrated();
return db
.prepare('SELECT * FROM gestiones ORDER BY fecha_inicio DESC')
.all()
.map(mapGestion);
}
export function getGestionActual() {
ensureGestionesMigrated();
const row = db
.prepare('SELECT * FROM gestiones WHERE fecha_fin IS NULL ORDER BY id DESC LIMIT 1')
.get();
return mapGestion(row);
}
export function getGestionById(id) {
ensureGestionesMigrated();
const row = db.prepare('SELECT * FROM gestiones WHERE id = ?').get(id);
return mapGestion(row);
}
export function findGestionForFecha(fecha) {
if (!fecha || String(fecha).trim() === '') return getGestionActual();
const f = String(fecha).slice(0, 10);
const row = db
.prepare(
`SELECT * FROM gestiones
WHERE fecha_inicio <= ? AND (fecha_fin IS NULL OR fecha_fin >= ?)
ORDER BY id DESC LIMIT 1`
)
.get(f, f);
return mapGestion(row);
}
export function rowBelongsToGestion(row, gestion, fechaField = 'fecha') {
if (!gestion) return true;
const fecha = row[fechaField];
if (!fecha || String(fecha).trim() === '') {
return gestion.esActual;
}
const f = String(fecha).slice(0, 10);
if (f < gestion.fechaInicio) return false;
if (gestion.fechaFin && f > gestion.fechaFin) return false;
return true;
}
/** Personal mensualizado: por gestion_id en el registro */
export function mensualBelongsToGestion(row, gestion) {
if (!gestion) return true;
if (gestion.esActual) {
return !row.gestionId || row.gestionId === gestion.id;
}
return row.gestionId === gestion.id;
}
export function filterRowsByGestion(rows, gestion, fechaField = 'fecha') {
if (!gestion) return rows;
return rows.filter((r) => rowBelongsToGestion(r, gestion, fechaField));
}
export function filterPersonalByGestion(mensualizados, eventuales, gestion) {
if (!gestion) return { mensualizados, eventuales };
return {
mensualizados: mensualizados.filter((r) => mensualBelongsToGestion(r, gestion)),
eventuales: eventuales.filter((r) => rowBelongsToGestion(r, gestion, 'fecha')),
};
}
export function isFechaEnPeriodoCerrado(fecha) {
const g = findGestionForFecha(fecha);
return g ? !g.esActual : false;
}
export function isCierreActivo() {
return db.prepare('SELECT COUNT(*) AS n FROM gestiones WHERE fecha_fin IS NOT NULL').get().n > 0
|| !!getGestionActual()?.fechaInicio;
}
export function getGestionStatus() {
ensureGestionesMigrated();
const actual = getGestionActual();
const historial = listGestiones().filter((g) => !g.esActual);
const ultimaCerrada = historial[0];
return {
gestionActual: actual,
historial,
fechaCierreGestion: ultimaCerrada?.fechaFin || null,
gestionCerrada: historial.length > 0,
hoy: todayStr(),
};
}
export function cerrarGestionActual(fechaFin, userId) {
ensureGestionesMigrated();
const actual = db
.prepare('SELECT * FROM gestiones WHERE fecha_fin IS NULL ORDER BY id DESC LIMIT 1')
.get();
if (!actual) throw new Error('No hay gestión actual abierta');
if (fechaFin < actual.fecha_inicio) {
throw new Error('La fecha de cierre no puede ser anterior al inicio de la gestión actual');
}
const etiqueta = etiquetaFromRange(actual.fecha_inicio, fechaFin);
db.prepare(
`UPDATE gestiones SET etiqueta = ?, fecha_fin = ?, cerrada_at = datetime('now'), cerrada_por = ?
WHERE id = ?`
).run(etiqueta, fechaFin, userId, actual.id);
const personalRows = db.prepare("SELECT id, data FROM registros WHERE sheet_type = 'personal'").all();
for (const r of personalRows) {
const data = JSON.parse(r.data);
if (data.tipo === 'mensualizado' && !data.gestionId) {
data.gestionId = actual.id;
db.prepare('UPDATE registros SET data = ? WHERE id = ?').run(JSON.stringify(data), r.id);
}
}
const nuevoInicio = addDays(fechaFin, 1);
const result = db
.prepare(
`INSERT INTO gestiones (etiqueta, fecha_inicio, fecha_fin) VALUES ('Gestión actual', ?, NULL)`
)
.run(nuevoInicio);
db.prepare(`DELETE FROM settings WHERE key = ?`).run(LEGACY_KEY);
return {
cerrada: mapGestion({ ...actual, etiqueta, fecha_fin: fechaFin }),
nueva: mapGestion({
id: result.lastInsertRowid,
etiqueta: 'Gestión actual',
fecha_inicio: nuevoInicio,
fecha_fin: null,
cerrada_at: null,
cerrada_por: null,
}),
};
}
function stripMeta(row) {
const { id, updated_at, bloqueada, gestionId: _g, ...rest } = row;
return rest;
}
export function mergeRowsForSave(existing, incoming, isAdmin, gestionActual, fechaField = 'fecha') {
const gid = gestionActual?.id;
const historial = existing.filter((r) => {
const f = r[fechaField];
if (!f || String(f).trim() === '') return false;
return !rowBelongsToGestion(r, gestionActual, fechaField);
});
let actuales;
if (isAdmin) {
actuales = (incoming || []).map((r) => ({ ...stripMeta(r), gestionId: gid }));
} else {
const locked = existing.filter(
(r) => rowBelongsToGestion(r, gestionActual, fechaField) && isFechaEnPeriodoCerrado(r[fechaField])
);
const unlockedIncoming = (incoming || []).filter((r) => !isFechaEnPeriodoCerrado(r[fechaField]));
actuales = [
...locked.map(stripMeta),
...unlockedIncoming.map((r) => ({ ...stripMeta(r), gestionId: gid })),
];
}
return [...historial.map(stripMeta), ...actuales];
}
export function mergePersonalForSave(existing, incomingMensual, incomingEventual, isAdmin, gestionActual) {
const gid = gestionActual?.id;
const existingM = existing.filter((r) => r.tipo === 'mensualizado');
const existingE = existing.filter((r) => r.tipo === 'eventual');
const historialM = existingM.filter((r) => r.gestionId && r.gestionId !== gid);
const historialE = existingE.filter((r) => !rowBelongsToGestion(r, gestionActual, 'fecha'));
let actualesM;
let actualesE;
if (isAdmin) {
actualesM = (incomingMensual || []).map((r) => ({ ...stripMeta(r), gestionId: r.gestionId || gid }));
actualesE = (incomingEventual || []).map((r) => ({ ...stripMeta(r), gestionId: gid }));
} else {
const unlockedM = (incomingMensual || []).filter((r) => !r.gestionId || r.gestionId === gid);
const lockedE = existingE.filter(
(r) => rowBelongsToGestion(r, gestionActual, 'fecha') && isFechaEnPeriodoCerrado(r.fecha)
);
const unlockedE = (incomingEventual || []).filter((r) => !isFechaEnPeriodoCerrado(r.fecha));
actualesM = unlockedM.map((r) => ({ ...stripMeta(r), gestionId: gid }));
actualesE = [
...lockedE.map(stripMeta),
...unlockedE.map((r) => ({ ...stripMeta(r), gestionId: gid })),
];
}
return {
mensualizados: [...historialM.map(stripMeta), ...actualesM],
eventuales: [...historialE.map(stripMeta), ...actualesE],
};
}
/** Etiqueta sugerida: último día del mes actual */
export function sugerirCierreMensual() {
const today = new Date();
const last = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return last.toISOString().slice(0, 10);
}