Hotel Maintenance Tickets
A centered maintenance ticket queue widget listing open issues by room, type (AC, plumbing, TV, lock), priority, age, assignee, and status. Interactive: submit a new ticket via a mini-form, cycle status through Open → In Progress → Resolved, filter by priority, and watch the open-count badge update live.
MCP
Codice
/* ── 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;
}
body {
font-family: var(--font-body);
background: var(--cream-2);
color: var(--ink);
-webkit-font-smoothing: antialiased;
display: grid;
place-items: start center;
min-height: 100vh;
padding: 32px 16px;
}
/* ── Widget wrapper ─────────────────────────────────────────────────────────── */
.widget-wrap {
width: 100%;
max-width: 780px;
}
.widget {
background: var(--bone);
border: 1px solid var(--line-strong);
border-radius: var(--r-lg);
box-shadow: var(--shadow-2);
overflow: hidden;
}
/* ── Widget header ───────────────────────────────────────────────────────────── */
.widget-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
background: var(--navy);
color: var(--bone);
}
.wh-left {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 40px;
height: 40px;
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;
}
.wh-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.25rem;
letter-spacing: 0.01em;
}
.wh-sub {
font-size: 0.7rem;
color: var(--gold-light);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-top: 2px;
}
.open-badge {
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 700;
background: var(--danger);
color: #fff;
padding: 5px 14px;
border-radius: 999px;
font-variant-numeric: tabular-nums;
transition: background 0.2s;
}
.open-badge[data-zero="true"] {
background: rgba(74, 119, 82, 0.8);
}
/* ── New ticket form ─────────────────────────────────────────────────────────── */
.new-form {
padding: 14px 22px;
background: rgba(22, 30, 44, 0.03);
border-bottom: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 8px;
}
.form-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.form-input,
.form-select {
background: var(--bone);
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
font-family: var(--font-body);
font-size: 0.84rem;
color: var(--ink);
padding: 9px 12px;
outline: none;
transition: border-color 0.1s;
}
.form-input:focus,
.form-select:focus {
border-color: var(--navy-2);
}
.form-input[id="fRoom"] {
width: 90px;
}
.form-input[id="fAssignee"] {
flex: 1;
min-width: 100px;
}
.form-input.desc {
flex: 1;
min-width: 200px;
}
.form-select {
flex: 1;
min-width: 110px;
cursor: pointer;
}
.form-submit {
background: var(--navy);
color: var(--bone);
border: none;
font-family: inherit;
font-size: 0.84rem;
font-weight: 700;
padding: 9px 18px;
border-radius: var(--r-sm);
cursor: pointer;
white-space: nowrap;
transition: background 0.1s;
}
.form-submit:hover {
background: var(--navy-d);
}
/* ── Filter bar ──────────────────────────────────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 22px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.filter-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--warm-gray);
margin-right: 2px;
}
.chip {
background: transparent;
border: 1px solid var(--line-strong);
font-family: inherit;
font-size: 0.74rem;
font-weight: 600;
color: var(--ink-2);
padding: 5px 12px;
border-radius: 999px;
cursor: pointer;
transition: background 0.1s, border-color 0.1s, color 0.1s;
}
.chip:hover {
background: var(--cream-2);
}
.chip.is-active {
background: var(--gold);
color: var(--navy-d);
border-color: var(--gold);
}
/* ── Ticket list ─────────────────────────────────────────────────────────────── */
.ticket-list {
list-style: none;
max-height: 520px;
overflow-y: auto;
}
/* ── Ticket item ─────────────────────────────────────────────────────────────── */
.ticket {
display: grid;
grid-template-columns: 60px 1fr auto;
align-items: start;
gap: 12px;
padding: 14px 22px;
border-bottom: 1px solid var(--line);
transition: background 0.1s;
}
.ticket:last-child {
border-bottom: none;
}
.ticket:hover {
background: rgba(22, 30, 44, 0.02);
}
.ticket.resolved {
opacity: 0.55;
}
/* room + id block */
.t-room-block {
display: flex;
flex-direction: column;
gap: 3px;
align-items: center;
text-align: center;
}
.t-room {
font-family: var(--font-mono);
font-weight: 700;
font-size: 1.05rem;
color: var(--navy-d);
}
.t-id {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--warm-gray);
font-variant-numeric: tabular-nums;
}
/* body */
.t-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.t-top {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.t-issue {
font-weight: 700;
font-size: 0.9rem;
color: var(--ink);
}
.pri-badge {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
}
.pri-badge.urgent {
background: rgba(179, 66, 50, 0.18);
color: var(--danger);
}
.pri-badge.high {
background: rgba(217, 144, 32, 0.16);
color: var(--warning);
}
.pri-badge.normal {
background: rgba(74, 109, 160, 0.14);
color: var(--info);
}
.pri-badge.low {
background: rgba(74, 119, 82, 0.12);
color: var(--success);
}
.t-desc {
font-size: 0.82rem;
color: var(--ink-2);
line-height: 1.4;
}
.t-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.74rem;
color: var(--warm-gray);
margin-top: 2px;
flex-wrap: wrap;
}
.t-assignee strong {
font-weight: 600;
color: var(--navy-2);
}
.t-age {
font-family: var(--font-mono);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
/* right side: status pill (clickable) */
.t-status-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
padding-top: 2px;
}
.status-btn {
border: none;
font-family: inherit;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 11px;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: filter 0.1s;
}
.status-btn:hover {
filter: brightness(0.9);
}
.status-btn.open {
background: rgba(179, 66, 50, 0.14);
color: var(--danger);
}
.status-btn.inprogress {
background: rgba(217, 144, 32, 0.16);
color: var(--warning);
}
.status-btn.resolved {
background: rgba(74, 119, 82, 0.14);
color: var(--success);
}
.status-hint {
font-size: 0.62rem;
color: var(--warm-gray);
white-space: nowrap;
}
/* ── Empty state ─────────────────────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 40px 24px;
font-size: 0.86rem;
color: var(--warm-gray);
font-style: italic;
}
/* ── Toast ───────────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--navy-d);
color: var(--bone);
padding: 11px 22px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: var(--shadow-2);
z-index: 100;
white-space: nowrap;
}
/* ── Responsive ─────────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
body {
padding: 16px 8px;
}
.ticket {
grid-template-columns: 50px 1fr;
}
.t-status-wrap {
grid-column: 2;
flex-direction: row;
align-items: center;
}
.form-row {
gap: 6px;
}
.form-input[id="fRoom"] {
width: 70px;
}
}// ── Mock data ─────────────────────────────────────────────────────────────────
let tickets = [
{
id: 1001,
room: "311",
issue: "AC",
priority: "urgent",
desc: "AC not cooling — guest reports 28 °C in room",
ageH: 1.5,
assignee: "Marco B.",
status: "open",
},
{
id: 1002,
room: "204",
issue: "Plumbing",
priority: "high",
desc: "Shower drain blocked, standing water",
ageH: 2.2,
assignee: "Davide R.",
status: "inprogress",
},
{
id: 1003,
room: "118",
issue: "TV",
priority: "normal",
desc: "TV remote unresponsive, batteries replaced — no change",
ageH: 3.0,
assignee: "Marco B.",
status: "open",
},
{
id: 1004,
room: "405",
issue: "Lock",
priority: "urgent",
desc: "Electronic lock fails on 3rd attempt — keycard issue",
ageH: 0.5,
assignee: "Davide R.",
status: "inprogress",
},
{
id: 1005,
room: "207",
issue: "Lighting",
priority: "normal",
desc: "Bedside lamp flickers — likely loose bulb",
ageH: 5.0,
assignee: "Tiago F.",
status: "open",
},
{
id: 1006,
room: "302",
issue: "Heating",
priority: "high",
desc: "Radiator making loud banging noise at night",
ageH: 8.0,
assignee: "Marco B.",
status: "open",
},
{
id: 1007,
room: "106",
issue: "Plumbing",
priority: "low",
desc: "Tap drips slowly when fully closed",
ageH: 14.0,
assignee: "Tiago F.",
status: "resolved",
},
{
id: 1008,
room: "219",
issue: "AC",
priority: "normal",
desc: "AC thermostat unresponsive after power outage",
ageH: 6.5,
assignee: "Davide R.",
status: "inprogress",
},
{
id: 1009,
room: "401",
issue: "Elevator",
priority: "high",
desc: "Floor 4 elevator door judders on close — safety concern",
ageH: 4.0,
assignee: "Chief Eng.",
status: "open",
},
{
id: 1010,
room: "112",
issue: "TV",
priority: "low",
desc: "HDMI input 2 not recognised by TV",
ageH: 11.0,
assignee: "Tiago F.",
status: "resolved",
},
];
let nextId = 1011;
let activeFilter = "all";
// ── Status cycle ──────────────────────────────────────────────────────────────
const STATUS_CYCLE = { open: "inprogress", inprogress: "resolved", resolved: "open" };
const STATUS_NEXT_LABEL = { open: "→ In Progress", inprogress: "→ Resolved", resolved: "→ Reopen" };
const STATUS_LABEL = { open: "Open", inprogress: "In Progress", resolved: "Resolved" };
// ── Toast helper ──────────────────────────────────────────────────────────────
const toast = document.getElementById("toast");
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2200);
}
// ── Age formatter ─────────────────────────────────────────────────────────────
function fmtAge(h) {
if (h < 1) return `${Math.round(h * 60)} min ago`;
if (h < 24) return `${h % 1 === 0 ? h : h.toFixed(1)} hr ago`;
return `${Math.floor(h / 24)} d ago`;
}
// ── Open count ────────────────────────────────────────────────────────────────
function updateBadge() {
const count = tickets.filter((t) => t.status !== "resolved").length;
const badge = document.getElementById("openBadge");
badge.textContent = `${count} open`;
badge.dataset.zero = count === 0 ? "true" : "false";
}
// ── Render list ───────────────────────────────────────────────────────────────
function renderList() {
const list = document.getElementById("ticketList");
const empty = document.getElementById("emptyState");
const filtered =
activeFilter === "all" ? tickets : tickets.filter((t) => t.priority === activeFilter);
if (filtered.length === 0) {
list.innerHTML = "";
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = filtered
.map(
(t) => `
<li class="ticket ${t.status}" data-id="${t.id}">
<div class="t-room-block">
<span class="t-room">${t.room}</span>
<span class="t-id">#${t.id}</span>
</div>
<div class="t-body">
<div class="t-top">
<span class="t-issue">${t.issue}</span>
<span class="pri-badge ${t.priority}">${t.priority}</span>
</div>
<p class="t-desc">${t.desc}</p>
<div class="t-meta">
<span class="t-assignee">Assignee: <strong>${t.assignee}</strong></span>
<span class="t-age">${fmtAge(t.ageH)}</span>
</div>
</div>
<div class="t-status-wrap">
<button class="status-btn ${t.status}" data-action="cycle" data-id="${t.id}">
${STATUS_LABEL[t.status]}
</button>
<span class="status-hint">${STATUS_NEXT_LABEL[t.status]}</span>
</div>
</li>
`
)
.join("");
}
// ── Status cycle click (event delegation) ─────────────────────────────────────
document.getElementById("ticketList").addEventListener("click", (e) => {
const btn = e.target.closest("button[data-action='cycle']");
if (!btn) return;
const id = parseInt(btn.dataset.id, 10);
const t = tickets.find((x) => x.id === id);
if (!t) return;
const prev = t.status;
t.status = STATUS_CYCLE[prev];
showToast(`Ticket #${id} · ${STATUS_LABEL[t.status]}`);
updateBadge();
renderList();
});
// ── Priority filter chips ─────────────────────────────────────────────────────
document.querySelectorAll(".chip[data-pri]").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".chip[data-pri]").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
activeFilter = btn.dataset.pri;
renderList();
});
});
// ── New ticket form ───────────────────────────────────────────────────────────
document.getElementById("newForm").addEventListener("submit", (e) => {
e.preventDefault();
const room = document.getElementById("fRoom").value.trim();
const issue = document.getElementById("fIssue").value;
const priority = document.getElementById("fPri").value;
const assignee = document.getElementById("fAssignee").value.trim() || "Unassigned";
const desc = document.getElementById("fDesc").value.trim();
if (!room || !issue || !priority) {
showToast("Please fill in room, issue type, and priority.");
return;
}
const t = {
id: nextId++,
room,
issue,
priority,
desc: desc || `${issue} issue reported in room ${room}.`,
ageH: 0,
assignee,
status: "open",
};
tickets.unshift(t);
// ── Reset form ──
document.getElementById("fRoom").value = "";
document.getElementById("fIssue").value = "";
document.getElementById("fPri").value = "";
document.getElementById("fAssignee").value = "";
document.getElementById("fDesc").value = "";
showToast(`Ticket #${t.id} created — room ${room}`);
updateBadge();
renderList();
});
// ── Initial render ────────────────────────────────────────────────────────────
updateBadge();
renderList();<!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>Maintenance Tickets · Aurelia Hotels</title>
</head>
<body>
<div class="widget-wrap">
<div class="widget">
<!-- ── Widget header ── -->
<div class="widget-head">
<div class="wh-left">
<span class="brand-mark">Æ</span>
<div>
<p class="wh-title">Maintenance Tickets</p>
<p class="wh-sub">Aurelia Hotels · Engineering</p>
</div>
</div>
<span class="open-badge" id="openBadge">0 open</span>
</div>
<!-- ── New ticket form ── -->
<form class="new-form" id="newForm" novalidate>
<div class="form-row">
<input class="form-input" id="fRoom" type="text" placeholder="Room (e.g. 214)" maxlength="5" />
<select class="form-select" id="fIssue">
<option value="">Issue type</option>
<option value="AC">AC</option>
<option value="Plumbing">Plumbing</option>
<option value="TV">TV</option>
<option value="Lock">Lock</option>
<option value="Elevator">Elevator</option>
<option value="Lighting">Lighting</option>
<option value="Heating">Heating</option>
<option value="Other">Other</option>
</select>
<select class="form-select" id="fPri">
<option value="">Priority</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
<input class="form-input" id="fAssignee" type="text" placeholder="Assignee" maxlength="24" />
</div>
<div class="form-row">
<input class="form-input desc" id="fDesc" type="text" placeholder="Brief description of the issue…" maxlength="80" />
<button class="form-submit" type="submit">+ Add ticket</button>
</div>
</form>
<!-- ── Filter bar ── -->
<div class="filter-bar">
<span class="filter-label">Priority</span>
<button class="chip is-active" data-pri="all">All</button>
<button class="chip" data-pri="urgent">Urgent</button>
<button class="chip" data-pri="high">High</button>
<button class="chip" data-pri="normal">Normal</button>
<button class="chip" data-pri="low">Low</button>
</div>
<!-- ── Ticket list ── -->
<ul class="ticket-list" id="ticketList">
<!-- rendered by JS -->
</ul>
<!-- ── Empty state ── -->
<p class="empty-state" id="emptyState" hidden>No tickets match the current filter.</p>
</div>
</div>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Hotel Maintenance Tickets
A self-contained maintenance ticket queue widget for Aurelia Hotels, displayed as a centered card on a neutral background. The list shows each ticket’s room number, issue category (AC, Plumbing, TV, Lock, Elevator), priority badge, age in hours, assigned engineer, and current status. A mini new-ticket form at the top lets staff prepend a fresh ticket; clicking the status pill cycles it through Open → In Progress → Resolved with matching colour changes. A priority filter and live open-count badge keep the supervisor view at a glance.