From 9c9d2d25f99b5518942a2e1eacc4ce31014995ca Mon Sep 17 00:00:00 2001 From: Juan Antonio Villanueva Date: Fri, 26 Jun 2026 12:15:31 +0200 Subject: [PATCH] =?UTF-8?q?L=C3=ADmites=20por=20d=C3=ADa/cargo,=20aprobaci?= =?UTF-8?q?=C3=B3n=20de=20d=C3=ADas=20y=20calendario=20de=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 11 +- public/admin.html | 92 ++++++++--- public/css/styles.css | 321 +++++++++++++++++++++++++++++++++++++ public/js/admin.js | 365 +++++++++++++++++++++++++++++++++++++++++- public/js/round.js | 142 +++++++++------- public/round.html | 9 +- server.js | 266 +++++++++++++++++++++++++----- 7 files changed, 1076 insertions(+), 130 deletions(-) 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 @@ -
-

Rondas

-
-
+
+

Límite de solicitudes por día y cargo

+
+
+ Vacío = sin límite. Ampliar o reducir afecta a las nuevas solicitudes. + +
+
+ +
+ +
+
+
+
+ -
Panel del administrador
+ + +
+
+ +
+ + + + +
+
+
diff --git a/public/css/styles.css b/public/css/styles.css index c0c0404..8e9bc34 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -14,6 +14,12 @@ --accent-deep: #9c3f1b; --accent-wash: #f3e0d2; --ok: #2f6b46; + /* estados de las solicitudes */ + --choosing: #8d8779; + --pending: #e0a52a; + --pending-ink: #5a4200; + --approved: #2f6b46; + --rejected: #b23b27; --radius: 14px; --shadow: 0 2px 0 rgba(30, 53, 40, 0.08), 0 14px 34px -18px rgba(30, 53, 40, 0.35); } @@ -281,6 +287,29 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4 margin: 6px 0 0; } +/* leyenda de colores del calendario */ +.legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px 16px; + margin: 0 0 16px; + font-size: 12.5px; + font-weight: 700; + color: var(--ink-soft); +} +.legend-item { display: inline-flex; align-items: center; gap: 6px; } +.legend .sw { + width: 16px; height: 16px; + border-radius: 5px; + border: 1.5px solid var(--line); + flex: none; +} +.legend .sw--choosing { background: var(--choosing); border-color: var(--choosing); } +.legend .sw--pending { background: var(--pending); border-color: #c98e1d; } +.legend .sw--approved { background: var(--approved); border-color: var(--approved); } +.legend .sw--rejected { background: #f6e3df; border-color: #e2b7af; } + /* ---------- calendario ---------- */ .month { @@ -350,6 +379,41 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4 color: var(--paper); border-color: var(--ink); } + +/* estados del día para el trabajador */ +.day.choosing { + background: var(--choosing); + color: #fff; + border-color: var(--choosing); +} +.day.pending { + background: var(--pending); + color: var(--pending-ink); + border-color: #c98e1d; +} +.day.approved { + background: var(--approved); + color: #fff; + border-color: var(--approved); + cursor: default; +} +.day.rejected { + background: #f6e3df; + color: var(--rejected); + border-color: #e2b7af; + text-decoration: line-through; + cursor: default; +} +/* día completo para el cargo: no se puede elegir */ +.day.full { + background: repeating-linear-gradient( + -45deg, #efe6d2, #efe6d2 4px, #e6dabf 4px, #e6dabf 8px + ); + color: #a99b7e; + cursor: default; +} +.day .count.is-full { background: var(--rejected); } + .day .count { position: absolute; top: 2px; @@ -497,6 +561,263 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4 padding: 22px 0; } +.pending-flag { color: var(--accent-deep); } + +/* ---------- editor de cargos (nueva ronda) ---------- */ + +#roles-editor { display: grid; gap: 8px; margin-top: 4px; } +.role-row { display: flex; gap: 8px; align-items: center; } +.role-row .role-label { flex: 1; } +.role-row .role-max { width: 92px; text-align: center; } +.role-row .role-del { + flex: none; + width: 40px; height: 44px; + border: 1.5px solid var(--line); + border-radius: 10px; + background: var(--paper); + color: var(--accent-deep); + font-size: 15px; + font-weight: 800; + cursor: pointer; +} +.role-row .role-del:hover { background: var(--accent-wash); border-color: var(--accent); } +#add-role { margin-top: 10px; } + +/* ---------- vista de calendario del admin ---------- */ + +body.cal-open .page--wide { max-width: 1080px; } + +.cal-topbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} +.cal-topbar .btn { margin-top: 0; } +.cal-heading { display: flex; align-items: center; gap: 10px; } +.cal-heading h2 { + font-family: "Fraunces", Georgia, serif; + font-weight: 600; + font-size: 24px; + margin: 0; +} +.cal-heading .badge { margin-left: 0; vertical-align: middle; } + +.cal-limits h3 { + font-family: "Fraunces", Georgia, serif; + font-weight: 600; + font-size: 18px; + margin: 0 0 12px; +} +.cal-limits-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; +} +.limit-field { margin: 0; display: flex; flex-direction: column; gap: 4px; } +.limit-field span { font-weight: 700; font-size: 13px; } +.limit-field input { width: 100%; text-align: center; } +.cal-limits-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-top: 12px; +} +.cal-limits-foot .field-hint { margin: 0; flex: 1 1 200px; } +.cal-limits-foot .btn { margin-top: 0; } + +.cal-layout { + display: grid; + grid-template-columns: 250px 1fr; + gap: 16px; + align-items: start; +} +@media (max-width: 720px) { + .cal-layout { grid-template-columns: 1fr; } +} + +.cal-sidebar { + position: sticky; + top: 12px; + margin-bottom: 0; + padding: 14px; + max-height: calc(100dvh - 24px); + overflow: auto; +} +@media (max-width: 720px) { + .cal-sidebar { position: static; max-height: none; } +} +.side-all { + width: 100%; + text-align: left; + font: inherit; + font-weight: 700; + font-size: 14px; + padding: 11px 12px; + border: 1.5px solid var(--line); + border-radius: 10px; + background: var(--paper); + color: var(--ink); + cursor: pointer; + margin-bottom: 10px; +} +.side-all.is-active { background: var(--ink); color: var(--paper); border-color: var(--ink); } +.side-role { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent-deep); + margin: 14px 2px 6px; +} +.side-role .side-lim { + color: var(--ink-soft); + letter-spacing: 0; + text-transform: none; + font-weight: 700; +} +.side-worker { + border: 1.5px solid var(--line); + border-radius: 11px; + margin-bottom: 7px; + overflow: hidden; + background: #fffdf7; +} +.side-worker.is-active { border-color: var(--ink); box-shadow: var(--shadow); } +.side-worker-main { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + font: inherit; + background: transparent; + border: none; + cursor: pointer; + text-align: left; +} +.side-worker-main .name { font-weight: 800; font-size: 14px; } +.side-worker .tags { display: flex; gap: 5px; flex: none; } +.side-worker .t { + font-size: 11px; + font-weight: 800; + border-radius: 999px; + padding: 1px 6px; + white-space: nowrap; +} +.side-worker .t-ap { background: #ddebdd; color: var(--approved); } +.side-worker .t-pe { background: #f7e6c2; color: var(--pending-ink); } +.side-worker .t-re { background: #f1ddd7; color: var(--rejected); } +.side-bulk { + display: flex; + gap: 6px; + padding: 0 10px 10px; +} +.side-bulk .btn { margin-top: 0; flex: 1; padding: 8px 6px; font-size: 12.5px; } + +.cal-main { min-width: 0; } +.cal-mode-hint { text-align: left; margin: 0 2px 12px; } +.cal-months { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); + gap: 14px; +} +.cal-months .month { margin-bottom: 0; animation: none; } + +.cday { + position: relative; + min-height: 42px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 3px 2px; + border: 1.5px solid var(--line); + border-radius: 9px; + background: var(--paper); + color: var(--ink); + font-family: inherit; + font-weight: 700; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.cday:not(.cday--info):active { transform: scale(0.94); } +.cday .n { font-size: 12px; line-height: 1; } +.cday--info { cursor: default; } +.cday.s-approved { background: var(--approved); color: #fff; border-color: var(--approved); } +.cday.s-pending { background: var(--pending); color: var(--pending-ink); border-color: #c98e1d; } +.cday.s-rejected { + background: #f6e3df; + color: var(--rejected); + border-color: #e2b7af; + text-decoration: line-through; +} +.cday.focused { outline: 3px solid var(--ink); outline-offset: 1px; z-index: 1; } +.cday .load { + font-size: 9.5px; + font-weight: 800; + background: rgba(30, 53, 40, 0.14); + border-radius: 999px; + padding: 0 5px; +} +.cday.s-approved .load { background: rgba(255, 255, 255, 0.28); } +.cday .load.is-full { background: var(--rejected); color: #fff; } +.cday .chips { display: flex; flex-wrap: wrap; gap: 2px; justify-content: center; } +.cday .rchip { + font-size: 9px; + font-weight: 800; + background: #e7dcc2; + color: var(--ink-soft); + border-radius: 4px; + padding: 0 3px; +} +.cday .rchip.is-full { background: var(--rejected); color: #fff; } + +/* barra de acción del admin (aprobar / rechazar / pendiente / quitar) */ +.actionbar { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 10; + padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); + background: linear-gradient(transparent, rgba(246, 240, 227, 0.92) 35%); + transform: translateY(115%); + transition: transform 0.25s ease; + pointer-events: none; +} +.actionbar.show { transform: translateY(0); pointer-events: auto; } +.actionbar .inner { + max-width: 760px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + background: var(--ink); + color: var(--paper); + border-radius: 16px; + padding: 12px 14px; + box-shadow: 0 16px 34px -12px rgba(30, 53, 40, 0.55); +} +.actionbar .label { flex: 1 1 160px; font-weight: 700; font-size: 14px; text-transform: capitalize; } +.actionbar .acts { display: flex; gap: 6px; flex-wrap: wrap; } +.actionbar .btn { margin-top: 0; width: auto; } +.actionbar .act-approve { background: var(--approved); } +.actionbar .act-reject { background: var(--rejected); } +.actionbar .btn--ghost { + background: transparent; + color: var(--paper); + border-color: rgba(246, 240, 227, 0.4); +} +.actionbar .btn--ghost:hover { background: rgba(246, 240, 227, 0.12); } + +/* en la barra lateral los botones de aprobar/rechazar conservan su color */ +.side-bulk .act-approve { background: var(--approved); } +.side-bulk .act-reject { background: var(--rejected); } + footer.colophon { text-align: center; font-size: 11px; diff --git a/public/js/admin.js b/public/js/admin.js index 076e9a4..1d733b0 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,6 +1,8 @@ -/* Panel de administración: login, rondas, Excel. */ +/* Panel de administración: login, rondas, calendario de aprobación, Excel. */ (() => { const $ = (id) => document.getElementById(id); + const MONTHS = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; + const DOW = ['L','M','X','J','V','S','D']; const toast = (msg, isError = false) => { const el = $('toast'); @@ -22,11 +24,22 @@ return data; }; + const esc = (s) => + String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + function show(view) { $('login-card').classList.toggle('hidden', view !== 'login'); $('panel').classList.toggle('hidden', view !== 'panel'); } + // Alterna entre la lista de rondas y la vista de calendario dentro del panel. + function showPanelView(which) { + $('rounds-view').classList.toggle('hidden', which !== 'rounds'); + $('calendar-view').classList.toggle('hidden', which !== 'calendar'); + document.body.classList.toggle('cal-open', which === 'calendar'); + if (which !== 'calendar') hideActionbar(); + } + // ---------- login ---------- $('login-form').addEventListener('submit', async (e) => { @@ -49,16 +62,42 @@ show('login'); }); - // ---------- rondas ---------- + // ---------- editor de cargos (nueva ronda) ---------- $('round-year').value = new Date().getFullYear() + 1; + function addRoleRow(label = '', max = '') { + const row = document.createElement('div'); + row.className = 'role-row'; + row.innerHTML = ` + + + `; + row.querySelector('.role-del').addEventListener('click', () => { + if ($('roles-editor').children.length > 1) row.remove(); + }); + $('roles-editor').appendChild(row); + } + // Cargos propuestos por defecto al abrir el panel. + [['Camarero/a', 2], ['Encargado', ''], ['Cocina', 1]].forEach(([l, m]) => addRoleRow(l, m)); + $('add-role').addEventListener('click', () => addRoleRow()); + + function readRoles() { + return [...$('roles-editor').querySelectorAll('.role-row')] + .map((row) => ({ + label: row.querySelector('.role-label').value.trim(), + max: row.querySelector('.role-max').value, + })) + .filter((r) => r.label) + .map((r) => ({ label: r.label, max: r.max === '' ? null : Number(r.max) })); + } + $('create-form').addEventListener('submit', async (e) => { e.preventDefault(); const err = $('create-error'); err.classList.remove('show'); try { - const roles = $('round-roles').value.split(',').map((s) => s.trim()).filter(Boolean); + const roles = readRoles(); const { round } = await fetchJSON('/api/admin/rounds', { method: 'POST', body: { name: $('round-name').value, year: Number($('round-year').value), roles }, @@ -73,6 +112,8 @@ } }); + // ---------- lista de rondas ---------- + const roundUrl = (token) => `${location.origin}/r/${token}`; async function copyUrl(token, notify = true) { @@ -93,6 +134,7 @@ show('login'); return; } + showPanelView('rounds'); list.innerHTML = ''; if (!rounds.length) { list.innerHTML = '

Todavía no hay rondas. Crea la primera arriba.

'; @@ -100,22 +142,28 @@ } for (const r of rounds) { const open = r.status === 'open'; + const pending = r.pending + ? ` · ${r.pending} pendiente${r.pending === 1 ? '' : 's'}` + : ''; const item = document.createElement('div'); item.className = 'round-item'; item.innerHTML = `
${esc(r.name)} ${r.year} ${open ? 'Abierta' : 'Cerrada'} -
${r.workers} persona${r.workers === 1 ? '' : 's'} · ${r.days} día${r.days === 1 ? '' : 's'} pedidos
+
${r.workers} persona${r.workers === 1 ? '' : 's'} · ${r.days} día${r.days === 1 ? '' : 's'} pedidos${pending}
${roundUrl(r.token)}
+ Excel - +
`; + item.querySelector('[data-act=calendar]').addEventListener('click', () => openCalendar(r.id)); item.querySelector('[data-act=copy]').addEventListener('click', () => copyUrl(r.token)); item.querySelector('[data-act=status]').addEventListener('click', async () => { if (open && !confirm(`¿Cerrar la ronda "${r.name} ${r.year}"? Nadie podrá pedir ni modificar días.`)) return; @@ -130,12 +178,313 @@ toast(ex.message, true); } }); + item.querySelector('[data-act=delete]').addEventListener('click', async () => { + if (!confirm(`¿Eliminar la ronda "${r.name} ${r.year}" y todas sus peticiones? No se puede deshacer.`)) return; + try { + await fetchJSON(`/api/admin/rounds/${r.id}`, { method: 'DELETE' }); + toast('Ronda eliminada'); + loadRounds(); + } catch (ex) { + toast(ex.message, true); + } + }); list.appendChild(item); } } - const esc = (s) => - s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + // =================================================================== + // Calendario de aprobación + // =================================================================== + + let cal = null; // datos de la ronda en curso + let selectedWorkerId = null;// empleado seleccionado (null = vista de ocupación) + let focusedDate = null; // día con la barra de acción abierta + + const iso = (y, m, d) => + `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + const initials = (label) => label.replace(/[^\p{L}\p{N}]/gu, '').slice(0, 2) || '·'; + + async function openCalendar(id) { + try { + cal = await fetchJSON(`/api/admin/rounds/${id}/calendar`); + } catch (ex) { + toast(ex.message, true); + return; + } + selectedWorkerId = null; + focusedDate = null; + const open = cal.round.status === 'open'; + $('cal-title').textContent = `${cal.round.name} ${cal.round.year}`; + $('cal-status').textContent = open ? 'Abierta' : 'Cerrada'; + $('cal-status').className = `badge ${open ? 'open' : 'closed'}`; + showPanelView('calendar'); + renderLimitsEditor(); + rerender(); + window.scrollTo(0, 0); + } + + $('cal-back').addEventListener('click', () => loadRounds()); + + // ----- editor de límites ----- + + function renderLimitsEditor() { + const box = $('cal-limits-fields'); + box.innerHTML = ''; + for (const [key, label] of Object.entries(cal.roles)) { + const field = document.createElement('label'); + field.className = 'limit-field'; + field.innerHTML = + `${esc(label)}` + + ``; + box.appendChild(field); + } + } + + $('cal-limits-save').addEventListener('click', async () => { + const limits = {}; + $('cal-limits-fields').querySelectorAll('input').forEach((inp) => { + const v = inp.value.trim(); + if (v !== '') limits[inp.dataset.role] = Number(v); + }); + try { + const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/limits`, { method: 'PUT', body: { limits } }); + cal.limits = data.limits; + toast('✓ Límites guardados'); + rerender(); + } catch (ex) { + toast(ex.message, true); + } + }); + + // ----- datos derivados ----- + + // Ocupación por fecha y cargo: {date: {role: {approved, pending, rejected}}}. + function occupancy() { + const map = {}; + for (const w of cal.workers) { + for (const r of w.requests) { + const cell = (map[r.date] ??= {}); + const c = (cell[w.role] ??= { approved: 0, pending: 0, rejected: 0 }); + c[r.status]++; + } + } + return map; + } + const held = (c) => (c ? c.approved + c.pending : 0); // ocupan hueco (no rechazadas) + + function workerById(id) { + return cal.workers.find((w) => w.id === id) || null; + } + + // ----- barra lateral ----- + + function renderSidebar() { + const box = $('cal-sidebar'); + box.innerHTML = ''; + + const all = document.createElement('button'); + all.type = 'button'; + all.className = 'side-all' + (selectedWorkerId == null ? ' is-active' : ''); + all.textContent = 'Ocupación (todo el equipo)'; + all.addEventListener('click', () => { selectedWorkerId = null; rerender(); }); + box.appendChild(all); + + if (!cal.workers.length) { + const empty = document.createElement('p'); + empty.className = 'empty'; + empty.textContent = 'Nadie se ha registrado todavía.'; + box.appendChild(empty); + return; + } + + let lastRole = null; + for (const w of cal.workers) { + if (w.role !== lastRole) { + lastRole = w.role; + const h = document.createElement('p'); + h.className = 'side-role'; + const lim = cal.limits[w.role]; + h.innerHTML = `${esc(cal.roles[w.role] || w.role)}${lim ? ` máx ${lim}/día` : ''}`; + box.appendChild(h); + } + const counts = { approved: 0, pending: 0, rejected: 0 }; + for (const r of w.requests) counts[r.status]++; + const active = w.id === selectedWorkerId; + const card = document.createElement('div'); + card.className = 'side-worker' + (active ? ' is-active' : ''); + card.innerHTML = ` + `; + card.querySelector('.side-worker-main').addEventListener('click', () => { + selectedWorkerId = active ? null : w.id; + rerender(); + }); + if (active) { + const bulk = document.createElement('div'); + bulk.className = 'side-bulk'; + const pend = w.requests.filter((r) => r.status === 'pending').map((r) => r.date); + bulk.innerHTML = ` + + `; + const [bApprove, bReject] = bulk.querySelectorAll('button'); + bApprove.addEventListener('click', () => setStatus(w.id, pend, 'approved')); + bReject.addEventListener('click', () => setStatus(w.id, pend, 'rejected')); + card.appendChild(bulk); + } + box.appendChild(card); + } + } + + // ----- calendario ----- + + function renderGrid() { + const occ = occupancy(); + const worker = workerById(selectedWorkerId); + const myReq = worker ? new Map(worker.requests.map((r) => [r.date, r.status])) : null; + + const hint = document.createElement('p'); + hint.className = 'hint cal-mode-hint'; + hint.innerHTML = worker + ? `Editando a ${esc(worker.name)} · ${esc(cal.roles[worker.role] || worker.role)}. Toca un día para aprobarlo, rechazarlo, dejarlo pendiente o quitarlo.` + : 'Vista de ocupación de todo el equipo. Elige un empleado en la izquierda para aprobar o editar sus días.'; + + const grid = $('cal-grid'); + grid.innerHTML = ''; + grid.appendChild(hint); + + const months = document.createElement('div'); + months.className = 'cal-months'; + + for (let m = 0; m < 12; m++) { + const month = document.createElement('section'); + month.className = 'month'; + month.innerHTML = + `

