first commit

This commit is contained in:
unknown
2026-06-03 16:59:27 -04:00
commit b35cc2f54b
36 changed files with 5634 additions and 0 deletions

56
backend/src/db.js Normal file
View File

@@ -0,0 +1,56 @@
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'donmarco.db');
import fs from 'fs';
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
export const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
export const SHEETS = [
'ventas',
'servicios',
'alquileres',
'gastos_diarios',
'gastos_fijos',
'personal',
'reportes',
];
export function initDb() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
sheet_key TEXT NOT NULL,
can_view INTEGER DEFAULT 1,
can_edit INTEGER DEFAULT 0,
UNIQUE(user_id, sheet_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS registros (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_type TEXT NOT NULL,
data TEXT NOT NULL,
created_by INTEGER,
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_registros_sheet ON registros(sheet_type);
`);
}

42
backend/src/index.js Normal file
View File

@@ -0,0 +1,42 @@
import express from 'express';
import cors from 'cors';
import { initDb } from './db.js';
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 bcrypt from 'bcryptjs';
import { db, SHEETS } from './db.js';
initDb();
// Seed automático del superusuario JEFE si no existe
const jefe = db.prepare('SELECT id FROM users WHERE username = ?').get('JEFE');
if (!jefe) {
const hash = bcrypt.hashSync('MACAN', 10);
const result = db
.prepare('INSERT INTO users (username, password_hash, is_admin, active) VALUES (?, ?, 1, 1)')
.run('JEFE', hash);
const userId = result.lastInsertRowid;
const permStmt = db.prepare(
'INSERT INTO permissions (user_id, sheet_key, can_view, can_edit) VALUES (?, ?, 1, 1)'
);
for (const sheet of SHEETS) permStmt.run(userId, sheet);
console.log('Superusuario JEFE creado (contraseña: MACAN)');
}
const app = express();
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.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/sheets', sheetRoutes);
app.use('/api/reports', reportRoutes);
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,31 @@
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'donmarco-lan-secret-change-in-prod';
export function signToken(user) {
return jwt.sign(
{ id: user.id, username: user.username, isAdmin: !!user.is_admin },
JWT_SECRET,
{ expiresIn: '12h' }
);
}
export function authRequired(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No autenticado' });
}
try {
req.user = jwt.verify(header.slice(7), JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'Token inválido o expirado' });
}
}
export function adminRequired(req, res, next) {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Solo administrador' });
}
next();
}

View File

@@ -0,0 +1,52 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { db } from '../db.js';
import { signToken, authRequired } from '../middleware/auth.js';
const router = Router();
router.post('/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Usuario y contraseña requeridos' });
}
const user = db.prepare('SELECT * FROM users WHERE username = ? AND active = 1').get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Credenciales inválidas' });
}
const permissions = db
.prepare('SELECT sheet_key, can_view, can_edit FROM permissions WHERE user_id = ?')
.all(user.id);
const token = signToken(user);
res.json({
token,
user: {
id: user.id,
username: user.username,
isAdmin: !!user.is_admin,
permissions: permissions.reduce((acc, p) => {
acc[p.sheet_key] = { view: !!p.can_view, edit: !!p.can_edit };
return acc;
}, {}),
},
});
});
router.get('/me', authRequired, (req, res) => {
const user = db.prepare('SELECT id, username, is_admin, active FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.active) return res.status(401).json({ error: 'Usuario inactivo' });
const permissions = db
.prepare('SELECT sheet_key, can_view, can_edit FROM permissions WHERE user_id = ?')
.all(user.id);
res.json({
id: user.id,
username: user.username,
isAdmin: !!user.is_admin,
permissions: permissions.reduce((acc, p) => {
acc[p.sheet_key] = { view: !!p.can_view, edit: !!p.can_edit };
return acc;
}, {}),
});
});
export default router;

View File

@@ -0,0 +1,169 @@
import { Router } from 'express';
import { db } from '../db.js';
import { authRequired } from '../middleware/auth.js';
import {
inRange,
sumIngresos,
sumGastos,
sumPersonal,
weekKey,
monthKey,
parseFecha,
} from '../utils/calculations.js';
const router = Router();
router.use(authRequired);
function loadRows(sheetType) {
return db
.prepare('SELECT data FROM registros WHERE sheet_type = ?')
.all(sheetType)
.map((r) => JSON.parse(r.data));
}
function filterByDate(rows, fechaField, desde, hasta) {
return rows.filter((r) => inRange(r[fechaField], desde, hasta));
}
function buildReport(desde, hasta, include = {}) {
const inc = {
ventas: include.ventas !== false,
servicios: include.servicios !== false,
alquileres: include.alquileres !== false,
gastos_diarios: include.gastos_diarios !== false,
gastos_fijos: include.gastos_fijos !== false,
personal: include.personal !== false,
};
const ventas = inc.ventas ? filterByDate(loadRows('ventas'), 'fecha', desde, hasta) : [];
const servicios = inc.servicios ? filterByDate(loadRows('servicios'), 'fecha', desde, hasta) : [];
const alquileres = inc.alquileres ? filterByDate(loadRows('alquileres'), 'fecha', desde, hasta) : [];
const gastosDiarios = inc.gastos_diarios
? filterByDate(loadRows('gastos_diarios'), 'fecha', desde, hasta)
: [];
const gastosFijos = inc.gastos_fijos
? filterByDate(loadRows('gastos_fijos'), 'fecha', desde, hasta)
: [];
const personalRows = inc.personal ? loadRows('personal') : [];
const mensualizados = personalRows.filter((r) => r.tipo === 'mensualizado');
const eventuales = inc.personal
? filterByDate(
personalRows.filter((r) => r.tipo === 'eventual'),
'fecha',
desde,
hasta
)
: [];
const tVentas = sumIngresos(ventas);
const tServicios = sumIngresos(servicios);
const tAlquileres = sumIngresos(alquileres);
const totalIngresos =
tVentas.totalIngresos + tServicios.totalIngresos + tAlquileres.totalIngresos;
const totalUtilidad =
tVentas.totalUtilidad + tServicios.totalUtilidad + tAlquileres.totalUtilidad;
const gastosDiariosTotal = sumGastos(gastosDiarios);
const gastosFijosTotal = sumGastos(gastosFijos);
const gastosVariables = [...gastosDiarios, ...gastosFijos].filter(
(g) => g.clasificacion === 'variable'
);
const gastosVariablesTotal = sumGastos(gastosVariables);
const totalGastos = gastosDiariosTotal + gastosFijosTotal;
const totalPersonal = sumPersonal(mensualizados, eventuales);
const resultadoNeto = totalIngresos - totalGastos - totalPersonal;
return {
periodo: { desde: desde || null, hasta: hasta || null },
desglose: {
ventas: tVentas,
servicios: tServicios,
alquileres: tAlquileres,
gastosDiarios: { total: gastosDiariosTotal, filas: gastosDiarios.length },
gastosFijos: { total: gastosFijosTotal, filas: gastosFijos.length },
gastosVariables: { total: gastosVariablesTotal },
personal: { total: totalPersonal, mensualizados: mensualizados.length, eventuales: eventuales.length },
},
totales: {
totalIngresos,
totalUtilidad,
totalGastos,
totalPersonal,
resultadoNeto,
estado: resultadoNeto >= 0 ? 'utilidad' : 'perdida',
},
};
}
router.get('/', (req, res) => {
const { desde, hasta, tipo = 'mensual', include } = req.query;
let d = desde;
let h = hasta;
const today = new Date();
if (tipo === 'diario') {
const f = (h || d || today.toISOString().slice(0, 10));
d = f;
h = f;
} else if (tipo === 'semanal') {
const ref = parseFecha(h || d || today.toISOString().slice(0, 10));
const day = ref.getDay();
const diff = day === 0 ? -6 : 1 - day;
const monday = new Date(ref);
monday.setDate(ref.getDate() + diff);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
d = monday.toISOString().slice(0, 10);
h = sunday.toISOString().slice(0, 10);
} else if (tipo === 'mensual' && !d && !h) {
d = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`;
const last = new Date(today.getFullYear(), today.getMonth() + 1, 0);
h = last.toISOString().slice(0, 10);
}
let includeObj = {};
if (include) {
try {
includeObj = typeof include === 'string' ? JSON.parse(include) : include;
} catch {
includeObj = {};
}
}
const report = buildReport(d, h, includeObj);
if (tipo === 'semanal' || tipo === 'mensual') {
const gastosDiariosAll = loadRows('gastos_diarios');
const weekly = {};
for (const g of gastosDiariosAll) {
const wk = weekKey(g.fecha);
if (!inRange(g.fecha, d, h)) continue;
weekly[wk] = (weekly[wk] || 0) + (Number(g.monto) || 0);
}
report.cierreSemanal = weekly;
report.totalGastosSemanales = Object.values(weekly).reduce((a, b) => a + b, 0);
if (tipo === 'mensual') {
const monthly = {};
for (const g of gastosDiariosAll) {
const mk = monthKey(g.fecha);
if (!inRange(g.fecha, d, h)) continue;
monthly[mk] = (monthly[mk] || 0) + (Number(g.monto) || 0);
}
const fijosAll = filterByDate(loadRows('gastos_fijos'), 'fecha', d, h);
report.cierreMensual = {
gastosDiariosPorMes: monthly,
gastosFijosEnPeriodo: sumGastos(fijosAll),
totalGastosMensualesConsolidado:
Object.values(monthly).reduce((a, b) => a + b, 0) + sumGastos(fijosAll),
};
}
}
res.json(report);
});
export default router;

