diff --git a/README.md b/README.md index 6c20424..d1bf2c7 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,16 @@ Aplicación web sencilla y pensada para el móvil con la que el equipo de un res 1. Reciben una URL del tipo `https://tudominio.com/r/AbC123xyz`. 2. Escriben su nombre y eligen su cargo (los que el administrador haya definido al crear la ronda; por defecto Camarero/a, Encargado o Cocina). 3. Marcan en el calendario los días que quieren pedir y guardan. -4. En cada día ven cuántas personas **de su mismo cargo** ya lo han pedido (nunca quiénes). Cada mes muestra una pequeña leyenda cuando tiene días ya pedidos por compañeros. -5. Una cookie les identifica: si vuelven a entrar con el mismo navegador, ven y pueden editar su petición mientras la ronda siga abierta. +4. En cada día ven cuántas personas **de su mismo cargo** ya lo han pedido (nunca quiénes). Si un día ya ha alcanzado el límite de su cargo, o es pasado o es hoy, no se puede elegir. +5. Cada solicitud tiene un estado, visible con un color: **gris** = eligiendo (sin guardar), **amarillo** = pendiente de aprobar, **verde** = aprobada, **rojo** = rechazada. Solo se pueden cambiar las pendientes o añadir nuevas; las aprobadas y rechazadas quedan bloqueadas. +6. Una cookie les identifica: si vuelven a entrar con el mismo navegador, ven el estado de todos sus días (aprobados, rechazados y pendientes) y pueden seguir editando los pendientes mientras la ronda siga abierta. **Administrador** (`/admin`, protegido por contraseña) -- Crea rondas de peticiones por local y año; en cada ronda define los cargos disponibles (separados por comas) y se genera una URL nueva para compartir. +- Crea rondas de peticiones por local y año; en cada ronda define los cargos y, opcionalmente, un **límite de solicitudes por día y cargo** (p. ej. Camarero/a: 2, Cocina: 1). Se genera una URL nueva para compartir. +- **Calendario de aprobación** por ronda: una barra lateral lista a cada empleado con su situación (aprobados / pendientes / rechazados) y el calendario muestra la ocupación del equipo. Desde ahí aprueba o rechaza cada día (uno a uno o en bloque por empleado) y puede añadir o quitar días directamente. +- **Edita los límites** por cargo en cualquier momento para ampliarlos o reducirlos (afecta a las nuevas solicitudes; las reservas ocupan hueco mientras estén pendientes o aprobadas, y rechazar libera el hueco). - Cierra una ronda (nadie puede pedir ni modificar) y puede reabrirla. -- Descarga un Excel con dos hojas: peticiones por persona y recuento por día y cargo. +- Descarga un Excel con dos hojas: días por persona separados por estado, y ocupación por día y cargo. ## Ejecutar en local diff --git a/public/admin.html b/public/admin.html index aa0a7e9..8089790 100644 --- a/public/admin.html +++ b/public/admin.html @@ -31,37 +31,83 @@
Toca los días que quieres pedir. El número 2 indica - cuántas personas de tu mismo cargo ya han pedido ese día. + cuántas personas de tu mismo cargo ya han pedido ese día. Los días completos + para tu cargo, pasados o de hoy no se pueden elegir.
+ diff --git a/server.js b/server.js index 69d93b9..7e064fd 100644 --- a/server.js +++ b/server.js @@ -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 };