bc4e232a80
- Admin puede bloquear/desbloquear días (rounds.blocked) desde la vista de ocupación; los trabajadores no pueden elegirlos (🔒). - El admin puede asignar cualquier día aunque supere el límite por día/cargo o esté bloqueado (override explícito en requests/set). - Editar el nombre de la ronda con edición inline en el calendario (PUT /api/admin/rounds/:id/name). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
622 lines
24 KiB
JavaScript
622 lines
24 KiB
JavaScript
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,
|
||
limits 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,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
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);
|
||
`);
|
||
|
||
// Migraciones para bases de datos creadas antes de algunas columnas.
|
||
const hasColumn = (table, col) =>
|
||
db.prepare(`PRAGMA table_info('${table}')`).all().some((c) => c.name === col);
|
||
|
||
// Cargos configurables por ronda.
|
||
if (!hasColumn('rounds', 'roles')) db.exec('ALTER TABLE rounds ADD COLUMN roles TEXT');
|
||
// Límite de solicitudes por día y cargo (JSON {clave: máximo}).
|
||
if (!hasColumn('rounds', 'limits')) db.exec('ALTER TABLE rounds ADD COLUMN limits TEXT');
|
||
// Días bloqueados por el admin para toda la ronda (JSON array de fechas).
|
||
if (!hasColumn('rounds', 'blocked')) db.exec('ALTER TABLE rounds ADD COLUMN blocked TEXT');
|
||
// Estado de aprobación de cada solicitud: pending | approved | rejected.
|
||
if (!hasColumn('requests', 'status')) {
|
||
db.exec("ALTER TABLE requests ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'");
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Límite de solicitudes por día y cargo (mapa clave → entero). Una clave
|
||
// ausente significa "sin límite". Solo se devuelven límites de cargos vigentes.
|
||
function roundLimits(round) {
|
||
const roles = roundRoles(round);
|
||
const out = {};
|
||
if (round.limits) {
|
||
try {
|
||
const parsed = JSON.parse(round.limits);
|
||
if (parsed && typeof parsed === 'object') {
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
const n = Number(v);
|
||
if (roles[k] && Number.isInteger(n) && n > 0) out[k] = n;
|
||
}
|
||
}
|
||
} catch { /* sin límites */ }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Días bloqueados por el admin (no elegibles por ningún trabajador). Devuelve
|
||
// una lista de fechas válidas del año de la ronda, sin duplicados y ordenadas.
|
||
function roundBlocked(round) {
|
||
if (!round.blocked) return [];
|
||
try {
|
||
const parsed = JSON.parse(round.blocked);
|
||
if (Array.isArray(parsed)) {
|
||
return [...new Set(parsed.map(String).filter((d) => isValidDate(d, round.year)))].sort();
|
||
}
|
||
} catch { /* sin bloqueos */ }
|
||
return [];
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Interpreta los cargos que llegan al crear una ronda. Acepta una lista de
|
||
// etiquetas ("Camarero, Cocina") o de objetos {label, max} con el límite por
|
||
// día. Devuelve {roles: {clave: etiqueta}, limits: {clave: máximo}}.
|
||
function parseRolesInput(input) {
|
||
const arr = Array.isArray(input) ? input : String(input).split(',');
|
||
const items = arr
|
||
.map((x) =>
|
||
x && typeof x === 'object'
|
||
? { label: String(x.label ?? '').trim(), max: x.max }
|
||
: { label: String(x).trim(), max: null }
|
||
)
|
||
.filter((it) => it.label);
|
||
if (items.length < 1 || items.length > 8) throw new Error('Indica entre 1 y 8 cargos');
|
||
if (items.some((it) => it.label.length > 40)) throw new Error('Cada cargo puede tener como máximo 40 caracteres');
|
||
|
||
const roles = buildRoles(items.map((it) => it.label));
|
||
const keys = Object.keys(roles);
|
||
const limits = {};
|
||
items.forEach((it, i) => {
|
||
const n = Number(it.max);
|
||
if (Number.isInteger(n) && n > 0 && n <= 99) limits[keys[i]] = n;
|
||
});
|
||
return { roles, limits };
|
||
}
|
||
|
||
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) {
|
||
// Solicitudes que ocupan hueco por fecha y cargo (pendientes + aprobadas,
|
||
// nunca rechazadas), 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 != ? AND r.status != 'rejected'
|
||
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;
|
||
}
|
||
|
||
// Solicitudes de un trabajador con su estado: [{date, status}].
|
||
function workerRequests(workerId) {
|
||
return db
|
||
.prepare('SELECT date, status FROM requests WHERE worker_id = ? ORDER BY date')
|
||
.all(workerId);
|
||
}
|
||
|
||
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;
|
||
};
|
||
|
||
// Fecha de hoy (YYYY-MM-DD) en la zona del restaurante, sea cual sea la zona
|
||
// horaria del servidor. Así "pasado / hoy" se calcula igual aquí y en el móvil.
|
||
const todayISO = () =>
|
||
new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Madrid' }).format(new Date());
|
||
|
||
// ---------- 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),
|
||
limits: roundLimits(round),
|
||
blocked: roundBlocked(round),
|
||
today: todayISO(),
|
||
me: worker
|
||
? { name: worker.name, role: worker.role, requests: workerRequests(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, requests: [] } });
|
||
});
|
||
|
||
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 wanted = [...new Set(dates.map(String))];
|
||
if (!wanted.every((d) => isValidDate(d, round.year))) {
|
||
return res.status(400).json({ error: `Las fechas deben ser días válidos de ${round.year}` });
|
||
}
|
||
|
||
const existing = workerRequests(worker.id);
|
||
const locked = new Set(existing.filter((r) => r.status !== 'pending').map((r) => r.date));
|
||
const existingPending = new Set(existing.filter((r) => r.status === 'pending').map((r) => r.date));
|
||
|
||
// El trabajador solo controla sus pendientes: los días aprobados o rechazados
|
||
// se conservan tal cual aunque vengan (o no) en la selección.
|
||
const desiredPending = wanted.filter((d) => !locked.has(d));
|
||
const added = desiredPending.filter((d) => !existingPending.has(d));
|
||
|
||
// Los días nuevos no pueden ser pasados ni el día de hoy.
|
||
const today = todayISO();
|
||
if (added.some((d) => d <= today)) {
|
||
return res.status(400).json({ error: 'No puedes elegir días pasados ni el día de hoy.' });
|
||
}
|
||
|
||
// Los días nuevos no pueden estar bloqueados por la administración.
|
||
const blocked = new Set(roundBlocked(round));
|
||
if (added.some((d) => blocked.has(d))) {
|
||
return res.status(409).json({
|
||
error: 'Algún día que elegiste está bloqueado por la administración. Recarga la página.',
|
||
});
|
||
}
|
||
|
||
// Los días nuevos no pueden superar el límite de tu cargo (cuentan las
|
||
// solicitudes de los demás que no estén rechazadas).
|
||
const limit = roundLimits(round)[worker.role];
|
||
if (limit) {
|
||
const others = roundCounts(round.id, worker.id);
|
||
if (added.some((d) => (others[d]?.[worker.role] ?? 0) >= limit)) {
|
||
return res.status(409).json({
|
||
error: 'Algún día que elegiste ya se ha completado para tu cargo. Recarga la página y vuelve a intentarlo.',
|
||
});
|
||
}
|
||
}
|
||
|
||
const apply = db.transaction((keep) => {
|
||
const keepSet = new Set(keep);
|
||
const del = db.prepare("DELETE FROM requests WHERE worker_id = ? AND status = 'pending' AND date = ?");
|
||
for (const d of existingPending) if (!keepSet.has(d)) del.run(worker.id, d);
|
||
const ins = db.prepare("INSERT OR IGNORE INTO requests (worker_id, date, status) VALUES (?, ?, 'pending')");
|
||
for (const d of keep) if (!existingPending.has(d)) ins.run(worker.id, d);
|
||
});
|
||
apply(desiredPending);
|
||
|
||
res.json({
|
||
ok: true,
|
||
requests: workerRequests(worker.id),
|
||
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,
|
||
(SELECT COUNT(*) FROM requests q JOIN workers w ON w.id = q.worker_id WHERE w.round_id = r.id AND q.status = 'pending') AS pending
|
||
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;
|
||
let limits = {};
|
||
if (req.body.roles != null) {
|
||
try {
|
||
({ roles, limits } = parseRolesInput(req.body.roles));
|
||
} catch (ex) {
|
||
return res.status(400).json({ error: ex.message });
|
||
}
|
||
}
|
||
|
||
const token = newToken();
|
||
const info = db
|
||
.prepare('INSERT INTO rounds (token, name, year, roles, limits) VALUES (?, ?, ?, ?, ?)')
|
||
.run(token, name, year, JSON.stringify(roles), JSON.stringify(limits));
|
||
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 });
|
||
});
|
||
|
||
// Editar los límites por día y cargo de una ronda (ampliar o reducir).
|
||
app.put('/api/admin/rounds/:id/limits', requireAdmin, (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' });
|
||
if (req.body.limits == null || typeof req.body.limits !== 'object') {
|
||
return res.status(400).json({ error: 'Límites no válidos' });
|
||
}
|
||
const limits = {};
|
||
for (const key of Object.keys(roundRoles(round))) {
|
||
const n = Number(req.body.limits[key]);
|
||
if (Number.isInteger(n) && n > 0 && n <= 99) limits[key] = n;
|
||
}
|
||
db.prepare('UPDATE rounds SET limits = ? WHERE id = ?').run(JSON.stringify(limits), round.id);
|
||
res.json({ ok: true, limits });
|
||
});
|
||
|
||
// Editar el nombre de una ronda (igual que se editan los límites).
|
||
app.put('/api/admin/rounds/:id/name', requireAdmin, (req, res) => {
|
||
const name = String(req.body.name ?? '').trim();
|
||
if (!name || name.length > 80) return res.status(400).json({ error: 'Indica el nombre del local' });
|
||
const info = db.prepare('UPDATE rounds SET name = ? WHERE id = ?').run(name, req.params.id);
|
||
if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' });
|
||
res.json({ ok: true, name });
|
||
});
|
||
|
||
// Bloquear / desbloquear días de una ronda. Los días bloqueados no son elegibles
|
||
// por ningún trabajador, pero el admin sí puede asignarlos manualmente. Recibe la
|
||
// lista completa de días bloqueados y la guarda (reemplazo total).
|
||
app.put('/api/admin/rounds/:id/blocked', requireAdmin, (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 dates = req.body.dates;
|
||
if (!Array.isArray(dates) || dates.length > 366) {
|
||
return res.status(400).json({ error: 'Días no válidos' });
|
||
}
|
||
const blocked = [...new Set(dates.map(String))].filter((d) => isValidDate(d, round.year)).sort();
|
||
db.prepare('UPDATE rounds SET blocked = ? WHERE id = ?').run(JSON.stringify(blocked), round.id);
|
||
res.json({ ok: true, blocked });
|
||
});
|
||
|
||
// Vista de calendario del administrador: empleados con sus solicitudes (y estado),
|
||
// más los cargos y límites de la ronda.
|
||
app.get('/api/admin/rounds/:id/calendar', requireAdmin, (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 workers = db
|
||
.prepare('SELECT id, name, role FROM workers WHERE round_id = ? ORDER BY role, name COLLATE NOCASE')
|
||
.all(round.id)
|
||
.map((w) => ({ ...w, requests: workerRequests(w.id) }));
|
||
res.json({
|
||
round: { id: round.id, name: round.name, year: round.year, status: round.status },
|
||
roles: roundRoles(round),
|
||
limits: roundLimits(round),
|
||
blocked: roundBlocked(round),
|
||
today: todayISO(),
|
||
workers,
|
||
});
|
||
});
|
||
|
||
// Aprobar / rechazar / volver a pendiente / añadir / quitar días de un empleado.
|
||
// El admin manda: no se aplica el límite por día/cargo ni el bloqueo de días,
|
||
// así que puede asignar cualquier día aunque esté completo o bloqueado.
|
||
app.post('/api/admin/rounds/:id/requests/set', requireAdmin, (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 worker = db
|
||
.prepare('SELECT * FROM workers WHERE id = ? AND round_id = ?')
|
||
.get(req.body.worker_id, round.id);
|
||
if (!worker) return res.status(404).json({ error: 'Empleado no encontrado' });
|
||
|
||
const status = req.body.status;
|
||
if (!['approved', 'rejected', 'pending', 'none'].includes(status)) {
|
||
return res.status(400).json({ error: 'Estado no válido' });
|
||
}
|
||
const dates = req.body.dates;
|
||
if (!Array.isArray(dates) || !dates.length || dates.length > 366) {
|
||
return res.status(400).json({ error: 'Fechas no válidas' });
|
||
}
|
||
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 apply = db.transaction((ds) => {
|
||
if (status === 'none') {
|
||
const del = db.prepare('DELETE FROM requests WHERE worker_id = ? AND date = ?');
|
||
for (const d of ds) del.run(worker.id, d);
|
||
} else {
|
||
const up = db.prepare(
|
||
`INSERT INTO requests (worker_id, date, status) VALUES (?, ?, ?)
|
||
ON CONFLICT(worker_id, date) DO UPDATE SET status = excluded.status`
|
||
);
|
||
for (const d of ds) up.run(worker.id, d, status);
|
||
}
|
||
});
|
||
apply(unique);
|
||
|
||
res.json({ ok: true, requests: workerRequests(worker.id) });
|
||
});
|
||
|
||
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 limits = roundLimits(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 días separados por estado.
|
||
const ws = wb.addWorksheet('Por persona');
|
||
ws.columns = [
|
||
{ header: 'Nombre', key: 'name', width: 28 },
|
||
{ header: 'Cargo', key: 'role', width: 16 },
|
||
{ header: 'Aprobados', key: 'na', width: 11 },
|
||
{ header: 'Pendientes', key: 'np', width: 11 },
|
||
{ header: 'Rechazados', key: 'nr', width: 11 },
|
||
{ header: 'Días aprobados', key: 'da', width: 60 },
|
||
{ header: 'Días pendientes', key: 'dp', width: 60 },
|
||
{ header: 'Días rechazados', key: 'dr', width: 40 },
|
||
];
|
||
ws.getRow(1).font = { bold: true };
|
||
for (const w of workers) {
|
||
const reqs = workerRequests(w.id);
|
||
const by = { approved: [], pending: [], rejected: [] };
|
||
for (const r of reqs) (by[r.status] ??= []).push(r.date);
|
||
ws.addRow({
|
||
name: w.name,
|
||
role: roles[w.role] ?? w.role,
|
||
na: by.approved.length,
|
||
np: by.pending.length,
|
||
nr: by.rejected.length,
|
||
da: by.approved.join(', '),
|
||
dp: by.pending.join(', '),
|
||
dr: by.rejected.join(', '),
|
||
});
|
||
}
|
||
|
||
// Hoja 2: ocupación por día y cargo (solicitudes no rechazadas, las que
|
||
// cuentan para el límite).
|
||
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 = ? AND q.status != 'rejected'
|
||
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: limits[k] ? `${roles[k]} (máx ${limits[k]})` : 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}`));
|