View File

@@ -0,0 +1,95 @@
import { Router } from 'express';
import { db, SHEETS } from '../db.js';
import { authRequired } from '../middleware/auth.js';
import { sumIngresos, sumGastos, sumPersonal } from '../utils/calculations.js';
const router = Router();
router.use(authRequired);
const INGRESO_SHEETS = ['ventas', 'servicios', 'alquileres'];
const GASTO_SHEETS = ['gastos_diarios', 'gastos_fijos'];
function checkPermission(req, sheet, needEdit = false) {
if (req.user.isAdmin) return true;
const row = db
.prepare('SELECT can_view, can_edit FROM permissions WHERE user_id = ? AND sheet_key = ?')
.get(req.user.id, sheet);
if (!row) return false;
if (needEdit) return !!row.can_edit;
return !!row.can_view;
}
function getSheetPayload(sheetType) {
const rows = 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 }));
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');
return {
mensualizados,
eventuales,
totals: { totalPersonal: sumPersonal(mensualizados, eventuales) },
};
}
return { rows: parsed };
}
router.get('/:sheetType', (req, res) => {
const { sheetType } = req.params;
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));
});
router.put('/:sheetType', (req, res) => {
const { sheetType } = req.params;
if (!SHEETS.includes(sheetType) || sheetType === 'reportes') {
return res.status(400).json({ error: 'Planilla inválida' });
}
if (!checkPermission(req, sheetType, true)) {
return res.status(403).json({ error: 'Sin permiso para editar' });
}
const { rows, mensualizados, eventuales } = req.body;
db.prepare('DELETE FROM registros WHERE sheet_type = ?').run(sheetType);
const insert = db.prepare(
'INSERT INTO registros (sheet_type, data, created_by) VALUES (?, ?, ?)'
);
if (sheetType === 'personal') {
const all = [
...(mensualizados || []).map((r) => ({ ...r, tipo: 'mensualizado' })),
...(eventuales || []).map((r) => ({ ...r, tipo: 'eventual' })),
];
for (const row of all) {
const { id: _id, updated_at: _u, ...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;
insert.run(sheetType, JSON.stringify(data), req.user.id);
}
}
res.json(getSheetPayload(sheetType));
});
export default router;

View File

@@ -0,0 +1,96 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { db, SHEETS } from '../db.js';
import { authRequired, adminRequired } from '../middleware/auth.js';
const router = Router();
router.use(authRequired, adminRequired);
router.get('/', (_req, res) => {
const users = db
.prepare('SELECT id, username, is_admin, active, created_at FROM users ORDER BY username')
.all();
const withPerms = users.map((u) => {
const permissions = db
.prepare('SELECT sheet_key, can_view, can_edit FROM permissions WHERE user_id = ?')
.all(u.id);
return {
...u,
isAdmin: !!u.is_admin,
active: !!u.active,
permissions: permissions.reduce((acc, p) => {
acc[p.sheet_key] = { view: !!p.can_view, edit: !!p.can_edit };
return acc;
}, {}),
};
});
res.json(withPerms);
});
router.post('/', (req, res) => {
const { username, password, permissions = {} } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Usuario y contraseña requeridos' });
}
const hash = bcrypt.hashSync(password, 10);
try {
const result = db
.prepare('INSERT INTO users (username, password_hash, is_admin, active) VALUES (?, ?, 0, 1)')
.run(username, hash);
const userId = result.lastInsertRowid;
const permStmt = db.prepare(
'INSERT INTO permissions (user_id, sheet_key, can_view, can_edit) VALUES (?, ?, ?, ?)'
);
for (const sheet of SHEETS) {
if (sheet === 'reportes') continue;
const p = permissions[sheet] || { view: false, edit: false };
permStmt.run(userId, sheet, p.view ? 1 : 0, p.edit ? 1 : 0);
}
permStmt.run(userId, 'reportes', 1, 0);
res.status(201).json({ id: userId, username });
} catch (e) {
if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ error: 'El usuario ya existe' });
}
throw e;
}
});
router.patch('/:id', (req, res) => {
const id = Number(req.params.id);
const { password, active, permissions } = req.body;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ error: 'Usuario no encontrado' });
if (user.is_admin && req.user.id !== id && active === 0) {
return res.status(400).json({ error: 'No se puede desactivar al administrador principal' });
}
if (password) {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(bcrypt.hashSync(password, 10), id);
}
if (active !== undefined) {
db.prepare('UPDATE users SET active = ? WHERE id = ?').run(active ? 1 : 0, id);
}
if (permissions) {
const upsert = db.prepare(`
INSERT INTO permissions (user_id, sheet_key, can_view, can_edit)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, sheet_key) DO UPDATE SET can_view = excluded.can_view, can_edit = excluded.can_edit
`);
for (const [sheet, p] of Object.entries(permissions)) {
if (!SHEETS.includes(sheet)) continue;
upsert.run(id, sheet, p.view ? 1 : 0, p.edit ? 1 : 0);
}
}
res.json({ ok: true });
});
router.delete('/:id', (req, res) => {
const id = Number(req.params.id);
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ error: 'Usuario no encontrado' });
if (user.is_admin) return res.status(400).json({ error: 'No se puede eliminar al administrador' });
db.prepare('DELETE FROM users WHERE id = ?').run(id);
res.json({ ok: true });
});
export default router;

