Vacaciones: app de peticiones de vacaciones para restaurante

Cargos configurables por ronda (por defecto Camarero/a, Encargado, Cocina),
calendario con días recuadrados, recuento de compañeros del mismo cargo por
día y minileyenda por mes. Panel de administración con login, rondas y
exportación a Excel. Node + Express + SQLite, listo para Docker/Coolify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:45:36 +02:00
commit 15fc6dac3b
13 changed files with 3584 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ronda no encontrada</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..700;1,9..144,400..700&family=Karla:wght@400;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="page">
<header class="masthead" style="margin-top: 18vh">
<p class="kicker">Vaya…</p>
<h1>Esta ronda <em>no existe</em></h1>
<p class="sub">Revisa el enlace que te han enviado o pide uno nuevo al encargado.</p>
</header>
</div>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Administración · Vacaciones</title>
<meta name="theme-color" content="#f6f0e3" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..700;1,9..144,400..700&family=Karla:wght@400;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="page page--wide">
<header class="masthead">
<p class="kicker">Administración</p>
<h1>Rondas de <em>vacaciones</em></h1>
<p class="sub">Crea rondas, comparte la URL con el equipo y descarga las peticiones</p>
</header>
<!-- Login -->
<section id="login-card" class="card hidden" style="max-width: 420px; margin-inline: auto;">
<h2>Acceso</h2>
<form id="login-form">
<label for="password">Contraseña de administración</label>
<input id="password" type="password" autocomplete="current-password" required />
<div class="error-msg" id="login-error"></div>
<button class="btn" type="submit">Entrar</button>
</form>
</section>
<!-- Panel -->
<div id="panel" class="hidden">
<section class="card">
<h2>Nueva ronda</h2>
<form id="create-form">
<div class="form-row">
<div>
<label for="round-name">Local</label>
<input id="round-name" type="text" maxlength="80" placeholder="P. ej. Restaurante Centro" required />
</div>
<div>
<label for="round-year">Año</label>
<input id="round-year" type="number" min="2020" max="2100" required />
</div>
</div>
<label for="round-roles">Cargos</label>
<input id="round-roles" type="text" maxlength="200" value="Camarero/a, Encargado, Cocina" />
<p class="field-hint">Sepáralos con comas. El equipo elegirá uno al registrarse y verá el recuento de su mismo cargo.</p>
<div class="error-msg" id="create-error"></div>
<button class="btn btn--accent" type="submit">Crear ronda y obtener URL</button>
</form>
</section>
<section class="card">
<h2>Rondas</h2>
<div id="rounds-list"></div>
</section>
<button class="btn btn--ghost" id="logout-btn">Cerrar sesión</button>
</div>
<footer class="colophon">Panel del administrador</footer>
</div>
<div class="toast" id="toast"></div>
<script src="/js/admin.js"></script>
</body>
</html>
+515
View File
@@ -0,0 +1,515 @@
/* ===================================================================
Vacaciones — estética "carta de restaurante"
Papel crema, tinta verde botella, acento terracota.
Tipos: Fraunces (display) + Karla (texto).
=================================================================== */
:root {
--paper: #f6f0e3;
--paper-deep: #efe6d2;
--ink: #1e3528;
--ink-soft: #57695c;
--line: #d9cdb2;
--accent: #c2562a;
--accent-deep: #9c3f1b;
--accent-wash: #f3e0d2;
--ok: #2f6b46;
--radius: 14px;
--shadow: 0 2px 0 rgba(30, 53, 40, 0.08), 0 14px 34px -18px rgba(30, 53, 40, 0.35);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100dvh;
font-family: "Karla", "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(120% 60% at 50% 0%, rgba(194, 86, 42, 0.07), transparent 60%),
radial-gradient(100% 50% at 50% 100%, rgba(30, 53, 40, 0.08), transparent 65%),
var(--paper);
}
/* grano de papel */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.5;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0.12 0 0 0 0 0.2 0 0 0 0 0.15 0 0 0 0.05 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.page {
position: relative;
z-index: 1;
max-width: 520px;
margin: 0 auto;
padding: 20px 16px 120px;
}
.page--wide { max-width: 760px; }
/* ---------- cabecera tipo carta ---------- */
.masthead {
text-align: center;
padding: 18px 8px 16px;
border-top: 3px double var(--ink);
border-bottom: 3px double var(--ink);
margin-bottom: 22px;
animation: rise 0.5s ease both;
}
.masthead .kicker {
font-size: 11px;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--accent-deep);
margin: 0 0 6px;
}
.masthead h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: clamp(28px, 8vw, 38px);
line-height: 1.08;
margin: 0;
font-variation-settings: "opsz" 60;
}
.masthead h1 em {
font-style: italic;
color: var(--accent);
}
.masthead .sub {
margin: 8px 0 0;
color: var(--ink-soft);
font-size: 14px;
}
/* ---------- tarjetas ---------- */
.card {
background: #fffdf7;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px 18px;
box-shadow: var(--shadow);
margin-bottom: 18px;
animation: rise 0.5s ease both;
}
.card h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 600;
font-size: 21px;
margin: 0 0 12px;
}
label { display: block; font-weight: 700; font-size: 14px; margin: 14px 0 6px; }
input[type="text"], input[type="password"], input[type="number"] {
width: 100%;
padding: 13px 14px;
font: inherit;
font-size: 17px;
color: var(--ink);
background: var(--paper);
border: 1.5px solid var(--line);
border-radius: 10px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(194, 86, 42, 0.15); }
/* selector de cargo como fichas grandes */
.role-grid { display: grid; gap: 10px; }
.role-option {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border: 1.5px solid var(--line);
border-radius: 12px;
background: var(--paper);
cursor: pointer;
font-size: 17px;
font-weight: 700;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
}
.role-option:active { transform: scale(0.98); }
.role-option input { position: absolute; opacity: 0; }
.role-option .dot {
width: 22px; height: 22px;
border: 2px solid var(--ink-soft);
border-radius: 50%;
flex: none;
display: grid; place-items: center;
transition: border-color 0.15s;
}
.role-option .dot::after {
content: "";
width: 11px; height: 11px;
border-radius: 50%;
background: var(--accent);
transform: scale(0);
transition: transform 0.15s;
}
.role-option:has(input:checked) {
border-color: var(--accent);
background: var(--accent-wash);
}
.role-option:has(input:checked) .dot { border-color: var(--accent); }
.role-option:has(input:checked) .dot::after { transform: scale(1); }
/* ---------- botones ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 18px;
margin-top: 18px;
font: inherit;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--paper);
background: var(--ink);
border: none;
border-radius: 999px;
cursor: pointer;
transition: transform 0.1s, background 0.15s, opacity 0.15s;
}
.btn:hover { background: #2a4736; }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.45; cursor: default; transform: none; }
.btn--accent { background: var(--accent); }
.btn--accent:hover { background: var(--accent-deep); }
.btn--ghost {
background: transparent;
color: var(--ink);
border: 1.5px solid var(--line);
}
.btn--ghost:hover { background: var(--paper-deep); }
.btn--small { width: auto; padding: 9px 14px; font-size: 14px; margin-top: 0; }
.error-msg {
color: var(--accent-deep);
background: var(--accent-wash);
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
font-weight: 700;
margin-top: 12px;
display: none;
}
.error-msg.show { display: block; }
/* ---------- ficha del trabajador ---------- */
.me-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
margin-bottom: 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fffdf7;
box-shadow: var(--shadow);
animation: rise 0.5s ease both;
}
.me-strip .who { font-weight: 800; font-size: 15px; }
.me-strip .who small {
display: block;
font-weight: 700;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent-deep);
}
.me-strip .tally {
font-family: "Fraunces", Georgia, serif;
font-size: 15px;
white-space: nowrap;
color: var(--ink-soft);
}
.me-strip .tally b {
font-size: 22px;
color: var(--ink);
font-style: italic;
}
.closed-banner {
text-align: center;
background: var(--ink);
color: var(--paper);
border-radius: var(--radius);
padding: 12px 16px;
font-weight: 700;
margin-bottom: 16px;
animation: rise 0.5s ease both;
}
.hint {
font-size: 13.5px;
color: var(--ink-soft);
text-align: center;
margin: 0 0 16px;
}
.hint .chip {
display: inline-block;
min-width: 17px;
padding: 1px 5px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 800;
}
/* ayuda bajo un campo del formulario */
.field-hint {
font-size: 12.5px;
color: var(--ink-soft);
margin: 6px 0 0;
}
/* ---------- calendario ---------- */
.month {
background: #fffdf7;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px 12px 16px;
margin-bottom: 14px;
box-shadow: var(--shadow);
animation: rise 0.45s ease both;
}
.month h3 {
font-family: "Fraunces", Georgia, serif;
font-style: italic;
font-weight: 600;
font-size: 20px;
text-align: center;
margin: 2px 0 10px;
position: relative;
}
.month h3::before, .month h3::after {
content: "";
position: absolute;
top: 50%;
width: 18%;
border-top: 1px solid var(--line);
}
.month h3::before { left: 2%; }
.month h3::after { right: 2%; }
.dow, .days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.dow {
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-soft);
text-align: center;
margin-bottom: 6px;
}
.day {
position: relative;
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
border-radius: 10px;
border: 1.5px solid var(--line);
background: var(--paper);
color: var(--ink);
cursor: pointer;
padding: 0;
font-family: inherit;
transition: background 0.12s, border-color 0.12s, transform 0.08s;
-webkit-tap-highlight-color: transparent;
}
.day:not(:disabled):active { transform: scale(0.92); }
.day:disabled { color: #c2b89f; cursor: default; }
.day.past { color: #c2b89f; border-color: #e7dcc2; background: transparent; }
.day.selected {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.day .count {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 3px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 10px;
font-weight: 800;
box-shadow: 0 0 0 1.5px var(--paper);
}
.day.selected .count { box-shadow: 0 0 0 1.5px var(--ink); }
/* minileyenda bajo el mes (solo si hay días pedidos por compañeros) */
.month-legend {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 2px 0;
padding-top: 11px;
border-top: 1px dashed var(--line);
font-size: 11.5px;
line-height: 1.3;
color: var(--ink-soft);
}
.month-legend .count-sample {
flex: none;
min-width: 17px;
height: 17px;
padding: 0 4px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 10.5px;
font-weight: 800;
}
/* ---------- barra de guardado ---------- */
.savebar {
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(110%);
transition: transform 0.25s ease;
pointer-events: none;
}
.savebar.show { transform: translateY(0); pointer-events: auto; }
.savebar .inner {
max-width: 520px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
background: var(--ink);
color: var(--paper);
border-radius: 999px;
padding: 10px 10px 10px 20px;
box-shadow: 0 16px 34px -12px rgba(30, 53, 40, 0.55);
}
.savebar .label { flex: 1; font-weight: 700; font-size: 14.5px; }
.savebar .btn { width: auto; margin: 0; padding: 11px 20px; background: var(--accent); }
.savebar .btn:hover { background: var(--accent-deep); }
.toast {
position: fixed;
left: 50%;
bottom: 90px;
z-index: 20;
transform: translate(-50%, 20px);
background: var(--ok);
color: #fff;
font-weight: 700;
font-size: 14.5px;
padding: 10px 18px;
border-radius: 999px;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.toast.error { background: var(--accent-deep); }
/* ---------- panel de administración ---------- */
.round-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 16px 0;
border-bottom: 1px dashed var(--line);
}
.round-item:last-child { border-bottom: none; }
.round-item .info { flex: 1 1 200px; min-width: 0; }
.round-item .title {
font-family: "Fraunces", Georgia, serif;
font-size: 19px;
font-weight: 600;
}
.round-item .meta { font-size: 13px; color: var(--ink-soft); margin-top: 2px; }
.round-item .url {
display: block;
font-size: 12.5px;
margin-top: 6px;
color: var(--accent-deep);
word-break: break-all;
text-decoration: none;
font-weight: 700;
}
.round-item .actions { display: flex; flex-wrap: wrap; gap: 8px; }
.badge {
display: inline-block;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 3px 9px;
border-radius: 999px;
vertical-align: 2px;
margin-left: 8px;
}
.badge.open { background: #ddebdd; color: var(--ok); }
.badge.closed { background: var(--accent-wash); color: var(--accent-deep); }
.form-row { display: flex; gap: 10px; }
.form-row > div:first-child { flex: 1; }
.form-row > div:last-child { width: 110px; }
.empty {
text-align: center;
color: var(--ink-soft);
font-style: italic;
font-family: "Fraunces", Georgia, serif;
padding: 22px 0;
}
footer.colophon {
text-align: center;
font-size: 11px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--ink-soft);
margin-top: 28px;
}
footer.colophon::before { content: "❧"; display: block; font-size: 18px; margin-bottom: 6px; color: var(--accent); }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.hidden { display: none !important; }
+145
View File
@@ -0,0 +1,145 @@
/* Panel de administración: login, rondas, Excel. */
(() => {
const $ = (id) => document.getElementById(id);
const toast = (msg, isError = false) => {
const el = $('toast');
el.textContent = msg;
el.classList.toggle('error', isError);
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2600);
};
const fetchJSON = async (url, opts) => {
const res = await fetch(url, opts && {
method: opts.method,
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Error de conexión');
return data;
};
function show(view) {
$('login-card').classList.toggle('hidden', view !== 'login');
$('panel').classList.toggle('hidden', view !== 'panel');
}
// ---------- login ----------
$('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = $('login-error');
err.classList.remove('show');
try {
await fetchJSON('/api/admin/login', { method: 'POST', body: { password: $('password').value } });
$('password').value = '';
show('panel');
loadRounds();
} catch (ex) {
err.textContent = ex.message;
err.classList.add('show');
}
});
$('logout-btn').addEventListener('click', async () => {
await fetchJSON('/api/admin/logout', { method: 'POST' }).catch(() => {});
show('login');
});
// ---------- rondas ----------
$('round-year').value = new Date().getFullYear() + 1;
$('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 { round } = await fetchJSON('/api/admin/rounds', {
method: 'POST',
body: { name: $('round-name').value, year: Number($('round-year').value), roles },
});
$('round-name').value = '';
await copyUrl(round.token, false);
toast('✓ Ronda creada y URL copiada');
loadRounds();
} catch (ex) {
err.textContent = ex.message;
err.classList.add('show');
}
});
const roundUrl = (token) => `${location.origin}/r/${token}`;
async function copyUrl(token, notify = true) {
try {
await navigator.clipboard.writeText(roundUrl(token));
if (notify) toast('✓ URL copiada al portapapeles');
} catch {
if (notify) toast('No se pudo copiar; mantén pulsado el enlace', true);
}
}
async function loadRounds() {
const list = $('rounds-list');
let rounds;
try {
({ rounds } = await fetchJSON('/api/admin/rounds'));
} catch {
show('login');
return;
}
list.innerHTML = '';
if (!rounds.length) {
list.innerHTML = '<p class="empty">Todavía no hay rondas. Crea la primera arriba.</p>';
return;
}
for (const r of rounds) {
const open = r.status === 'open';
const item = document.createElement('div');
item.className = 'round-item';
item.innerHTML = `
<div class="info">
<span class="title">${esc(r.name)} ${r.year}</span>
<span class="badge ${open ? 'open' : 'closed'}">${open ? 'Abierta' : 'Cerrada'}</span>
<div class="meta">${r.workers} persona${r.workers === 1 ? '' : 's'} · ${r.days} día${r.days === 1 ? '' : 's'} pedidos</div>
<a class="url" href="/r/${r.token}" target="_blank" rel="noopener">${roundUrl(r.token)}</a>
</div>
<div class="actions">
<button class="btn btn--ghost btn--small" data-act="copy">Copiar URL</button>
<a class="btn btn--ghost btn--small" href="/api/admin/rounds/${r.id}/excel" download>Excel</a>
<button class="btn btn--small ${open ? 'btn--accent' : ''}" data-act="status">
${open ? 'Cerrar ronda' : 'Reabrir'}
</button>
</div>`;
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;
try {
await fetchJSON(`/api/admin/rounds/${r.id}/status`, {
method: 'POST',
body: { status: open ? 'closed' : 'open' },
});
toast(open ? 'Ronda cerrada' : 'Ronda reabierta');
loadRounds();
} catch (ex) {
toast(ex.message, true);
}
});
list.appendChild(item);
}
}
const esc = (s) =>
s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// ---------- arranque ----------
fetchJSON('/api/admin/me')
.then(() => { show('panel'); loadRounds(); })
.catch(() => show('login'));
})();
+213
View File
@@ -0,0 +1,213 @@
/* Página del trabajador: registro + calendario de la ronda. */
(() => {
const token = location.pathname.split('/').pop();
const api = (p) => `/api/round/${token}${p}`;
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'];
let state = null; // respuesta del servidor
let selected = new Set(); // fechas elegidas (con cambios sin guardar)
let savedDates = new Set();
// ---------- utilidades ----------
const toast = (msg, isError = false) => {
const el = $('toast');
el.textContent = msg;
el.classList.toggle('error', isError);
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2600);
};
const fetchJSON = async (url, opts) => {
const res = await fetch(url, opts && {
...opts,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(opts.body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Error de conexión');
return data;
};
const iso = (y, m, d) =>
`${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
// ---------- registro ----------
function renderJoin() {
$('join-card').classList.remove('hidden');
const grid = $('role-grid');
grid.innerHTML = '';
for (const [value, label] of Object.entries(state.roles)) {
const opt = document.createElement('label');
opt.className = 'role-option';
opt.innerHTML = `<input type="radio" name="role" value="${value}" required /><span class="dot"></span>${label}`;
grid.appendChild(opt);
}
$('join-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = $('join-error');
err.classList.remove('show');
try {
const role = document.querySelector('input[name=role]:checked')?.value;
await fetchJSON(api('/join'), { method: 'POST', body: { name: $('name').value, role } });
await load();
} catch (ex) {
err.textContent = ex.message;
err.classList.add('show');
}
});
}
// ---------- calendario ----------
function renderCalendar() {
$('join-card').classList.add('hidden');
$('calendar-section').classList.remove('hidden');
const { me, round } = state;
const open = round.status === 'open';
$('me-name').textContent = me.name;
$('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());
const cal = $('calendar');
cal.innerHTML = '';
for (let m = 0; m < 12; m++) {
const month = document.createElement('section');
month.className = 'month';
month.style.animationDelay = `${m * 40}ms`;
month.innerHTML =
`<h3>${MONTHS[m]} ${round.year}</h3>` +
`<div class="dow">${DOW.map((d) => `<span>${d}</span>`).join('')}</div>`;
const days = document.createElement('div');
days.className = 'days';
const first = new Date(round.year, m, 1);
const blanks = (first.getDay() + 6) % 7; // semana empieza en lunes
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));
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 =
'<span class="count-sample">N</span> 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);
}
});
updateBars();
}
function updateBars() {
$('me-count').textContent = selected.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`;
}
$('save-btn').addEventListener('click', async () => {
const btn = $('save-btn');
btn.disabled = true;
try {
const data = await fetchJSON(api('/requests'), {
method: 'PUT',
body: { dates: [...selected] },
});
savedDates = new Set(data.dates);
selected = new Set(data.dates);
state.counts = data.counts;
paint();
toast('✓ Petición guardada');
} catch (ex) {
toast(ex.message, true);
} finally {
btn.disabled = false;
}
});
// ---------- carga inicial ----------
async function load() {
try {
state = await fetchJSON(api(''));
} catch {
$('round-title').textContent = 'Ronda no encontrada';
return;
}
$('round-title').innerHTML =
`${state.round.name} <em>${state.round.year}</em>`;
$('round-sub').textContent = state.me
? 'Marca en el calendario los días que quieres pedir'
: 'Pide tus días de vacaciones en un minuto';
document.title = `Vacaciones · ${state.round.name} ${state.round.year}`;
if (state.me) {
savedDates = new Set(state.me.dates);
selected = new Set(state.me.dates);
renderCalendar();
} else if (state.round.status !== 'open') {
$('closed-banner').classList.remove('hidden');
$('round-sub').textContent = 'Esta ronda ya no admite peticiones.';
} else {
renderJoin();
}
}
load();
})();
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Pedir vacaciones</title>
<meta name="theme-color" content="#f6f0e3" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..700;1,9..144,400..700&family=Karla:wght@400;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="page">
<header class="masthead">
<p class="kicker">Ronda de vacaciones</p>
<h1 id="round-title">Cargando…</h1>
<p class="sub" id="round-sub"></p>
</header>
<div id="closed-banner" class="closed-banner hidden">
🔒 Esta ronda está cerrada. Solo puedes consultar tu petición.
</div>
<!-- Registro -->
<section id="join-card" class="card hidden">
<h2>¿Quién eres?</h2>
<form id="join-form">
<label for="name">Tu nombre y apellido</label>
<input id="name" type="text" autocomplete="name" maxlength="60" placeholder="P. ej. María García" required />
<label>Tu cargo</label>
<div class="role-grid" id="role-grid"></div>
<div class="error-msg" id="join-error"></div>
<button class="btn btn--accent" type="submit">Empezar a elegir días</button>
</form>
</section>
<!-- Calendario -->
<section id="calendar-section" class="hidden">
<div class="me-strip">
<div class="who">
<small id="me-role"></small>
<span id="me-name"></span>
</div>
<div class="tally"><b id="me-count">0</b> días</div>
</div>
<p class="hint" id="calendar-hint">
Toca los días que quieres pedir. El número <span class="chip">2</span> indica
cuántas personas de tu mismo cargo ya han pedido ese día.
</p>
<div id="calendar"></div>
<footer class="colophon">Buen servicio · Buen descanso</footer>
</section>
</div>
<div class="savebar" id="savebar">
<div class="inner">
<span class="label" id="save-label"></span>
<button class="btn" id="save-btn">Guardar</button>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="/js/round.js"></script>
</body>
</html>