${MONTHS[m]} ${cal.round.year}

` + + `
${DOW.map((d) => `${d}`).join('')}
`; + const days = document.createElement('div'); + days.className = 'days'; + const first = new Date(cal.round.year, m, 1); + const blanks = (first.getDay() + 6) % 7; + for (let i = 0; i < blanks; i++) days.appendChild(document.createElement('span')); + + const total = new Date(cal.round.year, m + 1, 0).getDate(); + for (let d = 1; d <= total; d++) { + const date = iso(cal.round.year, m, d); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.dataset.date = date; + + if (worker) { + // Modo empleado: color por su estado + carga del cargo ese día. + const status = myReq.get(date); + let cls = 'cday'; + if (status) cls += ' s-' + status; + if (date === focusedDate) cls += ' focused'; + btn.className = cls; + btn.innerHTML = `${d}`; + const others = held(occ[date]?.[worker.role]) - (status && status !== 'rejected' ? 1 : 0); + const lim = cal.limits[worker.role]; + if (others > 0) { + const load = document.createElement('span'); + load.className = 'load' + (lim && others >= lim ? ' is-full' : ''); + load.textContent = lim ? `${others}/${lim}` : others; + btn.appendChild(load); + } + btn.addEventListener('click', () => focusDay(date)); + } else { + // Modo ocupación: chips por cargo con su carga. + btn.className = 'cday cday--info'; + btn.disabled = true; + btn.innerHTML = `${d}`; + const cell = occ[date]; + if (cell) { + const chips = document.createElement('span'); + chips.className = 'chips'; + for (const role of Object.keys(cal.roles)) { + const h = held(cell[role]); + if (!h) continue; + const lim = cal.limits[role]; + const chip = document.createElement('span'); + chip.className = 'rchip' + (lim && h >= lim ? ' is-full' : ''); + chip.textContent = `${initials(cal.roles[role])}${h}`; + chip.title = `${cal.roles[role]}: ${h}${lim ? ' / ' + lim : ''}`; + chips.appendChild(chip); + } + if (chips.children.length) btn.appendChild(chips); + } + } + days.appendChild(btn); + } + month.appendChild(days); + months.appendChild(month); + } + grid.appendChild(months); + } + + function rerender() { + const y = window.scrollY; + renderSidebar(); + renderGrid(); + if (!focusedDate) hideActionbar(); + window.scrollTo(0, y); + } + + // ----- barra de acción por día ----- + + function focusDay(date) { + focusedDate = focusedDate === date ? null : date; + const worker = workerById(selectedWorkerId); + if (focusedDate && worker) { + const status = worker.requests.find((r) => r.date === date)?.status; + const human = new Date(date + 'T00:00:00').toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' }); + const label = status + ? `${human} — ${({ approved: 'aprobado', pending: 'pendiente', rejected: 'rechazado' }[status])}` + : `${human} — sin solicitar`; + $('cal-action-label').textContent = label; + $('cal-actionbar').classList.add('show'); + } else { + hideActionbar(); + } + // Repinta el foco sin perder scroll. + rerender(); + } + + function hideActionbar() { + focusedDate = null; + $('cal-actionbar').classList.remove('show'); + } + + $('cal-actionbar').querySelectorAll('button[data-status]').forEach((b) => { + b.addEventListener('click', () => { + if (selectedWorkerId == null || !focusedDate) return; + setStatus(selectedWorkerId, [focusedDate], b.dataset.status); + }); + }); + + // Aplica un cambio de estado en el servidor y refresca la vista. + async function setStatus(workerId, dates, status) { + if (!dates.length) return; + try { + const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/requests/set`, { + method: 'POST', + body: { worker_id: workerId, dates, status }, + }); + const w = workerById(workerId); + if (w) w.requests = data.requests; + const labels = { approved: 'Aprobado', rejected: 'Rechazado', pending: 'Pendiente', none: 'Quitado' }; + toast(`✓ ${labels[status]}${dates.length > 1 ? ` (${dates.length} días)` : ''}`); + hideActionbar(); + rerender(); + } catch (ex) { + toast(ex.message, true); + } + } // ---------- arranque ---------- diff --git a/public/js/round.js b/public/js/round.js index 6e93f4b..f1132e7 100644 --- a/public/js/round.js +++ b/public/js/round.js @@ -1,4 +1,4 @@ -/* Página del trabajador: registro + calendario de la ronda. */ +/* Página del trabajador: registro + calendario de la ronda con estados. */ (() => { const token = location.pathname.split('/').pop(); const api = (p) => `/api/round/${token}${p}`; @@ -7,9 +7,11 @@ const MONTHS = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; const DOW = ['L','M','X','J','V','S','D']; - let state = null; // respuesta del servidor - let selected = new Set(); // fechas elegidas (con cambios sin guardar) - let savedDates = new Set(); + let state = null; // respuesta del servidor + let todayIso = ''; // hoy según el servidor (zona de España) + let myStatus = new Map(); // fecha → 'approved' | 'rejected' | 'pending' (guardado) + let savedPending = new Set();// pendientes ya guardadas en el servidor + let pending = new Set(); // pendientes en edición (con cambios sin guardar) // ---------- utilidades ---------- @@ -36,6 +38,13 @@ const iso = (y, m, d) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + // Reconstruye el estado local a partir de las solicitudes del servidor. + function setRequests(requests) { + myStatus = new Map(requests.map((r) => [r.date, r.status])); + savedPending = new Set(requests.filter((r) => r.status === 'pending').map((r) => r.date)); + pending = new Set(savedPending); + } + // ---------- registro ---------- function renderJoin() { @@ -65,6 +74,57 @@ // ---------- calendario ---------- + // Calcula cómo se ve y se comporta un día concreto para este trabajador. + function dayMeta(date) { + const role = state.me.role; + const status = myStatus.get(date); + const inPending = pending.has(date); + const others = state.counts[date]?.[role] || 0; + const limit = state.limits[role]; + const full = limit ? others >= limit : false; + const past = date <= todayIso; + const open = state.round.status === 'open'; + + let cls = 'day'; + let disabled = !open; + let clickable = false; + + if (status === 'approved') { + cls += ' approved'; + disabled = true; + } else if (status === 'rejected') { + cls += ' rejected'; + disabled = true; + } else if (inPending && savedPending.has(date)) { + cls += ' pending'; + if (past) disabled = true; else clickable = !disabled; + } else if (inPending) { + cls += ' choosing'; + clickable = !disabled; + } else { + // Día libre para este trabajador. + if (past) { cls += ' past'; disabled = true; } + else if (full) { cls += ' full'; disabled = true; } + else clickable = !disabled; + } + return { cls, disabled, clickable, others, full }; + } + + // Aplica el aspecto y el badge de compañeros a un botón de día. + function styleDay(btn) { + const date = btn.dataset.date; + const m = dayMeta(date); + btn.className = m.cls; + btn.disabled = m.disabled; + btn.querySelector('.count')?.remove(); + if (m.others) { + const b = document.createElement('span'); + b.className = 'count' + (m.full ? ' is-full' : ''); + b.textContent = m.others; + btn.appendChild(b); + } + } + function renderCalendar() { $('join-card').classList.add('hidden'); $('calendar-section').classList.remove('hidden'); @@ -75,9 +135,7 @@ $('me-role').textContent = state.roles[me.role] || me.role; $('closed-banner').classList.toggle('hidden', open); $('calendar-hint').classList.toggle('hidden', !open); - - const today = new Date(); - const todayIso = iso(today.getFullYear(), today.getMonth(), today.getDate()); + $('calendar-legend').classList.remove('hidden'); const cal = $('calendar'); cal.innerHTML = ''; @@ -96,69 +154,38 @@ for (let i = 0; i < blanks; i++) days.appendChild(document.createElement('span')); const total = new Date(round.year, m + 1, 0).getDate(); - let monthHasCounts = false; for (let d = 1; d <= total; d++) { const date = iso(round.year, m, d); const btn = document.createElement('button'); btn.type = 'button'; - btn.className = 'day'; btn.dataset.date = date; btn.textContent = d; - if (date < todayIso) btn.classList.add('past'); - if (state.counts[date]?.[me.role]) monthHasCounts = true; - if (!open) btn.disabled = true; - else btn.addEventListener('click', () => toggle(date, btn)); + styleDay(btn); + if (!btn.disabled) btn.addEventListener('click', () => toggle(btn)); days.appendChild(btn); } month.appendChild(days); - - // Minileyenda: solo si este mes tiene días ya pedidos por compañeros de tu cargo. - if (monthHasCounts) { - const legend = document.createElement('p'); - legend.className = 'month-legend'; - legend.innerHTML = - 'N compañeros de tu mismo cargo que ya han pedido ese día'; - month.appendChild(legend); - } - cal.appendChild(month); } - paint(); - } - - function toggle(date, btn) { - if (selected.has(date)) selected.delete(date); - else selected.add(date); - btn.classList.toggle('selected', selected.has(date)); updateBars(); } - // Pinta selección y contadores de compañeros del mismo cargo. - function paint() { - const role = state.me.role; - document.querySelectorAll('.day').forEach((btn) => { - const date = btn.dataset.date; - btn.classList.toggle('selected', selected.has(date)); - btn.querySelector('.count')?.remove(); - const n = state.counts[date]?.[role]; - if (n) { - const b = document.createElement('span'); - b.className = 'count'; - b.textContent = n; - btn.appendChild(b); - } - }); + function toggle(btn) { + const date = btn.dataset.date; + if (pending.has(date)) pending.delete(date); + else pending.add(date); + styleDay(btn); updateBars(); } function updateBars() { - $('me-count').textContent = selected.size; + const approved = [...myStatus.values()].filter((s) => s === 'approved').length; + $('me-count').textContent = approved + pending.size; const dirty = - selected.size !== savedDates.size || [...selected].some((d) => !savedDates.has(d)); - const bar = $('savebar'); - bar.classList.toggle('show', dirty && state.round.status === 'open'); - $('save-label').textContent = - selected.size === 1 ? '1 día elegido' : `${selected.size} días elegidos`; + pending.size !== savedPending.size || [...pending].some((d) => !savedPending.has(d)); + $('savebar').classList.toggle('show', dirty && state.round.status === 'open'); + const n = pending.size; + $('save-label').textContent = n === 1 ? '1 día pendiente' : `${n} días pendientes`; } $('save-btn').addEventListener('click', async () => { @@ -167,15 +194,16 @@ try { const data = await fetchJSON(api('/requests'), { method: 'PUT', - body: { dates: [...selected] }, + body: { dates: [...pending] }, }); - savedDates = new Set(data.dates); - selected = new Set(data.dates); + setRequests(data.requests); state.counts = data.counts; - paint(); + renderCalendar(); toast('✓ Petición guardada'); } catch (ex) { toast(ex.message, true); + // Si el servidor rechazó por días completos, recargamos para ver la realidad. + if (/complet/i.test(ex.message)) load(); } finally { btn.disabled = false; } @@ -190,6 +218,7 @@ $('round-title').textContent = 'Ronda no encontrada'; return; } + todayIso = state.today || new Date().toISOString().slice(0, 10); $('round-title').innerHTML = `${state.round.name} ${state.round.year}`; $('round-sub').textContent = state.me @@ -198,8 +227,7 @@ document.title = `Vacaciones · ${state.round.name} ${state.round.year}`; if (state.me) { - savedDates = new Set(state.me.dates); - selected = new Set(state.me.dates); + setRequests(state.me.requests || []); renderCalendar(); } else if (state.round.status !== 'open') { $('closed-banner').classList.remove('hidden'); diff --git a/public/round.html b/public/round.html index fd3930f..2dd8086 100644 --- a/public/round.html +++ b/public/round.html @@ -48,8 +48,15 @@

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 };