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
+6
View File
@@ -0,0 +1,6 @@
node_modules
data
*.db
*.db-*
.git
README.md
+10
View File
@@ -0,0 +1,10 @@
node_modules/
data/
*.db
*.db-shm
*.db-wal
.env
# Datos sensibles / locales (no subir nunca)
deploydata.txt
.claude/
+20
View File
@@ -0,0 +1,20 @@
# Imagen con glibc para que better-sqlite3 use binarios precompilados
FROM node:22-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev && npm cache clean --force
COPY server.js ./
COPY public ./public
# La base de datos SQLite vive aquí: montar un volumen persistente
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data && chown -R node:node /app
VOLUME /app/data
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+49
View File
@@ -0,0 +1,49 @@
# Vacaciones 🌴
Aplicación web sencilla y pensada para el móvil con la que el equipo de un restaurante pide sus días de vacaciones.
## Cómo funciona
**Trabajadores**
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.
**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.
- 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.
## Ejecutar en local
```bash
npm install
npm start # http://localhost:3000
```
Variables de entorno:
| Variable | Por defecto | Descripción |
|---|---|---|
| `ADMIN_PASSWORD` | `admin` | Contraseña del panel `/admin`. **Cámbiala en producción.** |
| `PORT` | `3000` | Puerto HTTP |
| `DATA_DIR` | `./data` | Carpeta donde se guarda la base de datos SQLite |
## Desplegar en Coolify
1. Sube este repositorio a Git (GitHub, GitLab…).
2. En Coolify: **New Resource → Application**, conecta el repositorio y elige **Build Pack: Dockerfile**.
3. Puerto expuesto: `3000`.
4. En **Environment Variables** añade `ADMIN_PASSWORD` con una contraseña fuerte.
5. En **Storages** añade un volumen persistente montado en `/app/data` (ahí vive la base de datos SQLite; sin esto perderías los datos en cada deploy).
6. Asigna tu dominio y despliega.
Después entra en `https://tudominio.com/admin`, crea la primera ronda y comparte la URL con el equipo.
## Notas técnicas
- Node.js + Express + SQLite (`better-sqlite3`): un solo contenedor, sin servicios externos.
- Las sesiones de administrador se guardan en memoria: si se reinicia el contenedor solo hay que volver a iniciar sesión.
- Excel generado con `exceljs`.
+2073
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "vacaciones",
"version": "1.0.0",
"description": "App para pedir días de vacaciones - rondas por local/año, con panel de administración y exportación a Excel",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"exceljs": "^4.4.0",
"express": "^4.21.2"
}
}
+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>
+378
View File
@@ -0,0 +1,378 @@
const express = require('express');
const cookieParser = require('cookie-parser');
const Database = require('better-sqlite3');
const ExcelJS = require('exceljs');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
if (!process.env.ADMIN_PASSWORD) {
console.warn('⚠ ADMIN_PASSWORD no está definida. Usando la contraseña por defecto "admin". Defínela en producción.');
}
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new Database(path.join(DATA_DIR, 'vacaciones.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
year INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
roles TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS workers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
round_id INTEGER NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
secret TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS requests (
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
date TEXT NOT NULL,
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');
}
// Cargos por defecto que se proponen al crear una ronda nueva.
const DEFAULT_ROLES = {
camarero: 'Camarero/a',
encargado: 'Encargado',
cocina: 'Cocina',
};
// Cargos de las rondas antiguas que no tienen cargos guardados (la clave
// histórica "jefe_rango" se conserva para no romper sus peticiones, pero se
// muestra ya como "Encargado").
const LEGACY_ROLES = {
camarero: 'Camarero/a',
jefe_rango: 'Encargado',
cocina: 'Cocina',
};
// Cargos de una ronda concreta (mapa clave → etiqueta).
function roundRoles(round) {
if (round.roles) {
try {
const parsed = JSON.parse(round.roles);
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length) return parsed;
} catch { /* cae al valor por defecto */ }
}
return LEGACY_ROLES;
}
// Convierte una etiqueta ("Camarero/a") en una clave estable ("camarero_a").
const slugify = (s) =>
String(s)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
// A partir de una lista de etiquetas crea el mapa {clave: etiqueta} con claves únicas.
function buildRoles(labels) {
const out = {};
labels.forEach((label, i) => {
const base = slugify(label) || `rol_${i + 1}`;
let key = base;
let n = 2;
while (out[key]) key = `${base}_${n++}`;
out[key] = label;
});
return out;
}
const app = express();
app.disable('x-powered-by');
app.use(express.json());
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
// ---------- helpers ----------
const newToken = (bytes = 9) => crypto.randomBytes(bytes).toString('base64url');
const getRound = db.prepare('SELECT * FROM rounds WHERE token = ?');
function getWorkerFromCookie(req, round) {
const secret = req.cookies[`vac_${round.token}`];
if (!secret) return null;
return db
.prepare('SELECT * FROM workers WHERE secret = ? AND round_id = ?')
.get(secret, round.id) || null;
}
function roundCounts(roundId, excludeWorkerId) {
// Recuento por fecha y cargo, 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 != ?
GROUP BY r.date, w.role`
)
.all(roundId, excludeWorkerId ?? -1);
const counts = {};
for (const row of rows) {
(counts[row.date] ??= {})[row.role] = row.n;
}
return counts;
}
function workerDates(workerId) {
return db
.prepare('SELECT date FROM requests WHERE worker_id = ? ORDER BY date')
.all(workerId)
.map((r) => r.date);
}
const isValidDate = (s, year) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
const d = new Date(s + 'T00:00:00Z');
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s && d.getUTCFullYear() === year;
};
// ---------- sesiones de administración (en memoria) ----------
const adminSessions = new Set();
function requireAdmin(req, res, next) {
const sid = req.cookies.admin_session;
if (sid && adminSessions.has(sid)) return next();
res.status(401).json({ error: 'No autorizado' });
}
// Comparación en tiempo constante para no filtrar la contraseña.
function passwordOk(given) {
const a = crypto.createHash('sha256').update(String(given ?? '')).digest();
const b = crypto.createHash('sha256').update(ADMIN_PASSWORD).digest();
return crypto.timingSafeEqual(a, b);
}
// ---------- páginas ----------
app.get('/', (_req, res) => res.redirect('/admin'));
app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
app.get('/r/:token', (req, res) => {
if (!getRound.get(req.params.token)) {
return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
}
res.sendFile(path.join(__dirname, 'public', 'round.html'));
});
// ---------- API trabajadores ----------
app.get('/api/round/:token', (req, res) => {
const round = getRound.get(req.params.token);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
const worker = getWorkerFromCookie(req, round);
res.json({
round: { name: round.name, year: round.year, status: round.status },
roles: roundRoles(round),
me: worker
? { name: worker.name, role: worker.role, dates: workerDates(worker.id) }
: null,
counts: roundCounts(round.id, worker?.id),
});
});
app.post('/api/round/:token/join', (req, res) => {
const round = getRound.get(req.params.token);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
if (round.status !== 'open') return res.status(403).json({ error: 'La ronda está cerrada' });
if (getWorkerFromCookie(req, round)) return res.status(409).json({ error: 'Ya estás registrado en esta ronda' });
const name = String(req.body.name ?? '').trim();
const role = String(req.body.role ?? '');
if (name.length < 2 || name.length > 60) return res.status(400).json({ error: 'Escribe tu nombre (260 caracteres)' });
if (!roundRoles(round)[role]) return res.status(400).json({ error: 'Elige un cargo válido' });
const secret = newToken(24);
db.prepare('INSERT INTO workers (round_id, name, role, secret) VALUES (?, ?, ?, ?)').run(
round.id, name, role, secret
);
res.cookie(`vac_${round.token}`, secret, {
httpOnly: true,
sameSite: 'lax',
maxAge: 400 * 24 * 3600 * 1000,
path: '/',
});
res.json({ ok: true, me: { name, role, dates: [] } });
});
app.put('/api/round/:token/requests', (req, res) => {
const round = getRound.get(req.params.token);
if (!round) return res.status(404).json({ error: 'Ronda no encontrada' });
if (round.status !== 'open') return res.status(403).json({ error: 'La ronda está cerrada, ya no se puede modificar' });
const worker = getWorkerFromCookie(req, round);
if (!worker) return res.status(401).json({ error: 'No estás registrado en esta ronda' });
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))) {
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);
});
replace(unique);
res.json({ ok: true, dates: unique.sort(), counts: roundCounts(round.id, worker.id) });
});
// ---------- API administración ----------
app.post('/api/admin/login', (req, res) => {
if (!passwordOk(req.body.password)) {
return res.status(401).json({ error: 'Contraseña incorrecta' });
}
const sid = newToken(24);
adminSessions.add(sid);
res.cookie('admin_session', sid, { httpOnly: true, sameSite: 'lax', maxAge: 30 * 24 * 3600 * 1000 });
res.json({ ok: true });
});
app.post('/api/admin/logout', requireAdmin, (req, res) => {
adminSessions.delete(req.cookies.admin_session);
res.clearCookie('admin_session');
res.json({ ok: true });
});
app.get('/api/admin/me', requireAdmin, (_req, res) => res.json({ ok: true }));
app.get('/api/admin/rounds', requireAdmin, (_req, res) => {
const rounds = db
.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
FROM rounds r ORDER BY r.created_at DESC`
)
.all();
res.json({ rounds });
});
app.post('/api/admin/rounds', requireAdmin, (req, res) => {
const name = String(req.body.name ?? '').trim();
const year = Number(req.body.year);
if (!name || name.length > 80) return res.status(400).json({ error: 'Indica el nombre del local' });
if (!Number.isInteger(year) || year < 2020 || year > 2100) return res.status(400).json({ error: 'Año no válido' });
let roles = DEFAULT_ROLES;
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);
}
const token = newToken();
const info = db
.prepare('INSERT INTO rounds (token, name, year, roles) VALUES (?, ?, ?, ?)')
.run(token, name, year, JSON.stringify(roles));
res.json({ ok: true, round: { id: info.lastInsertRowid, token, name, year, status: 'open' } });
});
app.post('/api/admin/rounds/:id/status', requireAdmin, (req, res) => {
const status = req.body.status === 'open' ? 'open' : 'closed';
const info = db.prepare('UPDATE rounds SET status = ? WHERE id = ?').run(status, req.params.id);
if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' });
res.json({ ok: true, status });
});
app.delete('/api/admin/rounds/:id', requireAdmin, (req, res) => {
const info = db.prepare('DELETE FROM rounds WHERE id = ?').run(req.params.id);
if (!info.changes) return res.status(404).json({ error: 'Ronda no encontrada' });
res.json({ ok: true });
});
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 workers = db
.prepare('SELECT * FROM workers WHERE round_id = ? ORDER BY role, name COLLATE NOCASE')
.all(round.id);
const wb = new ExcelJS.Workbook();
wb.creator = 'Vacaciones';
// Hoja 1: una fila por persona con sus fechas
const ws = wb.addWorksheet('Peticiones');
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 },
];
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(', ') });
}
// Hoja 2: recuento por día y cargo
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`
)
.all(round.id);
const dayMap = new Map();
for (const row of byDay) {
if (!dayMap.has(row.date)) dayMap.set(row.date, {});
dayMap.get(row.date)[row.role] = row.n;
}
const roleKeys = Object.keys(roles);
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 })),
{ header: 'Total', key: 'total', width: 9 },
];
ws2.getRow(1).font = { bold: true };
for (const [date, byRole] of dayMap) {
const row = { date };
let total = 0;
for (const k of roleKeys) {
row[k] = byRole[k] ?? 0;
total += row[k];
}
row.total = total;
ws2.addRow(row);
}
const safe = round.name.replace(/[^\p{L}\p{N} _-]/gu, '').trim() || 'ronda';
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="vacaciones-${safe}-${round.year}.xlsx"`);
await wb.xlsx.write(res);
res.end();
});
app.listen(PORT, () => console.log(`Vacaciones escuchando en http://localhost:${PORT}`));