25
backend/src/seed.js Normal file
View File

@@ -0,0 +1,25 @@
import bcrypt from 'bcryptjs';
import { db, initDb, SHEETS } from './db.js';
initDb();
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get('JEFE');
if (existing) {
console.log('Usuario JEFE ya existe.');
process.exit(0);
}
const hash = bcrypt.hashSync('MACAN', 10);
const result = db
.prepare('INSERT INTO users (username, password_hash, is_admin, active) VALUES (?, ?, 1, 1)')
.run('JEFE', hash);
const userId = result.lastInsertRowid;
const permStmt = db.prepare(
'INSERT INTO permissions (user_id, sheet_key, can_view, can_edit) VALUES (?, ?, 1, 1)'
);
for (const sheet of SHEETS) {
permStmt.run(userId, sheet);
}
console.log('Superusuario JEFE creado (contraseña: MACAN)');

View File

@@ -0,0 +1,69 @@
/** Cálculos compartidos para ingresos, egresos y reportes */
export function calcIngresoRow(row) {
const cantidad = Number(row.cantidad) || 0;
const precioUnitario = Number(row.precioUnitario) || 0;
const costoUnitario = Number(row.costoUnitario) || 0;
const precioTotal = cantidad * precioUnitario;
const utilidad = precioTotal - cantidad * costoUnitario;
return { precioTotal, utilidad };
}
export function sumIngresos(rows) {
let totalIngresos = 0;
let totalUtilidad = 0;
for (const row of rows) {
const { precioTotal, utilidad } = calcIngresoRow(row);
totalIngresos += precioTotal;
totalUtilidad += utilidad;
}
return { totalIngresos, totalUtilidad };
}
export function sumGastos(rows) {
return rows.reduce((acc, r) => acc + (Number(r.monto) || 0), 0);
}
export function sumPersonal(mensualizados = [], eventuales = []) {
const mensual = mensualizados.reduce((acc, e) => {
const base = Number(e.sueldoBase) || 0;
const cargas = Number(e.cargasSociales) || 0;
const bonos = Number(e.bonos) || 0;
return acc + base + cargas + bonos;
}, 0);
const event = eventuales.reduce((acc, e) => acc + (Number(e.pagoDiario) || 0), 0);
return mensual + event;
}
export function parseFecha(fecha) {
if (!fecha) return null;
const d = new Date(fecha + 'T12:00:00');
return isNaN(d.getTime()) ? null : d;
}
export function inRange(fecha, desde, hasta) {
const d = parseFecha(fecha);
if (!d) return !desde && !hasta;
const ds = desde ? parseFecha(desde) : null;
const hs = hasta ? parseFecha(hasta) : null;
if (ds && d < ds) return false;
if (hs && d > hs) return false;
return true;
}
/** Lunes de la semana ISO (lunes como inicio) */
export function weekKey(fecha) {
const d = parseFecha(fecha);
if (!d) return 'sin-fecha';
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setDate(d.getDate() + diff);
return monday.toISOString().slice(0, 10);
}
export function monthKey(fecha) {
const d = parseFecha(fecha);
if (!d) return 'sin-fecha';
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}