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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
// ---------- arranque ----------
|
||||
|
||||
fetchJSON('/api/admin/me')
|
||||
.then(() => { show('panel'); loadRounds(); })
|
||||
.catch(() => show('login'));
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user