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:
2026-06-11 16:45:36 +02:00
commit 15fc6dac3b
13 changed files with 3584 additions and 0 deletions
+378
View File
@@ -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 (260 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}`));