Vacaciones: app de peticiones de vacaciones para restaurante
Cargos configurables por ronda (por defecto Camarero/a, Encargado, Cocina), calendario con días recuadrados, recuento de compañeros del mismo cargo por día y minileyenda por mes. Panel de administración con login, rondas y exportación a Excel. Node + Express + SQLite, listo para Docker/Coolify. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const Database = require('better-sqlite3');
|
||||
const ExcelJS = require('exceljs');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
|
||||
|
||||
if (!process.env.ADMIN_PASSWORD) {
|
||||
console.warn('⚠ ADMIN_PASSWORD no está definida. Usando la contraseña por defecto "admin". Defínela en producción.');
|
||||
}
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const db = new Database(path.join(DATA_DIR, 'vacaciones.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS rounds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
roles TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS workers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
round_id INTEGER NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
secret TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
PRIMARY KEY (worker_id, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workers_round ON workers(round_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_date ON requests(date);
|
||||
`);
|
||||
|
||||
// Migración para bases de datos creadas antes de los cargos configurables.
|
||||
if (!db.prepare("PRAGMA table_info('rounds')").all().some((c) => c.name === 'roles')) {
|
||||
db.exec('ALTER TABLE rounds ADD COLUMN roles TEXT');
|
||||
}
|
||||
|
||||
// Cargos por defecto que se proponen al crear una ronda nueva.
|
||||
const DEFAULT_ROLES = {
|
||||
camarero: 'Camarero/a',
|
||||
encargado: 'Encargado',
|
||||
cocina: 'Cocina',
|
||||
};
|
||||
|
||||
// Cargos de las rondas antiguas que no tienen cargos guardados (la clave
|
||||
// histórica "jefe_rango" se conserva para no romper sus peticiones, pero se
|
||||
// muestra ya como "Encargado").
|
||||
const LEGACY_ROLES = {
|
||||
camarero: 'Camarero/a',
|
||||
jefe_rango: 'Encargado',
|
||||
cocina: 'Cocina',
|
||||
};
|
||||
|
||||
// Cargos de una ronda concreta (mapa clave → etiqueta).
|
||||
function roundRoles(round) {
|
||||
if (round.roles) {
|
||||
try {
|
||||
const parsed = JSON.parse(round.roles);
|
||||
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length) return parsed;
|
||||
} catch { /* cae al valor por defecto */ }
|
||||
}
|
||||
return LEGACY_ROLES;
|
||||
}
|
||||
|
||||
// Convierte una etiqueta ("Camarero/a") en una clave estable ("camarero_a").
|
||||
const slugify = (s) =>
|
||||
String(s)
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
|
||||
// A partir de una lista de etiquetas crea el mapa {clave: etiqueta} con claves únicas.
|
||||
function buildRoles(labels) {
|
||||
const out = {};
|
||||
labels.forEach((label, i) => {
|
||||
const base = slugify(label) || `rol_${i + 1}`;
|
||||
let key = base;
|
||||
let n = 2;
|
||||
while (out[key]) key = `${base}_${n++}`;
|
||||
out[key] = label;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
const newToken = (bytes = 9) => crypto.randomBytes(bytes).toString('base64url');
|
||||
|
||||
const getRound = db.prepare('SELECT * FROM rounds WHERE token = ?');
|
||||
|
||||
function getWorkerFromCookie(req, round) {
|
||||
const secret = req.cookies[`vac_${round.token}`];
|
||||
if (!secret) return null;
|
||||
return db
|
||||
.prepare('SELECT * FROM workers WHERE secret = ? AND round_id = ?')
|
||||
.get(secret, round.id) || null;
|
||||
}
|
||||
|
||||
function roundCounts(roundId, excludeWorkerId) {
|
||||
// Recuento por fecha y cargo, excluyendo al propio trabajador.
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT r.date, w.role, COUNT(*) AS n
|
||||
FROM requests r JOIN workers w ON w.id = r.worker_id
|
||||
WHERE w.round_id = ? AND w.id != ?
|
||||
GROUP BY r.date, w.role`
|
||||
)
|
||||
.all(roundId, excludeWorkerId ?? -1);
|
||||
const counts = {};
|
||||
for (const row of rows) {
|
||||
(counts[row.date] ??= {})[row.role] = row.n;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function workerDates(workerId) {
|
||||
return db
|
||||
.prepare('SELECT date FROM requests WHERE worker_id = ? ORDER BY date')
|
||||
.all(workerId)
|
||||
.map((r) => r.date);
|
||||
}
|
||||
|
||||
const isValidDate = (s, year) => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
|
||||
const d = new Date(s + 'T00:00:00Z');
|
||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s && d.getUTCFullYear() === year;
|
||||
};
|
||||
|
||||
// ---------- sesiones de administración (en memoria) ----------
|
||||
|
||||
const adminSessions = new Set();
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
const sid = req.cookies.admin_session;
|
||||
if (sid && adminSessions.has(sid)) return next();
|
||||
res.status(401).json({ error: 'No autorizado' });
|
||||
}
|
||||
|
||||
// Comparación en tiempo constante para no filtrar la contraseña.
|
||||
function passwordOk(given) {
|
||||
const a = crypto.createHash('sha256').update(String(given ?? '')).digest();
|
||||
const b = crypto.createHash('sha256').update(ADMIN_PASSWORD).digest();
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// ---------- páginas ----------
|
||||
|
||||
app.get('/', (_req, res) => res.redirect('/admin'));
|
||||
app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
|
||||
app.get('/r/:token', (req, res) => {
|
||||
if (!getRound.get(req.params.token)) {
|
||||
return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
|
||||
}
|
||||
res.sendFile(path.join(__dirname, 'public', 'round.html'));
|
||||
});
|
||||
|
||||
// ---------- API trabajadores ----------
|
||||
|
||||
app.get('/api/round/:token', (req, res) => {
|
||||
const round = getRound.get(req.params.token);
|
||||
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
|
||||
const worker = getWorkerFromCookie(req, round);
|
||||
res.json({
|
||||
round: { name: round.name, year: round.year, status: round.status },
|
||||
roles: roundRoles(round),
|
||||
me: worker
|
||||
? { name: worker.name, role: worker.role, dates: workerDates(worker.id) }
|
||||
: null,
|
||||
counts: roundCounts(round.id, worker?.id),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/round/:token/join', (req, res) => {
|
||||
const round = getRound.get(req.params.token);
|
||||
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
|
||||
if (round.status !== 'open') return res.status(403).json({ error: 'La ronda está cerrada' });
|
||||
if (getWorkerFromCookie(req, round)) return res.status(409).json({ error: 'Ya estás registrado en esta ronda' });
|
||||
|
||||
const name = String(req.body.name ?? '').trim();
|
||||
const role = String(req.body.role ?? '');
|
||||
if (name.length < 2 || name.length > 60) return res.status(400).json({ error: 'Escribe tu nombre (2–60 caracteres)' });
|
||||
if (!roundRoles(round)[role]) return res.status(400).json({ error: 'Elige un cargo válido' });
|
||||
|
||||
const secret = newToken(24);
|
||||
db.prepare('INSERT INTO workers (round_id, name, role, secret) VALUES (?, ?, ?, ?)').run(
|
||||
round.id, name, role, secret
|
||||
);
|
||||
res.cookie(`vac_${round.token}`, secret, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 400 * 24 * 3600 * 1000,
|
||||
path: '/',
|
||||
});
|
||||
res.json({ ok: true, me: { name, role, dates: [] } });
|
||||
});
|
||||
|
||||
app.put('/api/round/:token/requests', (req, res) => {
|
||||
const round = getRound.get(req.params.token);
|
||||
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
|
||||
if (round.status !== 'open') return res.status(403).json({ error: 'La ronda está cerrada, ya no se puede modificar' });
|
||||
const worker = getWorkerFromCookie(req, round);
|
||||
if (!worker) return res.status(401).json({ error: 'No estás registrado en esta ronda' });
|
||||
|
||||
const dates = req.body.dates;
|
||||
if (!Array.isArray(dates) || dates.length > 366) return res.status(400).json({ error: 'Petición no válida' });
|
||||
const unique = [...new Set(dates.map(String))];
|
||||
if (!unique.every((d) => isValidDate(d, round.year))) {
|
||||
return res.status(400).json({ error: `Las fechas deben ser días válidos de ${round.year}` });
|
||||
}
|
||||
|
||||
const replace = db.transaction((ds) => {
|
||||
db.prepare('DELETE FROM requests WHERE worker_id = ?').run(worker.id);
|
||||
const ins = db.prepare('INSERT INTO requests (worker_id, date) VALUES (?, ?)');
|
||||
for (const d of ds) ins.run(worker.id, d);
|
||||
});
|
||||
replace(unique);
|
||||
res.json({ ok: true, dates: unique.sort(), counts: roundCounts(round.id, worker.id) });
|
||||
});
|
||||
|
||||
// ---------- API administración ----------
|
||||
|
||||
app.post('/api/admin/login', (req, res) => {
|
||||
if (!passwordOk(req.body.password)) {
|
||||
return res.status(401).json({ error: 'Contraseña incorrecta' });
|
||||
}
|
||||
const sid = newToken(24);
|
||||
adminSessions.add(sid);
|
||||
res.cookie('admin_session', sid, { httpOnly: true, sameSite: 'lax', maxAge: 30 * 24 * 3600 * 1000 });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/logout', requireAdmin, (req, res) => {
|
||||
adminSessions.delete(req.cookies.admin_session);
|
||||
res.clearCookie('admin_session');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/me', requireAdmin, (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.get('/api/admin/rounds', requireAdmin, (_req, res) => {
|
||||
const rounds = db
|
||||
.prepare(
|
||||
`SELECT r.*,
|
||||
(SELECT COUNT(*) FROM workers w WHERE w.round_id = r.id) AS workers,
|
||||
(SELECT COUNT(*) FROM requests q JOIN workers w ON w.id = q.worker_id WHERE w.round_id = r.id) AS days
|
||||
FROM rounds r ORDER BY r.created_at DESC`
|
||||
)
|
||||
.all();
|
||||
res.json({ rounds });
|
||||
});
|
||||
|
||||
app.post('/api/admin/rounds', requireAdmin, (req, res) => {
|
||||
const name = String(req.body.name ?? '').trim();
|
||||
const year = Number(req.body.year);
|
||||
if (!name || name.length > 80) return res.status(400).json({ error: 'Indica el nombre del local' });
|
||||
if (!Number.isInteger(year) || year < 2020 || year > 2100) return res.status(400).json({ error: 'Año no válido' });
|
||||
|
||||
let roles = DEFAULT_ROLES;
|
||||
if (req.body.roles != null) {
|
||||
const labels = (Array.isArray(req.body.roles) ? req.body.roles : String(req.body.roles).split(','))
|
||||
.map((s) => String(s).trim())
|
||||
.filter(Boolean);
|
||||
if (labels.length < 1 || labels.length > 8) return res.status(400).json({ error: 'Indica entre 1 y 8 cargos' });
|
||||
if (labels.some((l) => l.length > 40)) return res.status(400).json({ error: 'Cada cargo puede tener como máximo 40 caracteres' });
|
||||
roles = buildRoles(labels);
|
||||
}
|
||||
|
||||
const token = newToken();
|
||||
const info = db
|
||||
.prepare('INSERT INTO rounds (token, name, year, roles) VALUES (?, ?, ?, ?)')
|
||||
.run(token, name, year, JSON.stringify(roles));
|
||||
res.json({ ok: true, round: { id: info.lastInsertRowid, token, name, year, status: 'open' } });
|
||||
});
|
||||
|
||||
app.post('/api/admin/rounds/:id/status', requireAdmin, (req, res) => {
|
||||
const status = req.body.status === 'open' ? 'open' : 'closed';
|
||||
const info = db.prepare('UPDATE rounds SET status = ? WHERE id = ?').run(status, req.params.id);
|
||||
if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' });
|
||||
res.json({ ok: true, status });
|
||||
});
|
||||
|
||||
app.delete('/api/admin/rounds/:id', requireAdmin, (req, res) => {
|
||||
const info = db.prepare('DELETE FROM rounds WHERE id = ?').run(req.params.id);
|
||||
if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/rounds/:id/excel', requireAdmin, async (req, res) => {
|
||||
const round = db.prepare('SELECT * FROM rounds WHERE id = ?').get(req.params.id);
|
||||
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
|
||||
|
||||
const roles = roundRoles(round);
|
||||
|
||||
const workers = db
|
||||
.prepare('SELECT * FROM workers WHERE round_id = ? ORDER BY role, name COLLATE NOCASE')
|
||||
.all(round.id);
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'Vacaciones';
|
||||
|
||||
// Hoja 1: una fila por persona con sus fechas
|
||||
const ws = wb.addWorksheet('Peticiones');
|
||||
ws.columns = [
|
||||
{ header: 'Nombre', key: 'name', width: 28 },
|
||||
{ header: 'Cargo', key: 'role', width: 16 },
|
||||
{ header: 'Nº días', key: 'n', width: 9 },
|
||||
{ header: 'Fechas pedidas', key: 'dates', width: 90 },
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
for (const w of workers) {
|
||||
const dates = workerDates(w.id);
|
||||
ws.addRow({ name: w.name, role: roles[w.role] ?? w.role, n: dates.length, dates: dates.join(', ') });
|
||||
}
|
||||
|
||||
// Hoja 2: recuento por día y cargo
|
||||
const byDay = db
|
||||
.prepare(
|
||||
`SELECT q.date, w.role, COUNT(*) AS n
|
||||
FROM requests q JOIN workers w ON w.id = q.worker_id
|
||||
WHERE w.round_id = ? GROUP BY q.date, w.role ORDER BY q.date`
|
||||
)
|
||||
.all(round.id);
|
||||
const dayMap = new Map();
|
||||
for (const row of byDay) {
|
||||
if (!dayMap.has(row.date)) dayMap.set(row.date, {});
|
||||
dayMap.get(row.date)[row.role] = row.n;
|
||||
}
|
||||
const roleKeys = Object.keys(roles);
|
||||
const ws2 = wb.addWorksheet('Por día');
|
||||
ws2.columns = [
|
||||
{ header: 'Fecha', key: 'date', width: 14 },
|
||||
...roleKeys.map((k) => ({ header: roles[k], key: k, width: 16 })),
|
||||
{ header: 'Total', key: 'total', width: 9 },
|
||||
];
|
||||
ws2.getRow(1).font = { bold: true };
|
||||
for (const [date, byRole] of dayMap) {
|
||||
const row = { date };
|
||||
let total = 0;
|
||||
for (const k of roleKeys) {
|
||||
row[k] = byRole[k] ?? 0;
|
||||
total += row[k];
|
||||
}
|
||||
row.total = total;
|
||||
ws2.addRow(row);
|
||||
}
|
||||
|
||||
const safe = round.name.replace(/[^\p{L}\p{N} _-]/gu, '').trim() || 'ronda';
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="vacaciones-${safe}-${round.year}.xlsx"`);
|
||||
await wb.xlsx.write(res);
|
||||
res.end();
|
||||
});
|
||||
|
||||
app.listen(PORT, () => console.log(`Vacaciones escuchando en http://localhost:${PORT}`));
|
||||
Reference in New Issue
Block a user