Pages Medium
Admin — Staff & Shift Roster
Weekly staff schedule grid — 12 staff across 7 days · 3 service blocks · color-coded by role · drag-to-add hint · per-staff weekly hours total · role filter.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
:root {
--cream: #f5f0e8;
--cream-2: #ece4d4;
--bone: #faf7f1;
--terracotta: #c1714a;
--terracotta-d: #a05a38;
--forest: #2d4a3e;
--forest-d: #1e3329;
--gold: #c9a84c;
--gold-light: #e6c97a;
--ink: #2c1a0e;
--ink-2: #4a3828;
--warm-gray: #7a6a58;
--success: #4f7a3a;
--danger: #b3432a;
--warning: #d99020;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
.app {
height: 100vh;
display: grid;
grid-template-columns: 248px 1fr;
}
/* Rail (shared) */
.rail {
background: var(--forest);
color: var(--bone);
display: flex;
flex-direction: column;
border-right: 1px solid var(--forest-d);
overflow-y: auto;
}
.rail-brand {
padding: 22px 22px 18px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid rgba(250, 247, 241, 0.1);
}
.brand-mark {
width: 36px;
height: 36px;
background: var(--gold);
color: var(--ink);
border-radius: 8px;
font-family: var(--font-display);
font-weight: 800;
display: grid;
place-items: center;
}
.brand-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.05rem;
}
.rail-nav {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.r-link {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 8px;
text-decoration: none;
color: rgba(250, 247, 241, 0.78);
font-size: 0.92rem;
font-weight: 500;
}
.r-link:hover {
background: rgba(250, 247, 241, 0.06);
color: var(--bone);
}
.r-link.is-active {
background: var(--bone);
color: var(--forest-d);
font-weight: 700;
}
.r-icon {
width: 22px;
text-align: center;
}
.rail-foot {
padding: 14px 16px;
border-top: 1px solid rgba(250, 247, 241, 0.1);
}
.rail-user {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 38px;
height: 38px;
background: var(--terracotta);
color: var(--bone);
border-radius: 999px;
display: grid;
place-items: center;
font-family: var(--font-display);
font-weight: 800;
}
.user-name {
font-size: 0.92rem;
font-weight: 700;
color: var(--bone);
}
.user-role {
font-size: 0.74rem;
color: rgba(250, 247, 241, 0.5);
}
/* Main */
.main {
overflow-y: auto;
padding: 28px 32px 48px;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 22px;
gap: 14px;
flex-wrap: wrap;
}
.kicker {
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
}
.top h1 {
font-family: var(--font-display);
font-weight: 800;
font-size: 2rem;
letter-spacing: -0.015em;
}
.top-tools {
display: flex;
gap: 6px;
}
.ghost,
.primary {
border-radius: 999px;
padding: 9px 16px;
font-family: inherit;
font-size: 0.84rem;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
}
.ghost {
background: var(--bone);
border-color: rgba(44, 26, 14, 0.12);
color: var(--ink-2);
}
.ghost:hover {
background: var(--cream-2);
color: var(--ink);
}
.primary {
background: var(--forest);
color: var(--bone);
}
.primary:hover {
background: var(--forest-d);
}
/* Status */
.status {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
}
@media (max-width: 980px) {
.status {
grid-template-columns: 1fr 1fr;
}
}
.stat {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 10px;
padding: 14px 16px;
}
.stat-warn {
border-left: 3px solid var(--warning);
}
.stat-label {
font-size: 0.72rem;
color: var(--warm-gray);
font-weight: 600;
}
.stat-value {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.55rem;
margin-top: 2px;
}
.stat-value small {
font-family: var(--font-body);
font-weight: 600;
font-size: 0.82rem;
color: var(--warm-gray);
}
.stat-meta {
font-size: 0.72rem;
color: var(--warm-gray);
margin-top: 2px;
font-weight: 600;
}
.is-up {
color: var(--success);
}
/* Chips */
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.chip {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.1);
color: var(--ink-2);
padding: 7px 14px;
border-radius: 999px;
font-family: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
gap: 6px;
}
.chip span {
font-family: var(--font-mono);
font-size: 0.7rem;
background: var(--cream-2);
color: var(--warm-gray);
padding: 2px 7px;
border-radius: 999px;
}
.chip:hover {
border-color: var(--terracotta);
color: var(--terracotta-d);
}
.chip.is-active {
background: var(--forest);
color: var(--bone);
border-color: var(--forest-d);
}
.chip.is-active span {
background: rgba(250, 247, 241, 0.18);
color: var(--gold-light);
}
/* Schedule grid */
.schedule {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 14px;
overflow: hidden;
}
.sched-head,
.row {
display: grid;
grid-template-columns: 200px repeat(7, 1fr) 90px;
align-items: stretch;
}
.sched-head {
background: var(--cream-2);
}
.sched-head > div {
padding: 12px 12px;
border-right: 1px solid rgba(44, 26, 14, 0.08);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--warm-gray);
font-weight: 700;
}
.sched-head > div:last-child {
border-right: none;
text-align: right;
}
.sched-head .h-day {
display: flex;
flex-direction: column;
gap: 2px;
}
.sched-head .h-num {
font-family: var(--font-mono);
font-size: 0.86rem;
color: var(--ink);
font-weight: 700;
letter-spacing: 0;
}
.sched-head .is-today {
background: var(--terracotta);
color: var(--bone);
}
.sched-head .is-today .h-num {
color: var(--bone);
}
.rows {
list-style: none;
}
.row {
border-top: 1px solid rgba(44, 26, 14, 0.06);
}
.row-hidden {
display: none !important;
}
.r-cell {
border-right: 1px solid rgba(44, 26, 14, 0.06);
padding: 8px 8px;
min-height: 86px;
display: flex;
flex-direction: column;
gap: 3px;
}
.r-cell:last-child {
border-right: none;
}
.r-person {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-right: 1px solid rgba(44, 26, 14, 0.06);
}
.r-avatar {
width: 30px;
height: 30px;
border-radius: 999px;
background: var(--cream-2);
color: var(--ink);
display: grid;
place-items: center;
font-family: var(--font-display);
font-weight: 800;
font-size: 0.82rem;
}
.r-name {
font-weight: 700;
font-size: 0.9rem;
}
.r-role {
font-size: 0.7rem;
color: var(--warm-gray);
}
.r-total {
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 14px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--ink);
}
/* Shift pills */
.shift {
border-radius: 4px;
padding: 4px 6px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 1px;
line-height: 1.15;
border: 1px solid transparent;
}
.shift .s-when {
font-family: var(--font-mono);
font-size: 0.66rem;
font-weight: 700;
opacity: 0.85;
}
.shift-kitchen {
background: rgba(193, 113, 74, 0.18);
color: var(--terracotta-d);
border-color: rgba(193, 113, 74, 0.3);
}
.shift-floor {
background: rgba(45, 74, 62, 0.16);
color: var(--forest-d);
border-color: rgba(45, 74, 62, 0.3);
}
.shift-bar {
background: rgba(201, 168, 76, 0.22);
color: #8a7325;
border-color: rgba(201, 168, 76, 0.4);
}
.shift-open {
background: var(--cream);
color: var(--warm-gray);
border: 1px dashed rgba(44, 26, 14, 0.32);
text-align: center;
}
.shift-off {
font-size: 0.72rem;
color: var(--warm-gray);
font-style: italic;
font-weight: 500;
align-self: center;
letter-spacing: 0.04em;
}
/* Legend */
.legend-row {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 0.78rem;
color: var(--warm-gray);
}
.lg {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
padding: 5px 12px;
border-radius: 999px;
font-weight: 600;
}
.lg::before {
content: "";
width: 10px;
height: 10px;
border-radius: 3px;
}
.lg-kitchen::before {
background: var(--terracotta);
}
.lg-floor::before {
background: var(--forest);
}
.lg-bar::before {
background: var(--gold);
}
.lg-off::before {
background: var(--cream-2);
border: 1px solid rgba(44, 26, 14, 0.16);
}
.lg-open::before {
background: transparent;
border: 1px dashed var(--warm-gray);
}
/* Toast */
.toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
background: var(--forest-d);
color: var(--bone);
padding: 10px 18px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
z-index: 10;
}
/* Responsive */
@media (max-width: 1180px) {
.sched-head,
.row {
grid-template-columns: 160px repeat(7, 1fr) 70px;
}
}
@media (max-width: 880px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.rail {
flex-direction: row;
border-right: none;
border-bottom: 1px solid var(--forest-d);
overflow-x: auto;
padding: 8px 12px;
}
.rail-brand,
.rail-foot {
display: none;
}
.rail-nav {
flex-direction: row;
padding: 0;
}
.schedule {
overflow-x: auto;
}
.sched-head,
.row {
min-width: 880px;
}
}// ─── Staff data ───
// shifts: array of 7 days, each day is array of shift codes:
// "k:11-15" "k:17-23" "f:17-23" "b:18-1" or "" for off, "open:f:17-23" for open shift
const STAFF = [
{
id: "aitor",
name: "Aitor M.",
role: "kitchen",
title: "Head chef",
shifts: [
"",
"k:10-15:k:17-23",
"k:17-23",
"k:10-15:k:17-23",
"k:17-23",
"k:10-15:k:17-23",
"k:10-15",
],
},
{
id: "lina",
name: "Lina M.",
role: "kitchen",
title: "Sous-chef",
shifts: [
"",
"k:10-15:k:17-23",
"k:17-23",
"k:10-15:k:17-23",
"k:17-23",
"k:10-15:k:17-23",
"k:10-15",
],
},
{
id: "kenji",
name: "Kenji T.",
role: "kitchen",
title: "Line · grill",
shifts: ["", "", "k:17-23", "k:17-23", "k:17-23", "k:17-23", "k:10-15"],
},
{
id: "marta",
name: "Marta L.",
role: "kitchen",
title: "Pastry",
shifts: ["", "k:14-22", "k:14-22", "k:14-22", "k:14-22", "k:14-22", ""],
},
{
id: "santi",
name: "Santi P.",
role: "kitchen",
title: "Prep",
shifts: ["", "k:08-14", "k:08-14", "k:08-14", "k:08-14", "k:08-14", "k:08-14"],
},
{
id: "iria",
name: "Iria C.",
role: "floor",
title: "Floor lead · wine",
shifts: ["", "f:17-23", "f:17-23", "f:17-23", "f:17-23", "f:17-23", "f:13-22"],
},
{
id: "sofia",
name: "Sofía R.",
role: "floor",
title: "Server",
shifts: ["", "f:17-23", "f:17-23", "open:f:17-23", "f:17-23", "f:17-23", "f:13-22"],
},
{
id: "marco",
name: "Marco D.",
role: "floor",
title: "Server",
shifts: ["", "f:17-23", "f:17-23", "f:17-23", "f:17-23", "f:17-23", "f:13-22"],
},
{
id: "aitana",
name: "Aitana V.",
role: "floor",
title: "Server (patio)",
shifts: ["", "", "f:17-23", "f:17-23", "f:17-23", "open:f:11-15", ""],
},
{
id: "diego",
name: "Diego R.",
role: "floor",
title: "Host / runner",
shifts: ["", "f:17-23", "f:17-23", "f:17-23", "f:17-23", "f:17-23", "f:13-22"],
},
{
id: "theo",
name: "Theo K.",
role: "bar",
title: "Bartender",
shifts: ["", "b:17-1", "b:17-1", "b:17-1", "b:17-1", "b:17-1", ""],
},
{
id: "sasha",
name: "Sasha M.",
role: "bar",
title: "Barback",
shifts: ["", "b:17-1", "b:17-1", "b:17-1", "b:17-1", "b:17-1", ""],
},
];
const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function shiftToObj(code) {
if (!code) return null;
if (code.startsWith("open:")) {
const inner = code.slice(5);
const obj = shiftToObj(inner);
return obj ? { ...obj, open: true } : null;
}
const [role, range] = code.split(":");
if (!range) return null;
const [a, b] = range.split("-").map(Number);
return { role, start: a, end: b, hours: b > a ? b - a : 24 - a + b };
}
function parseShifts(dayCode) {
if (!dayCode) return [];
return (
dayCode.split(":").reduce((acc, part, i, arr) => {
// Pair role:range halves. Each shift = 2 segments (role:range), unless "open:" prefixes.
return acc;
}, []) || []
);
}
// Simpler parse: each shift is "role:start-end", entries separated by ":"
function splitShifts(dayCode) {
if (!dayCode) return [];
// tokens are pairs of "role" and "range"
const tokens = dayCode.split(":");
const out = [];
let i = 0;
while (i < tokens.length) {
if (tokens[i] === "open") {
// open:role:range
out.push({ open: true, role: tokens[i + 1], range: tokens[i + 2] });
i += 3;
} else {
// role:range
out.push({ role: tokens[i], range: tokens[i + 1] });
i += 2;
}
}
return out;
}
function rangeHours(range) {
const [a, b] = range.split("-").map(Number);
return b > a ? b - a : 24 - a + b;
}
const ROLE_RATE = { kitchen: 22, floor: 19, bar: 21 };
const ROLE_CLASS = { kitchen: "shift-kitchen", floor: "shift-floor", bar: "shift-bar" };
const ROLE_LABEL = { kitchen: "Kitchen", floor: "Floor", bar: "Bar" };
// ─── Week header ───
const schedHead = document.getElementById("schedHead");
const weekLabel = document.getElementById("weekLabel");
const rowsEl = document.getElementById("rows");
const totalHoursEl = document.getElementById("totalHours");
const totalCostEl = document.getElementById("totalCost");
const toast = document.getElementById("toast");
const chips = document.getElementById("chips");
let weekOffset = 0;
let activeRole = "all";
function weekDates() {
const now = new Date();
const day = (now.getDay() + 6) % 7; // Monday=0
const mon = new Date(now);
mon.setDate(now.getDate() - day + weekOffset * 7);
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(mon);
d.setDate(mon.getDate() + i);
return d;
});
}
function isToday(d) {
const t = new Date();
return d.getDate() === t.getDate() && d.getMonth() === t.getMonth() && weekOffset === 0;
}
function renderHead() {
const dates = weekDates();
weekLabel.textContent = `${dates[0].getDate()} – ${dates[6].getDate()} ${dates[6].toLocaleString(undefined, { month: "long" })}`;
schedHead.innerHTML = `
<div>Staff</div>
${dates
.map(
(d, i) => `<div class="h-day ${isToday(d) ? "is-today" : ""}">
<span>${DAY_NAMES[i]}</span>
<span class="h-num">${d.getDate()}</span>
</div>`
)
.join("")}
<div>Hours</div>`;
}
function renderRows() {
let total = 0;
let cost = 0;
rowsEl.innerHTML = STAFF.map((s) => {
let personHours = 0;
const cells = s.shifts
.map((dayCode) => {
const shifts = splitShifts(dayCode);
if (shifts.length === 0) {
return `<div class="r-cell"><span class="shift-off">— off —</span></div>`;
}
const cellHtml = shifts
.map((sh) => {
const h = rangeHours(sh.range);
if (!sh.open) personHours += h;
const cls = sh.open ? "shift-open" : ROLE_CLASS[sh.role];
const label = sh.open ? "OPEN" : ROLE_LABEL[sh.role];
return `<div class="shift ${cls}" data-role="${sh.role}" data-staff="${s.name}">
<span class="s-when">${sh.range.replace("-", "–")}</span>
<span>${label}${sh.open ? " · fill" : ""}</span>
</div>`;
})
.join("");
return `<div class="r-cell">${cellHtml}</div>`;
})
.join("");
total += personHours;
cost += personHours * ROLE_RATE[s.role];
return `<li class="row row-${s.role}" data-role="${s.role}">
<div class="r-person">
<span class="r-avatar">${s.name[0]}</span>
<div>
<p class="r-name">${s.name}</p>
<p class="r-role">${s.title}</p>
</div>
</div>
${cells}
<div class="r-total">${personHours} h</div>
</li>`;
}).join("");
totalHoursEl.textContent = `${total} h`;
totalCostEl.textContent = `$${cost.toLocaleString()}`;
document.getElementById("cKit").textContent = STAFF.filter((s) => s.role === "kitchen").length;
document.getElementById("cFlo").textContent = STAFF.filter((s) => s.role === "floor").length;
document.getElementById("cBar").textContent = STAFF.filter((s) => s.role === "bar").length;
applyFilter();
}
function applyFilter() {
rowsEl.querySelectorAll(".row").forEach((r) => {
const show = activeRole === "all" || r.dataset.role === activeRole;
r.classList.toggle("row-hidden", !show);
});
}
chips.addEventListener("click", (e) => {
const btn = e.target.closest("[data-role]");
if (!btn) return;
activeRole = btn.dataset.role;
chips.querySelectorAll(".chip").forEach((c) => c.classList.toggle("is-active", c === btn));
applyFilter();
});
rowsEl.addEventListener("click", (e) => {
const shift = e.target.closest(".shift");
if (!shift) return;
if (shift.classList.contains("shift-open")) {
showToast(`Open shift · ${shift.dataset.role} · drag a name onto it to fill`);
} else {
showToast(`Edit shift · ${shift.dataset.staff} · ${ROLE_LABEL[shift.dataset.role]}`);
}
});
document.getElementById("prevWk").addEventListener("click", () => {
weekOffset -= 1;
renderHead();
renderRows();
});
document.getElementById("nextWk").addEventListener("click", () => {
weekOffset += 1;
renderHead();
renderRows();
});
document.getElementById("publish").addEventListener("click", () => {
showToast("Schedule published · team notified via email");
});
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2400);
}
renderHead();
renderRows();<!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=Playfair+Display:wght@700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Staff · Casa Olivar Admin</title>
</head>
<body>
<div class="app">
<aside class="rail">
<header class="rail-brand">
<span class="brand-mark">CO</span>
<span class="brand-name">Casa Olivar</span>
</header>
<nav class="rail-nav">
<a class="r-link" href="#"><span class="r-icon">📊</span><span>Dashboard</span></a>
<a class="r-link" href="#"><span class="r-icon">📅</span><span>Reservations</span></a>
<a class="r-link" href="#"><span class="r-icon">🍽</span><span>Menu</span></a>
<a class="r-link" href="#"><span class="r-icon">📦</span><span>Inventory</span></a>
<a class="r-link is-active" href="#"><span class="r-icon">👥</span><span>Staff</span></a>
</nav>
<footer class="rail-foot">
<div class="rail-user">
<span class="user-avatar">M</span>
<div>
<p class="user-name">Marco Reyes</p>
<p class="user-role">Floor manager</p>
</div>
</div>
</footer>
</aside>
<main class="main">
<header class="top">
<div>
<p class="kicker">Schedule · week of <span id="weekLabel">—</span></p>
<h1>Staff</h1>
</div>
<div class="top-tools">
<button class="ghost" type="button" id="prevWk">← Prev</button>
<button class="ghost" type="button" id="nextWk">Next →</button>
<button class="ghost" type="button">⤓ Export</button>
<button class="primary" type="button" id="publish">Publish schedule</button>
</div>
</header>
<!-- Status strip -->
<section class="status">
<article class="stat">
<p class="stat-label">Total hours</p>
<p class="stat-value" id="totalHours">— h</p>
<p class="stat-meta is-up">▲ 14h vs last week</p>
</article>
<article class="stat">
<p class="stat-label">Est. labour cost</p>
<p class="stat-value" id="totalCost">—</p>
<p class="stat-meta">target · < $9,500</p>
</article>
<article class="stat stat-warn">
<p class="stat-label">Open shifts</p>
<p class="stat-value">2 <small>need to fill</small></p>
<p class="stat-meta">Fri close · Sat lunch</p>
</article>
<article class="stat">
<p class="stat-label">Time-off requests</p>
<p class="stat-value">1 <small>pending</small></p>
<p class="stat-meta">Sofía · 28–30 May</p>
</article>
</section>
<!-- Filter -->
<nav class="chips" id="chips">
<button class="chip is-active" data-role="all">All <span id="cAll">12</span></button>
<button class="chip" data-role="kitchen">Kitchen <span id="cKit">5</span></button>
<button class="chip" data-role="floor">Floor <span id="cFlo">5</span></button>
<button class="chip" data-role="bar">Bar <span id="cBar">2</span></button>
</nav>
<!-- Schedule -->
<section class="schedule">
<header class="sched-head" id="schedHead"></header>
<ul class="rows" id="rows"></ul>
</section>
<p class="legend-row">
<span class="lg lg-kitchen">Kitchen</span>
<span class="lg lg-floor">Floor</span>
<span class="lg lg-bar">Bar</span>
<span class="lg lg-off">Off</span>
<span class="lg lg-open">Open shift</span>
</p>
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
</div>
<script src="script.js"></script>
</body>
</html>Admin · Staff Roster
The weekly schedule the floor manager publishes every Sunday. Top strip: this-week vs last-week labour cost, total hours, open shifts to fill. Below: a 12-row × 7-day grid with three service blocks per day (lunch · dinner · close), color-coded by role (kitchen amber, floor forest, bar gold). Per-staff weekly hours total at the right. Role filter pills hide rows by role. Click any shift to “edit” it (toast for the demo).