UI 元件 簡單
Hotel PMS — Guest Folio
Itemized guest bill widget showing posted charges, running totals, and balance due. Staff can add new charge lines, void existing ones, and settle the folio with a paid stamp.
在 Lab 開啟
MCP
html css vanilla-js
目標: JS HTML
程式碼
/* ── 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);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* ── Outer background ── */
.bg {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 16px;
background: radial-gradient(circle at 18% 14%, rgba(201, 166, 73, 0.18), transparent 52%),
linear-gradient(160deg, #14213b 0%, #0f1d36 100%);
}
/* ── Sheet card ── */
.sheet {
width: min(680px, 100%);
background: var(--bone);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: 0 40px 100px rgba(15, 29, 54, 0.55);
display: flex;
flex-direction: column;
}
/* ── Header ── */
.sheet-head {
padding: 22px 26px 18px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--navy-d);
}
.kicker {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--gold-light);
font-weight: 700;
}
.sheet-head h2 {
font-family: var(--font-display);
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--bone);
margin-top: 2px;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--gold);
color: var(--navy-d);
font-family: var(--font-display);
font-size: 1.3rem;
font-weight: 700;
display: grid;
place-items: center;
flex-shrink: 0;
}
/* ── Guest bar ── */
.guest-bar {
display: flex;
flex-wrap: wrap;
gap: 0;
border-bottom: 1px solid var(--line);
background: var(--cream);
}
.guest-info {
display: flex;
flex-direction: column;
padding: 10px 20px;
border-right: 1px solid var(--line);
gap: 2px;
min-width: 0;
}
.guest-info:last-child {
border-right: none;
}
.g-label {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--warm-gray);
font-weight: 700;
}
.g-val {
font-size: 0.88rem;
font-weight: 600;
color: var(--navy-d);
white-space: nowrap;
}
.g-val.mono {
font-family: var(--font-mono);
font-size: 0.84rem;
font-variant-numeric: tabular-nums;
}
/* ── Table ── */
.table-wrap {
overflow-x: auto;
flex: 1;
}
.folio-table {
width: 100%;
border-collapse: collapse;
font-size: 0.86rem;
}
.folio-table thead {
background: var(--cream);
border-bottom: 1px solid var(--line-strong);
}
.folio-table th {
padding: 9px 14px;
text-align: left;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--warm-gray);
font-weight: 700;
}
.col-date {
width: 96px;
}
.col-ref {
width: 80px;
}
.col-amt {
width: 88px;
text-align: right;
}
.col-act {
width: 44px;
}
.folio-table td {
padding: 9px 14px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.folio-table tbody tr:last-child td {
border-bottom: none;
}
.folio-table tbody tr:hover {
background: rgba(201, 166, 73, 0.04);
}
.folio-table tbody tr.is-voided {
opacity: 0.42;
}
.folio-table tbody tr.is-voided td {
text-decoration: line-through;
color: var(--warm-gray);
}
.td-date {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--warm-gray);
font-variant-numeric: tabular-nums;
}
.td-desc {
font-weight: 500;
color: var(--ink);
}
.td-cat {
display: inline-block;
margin-left: 6px;
font-size: 0.68rem;
padding: 2px 7px;
border-radius: 999px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
vertical-align: middle;
}
.cat-room {
background: rgba(26, 43, 74, 0.1);
color: var(--navy-2);
}
.cat-tax {
background: rgba(108, 114, 128, 0.1);
color: var(--warm-gray);
}
.cat-fb {
background: rgba(74, 119, 82, 0.12);
color: var(--success);
}
.cat-spa {
background: rgba(74, 109, 160, 0.12);
color: var(--info);
}
.cat-minibar {
background: rgba(201, 166, 73, 0.15);
color: var(--gold-d);
}
.cat-other {
background: rgba(179, 66, 50, 0.1);
color: var(--danger);
}
.td-ref {
font-family: var(--font-mono);
font-size: 0.76rem;
color: var(--warm-gray);
}
.td-amt {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-weight: 600;
text-align: right;
color: var(--ink);
}
.td-act {
text-align: center;
}
.btn-void {
background: none;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
padding: 4px 8px;
font-size: 0.72rem;
color: var(--warm-gray);
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.btn-void:hover {
background: rgba(179, 66, 50, 0.08);
color: var(--danger);
border-color: var(--danger);
}
.btn-void.is-voided {
color: var(--warm-gray);
cursor: not-allowed;
}
/* ── Totals ── */
.totals-block {
border-top: 2px solid var(--line-strong);
background: var(--cream);
padding: 14px 26px;
}
.totals-inner {
max-width: 320px;
margin-left: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.total-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.88rem;
color: var(--ink-2);
}
.total-row .mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.total-row--bold {
font-weight: 700;
color: var(--ink);
padding-top: 6px;
border-top: 1px solid var(--line-strong);
margin-top: 4px;
}
.total-row.separator {
padding-top: 8px;
border-top: 1px solid var(--line);
margin-top: 4px;
}
.negative {
color: var(--success) !important;
}
.total-row--balance {
font-size: 1rem;
font-weight: 700;
color: var(--navy-d);
padding-top: 6px;
border-top: 2px solid var(--line-strong);
margin-top: 4px;
}
/* ── Add charge bar ── */
.add-charge-bar {
display: flex;
gap: 8px;
padding: 14px 26px;
border-top: 1px solid var(--line);
align-items: center;
flex-wrap: wrap;
}
.charge-select {
flex: 1;
min-width: 200px;
font-family: inherit;
font-size: 0.88rem;
background: var(--cream);
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
padding: 9px 12px;
color: var(--ink);
outline: none;
cursor: pointer;
}
.charge-select:focus {
border-color: var(--gold);
}
.btn-add {
font-family: inherit;
font-size: 0.86rem;
font-weight: 600;
padding: 9px 20px;
background: var(--navy);
color: var(--bone);
border: none;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.btn-add:hover {
background: var(--navy-2);
}
/* ── Footer ── */
.sheet-foot {
padding: 16px 26px 22px;
background: var(--bone);
border-top: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.foot-acts {
display: flex;
gap: 8px;
margin-left: auto;
}
.ghost,
.primary {
font-family: inherit;
font-weight: 600;
font-size: 0.86rem;
padding: 11px 18px;
border-radius: 999px;
cursor: pointer;
}
.ghost {
background: transparent;
border: 1px solid var(--line-strong);
color: var(--ink-2);
}
.ghost:hover {
background: var(--cream);
border-color: var(--navy-2);
color: var(--navy-d);
}
.primary {
background: var(--navy);
color: var(--bone);
border: none;
}
.primary:hover {
background: var(--navy-d);
}
.primary:disabled {
background: var(--warm-gray);
cursor: not-allowed;
opacity: 0.6;
}
/* ── Paid stamp ── */
.paid-stamp {
font-family: var(--font-mono);
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--success);
border: 3px solid var(--success);
border-radius: var(--r-sm);
padding: 6px 18px;
text-transform: uppercase;
transform: rotate(-2deg);
opacity: 0.9;
}
/* ── 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: 0 10px 30px rgba(22, 30, 44, 0.25);
z-index: 100;
white-space: nowrap;
}
/* ── Responsive ── */
@media (max-width: 560px) {
.guest-bar {
flex-direction: column;
}
.guest-info {
border-right: none;
border-bottom: 1px solid var(--line);
}
.guest-info:last-child {
border-bottom: none;
}
.add-charge-bar {
flex-direction: column;
}
.charge-select {
min-width: 0;
width: 100%;
}
.foot-acts {
width: 100%;
justify-content: flex-end;
}
.folio-table .col-ref {
display: none;
}
}// ── 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);
}
// ── Formatters ──
const fmt = (n) =>
"€" + n.toLocaleString("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const $ = (id) => document.getElementById(id);
// ── Initial charge data (mock, dates around 9–12 Jun 2026) ──
let charges = [
{
id: 1,
date: "09 Jun",
desc: "Room 302 — Standard Double",
cat: "room",
ref: "RM-001",
amount: 184.0,
voided: false,
},
{
id: 2,
date: "09 Jun",
desc: "City tourist tax",
cat: "tax",
ref: "TX-001",
amount: 1.65,
voided: false,
},
{
id: 3,
date: "09 Jun",
desc: "Restaurant — Dinner",
cat: "fb",
ref: "FB-101",
amount: 62.4,
voided: false,
},
{
id: 4,
date: "10 Jun",
desc: "Room 302 — Standard Double",
cat: "room",
ref: "RM-002",
amount: 184.0,
voided: false,
},
{
id: 5,
date: "10 Jun",
desc: "City tourist tax",
cat: "tax",
ref: "TX-002",
amount: 1.65,
voided: false,
},
{
id: 6,
date: "10 Jun",
desc: "Minibar restock",
cat: "minibar",
ref: "MB-031",
amount: 18.0,
voided: false,
},
{
id: 7,
date: "10 Jun",
desc: "Spa — Massage 60 min",
cat: "spa",
ref: "SP-017",
amount: 85.0,
voided: false,
},
{
id: 8,
date: "11 Jun",
desc: "Room 302 — Standard Double",
cat: "room",
ref: "RM-003",
amount: 184.0,
voided: false,
},
{
id: 9,
date: "11 Jun",
desc: "City tourist tax",
cat: "tax",
ref: "TX-003",
amount: 1.65,
voided: false,
},
{
id: 10,
date: "11 Jun",
desc: "Restaurant — Breakfast",
cat: "fb",
ref: "FB-102",
amount: 28.5,
voided: false,
},
];
let nextId = 11;
let settled = false;
// ── Payments applied (fixed mock) ──
const paymentsApplied = 400.0;
// ── Category label map ──
const catLabel = {
room: ["Room night", "cat-room"],
tax: ["Tax", "cat-tax"],
fb: ["F&B", "cat-fb"],
spa: ["Spa", "cat-spa"],
minibar: ["Minibar", "cat-minibar"],
other: ["Other", "cat-other"],
};
// ── Render table ──
function renderCharges() {
const tbody = $("chargesBody");
tbody.innerHTML = charges
.map((c) => {
const [label, cls] = catLabel[c.cat] || ["Other", "cat-other"];
return `
<tr class="${c.voided ? "is-voided" : ""}" data-id="${c.id}">
<td class="td-date">${c.date}</td>
<td class="td-desc">
${c.desc}
<span class="td-cat ${cls}">${label}</span>
</td>
<td class="td-ref">${c.ref}</td>
<td class="td-amt">${fmt(c.amount)}</td>
<td class="td-act">
<button
class="btn-void ${c.voided ? "is-voided" : ""}"
data-id="${c.id}"
type="button"
title="${c.voided ? "Already voided" : "Void this charge"}"
${c.voided ? "disabled" : ""}
>${c.voided ? "Void" : "Void"}</button>
</td>
</tr>`;
})
.join("");
}
// ── Recalculate totals ──
function recalcTotals() {
const activeCharges = charges.filter((c) => !c.voided);
const subtotalNet = activeCharges.reduce((s, c) => s + c.amount, 0);
// Treat subtotalNet as net-of-VAT for F&B/Spa; room/tax already gross for simplicity
// For the demo: VAT = 10% of room+fb+spa+minibar lines; tax line is itself a tax
const vatBase = activeCharges.filter((c) => c.cat !== "tax").reduce((s, c) => s + c.amount, 0);
const vat = parseFloat((vatBase * 0.1).toFixed(2));
const total = parseFloat((subtotalNet + vat).toFixed(2));
const balance = parseFloat((total - paymentsApplied).toFixed(2));
$("subtotal").textContent = fmt(subtotalNet);
$("vat").textContent = fmt(vat);
$("total").textContent = fmt(total);
$("payments").textContent = "−" + fmt(paymentsApplied);
$("balance").textContent = fmt(Math.max(0, balance));
}
// ── Void handler ──
document.getElementById("chargesBody").addEventListener("click", (e) => {
const btn = e.target.closest(".btn-void");
if (!btn || btn.disabled) return;
const id = parseInt(btn.dataset.id, 10);
const charge = charges.find((c) => c.id === id);
if (!charge || charge.voided) return;
charge.voided = true;
renderCharges();
recalcTotals();
showToast(`Voided: ${charge.desc} (${fmt(charge.amount)})`);
});
// ── Add charge ──
const chargeDescMap = {
restaurant: { desc: "Restaurant — Dinner", cat: "fb", ref: () => `FB-${100 + nextId}` },
spa: { desc: "Spa — Massage 60 min", cat: "spa", ref: () => `SP-${16 + nextId}` },
minibar: { desc: "Minibar restock", cat: "minibar", ref: () => `MB-${30 + nextId}` },
laundry: { desc: "Laundry service", cat: "other", ref: () => `LN-${nextId}` },
parking: { desc: "Parking", cat: "other", ref: () => `PK-${nextId}` },
phone: { desc: "International call", cat: "other", ref: () => `PH-${nextId}` },
};
$("btnAdd").addEventListener("click", () => {
if (settled) {
showToast("Folio is already settled.");
return;
}
const sel = $("chargeType");
const key = sel.value;
if (!key) {
showToast("Pick a charge type first.");
return;
}
const opt = sel.options[sel.selectedIndex];
const amount = parseFloat(opt.dataset.amount);
const meta = chargeDescMap[key];
const todayDates = ["09 Jun", "10 Jun", "11 Jun", "12 Jun"];
const date = todayDates[Math.floor(Math.random() * 3)];
charges.push({
id: nextId,
date,
desc: meta.desc,
cat: meta.cat,
ref: meta.ref(),
amount,
voided: false,
});
nextId++;
sel.value = "";
renderCharges();
recalcTotals();
showToast(`Posted: ${meta.desc} — ${fmt(amount)}`);
});
// ── Settle / mark paid ──
$("btnSettle").addEventListener("click", () => {
if (settled) {
showToast("Already settled.");
return;
}
settled = true;
$("paidStamp").hidden = false;
$("footActs").querySelector(".primary").disabled = true;
$("addChargeBar").style.opacity = "0.4";
$("addChargeBar").style.pointerEvents = "none";
$("balance").textContent = "€0.00";
showToast("Folio settled — receipt ready to print.");
});
// ── Print folio (mock) ──
$("btnPrint").addEventListener("click", () => {
showToast("Print job sent to front-desk printer.");
});
// ── Init ──
renderCharges();
recalcTotals();<!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>Guest Folio · Aurelia Hotels</title>
</head>
<body>
<div class="bg">
<section class="sheet" role="main" aria-labelledby="folioTitle">
<!-- ── Header ── -->
<header class="sheet-head">
<div class="head-left">
<p class="kicker">Front desk · Folio</p>
<h2 id="folioTitle">Guest Folio</h2>
</div>
<div class="head-right">
<div class="brand-mark">A</div>
</div>
</header>
<!-- ── Guest / Room info ── -->
<div class="guest-bar">
<div class="guest-info">
<span class="g-label">Guest</span>
<span class="g-val">Margaux Lefèvre</span>
</div>
<div class="guest-info">
<span class="g-label">Room</span>
<span class="g-val mono">302</span>
</div>
<div class="guest-info">
<span class="g-label">Check-in</span>
<span class="g-val mono">09 Jun 2026</span>
</div>
<div class="guest-info">
<span class="g-label">Check-out</span>
<span class="g-val mono">12 Jun 2026</span>
</div>
<div class="guest-info">
<span class="g-label">Res. #</span>
<span class="g-val mono">AH-28341</span>
</div>
</div>
<!-- ── Charges table ── -->
<div class="table-wrap">
<table class="folio-table" aria-label="Posted charges">
<thead>
<tr>
<th class="col-date">Date</th>
<th class="col-desc">Description</th>
<th class="col-ref">Ref</th>
<th class="col-amt">Amount</th>
<th class="col-act" aria-label="Actions"></th>
</tr>
</thead>
<tbody id="chargesBody">
<!-- populated by JS -->
</tbody>
</table>
</div>
<!-- ── Totals ── -->
<div class="totals-block">
<div class="totals-inner">
<div class="total-row">
<span>Subtotal</span>
<span class="mono" id="subtotal">€0.00</span>
</div>
<div class="total-row">
<span>VAT 10%</span>
<span class="mono" id="vat">€0.00</span>
</div>
<div class="total-row total-row--bold">
<span>Total incl. VAT</span>
<span class="mono" id="total">€0.00</span>
</div>
<div class="total-row separator">
<span>Payments applied</span>
<span class="mono negative" id="payments">−€0.00</span>
</div>
<div class="total-row total-row--balance">
<span>Balance due</span>
<span class="mono" id="balance">€0.00</span>
</div>
</div>
</div>
<!-- ── Add charge control ── -->
<div class="add-charge-bar" id="addChargeBar">
<select class="charge-select" id="chargeType" aria-label="Charge type">
<option value="">Pick charge type…</option>
<option value="restaurant" data-amount="47.50">Restaurant — Dinner (€47.50)</option>
<option value="spa" data-amount="85.00">Spa — Massage 60 min (€85.00)</option>
<option value="minibar" data-amount="18.00">Minibar restock (€18.00)</option>
<option value="laundry" data-amount="22.00">Laundry service (€22.00)</option>
<option value="parking" data-amount="15.00">Parking (€15.00)</option>
<option value="phone" data-amount="4.20">International call (€4.20)</option>
</select>
<button class="btn-add" id="btnAdd" type="button">Post charge</button>
</div>
<!-- ── Footer actions ── -->
<footer class="sheet-foot">
<div class="paid-stamp" id="paidStamp" hidden aria-live="polite">✓ PAID IN FULL</div>
<div class="foot-acts" id="footActs">
<button class="ghost" id="btnPrint" type="button">⊞ Print folio</button>
<button class="primary" id="btnSettle" type="button">Settle · Mark paid</button>
</div>
</footer>
</section>
</div>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Guest Folio
A centered PMS folio card showing all posted charges for a stay: room nights, city tax, F&B, spa, and minibar lines with dates and amounts. The running subtotal, VAT, and total update live as lines are added or voided. An “Add charge” control lets staff post a new line by picking a charge type; “Settle / mark paid” zeroes the balance and displays a green paid stamp; individual lines can be voided via the row action menu.