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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user