Hotel Rate Management
An interactive rate-management grid for Aurelia Hotels, showing room types × date columns with editable nightly rates, per-cell occupancy hints, demand-based colour coding, bulk adjustment by % or fixed amount, min-stay and closed-cell toggles, and a live save bar tracking changed cells.
MCP
Code
/* ── Design tokens ───────────────────────────────────────────────────────── */
:root {
--navy: #1a2b4a;
--navy-d: #0f1d36;
--navy-2: #2d4570;
--cream: #f7f3eb;
--cream-2: #ece5d4;
--bone: #fbf8f2;
--gold: #c9a649;
--gold-light: #e0c378;
--gold-d: #a88a2e;
--ink: #161e2c;
--ink-2: #2e3a52;
--warm-gray: #6c7280;
--line: rgba(22, 30, 44, 0.1);
--line-strong: rgba(22, 30, 44, 0.18);
--success: #4a7752;
--danger: #b34232;
--warning: #d99020;
--info: #4a6da0;
--font-display: "Cormorant Garamond", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-1: 0 1px 2px rgba(22, 30, 44, 0.06), 0 2px 8px rgba(22, 30, 44, 0.06);
--shadow-2: 0 12px 36px rgba(15, 29, 54, 0.16);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* ── Layout ─────────────────────────────────────────────────────────────── */
.rms {
height: 100vh;
display: grid;
grid-template-columns: 220px 1fr;
}
/* ── Rail ───────────────────────────────────────────────────────────────── */
.rail {
background: var(--navy);
color: var(--bone);
display: flex;
flex-direction: column;
padding: 22px 16px 14px;
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 4px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: var(--gold);
color: var(--navy-d);
font-family: var(--font-display);
font-weight: 700;
font-size: 1.35rem;
flex-shrink: 0;
}
.brand-name {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.brand-prop {
font-size: 0.68rem;
color: var(--gold-light);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-top: 2px;
}
.nav {
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.nav-item {
font-size: 0.84rem;
font-weight: 500;
color: rgba(251, 248, 242, 0.72);
text-decoration: none;
padding: 9px 12px;
border-radius: var(--r-md);
display: flex;
align-items: center;
gap: 8px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--bone);
}
.nav-item.is-active {
background: rgba(201, 166, 73, 0.16);
color: var(--gold-light);
font-weight: 600;
}
.nav-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--gold);
flex-shrink: 0;
}
/* ── Legend ─────────────────────────────────────────────────────────────── */
.rail-legend {
margin-top: 20px;
padding: 14px 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--r-md);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.legend-title {
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold-light);
margin-bottom: 8px;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.76rem;
color: rgba(251, 248, 242, 0.72);
margin-bottom: 5px;
}
.swatch {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.demand-low {
background: rgba(74, 119, 82, 0.5);
border: 1px solid rgba(74, 119, 82, 0.7);
}
.demand-med {
background: rgba(201, 166, 73, 0.5);
border: 1px solid rgba(201, 166, 73, 0.7);
}
.demand-high {
background: rgba(217, 144, 32, 0.5);
border: 1px solid rgba(217, 144, 32, 0.7);
}
.demand-peak {
background: rgba(179, 66, 50, 0.5);
border: 1px solid rgba(179, 66, 50, 0.7);
}
.demand-closed {
background: rgba(22, 30, 44, 0.4);
border: 1px solid rgba(22, 30, 44, 0.5);
}
.rail-foot {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.clock {
font-family: var(--font-mono);
font-weight: 600;
font-size: 0.84rem;
color: var(--gold-light);
}
.agent {
font-size: 0.7rem;
color: rgba(251, 248, 242, 0.55);
letter-spacing: 0.04em;
}
/* ── Main ───────────────────────────────────────────────────────────────── */
.main {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--cream);
}
/* ── Topbar ─────────────────────────────────────────────────────────────── */
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 20px;
padding: 22px 28px 14px;
border-bottom: 1px solid var(--line);
background: var(--bone);
flex-shrink: 0;
}
.kicker {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--gold-d);
font-weight: 600;
}
.topbar h1 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.8rem;
letter-spacing: -0.005em;
margin-top: 2px;
}
.topbar h1 span {
color: var(--navy-2);
font-weight: 500;
font-size: 1.4rem;
}
.topbar-controls {
display: flex;
align-items: center;
gap: 10px;
}
.ctrl-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--warm-gray);
display: flex;
flex-direction: column;
gap: 3px;
}
.ctrl-select {
font-family: var(--font-body);
font-size: 0.84rem;
font-weight: 500;
color: var(--ink);
background: var(--bone);
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
padding: 7px 10px;
cursor: pointer;
outline: none;
}
.ctrl-select:focus {
border-color: var(--navy-2);
}
.ctrl-select.sm {
font-size: 0.8rem;
padding: 6px 8px;
}
.icon-btn {
background: var(--bone);
border: 1px solid var(--line);
width: 38px;
height: 38px;
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
color: var(--ink-2);
align-self: flex-end;
}
.icon-btn:hover {
background: var(--cream-2);
}
/* ── Bulk bar ───────────────────────────────────────────────────────────── */
.bulk-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 28px;
background: var(--navy);
flex-shrink: 0;
flex-wrap: wrap;
}
.bulk-count {
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 600;
color: var(--gold-light);
min-width: 140px;
}
.bulk-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bulk-input {
font-family: var(--font-mono);
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
background: var(--bone);
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
padding: 7px 10px;
width: 80px;
outline: none;
}
.bulk-input:focus {
border-color: var(--gold);
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.action {
background: rgba(251, 248, 242, 0.12);
border: 1px solid rgba(251, 248, 242, 0.22);
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
color: var(--bone);
padding: 7px 14px;
border-radius: var(--r-sm);
cursor: pointer;
white-space: nowrap;
}
.action:hover {
background: rgba(251, 248, 242, 0.2);
}
.action.primary {
background: var(--gold);
color: var(--navy-d);
border-color: var(--gold);
}
.action.primary:hover {
background: var(--gold-light);
}
/* ── Grid wrap ──────────────────────────────────────────────────────────── */
.grid-wrap {
flex: 1;
overflow: auto;
padding: 0 0 4px;
}
/* ── Rate grid ──────────────────────────────────────────────────────────── */
.rate-grid {
border-collapse: collapse;
width: max-content;
min-width: 100%;
font-size: 0.82rem;
}
.rate-grid thead th {
position: sticky;
top: 0;
z-index: 10;
background: var(--bone);
border-bottom: 2px solid var(--line-strong);
padding: 0;
}
.rate-grid thead th:first-child {
position: sticky;
left: 0;
z-index: 20;
min-width: 170px;
}
.th-inner {
padding: 10px 12px;
text-align: center;
min-width: 108px;
}
.th-date {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.78rem;
color: var(--navy-d);
display: block;
}
.th-dow {
font-size: 0.68rem;
font-weight: 600;
color: var(--warm-gray);
text-transform: uppercase;
letter-spacing: 0.1em;
display: block;
margin-top: 2px;
}
.th-dow.weekend {
color: var(--gold-d);
}
.th-room {
text-align: left;
padding: 10px 16px;
}
.th-room-inner {
display: flex;
align-items: center;
gap: 10px;
}
.th-room-cb {
cursor: pointer;
accent-color: var(--gold);
width: 15px;
height: 15px;
}
.room-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--warm-gray);
}
/* ── Row header ─────────────────────────────────────────────────────────── */
.rate-grid tbody td.row-head {
position: sticky;
left: 0;
z-index: 5;
background: var(--bone);
border-right: 2px solid var(--line-strong);
padding: 0;
}
.row-head-inner {
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
min-height: 64px;
}
.row-cb {
cursor: pointer;
accent-color: var(--gold);
width: 15px;
height: 15px;
flex-shrink: 0;
}
.row-type-name {
font-weight: 700;
font-size: 0.88rem;
color: var(--navy-d);
letter-spacing: -0.005em;
}
.row-type-code {
font-family: var(--font-mono);
font-size: 0.66rem;
color: var(--warm-gray);
letter-spacing: 0.08em;
margin-top: 1px;
}
/* ── Rate cell ──────────────────────────────────────────────────────────── */
.rate-grid tbody td.rate-cell {
padding: 0;
border: 1px solid var(--line);
vertical-align: top;
position: relative;
cursor: pointer;
min-width: 108px;
height: 64px;
}
.rate-grid tbody tr:hover td.rate-cell {
background: rgba(201, 166, 73, 0.04);
}
.cell-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 6px 8px;
position: relative;
gap: 2px;
transition: background 0.12s;
}
/* demand colouring via data-demand */
.rate-cell[data-demand="low"] .cell-inner {
background: rgba(74, 119, 82, 0.1);
}
.rate-cell[data-demand="med"] .cell-inner {
background: rgba(201, 166, 73, 0.14);
}
.rate-cell[data-demand="high"] .cell-inner {
background: rgba(217, 144, 32, 0.18);
}
.rate-cell[data-demand="peak"] .cell-inner {
background: rgba(179, 66, 50, 0.16);
}
.rate-cell[data-demand="closed"] .cell-inner {
background: rgba(22, 30, 44, 0.07);
}
.rate-cell[data-demand="closed"] .cell-rate {
text-decoration: line-through;
color: var(--warm-gray);
}
/* selected state */
.rate-cell.is-selected .cell-inner {
outline: 2px solid var(--gold);
outline-offset: -2px;
}
/* dirty (changed) state */
.rate-cell.is-dirty::after {
content: "";
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--gold);
}
.cell-rate {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.9rem;
color: var(--navy-d);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.cell-occ {
font-size: 0.64rem;
font-weight: 600;
color: var(--warm-gray);
letter-spacing: 0.06em;
}
.cell-flags {
display: flex;
gap: 3px;
margin-top: 1px;
}
.cell-flag {
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.06em;
padding: 1px 4px;
border-radius: 3px;
text-transform: uppercase;
}
.cell-flag.min {
background: rgba(74, 109, 160, 0.16);
color: var(--info);
}
.cell-flag.cls {
background: rgba(22, 30, 44, 0.1);
color: var(--warm-gray);
}
/* cell hover controls */
.cell-controls {
display: none;
position: absolute;
bottom: 3px;
right: 3px;
gap: 2px;
}
.rate-cell:hover .cell-controls,
.rate-cell:focus-within .cell-controls {
display: flex;
}
.cc-btn {
width: 18px;
height: 18px;
border-radius: 3px;
border: 1px solid var(--line-strong);
background: var(--bone);
font-size: 0.6rem;
font-weight: 700;
color: var(--ink-2);
cursor: pointer;
display: grid;
place-items: center;
line-height: 1;
}
.cc-btn:hover {
background: var(--cream-2);
}
.cc-btn.active {
background: var(--navy);
color: var(--bone);
border-color: var(--navy);
}
/* inline edit input */
.cell-edit-input {
font-family: var(--font-mono);
font-size: 0.88rem;
font-weight: 700;
color: var(--navy-d);
background: var(--bone);
border: 2px solid var(--gold);
border-radius: var(--r-sm);
width: 76px;
padding: 4px 6px;
text-align: center;
outline: none;
font-variant-numeric: tabular-nums;
}
/* ── Save bar ───────────────────────────────────────────────────────────── */
.save-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 28px;
background: var(--navy-d);
border-top: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
#saveMsg {
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 600;
color: var(--gold-light);
}
.save-actions {
display: flex;
gap: 8px;
}
/* ── Toast ──────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
background: var(--navy-d);
color: var(--bone);
padding: 11px 20px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: var(--shadow-2);
white-space: nowrap;
pointer-events: none;
}
/* ── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 960px) {
.rms {
grid-template-columns: 1fr;
}
.rail {
display: none;
}
.topbar {
flex-direction: column;
align-items: flex-start;
padding: 14px 16px 10px;
}
.topbar-controls {
flex-wrap: wrap;
}
.bulk-bar {
padding: 8px 16px;
}
}
@media (max-width: 560px) {
.topbar h1 {
font-size: 1.3rem;
}
.bulk-controls {
gap: 6px;
}
.save-bar {
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 10px 16px;
}
}// ── Config ───────────────────────────────────────────────────────────────────
const START_DATE = new Date("2026-06-09T00:00:00");
const NUM_DAYS = 14;
// ── Room types ───────────────────────────────────────────────────────────────
const ROOM_TYPES = [
{ code: "STD", name: "Standard Double", base: 145 },
{ code: "STD", name: "Standard Twin", base: 140 },
{ code: "DLX", name: "Deluxe Double", base: 185 },
{ code: "DLX", name: "Deluxe Sea View", base: 210 },
{ code: "JNR", name: "Junior Suite", base: 285 },
{ code: "STE", name: "Grand Suite", base: 420 },
{ code: "STE", name: "Penthouse Suite", base: 595 },
];
// ── Occupancy seeds (per date index, 0–13) ───────────────────────────────────
const OCC_SEEDS = [58, 62, 74, 81, 88, 95, 96, 70, 65, 72, 84, 90, 93, 78];
// ── State ────────────────────────────────────────────────────────────────────
// cells[rtIdx][dayIdx] = { rate, occ, minStay, closed }
const cells = [];
let dirtySet = new Set(); // "rtIdx-dayIdx" keys
let selectedSet = new Set(); // same key format
// ── Initialise cell state ────────────────────────────────────────────────────
function initCells() {
ROOM_TYPES.forEach((rt, ri) => {
cells[ri] = [];
for (let di = 0; di < NUM_DAYS; di++) {
const occ = Math.min(
100,
OCC_SEEDS[di] + Math.round((rt.base / 145 - 1) * -8) + Math.round(Math.random() * 6 - 3)
);
const rate = rt.base + Math.round((occ - 65) * (rt.base * 0.004));
// weekend uplift
const dayOfWeek = new Date(START_DATE.getTime() + di * 86400000).getDay();
const wkuplift = dayOfWeek === 5 || dayOfWeek === 6 ? Math.round(rt.base * 0.12) : 0;
cells[ri][di] = {
rate: Math.round(rate + wkuplift),
occ: Math.max(20, Math.min(100, occ)),
minStay: di === 5 || di === 6 || di === 12 || di === 13,
closed: false,
};
}
});
}
// ── Demand tier from occupancy ────────────────────────────────────────────────
function demandTier(c) {
if (c.closed) return "closed";
if (c.occ >= 95) return "peak";
if (c.occ >= 80) return "high";
if (c.occ >= 50) return "med";
return "low";
}
// ── Date helpers ─────────────────────────────────────────────────────────────
function addDays(d, n) {
return new Date(d.getTime() + n * 86400000);
}
function fmtDate(d) {
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short" });
}
function fmtDow(d) {
return d.toLocaleDateString("en-GB", { weekday: "short" });
}
// ── Render helpers ───────────────────────────────────────────────────────────
const gridHead = document.getElementById("gridHead");
const gridBody = document.getElementById("gridBody");
const bulkCount = document.getElementById("bulkCount");
const saveMsg = document.getElementById("saveMsg");
const toast = document.getElementById("toast");
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2200);
}
function updateSaveBar() {
saveMsg.textContent =
dirtySet.size === 0
? "No unsaved changes"
: `${dirtySet.size} unsaved change${dirtySet.size !== 1 ? "s" : ""}`;
}
function updateBulkBar() {
bulkCount.textContent = `${selectedSet.size} cell${selectedSet.size !== 1 ? "s" : ""} selected`;
}
// ── Build thead ──────────────────────────────────────────────────────────────
function buildHead() {
const tr = document.createElement("tr");
// corner cell
const thCorner = document.createElement("th");
thCorner.className = "th-room";
thCorner.innerHTML = `<div class="th-room-inner">
<input type="checkbox" class="th-room-cb" id="cbAll" title="Select all" />
<span class="room-label">Room Type</span>
</div>`;
tr.appendChild(thCorner);
for (let di = 0; di < NUM_DAYS; di++) {
const d = addDays(START_DATE, di);
const dow = fmtDow(d);
const isWk = d.getDay() === 0 || d.getDay() === 6;
const th = document.createElement("th");
th.innerHTML = `<div class="th-inner">
<span class="th-date">${fmtDate(d)}</span>
<span class="th-dow${isWk ? " weekend" : ""}">${dow}</span>
</div>`;
tr.appendChild(th);
}
gridHead.innerHTML = "";
gridHead.appendChild(tr);
// select-all checkbox
document.getElementById("cbAll").addEventListener("change", (e) => {
const visRt = getVisibleRtIndices();
if (e.target.checked) {
visRt.forEach((ri) => {
for (let di = 0; di < NUM_DAYS; di++) {
const key = `${ri}-${di}`;
if (!cells[ri][di].closed) selectedSet.add(key);
}
});
} else {
selectedSet.clear();
}
renderSelected();
updateBulkBar();
});
}
// ── Build tbody ──────────────────────────────────────────────────────────────
function buildBody() {
gridBody.innerHTML = "";
ROOM_TYPES.forEach((rt, ri) => {
const tr = document.createElement("tr");
tr.dataset.ri = ri;
// row header
const tdHead = document.createElement("td");
tdHead.className = "row-head";
tdHead.innerHTML = `<div class="row-head-inner">
<input type="checkbox" class="row-cb" data-ri="${ri}" title="Select row" />
<div>
<div class="row-type-name">${rt.name}</div>
<div class="row-type-code">${rt.code}</div>
</div>
</div>`;
tr.appendChild(tdHead);
// rate cells
for (let di = 0; di < NUM_DAYS; di++) {
const td = buildCell(ri, di);
tr.appendChild(td);
}
gridBody.appendChild(tr);
});
// row checkboxes
gridBody.querySelectorAll(".row-cb").forEach((cb) => {
cb.addEventListener("change", (e) => {
const ri = parseInt(e.target.dataset.ri);
for (let di = 0; di < NUM_DAYS; di++) {
const key = `${ri}-${di}`;
if (e.target.checked && !cells[ri][di].closed) selectedSet.add(key);
else selectedSet.delete(key);
}
renderSelected();
updateBulkBar();
});
});
}
// ── Build a single rate cell ──────────────────────────────────────────────────
function buildCell(ri, di) {
const c = cells[ri][di];
const key = `${ri}-${di}`;
const td = document.createElement("td");
td.className = "rate-cell";
td.dataset.ri = ri;
td.dataset.di = di;
td.dataset.demand = demandTier(c);
if (dirtySet.has(key)) td.classList.add("is-dirty");
if (selectedSet.has(key)) td.classList.add("is-selected");
td.innerHTML = `<div class="cell-inner">
<span class="cell-rate">${c.closed ? "Closed" : "€" + c.rate}</span>
<span class="cell-occ">${c.occ}% occ</span>
<div class="cell-flags">
${c.minStay ? `<span class="cell-flag min">2N min</span>` : ""}
${c.closed ? `<span class="cell-flag cls">Closed</span>` : ""}
</div>
<div class="cell-controls">
<button class="cc-btn min-btn${c.minStay ? " active" : ""}" title="Toggle min-stay">M</button>
<button class="cc-btn cls-btn${c.closed ? " active" : ""}" title="Toggle closed">X</button>
</div>
</div>`;
// click to select / edit
td.addEventListener("click", (e) => {
if (e.target.closest(".cc-btn")) return; // handled separately
if (e.target.closest(".cell-edit-input")) return;
if (c.closed) return;
startEdit(td, ri, di);
});
// min-stay toggle
td.querySelector(".min-btn").addEventListener("click", (e) => {
e.stopPropagation();
cells[ri][di].minStay = !cells[ri][di].minStay;
dirtySet.add(key);
refreshCell(td, ri, di);
updateSaveBar();
showToast(cells[ri][di].minStay ? "Min-stay set to 2 nights" : "Min-stay removed");
});
// closed toggle
td.querySelector(".cls-btn").addEventListener("click", (e) => {
e.stopPropagation();
cells[ri][di].closed = !cells[ri][di].closed;
dirtySet.add(key);
refreshCell(td, ri, di);
updateSaveBar();
showToast(cells[ri][di].closed ? "Cell marked closed" : "Cell reopened");
});
return td;
}
// ── Refresh a single cell in place ───────────────────────────────────────────
function refreshCell(td, ri, di) {
const c = cells[ri][di];
const key = `${ri}-${di}`;
td.dataset.demand = demandTier(c);
td.classList.toggle("is-dirty", dirtySet.has(key));
td.classList.toggle("is-selected", selectedSet.has(key));
const inner = td.querySelector(".cell-inner");
inner.innerHTML = `
<span class="cell-rate">${c.closed ? "Closed" : "€" + c.rate}</span>
<span class="cell-occ">${c.occ}% occ</span>
<div class="cell-flags">
${c.minStay ? `<span class="cell-flag min">2N min</span>` : ""}
${c.closed ? `<span class="cell-flag cls">Closed</span>` : ""}
</div>
<div class="cell-controls">
<button class="cc-btn min-btn${c.minStay ? " active" : ""}" title="Toggle min-stay">M</button>
<button class="cc-btn cls-btn${c.closed ? " active" : ""}" title="Toggle closed">X</button>
</div>`;
// rebind toggle buttons
inner.querySelector(".min-btn").addEventListener("click", (e) => {
e.stopPropagation();
cells[ri][di].minStay = !cells[ri][di].minStay;
dirtySet.add(key);
refreshCell(td, ri, di);
updateSaveBar();
showToast(cells[ri][di].minStay ? "Min-stay set to 2 nights" : "Min-stay removed");
});
inner.querySelector(".cls-btn").addEventListener("click", (e) => {
e.stopPropagation();
cells[ri][di].closed = !cells[ri][di].closed;
dirtySet.add(key);
refreshCell(td, ri, di);
updateSaveBar();
showToast(cells[ri][di].closed ? "Cell marked closed" : "Cell reopened");
});
}
// ── Inline edit ──────────────────────────────────────────────────────────────
function startEdit(td, ri, di) {
if (td.querySelector(".cell-edit-input")) return; // already editing
const c = cells[ri][di];
const key = `${ri}-${di}`;
const inner = td.querySelector(".cell-inner");
const rateEl = inner.querySelector(".cell-rate");
const input = document.createElement("input");
input.className = "cell-edit-input";
input.type = "number";
input.value = c.rate;
input.min = 1;
input.step = 1;
rateEl.replaceWith(input);
input.focus();
input.select();
function commit() {
const val = Math.max(1, Math.round(parseFloat(input.value) || c.rate));
const changed = val !== c.rate;
cells[ri][di].rate = val;
if (changed) dirtySet.add(key);
input.replaceWith(rateEl);
rateEl.textContent = "€" + val;
td.dataset.demand = demandTier(cells[ri][di]);
td.classList.toggle("is-dirty", dirtySet.has(key));
updateSaveBar();
if (changed) showToast(`Rate updated → €${val}`);
}
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
}
if (e.key === "Escape") {
input.replaceWith(rateEl);
}
});
input.addEventListener("blur", commit);
}
// ── Render selected state ─────────────────────────────────────────────────────
function renderSelected() {
gridBody.querySelectorAll(".rate-cell").forEach((td) => {
const key = `${td.dataset.ri}-${td.dataset.di}`;
td.classList.toggle("is-selected", selectedSet.has(key));
});
}
// ── Bulk apply ───────────────────────────────────────────────────────────────
document.getElementById("bulkApply").addEventListener("click", () => {
const op = document.getElementById("bulkOp").value;
const val = parseFloat(document.getElementById("bulkVal").value);
if (isNaN(val) || val <= 0) {
showToast("Enter a positive value first");
return;
}
if (selectedSet.size === 0) {
showToast("Select at least one cell first");
return;
}
selectedSet.forEach((key) => {
const [ri, di] = key.split("-").map(Number);
const c = cells[ri][di];
if (c.closed) return;
let newRate = c.rate;
if (op === "raise-pct") newRate = Math.round(c.rate * (1 + val / 100));
if (op === "lower-pct") newRate = Math.round(c.rate * (1 - val / 100));
if (op === "raise-amt") newRate = c.rate + Math.round(val);
if (op === "lower-amt") newRate = c.rate - Math.round(val);
if (op === "set-amt") newRate = Math.round(val);
cells[ri][di].rate = Math.max(1, newRate);
dirtySet.add(key);
});
// re-render affected cells
gridBody.querySelectorAll(".rate-cell").forEach((td) => {
const key = `${td.dataset.ri}-${td.dataset.di}`;
if (selectedSet.has(key)) refreshCell(td, parseInt(td.dataset.ri), parseInt(td.dataset.di));
});
updateSaveBar();
showToast(`Bulk applied to ${selectedSet.size} cell${selectedSet.size !== 1 ? "s" : ""}`);
});
// ── Clear selection ──────────────────────────────────────────────────────────
document.getElementById("bulkClear").addEventListener("click", () => {
selectedSet.clear();
renderSelected();
updateBulkBar();
document.getElementById("cbAll").checked = false;
gridBody.querySelectorAll(".row-cb").forEach((cb) => (cb.checked = false));
});
// ── Save / discard ───────────────────────────────────────────────────────────
document.getElementById("saveBtn").addEventListener("click", () => {
const n = dirtySet.size;
if (n === 0) {
showToast("Nothing to save");
return;
}
dirtySet.clear();
gridBody.querySelectorAll(".rate-cell.is-dirty").forEach((td) => td.classList.remove("is-dirty"));
updateSaveBar();
showToast(`${n} rate${n !== 1 ? "s" : ""} saved successfully`);
});
document.getElementById("discardBtn").addEventListener("click", () => {
if (dirtySet.size === 0) {
showToast("Nothing to discard");
return;
}
initCells();
dirtySet.clear();
selectedSet.clear();
buildBody();
updateSaveBar();
updateBulkBar();
showToast("Changes discarded");
});
// ── Room type filter ──────────────────────────────────────────────────────────
function getVisibleRtIndices() {
const f = document.getElementById("filterType").value;
return ROOM_TYPES.reduce((acc, rt, i) => {
if (f === "all" || rt.code === f) acc.push(i);
return acc;
}, []);
}
function applyRtFilter() {
const vis = new Set(getVisibleRtIndices());
gridBody.querySelectorAll("tr[data-ri]").forEach((tr) => {
tr.style.display = vis.has(parseInt(tr.dataset.ri)) ? "" : "none";
});
}
document.getElementById("filterType").addEventListener("change", applyRtFilter);
document.getElementById("filterPlan").addEventListener("change", () => {
showToast("Rate plan switched — reloading rates…");
});
// ── Date range label ─────────────────────────────────────────────────────────
function setDateRange() {
const end = addDays(START_DATE, NUM_DAYS - 1);
document.getElementById("dateRange").textContent = `${fmtDate(START_DATE)} – ${fmtDate(end)}`;
}
// ── Clock ────────────────────────────────────────────────────────────────────
function tick() {
const now = new Date();
const hhmm = now.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
const wk = now.toLocaleDateString("en-GB", { weekday: "short" });
document.getElementById("clock").textContent = `${hhmm} · ${wk}`;
}
tick();
setInterval(tick, 1000);
// ── Boot ─────────────────────────────────────────────────────────────────────
initCells();
buildHead();
buildBody();
setDateRange();
updateSaveBar();
updateBulkBar();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Rate Management · Aurelia Hotels</title>
</head>
<body>
<main class="rms">
<!-- ── Rail ── -->
<aside class="rail">
<div class="brand">
<span class="brand-mark">Æ</span>
<div>
<p class="brand-name">Aurelia Hotels</p>
<p class="brand-prop">Aurelia · Madrid</p>
</div>
</div>
<nav class="nav">
<a class="nav-item" href="#">Dashboard</a>
<a class="nav-item" href="#">Reservations</a>
<a class="nav-item" href="#">Room rack</a>
<a class="nav-item" href="#">Housekeeping</a>
<a class="nav-item is-active" href="#"><span class="nav-dot"></span>Rate mgmt</a>
<a class="nav-item" href="#">Channels</a>
<a class="nav-item" href="#">Inventory</a>
<a class="nav-item" href="#">Reports</a>
</nav>
<div class="rail-legend">
<p class="legend-title">Demand key</p>
<div class="legend-row"><span class="swatch demand-low"></span>Low <50%</div>
<div class="legend-row"><span class="swatch demand-med"></span>Moderate 50–79%</div>
<div class="legend-row"><span class="swatch demand-high"></span>High 80–94%</div>
<div class="legend-row"><span class="swatch demand-peak"></span>Peak ≥95%</div>
<div class="legend-row"><span class="swatch demand-closed"></span>Closed</div>
</div>
<footer class="rail-foot">
<span class="clock" id="clock">--:-- · --</span>
<span class="agent">Revenue · Sofia M.</span>
</footer>
</aside>
<!-- ── Main ── -->
<section class="main">
<header class="topbar">
<div>
<p class="kicker">Revenue Management</p>
<h1>Rate Grid · <span id="dateRange">—</span></h1>
</div>
<div class="topbar-controls">
<label class="ctrl-label">Room type
<select id="filterType" class="ctrl-select">
<option value="all">All types</option>
<option value="STD">Standard</option>
<option value="DLX">Deluxe</option>
<option value="JNR">Junior Suite</option>
<option value="STE">Suite</option>
</select>
</label>
<label class="ctrl-label">Rate plan
<select id="filterPlan" class="ctrl-select">
<option value="BAR">BAR</option>
<option value="NR">Non-refundable</option>
<option value="BB">Bed & Breakfast</option>
</select>
</label>
<button class="icon-btn" title="Notifications">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 22a2 2 0 0 0 2-2h-4a2 2 0 0 0 2 2zm6-6V11a6 6 0 0 0-12 0v5l-2 2v1h16v-1l-2-2z" fill="currentColor"/></svg>
</button>
</div>
</header>
<!-- ── Bulk toolbar ── -->
<div class="bulk-bar" id="bulkBar">
<span class="bulk-count" id="bulkCount">0 cells selected</span>
<div class="bulk-controls">
<select id="bulkOp" class="ctrl-select sm">
<option value="raise-pct">Raise %</option>
<option value="lower-pct">Lower %</option>
<option value="raise-amt">Raise €</option>
<option value="lower-amt">Lower €</option>
<option value="set-amt">Set to €</option>
</select>
<input id="bulkVal" type="number" class="bulk-input" placeholder="10" min="0" step="1" />
<button class="action primary" id="bulkApply">Apply</button>
<button class="action" id="bulkClear">Clear selection</button>
</div>
</div>
<!-- ── Rate grid ── -->
<div class="grid-wrap">
<table class="rate-grid" id="rateGrid">
<thead id="gridHead"></thead>
<tbody id="gridBody"></tbody>
</table>
</div>
<!-- ── Save bar ── -->
<div class="save-bar" id="saveBar">
<span id="saveMsg">0 unsaved changes</span>
<div class="save-actions">
<button class="action" id="discardBtn">Discard</button>
<button class="action primary" id="saveBtn">Save changes</button>
</div>
</div>
</section>
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Hotel Rate Management
A full-screen revenue-management console for Aurelia Hotels. The grid displays room types as rows against a 14-day date window as columns; each cell shows the current nightly rate, an occupancy-percentage hint, and a demand colour (green → amber → red). Click any cell to edit its rate inline and commit with Enter or blur. Select cells via checkbox to apply a bulk raise or lower by percentage or fixed euro amount. Toggle a cell’s min-stay requirement or mark it closed with the per-cell controls that appear on hover. The floating save bar counts pending changes and lets you persist or discard them. A room-type and rate-plan selector at the top narrows the visible rows.