diff --git a/public/admin.html b/public/admin.html index 8089790..3a8adfe 100644 --- a/public/admin.html +++ b/public/admin.html @@ -71,8 +71,14 @@

Calendario

+
+

Límite de solicitudes por día y cargo

diff --git a/public/css/styles.css b/public/css/styles.css index 8e9bc34..fcfd9b9 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -309,6 +309,7 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4 .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; } +.legend .sw--blocked { background: #d3c6a8; border-color: #bdae8c; } /* ---------- calendario ---------- */ @@ -412,6 +413,22 @@ input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 4 color: #a99b7e; cursor: default; } +/* día bloqueado por el admin: no elegible por nadie */ +.day.blocked { + background: repeating-linear-gradient( + -45deg, #e6dccb, #e6dccb 4px, #d3c6a8 4px, #d3c6a8 8px + ); + color: #8f836a; + cursor: default; +} +.day.blocked::after { + content: '🔒'; + position: absolute; + top: 2px; + left: 3px; + font-size: 9px; + line-height: 1; +} .day .count.is-full { background: var(--rejected); } .day .count { @@ -777,6 +794,36 @@ body.cal-open .page--wide { max-width: 1080px; } } .cday .rchip.is-full { background: var(--rejected); color: #fff; } +/* días bloqueados por el admin (no elegibles por nadie) */ +.cday--blocked { + background: repeating-linear-gradient( + -45deg, #efe6d2, #efe6d2 4px, #e6dabf 4px, #e6dabf 8px + ); + color: #a99b7e; + border-color: #cdbf9d; +} +.cday--blocked::after, +.cday.is-blocked::after { + content: '🔒'; + position: absolute; + top: 1px; + left: 3px; + font-size: 9px; + line-height: 1; +} + +/* editor del nombre de la ronda */ +.cal-name-editor { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin: 6px 0 4px; +} +.cal-name-editor input { width: min(320px, 100%); margin: 0; } +.cal-name-editor .btn { margin-top: 0; } +#cal-name-edit { margin-top: 0; } + /* barra de acción del admin (aprobar / rechazar / pendiente / quitar) */ .actionbar { position: fixed; diff --git a/public/js/admin.js b/public/js/admin.js index 1d733b0..807f136 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -218,6 +218,7 @@ $('cal-status').textContent = open ? 'Abierta' : 'Cerrada'; $('cal-status').className = `badge ${open ? 'open' : 'closed'}`; showPanelView('calendar'); + closeNameEditor(); renderLimitsEditor(); rerender(); window.scrollTo(0, 0); @@ -225,6 +226,42 @@ $('cal-back').addEventListener('click', () => loadRounds()); + // ----- editar el nombre de la ronda ----- + + function closeNameEditor() { + $('cal-name-editor').classList.add('hidden'); + $('cal-name-edit').classList.remove('hidden'); + } + + $('cal-name-edit').addEventListener('click', () => { + $('cal-name-input').value = cal.round.name; + $('cal-name-editor').classList.remove('hidden'); + $('cal-name-edit').classList.add('hidden'); + $('cal-name-input').focus(); + $('cal-name-input').select(); + }); + + $('cal-name-cancel').addEventListener('click', closeNameEditor); + + $('cal-name-save').addEventListener('click', async () => { + const name = $('cal-name-input').value.trim(); + if (!name) { toast('Escribe un nombre', true); return; } + try { + const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/name`, { method: 'PUT', body: { name } }); + cal.round.name = data.name; + $('cal-title').textContent = `${data.name} ${cal.round.year}`; + toast('✓ Nombre actualizado'); + closeNameEditor(); + } catch (ex) { + toast(ex.message, true); + } + }); + + $('cal-name-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); $('cal-name-save').click(); } + if (e.key === 'Escape') { e.preventDefault(); closeNameEditor(); } + }); + // ----- editor de límites ----- function renderLimitsEditor() { @@ -256,6 +293,26 @@ } }); + // ----- bloquear / desbloquear días ----- + + // Alterna el bloqueo de un día para toda la ronda (envía la lista completa). + async function toggleBlocked(date) { + const current = new Set(cal.blocked || []); + const willBlock = !current.has(date); + if (willBlock) current.add(date); else current.delete(date); + try { + const data = await fetchJSON(`/api/admin/rounds/${cal.round.id}/blocked`, { + method: 'PUT', + body: { dates: [...current] }, + }); + cal.blocked = data.blocked; + toast(willBlock ? '🔒 Día bloqueado para todo el equipo' : 'Día desbloqueado'); + rerender(); + } catch (ex) { + toast(ex.message, true); + } + } + // ----- datos derivados ----- // Ocupación por fecha y cargo: {date: {role: {approved, pending, rejected}}}. @@ -345,14 +402,15 @@ function renderGrid() { const occ = occupancy(); + const blockedSet = new Set(cal.blocked || []); 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.'; + ? `Editando a ${esc(worker.name)} · ${esc(cal.roles[worker.role] || worker.role)}. Toca un día para aprobarlo, rechazarlo, dejarlo pendiente o quitarlo. Puedes asignar días aunque estén completos o bloqueados.` + : 'Vista de ocupación de todo el equipo. Toca un día para bloquearlo o desbloquearlo para todo el equipo (🔒 no elegible por nadie). Elige un empleado en la izquierda para aprobar o editar sus días.'; const grid = $('cal-grid'); grid.innerHTML = ''; @@ -388,6 +446,7 @@ if (date === focusedDate) cls += ' focused'; btn.className = cls; btn.innerHTML = `${d}`; + if (blockedSet.has(date)) btn.classList.add('is-blocked'); const others = held(occ[date]?.[worker.role]) - (status && status !== 'rejected' ? 1 : 0); const lim = cal.limits[worker.role]; if (others > 0) { @@ -398,9 +457,9 @@ } btn.addEventListener('click', () => focusDay(date)); } else { - // Modo ocupación: chips por cargo con su carga. - btn.className = 'cday cday--info'; - btn.disabled = true; + // Modo ocupación: chips por cargo con su carga. Tocar bloquea/desbloquea + // el día para todo el equipo. + btn.className = 'cday' + (blockedSet.has(date) ? ' cday--blocked' : ''); btn.innerHTML = `${d}`; const cell = occ[date]; if (cell) { @@ -418,6 +477,7 @@ } if (chips.children.length) btn.appendChild(chips); } + btn.addEventListener('click', () => toggleBlocked(date)); } days.appendChild(btn); } diff --git a/public/js/round.js b/public/js/round.js index f1132e7..15d0ec1 100644 --- a/public/js/round.js +++ b/public/js/round.js @@ -9,6 +9,7 @@ let state = null; // respuesta del servidor let todayIso = ''; // hoy según el servidor (zona de España) + let blockedSet = new Set(); // días bloqueados por el admin (no elegibles) 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) @@ -82,6 +83,7 @@ const others = state.counts[date]?.[role] || 0; const limit = state.limits[role]; const full = limit ? others >= limit : false; + const blocked = blockedSet.has(date); const past = date <= todayIso; const open = state.round.status === 'open'; @@ -104,6 +106,7 @@ } else { // Día libre para este trabajador. if (past) { cls += ' past'; disabled = true; } + else if (blocked) { cls += ' blocked'; disabled = true; } else if (full) { cls += ' full'; disabled = true; } else clickable = !disabled; } @@ -202,8 +205,8 @@ 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(); + // Si el servidor rechazó por días completos o bloqueados, recargamos para ver la realidad. + if (/complet|bloquead/i.test(ex.message)) load(); } finally { btn.disabled = false; } @@ -219,6 +222,7 @@ return; } todayIso = state.today || new Date().toISOString().slice(0, 10); + blockedSet = new Set(state.blocked || []); $('round-title').innerHTML = `${state.round.name} ${state.round.year}`; $('round-sub').textContent = state.me diff --git a/public/round.html b/public/round.html index 2dd8086..baf0a44 100644 --- a/public/round.html +++ b/public/round.html @@ -49,13 +49,14 @@

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. Los días completos - para tu cargo, pasados o de hoy no se pueden elegir. + para tu cargo, bloqueados (🔒), pasados o de hoy no se pueden elegir.

diff --git a/server.js b/server.js index 7e064fd..b53d89d 100644 --- a/server.js +++ b/server.js @@ -56,6 +56,8 @@ const hasColumn = (table, col) => if (!hasColumn('rounds', 'roles')) db.exec('ALTER TABLE rounds ADD COLUMN roles TEXT'); // Límite de solicitudes por día y cargo (JSON {clave: máximo}). if (!hasColumn('rounds', 'limits')) db.exec('ALTER TABLE rounds ADD COLUMN limits TEXT'); +// Días bloqueados por el admin para toda la ronda (JSON array de fechas). +if (!hasColumn('rounds', 'blocked')) db.exec('ALTER TABLE rounds ADD COLUMN blocked TEXT'); // Estado de aprobación de cada solicitud: pending | approved | rejected. if (!hasColumn('requests', 'status')) { db.exec("ALTER TABLE requests ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'"); @@ -107,6 +109,19 @@ function roundLimits(round) { return out; } +// Días bloqueados por el admin (no elegibles por ningún trabajador). Devuelve +// una lista de fechas válidas del año de la ronda, sin duplicados y ordenadas. +function roundBlocked(round) { + if (!round.blocked) return []; + try { + const parsed = JSON.parse(round.blocked); + if (Array.isArray(parsed)) { + return [...new Set(parsed.map(String).filter((d) => isValidDate(d, round.year)))].sort(); + } + } catch { /* sin bloqueos */ } + return []; +} + // Convierte una etiqueta ("Camarero/a") en una clave estable ("camarero_a"). const slugify = (s) => String(s) @@ -248,6 +263,7 @@ app.get('/api/round/:token', (req, res) => { round: { name: round.name, year: round.year, status: round.status }, roles: roundRoles(round), limits: roundLimits(round), + blocked: roundBlocked(round), today: todayISO(), me: worker ? { name: worker.name, role: worker.role, requests: workerRequests(worker.id) } @@ -309,6 +325,14 @@ app.put('/api/round/:token/requests', (req, res) => { return res.status(400).json({ error: 'No puedes elegir días pasados ni el día de hoy.' }); } + // Los días nuevos no pueden estar bloqueados por la administración. + const blocked = new Set(roundBlocked(round)); + if (added.some((d) => blocked.has(d))) { + return res.status(409).json({ + error: 'Algún día que elegiste está bloqueado por la administración. Recarga la página.', + }); + } + // Los días nuevos no pueden superar el límite de tu cargo (cuentan las // solicitudes de los demás que no estén rechazadas). const limit = roundLimits(round)[worker.role]; @@ -422,6 +446,30 @@ app.put('/api/admin/rounds/:id/limits', requireAdmin, (req, res) => { res.json({ ok: true, limits }); }); +// Editar el nombre de una ronda (igual que se editan los límites). +app.put('/api/admin/rounds/:id/name', requireAdmin, (req, res) => { + const name = String(req.body.name ?? '').trim(); + if (!name || name.length > 80) return res.status(400).json({ error: 'Indica el nombre del local' }); + const info = db.prepare('UPDATE rounds SET name = ? WHERE id = ?').run(name, req.params.id); + if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' }); + res.json({ ok: true, name }); +}); + +// Bloquear / desbloquear días de una ronda. Los días bloqueados no son elegibles +// por ningún trabajador, pero el admin sí puede asignarlos manualmente. Recibe la +// lista completa de días bloqueados y la guarda (reemplazo total). +app.put('/api/admin/rounds/:id/blocked', requireAdmin, (req, res) => { + const round = db.prepare('SELECT * FROM rounds WHERE id = ?').get(req.params.id); + if (!round) return res.status(404).json({ error: 'Ronda no encontrada' }); + const dates = req.body.dates; + if (!Array.isArray(dates) || dates.length > 366) { + return res.status(400).json({ error: 'Días no válidos' }); + } + const blocked = [...new Set(dates.map(String))].filter((d) => isValidDate(d, round.year)).sort(); + db.prepare('UPDATE rounds SET blocked = ? WHERE id = ?').run(JSON.stringify(blocked), round.id); + res.json({ ok: true, blocked }); +}); + // Vista de calendario del administrador: empleados con sus solicitudes (y estado), // más los cargos y límites de la ronda. app.get('/api/admin/rounds/:id/calendar', requireAdmin, (req, res) => { @@ -435,12 +483,15 @@ app.get('/api/admin/rounds/:id/calendar', requireAdmin, (req, res) => { round: { id: round.id, name: round.name, year: round.year, status: round.status }, roles: roundRoles(round), limits: roundLimits(round), + blocked: roundBlocked(round), today: todayISO(), workers, }); }); // Aprobar / rechazar / volver a pendiente / añadir / quitar días de un empleado. +// El admin manda: no se aplica el límite por día/cargo ni el bloqueo de días, +// así que puede asignar cualquier día aunque esté completo o bloqueado. app.post('/api/admin/rounds/:id/requests/set', requireAdmin, (req, res) => { const round = db.prepare('SELECT * FROM rounds WHERE id = ?').get(req.params.id); if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });