first commit
This commit is contained in:
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV PORT=4000
|
||||
ENV DB_PATH=/app/data/donmarco.db
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
1392
backend/package-lock.json
generated
Normal file
1392
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
backend/package.json
Normal file
19
backend/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "donmarco-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "API administración financiera Don Marco",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"seed": "node src/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
}
|
||||
56
backend/src/db.js
Normal file
56
backend/src/db.js
Normal 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
42
backend/src/index.js
Normal 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}`);
|
||||
});
|
||||
31
backend/src/middleware/auth.js
Normal file
31
backend/src/middleware/auth.js
Normal 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();
|
||||
}
|
||||
52
backend/src/routes/auth.js
Normal file
52
backend/src/routes/auth.js
Normal 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;
|
||||
169
backend/src/routes/reports.js
Normal file
169
backend/src/routes/reports.js
Normal 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;
|
||||
95
backend/src/routes/sheets.js
Normal file
95
backend/src/routes/sheets.js
Normal 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;
|
||||
96
backend/src/routes/users.js
Normal file
96
backend/src/routes/users.js
Normal 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
25
backend/src/seed.js
Normal 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)');
|
||||
69
backend/src/utils/calculations.js
Normal file
69
backend/src/utils/calculations.js
Normal 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')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user