Límites por día/cargo, aprobación de días y calendario de admin

- Límite de solicitudes por día y cargo (modelo reserva: pendientes + aprobadas
  ocupan hueco, rechazar lo libera). Días completos, pasados o el de hoy no se
  pueden elegir; validado también en el servidor.
- Estados de aprobación en las peticiones (pendiente/aprobado/rechazado) visibles
  para el trabajador con color; solo puede editar pendientes o añadir nuevas.
- Calendario de admin con barra lateral por empleado: aprobar/rechazar por día y
  en bloque, añadir/quitar días, y editar los límites por cargo.
- Excel con días por estado y ocupación por día/cargo.
- Migraciones automáticas no destructivas (columnas rounds.limits y requests.status;
  las peticiones existentes pasan a "pendiente").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 12:15:31 +02:00
parent 15fc6dac3b
commit 9c9d2d25f9
7 changed files with 1076 additions and 130 deletions
+229 -37
View File
@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS rounds (
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 (
@@ -40,15 +41,24 @@ CREATE TABLE IF NOT EXISTS workers (
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);
`);
// 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');
// 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');
// 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.
@@ -78,6 +88,25 @@ function roundRoles(round) {
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;
}
// Convierte una etiqueta ("Camarero/a") en una clave estable ("camarero_a").
const slugify = (s) =>
String(s)
@@ -100,6 +129,31 @@ function buildRoles(labels) {
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());
@@ -121,12 +175,13 @@ function getWorkerFromCookie(req, round) {
}
function roundCounts(roundId, excludeWorkerId) {
// Recuento por fecha y cargo, excluyendo al propio trabajador.
// 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 != ?
WHERE w.round_id = ? AND w.id != ? AND r.status != 'rejected'
GROUP BY r.date, w.role`
)
.all(roundId, excludeWorkerId ?? -1);
@@ -137,11 +192,11 @@ function roundCounts(roundId, excludeWorkerId) {
return counts;
}
function workerDates(workerId) {
// Solicitudes de un trabajador con su estado: [{date, status}].
function workerRequests(workerId) {
return db
.prepare('SELECT date FROM requests WHERE worker_id = ? ORDER BY date')
.all(workerId)
.map((r) => r.date);
.prepare('SELECT date, status FROM requests WHERE worker_id = ? ORDER BY date')
.all(workerId);
}
const isValidDate = (s, year) => {
@@ -150,6 +205,11 @@ const isValidDate = (s, year) => {
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();
@@ -187,8 +247,10 @@ app.get('/api/round/:token', (req, res) => {
res.json({
round: { name: round.name, year: round.year, status: round.status },
roles: roundRoles(round),
limits: roundLimits(round),
today: todayISO(),
me: worker
? { name: worker.name, role: worker.role, dates: workerDates(worker.id) }
? { name: worker.name, role: worker.role, requests: workerRequests(worker.id) }
: null,
counts: roundCounts(round.id, worker?.id),
});
@@ -215,7 +277,7 @@ app.post('/api/round/:token/join', (req, res) => {
maxAge: 400 * 24 * 3600 * 1000,
path: '/',
});
res.json({ ok: true, me: { name, role, dates: [] } });
res.json({ ok: true, me: { name, role, requests: [] } });
});
app.put('/api/round/:token/requests', (req, res) => {
@@ -227,18 +289,52 @@ app.put('/api/round/:token/requests', (req, res) => {
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))) {
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 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);
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 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),
});
replace(unique);
res.json({ ok: true, dates: unique.sort(), counts: roundCounts(round.id, worker.id) });
});
// ---------- API administración ----------
@@ -266,7 +362,8 @@ app.get('/api/admin/rounds', requireAdmin, (_req, res) => {
.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) 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();
@@ -280,19 +377,19 @@ app.post('/api/admin/rounds', requireAdmin, (req, res) => {
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) {
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);
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) VALUES (?, ?, ?, ?)')
.run(token, name, year, JSON.stringify(roles));
.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' } });
});
@@ -309,11 +406,85 @@ app.delete('/api/admin/rounds/:id', requireAdmin, (req, res) => {
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 });
});
// 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),
today: todayISO(),
workers,
});
});
// Aprobar / rechazar / volver a pendiente / añadir / quitar días de un empleado.
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')
@@ -322,26 +493,43 @@ app.get('/api/admin/rounds/:id/excel', requireAdmin, async (req, res) => {
const wb = new ExcelJS.Workbook();
wb.creator = 'Vacaciones';
// Hoja 1: una fila por persona con sus fechas
const ws = wb.addWorksheet('Peticiones');
// 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: 'Nº días', key: 'n', width: 9 },
{ header: 'Fechas pedidas', key: 'dates', width: 90 },
{ 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 dates = workerDates(w.id);
ws.addRow({ name: w.name, role: roles[w.role] ?? w.role, n: dates.length, dates: dates.join(', ') });
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: recuento por día y cargo
// 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 = ? GROUP BY q.date, w.role ORDER BY q.date`
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();
@@ -353,7 +541,11 @@ app.get('/api/admin/rounds/:id/excel', requireAdmin, async (req, res) => {
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 })),
...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 };