Clinic — Supplies Inventory
A medical supplies inventory screen for clinic staff, with a sortable-feeling table of items, SKUs, on-hand counts and par levels. Inline plus and minus steppers adjust stock and recompute each item's OK, Low or Out status pill and row highlight in real time. Filter tabs with live counts, a name-and-SKU search box, summary cards and per-row reorder actions round out a realistic stockroom admin view.
MCP
程式碼
:root {
--teal: #129c93;
--teal-d: #0c7a73;
--teal-700: #0a655f;
--teal-50: #e7f5f3;
--coral: #ff7a66;
--coral-soft: #ffe6df;
--ink: #16322f;
--ink-2: #3a534f;
--muted: #6b827e;
--bg: #f1f7f6;
--white: #ffffff;
--line: rgba(16, 50, 47, 0.1);
--line-2: rgba(16, 50, 47, 0.18);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--font: "Inter", system-ui, -apple-system, sans-serif;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-1: 0 1px 2px rgba(16, 50, 47, 0.05), 0 4px 14px rgba(16, 50, 47, 0.06);
--shadow-2: 0 16px 40px rgba(12, 122, 115, 0.16);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
button:focus-visible,
input:focus-visible,
.tab:focus-visible {
outline: 2px solid var(--teal);
outline-offset: 2px;
}
/* ── Layout ── */
.inv {
max-width: 1000px;
margin: 0 auto;
padding: 32px 22px 64px;
display: flex;
flex-direction: column;
gap: 22px;
}
/* ── Header ── */
.inv-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.eyebrow {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--teal-d);
}
.inv-title h1 {
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-top: 4px;
}
.sub {
color: var(--muted);
font-size: 0.9rem;
max-width: 46ch;
margin-top: 6px;
}
/* ── Summary cards ── */
.summary {
display: flex;
gap: 12px;
}
.stat {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px 18px;
min-width: 96px;
box-shadow: var(--shadow-1);
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-label {
font-size: 0.74rem;
font-weight: 600;
color: var(--muted);
white-space: nowrap;
}
.stat-val {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--ink);
line-height: 1.1;
}
.stat.is-low {
border-color: rgba(217, 138, 43, 0.35);
background: linear-gradient(180deg, #fff, #fff7ec);
}
.stat.is-low .stat-val {
color: var(--warn);
}
.stat.is-out {
border-color: rgba(212, 80, 62, 0.35);
background: linear-gradient(180deg, #fff, #fff1ee);
}
.stat.is-out .stat-val {
color: var(--danger);
}
/* ── Toolbar ── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.tabs {
display: inline-flex;
gap: 4px;
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px;
box-shadow: var(--shadow-1);
}
.tab {
border: none;
background: transparent;
border-radius: 999px;
padding: 8px 18px;
font: inherit;
font-weight: 600;
font-size: 0.88rem;
color: var(--muted);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background 0.15s, color 0.15s;
}
.tab:hover {
color: var(--ink-2);
}
.tab.is-active {
background: var(--teal-d);
color: #fff;
}
.count {
display: inline-grid;
place-items: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--teal-50);
color: var(--teal-d);
font-size: 0.74rem;
font-weight: 700;
}
.tab.is-active .count {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.search {
position: relative;
flex: 1;
min-width: 200px;
max-width: 320px;
}
.search-ico {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
width: 17px;
height: 17px;
fill: none;
stroke: var(--muted);
stroke-width: 2;
stroke-linecap: round;
pointer-events: none;
}
.search input {
width: 100%;
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--white);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 10px 16px 10px 38px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.search input::placeholder {
color: var(--muted);
}
.search input:focus {
outline: none;
border-color: var(--teal);
box-shadow: 0 0 0 3px var(--teal-50);
}
/* ── Table ── */
.table-wrap {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-1);
overflow: hidden;
}
.inv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.inv-table thead th {
text-align: left;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
padding: 14px 16px;
background: #f7fbfa;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.col-onhand,
.col-par {
text-align: center;
}
.inv-table tbody tr {
border-bottom: 1px solid var(--line);
transition: background 0.15s;
}
.inv-table tbody tr:last-child {
border-bottom: none;
}
.inv-table tbody tr:hover {
background: #f6fbfa;
}
.inv-table td {
padding: 14px 16px;
vertical-align: middle;
}
/* Row status accents */
tr[data-status="low"] {
background: #fffaf1;
}
tr[data-status="low"]:hover {
background: #fff5e3;
}
tr[data-status="low"] td.col-item {
box-shadow: inset 3px 0 0 var(--warn);
}
tr[data-status="out"] {
background: #fff4f1;
}
tr[data-status="out"]:hover {
background: #ffece7;
}
tr[data-status="out"] td.col-item {
box-shadow: inset 3px 0 0 var(--danger);
}
/* Item cell */
.item-name {
font-weight: 700;
color: var(--ink);
letter-spacing: -0.01em;
}
.item-sku {
font-size: 0.76rem;
color: var(--muted);
margin-top: 2px;
font-variant-numeric: tabular-nums;
}
.par-val,
.restock,
.supplier {
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.col-par {
font-weight: 600;
}
.restock-rel {
display: block;
font-size: 0.74rem;
color: var(--muted);
margin-top: 1px;
}
.supplier {
font-size: 0.86rem;
}
/* Stepper */
.stepper {
display: inline-flex;
align-items: center;
gap: 0;
border: 1px solid var(--line-2);
border-radius: 999px;
background: var(--white);
overflow: hidden;
}
.step {
border: none;
background: transparent;
width: 28px;
height: 30px;
font-size: 1.1rem;
font-weight: 700;
line-height: 1;
color: var(--teal-d);
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.12s, color 0.12s;
}
.step:hover {
background: var(--teal-50);
}
.step:active {
background: #d4ece9;
}
.step:disabled {
color: var(--line-2);
cursor: not-allowed;
background: transparent;
}
.onhand-val {
min-width: 30px;
text-align: center;
font-weight: 700;
font-size: 0.95rem;
font-variant-numeric: tabular-nums;
color: var(--ink);
padding: 0 4px;
}
tr[data-status="out"] .onhand-val {
color: var(--danger);
}
/* Status pill */
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.74rem;
font-weight: 700;
padding: 4px 11px;
border-radius: 999px;
white-space: nowrap;
}
.pill::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.pill.ok {
background: rgba(47, 158, 111, 0.14);
color: var(--ok);
}
.pill.low {
background: rgba(217, 138, 43, 0.16);
color: var(--warn);
}
.pill.out {
background: rgba(212, 80, 62, 0.14);
color: var(--danger);
}
/* Reorder action */
.col-action {
text-align: right;
white-space: nowrap;
}
.reorder {
border: 1px solid rgba(212, 80, 62, 0.4);
background: var(--white);
color: var(--danger);
border-radius: 10px;
padding: 7px 14px;
font: inherit;
font-weight: 600;
font-size: 0.82rem;
cursor: pointer;
transition: transform 0.12s, background 0.15s, border-color 0.15s;
}
.reorder:hover {
background: rgba(212, 80, 62, 0.08);
border-color: var(--danger);
}
.reorder:active {
transform: translateY(1px);
}
.reorder.is-low {
border-color: rgba(217, 138, 43, 0.45);
color: var(--warn);
}
.reorder.is-low:hover {
background: rgba(217, 138, 43, 0.1);
border-color: var(--warn);
}
/* Empty state */
.empty {
padding: 40px 20px;
text-align: center;
color: var(--muted);
font-size: 0.92rem;
}
/* ── Toast ── */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
background: var(--ink);
color: #fff;
padding: 13px 20px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 500;
box-shadow: var(--shadow-2);
z-index: 50;
max-width: 90vw;
}
/* ── Responsive ── */
@media (max-width: 760px) {
.col-supplier,
.col-restock {
display: none;
}
}
@media (max-width: 520px) {
.inv {
padding: 22px 14px 48px;
}
.inv-head {
align-items: flex-start;
}
.summary {
width: 100%;
}
.stat {
flex: 1;
min-width: 0;
padding: 10px 12px;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.tabs {
justify-content: space-between;
}
.search {
max-width: none;
}
.table-wrap {
border-radius: var(--r-md);
}
.inv-table thead {
display: none;
}
.inv-table,
.inv-table tbody,
.inv-table tr,
.inv-table td {
display: block;
width: 100%;
}
.inv-table tbody tr {
padding: 14px 16px;
position: relative;
}
.inv-table td {
padding: 4px 0;
}
.inv-table td.col-item {
box-shadow: none !important;
padding-right: 90px;
}
tr[data-status="low"],
tr[data-status="out"] {
border-left: 3px solid transparent;
}
tr[data-status="low"] {
border-left-color: var(--warn);
}
tr[data-status="out"] {
border-left-color: var(--danger);
}
.col-onhand {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.col-onhand::before {
content: "On hand";
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.col-par {
text-align: left;
}
.col-par::before {
content: "Par: ";
font-weight: 600;
color: var(--muted);
}
.col-status {
margin-top: 6px;
}
.col-action {
position: absolute;
top: 14px;
right: 16px;
text-align: right;
}
}// ── Inventory data ───────────────────────────────────────────────────────────
// Status is derived, never stored: out = 0, low = onHand <= par, else ok.
const ITEMS = [
{ name: "Nitrile exam gloves (M)", sku: "GLV-NTR-M", onHand: 42, par: 30, restock: "2026-05-28", supplier: "MedSupply Co." },
{ name: "Adhesive bandages, assorted", sku: "BND-ASRT", onHand: 8, par: 20, restock: "2026-04-19", supplier: "CareLine Health" },
{ name: "Isopropyl alcohol prep pads", sku: "PRP-ALC-70", onHand: 0, par: 25, restock: "2026-03-30", supplier: "MedSupply Co." },
{ name: "Sterile gauze 4×4 in", sku: "GZE-4X4-S", onHand: 60, par: 40, restock: "2026-06-02", supplier: "CareLine Health" },
{ name: "Disposable face masks", sku: "MSK-3PLY", onHand: 18, par: 50, restock: "2026-05-11", supplier: "Vireo Medical" },
{ name: "Tongue depressors, wood", sku: "TDP-WD-100", onHand: 120, par: 60, restock: "2026-05-22", supplier: "MedSupply Co." },
{ name: "Vinyl exam gloves (L)", sku: "GLV-VNL-L", onHand: 0, par: 30, restock: "2026-02-14", supplier: "Vireo Medical" },
{ name: "Syringes 3 mL, Luer-lock", sku: "SYR-3ML-LL", onHand: 22, par: 35, restock: "2026-05-30", supplier: "Northgate Pharma" },
{ name: "Examination table paper", sku: "PPR-EXM-RL", onHand: 14, par: 12, restock: "2026-06-04", supplier: "CareLine Health" },
{ name: "Surface disinfectant wipes", sku: "DIS-WIPE-XL", onHand: 9, par: 24, restock: "2026-04-27", supplier: "Vireo Medical" },
{ name: "Cotton-tipped applicators", sku: "APP-CTN-200", onHand: 75, par: 40, restock: "2026-05-18", supplier: "MedSupply Co." },
{ name: "Lancets, 28G safety", sku: "LNC-28G-SF", onHand: 33, par: 30, restock: "2026-06-01", supplier: "Northgate Pharma" },
];
let activeFilter = "all";
let query = "";
const rowsEl = document.getElementById("rows");
const emptyEl = document.getElementById("empty");
const searchEl = document.getElementById("search");
const toastEl = document.getElementById("toast");
// ── Helpers ──────────────────────────────────────────────────────────────────
function statusOf(item) {
if (item.onHand <= 0) return "out";
if (item.onHand <= item.par) return "low";
return "ok";
}
const STATUS_LABEL = { ok: "OK", low: "Low", out: "Out" };
function showToast(msg) {
toastEl.textContent = msg;
toastEl.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toastEl.hidden = true), 2600);
}
function formatDate(iso) {
const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function relativeDays(iso) {
const then = new Date(iso + "T00:00:00");
const now = new Date("2026-06-08T00:00:00");
const days = Math.round((now - then) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "1 day ago";
if (days < 30) return days + " days ago";
const months = Math.round(days / 30);
return months + (months === 1 ? " month ago" : " months ago");
}
// ── Render ───────────────────────────────────────────────────────────────────
function rowMarkup(item, idx) {
const status = statusOf(item);
const needsReorder = status !== "ok";
const reorder = needsReorder
? `<button class="reorder${status === "low" ? " is-low" : ""}" data-reorder="${idx}">Reorder</button>`
: "";
return `
<tr data-idx="${idx}" data-status="${status}">
<td class="col-item">
<div class="item-name">${item.name}</div>
<div class="item-sku">${item.sku}</div>
</td>
<td class="col-onhand">
<div class="stepper" role="group" aria-label="Adjust on-hand for ${item.name}">
<button class="step" data-dec="${idx}" aria-label="Decrease on-hand"${item.onHand <= 0 ? " disabled" : ""}>−</button>
<span class="onhand-val" aria-live="polite">${item.onHand}</span>
<button class="step" data-inc="${idx}" aria-label="Increase on-hand">+</button>
</div>
</td>
<td class="col-par"><span class="par-val">${item.par}</span></td>
<td class="col-status">
<span class="pill ${status}">${STATUS_LABEL[status]}</span>
</td>
<td class="col-restock">
<span class="restock">${formatDate(item.restock)}</span>
<span class="restock-rel">${relativeDays(item.restock)}</span>
</td>
<td class="col-supplier"><span class="supplier">${item.supplier}</span></td>
<td class="col-action">${reorder}</td>
</tr>`;
}
function matchesFilter(item) {
if (activeFilter !== "all" && statusOf(item) !== activeFilter) return false;
if (query) {
const hay = (item.name + " " + item.sku).toLowerCase();
if (!hay.includes(query)) return false;
}
return true;
}
function render() {
const visible = ITEMS.map((item, idx) => ({ item, idx })).filter(({ item }) => matchesFilter(item));
rowsEl.innerHTML = visible.map(({ item, idx }) => rowMarkup(item, idx)).join("");
emptyEl.hidden = visible.length > 0;
}
// ── Counts & summary ─────────────────────────────────────────────────────────
function refreshCounts() {
let low = 0;
let out = 0;
ITEMS.forEach((item) => {
const s = statusOf(item);
if (s === "low") low++;
else if (s === "out") out++;
});
document.getElementById("stat-total").textContent = ITEMS.length;
document.getElementById("stat-low").textContent = low;
document.getElementById("stat-out").textContent = out;
document.getElementById("count-all").textContent = ITEMS.length;
document.getElementById("count-low").textContent = low;
document.getElementById("count-out").textContent = out;
}
// ── Stepper: update a single row in place when possible ──────────────────────
function adjust(idx, delta) {
const item = ITEMS[idx];
const next = Math.max(0, item.onHand + delta);
if (next === item.onHand) return;
item.onHand = next;
refreshCounts();
// If the row would leave the current filter, re-render the whole list.
if (!matchesFilter(item)) {
render();
return;
}
const row = rowsEl.querySelector(`tr[data-idx="${idx}"]`);
if (!row) {
render();
return;
}
const status = statusOf(item);
row.dataset.status = status;
row.querySelector(".onhand-val").textContent = item.onHand;
const pill = row.querySelector(".pill");
pill.className = "pill " + status;
pill.textContent = STATUS_LABEL[status];
row.querySelector(".step[data-dec]").disabled = item.onHand <= 0;
const actionCell = row.querySelector(".col-action");
if (status === "ok") {
actionCell.innerHTML = "";
} else {
actionCell.innerHTML = `<button class="reorder${status === "low" ? " is-low" : ""}" data-reorder="${idx}">Reorder</button>`;
}
}
// ── Events ───────────────────────────────────────────────────────────────────
rowsEl.addEventListener("click", (e) => {
const dec = e.target.closest("[data-dec]");
const inc = e.target.closest("[data-inc]");
const reorder = e.target.closest("[data-reorder]");
if (dec) adjust(Number(dec.dataset.dec), -1);
else if (inc) adjust(Number(inc.dataset.inc), +1);
else if (reorder) {
const item = ITEMS[Number(reorder.dataset.reorder)];
const status = statusOf(item);
const shortfall = Math.max(item.par - item.onHand, item.par);
showToast(
status === "out"
? `Reorder raised for ${item.name} — purchase order sent to ${item.supplier}.`
: `Reorder raised for ${item.name} (suggested ${shortfall} units) — sent to ${item.supplier}.`
);
}
});
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
activeFilter = tab.dataset.filter;
document.querySelectorAll(".tab").forEach((t) => {
const on = t === tab;
t.classList.toggle("is-active", on);
t.setAttribute("aria-selected", String(on));
});
render();
});
});
searchEl.addEventListener("input", () => {
query = searchEl.value.trim().toLowerCase();
render();
});
// ── Init ─────────────────────────────────────────────────────────────────────
refreshCounts();
render();<!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=Inter:wght@400;500;600;700;800&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Supplies Inventory · Northpoint Clinic</title>
</head>
<body>
<main class="inv">
<header class="inv-head">
<div class="inv-title">
<p class="eyebrow">Northpoint Clinic · Stockroom</p>
<h1>Supplies inventory</h1>
<p class="sub">
Adjust on-hand counts as items are used or restocked. Status updates
against each item’s par level in real time.
</p>
</div>
<div class="summary" aria-label="Inventory summary">
<div class="stat">
<span class="stat-label">Total items</span>
<span class="stat-val" id="stat-total">0</span>
</div>
<div class="stat is-low">
<span class="stat-label">Low</span>
<span class="stat-val" id="stat-low">0</span>
</div>
<div class="stat is-out">
<span class="stat-label">Out of stock</span>
<span class="stat-val" id="stat-out">0</span>
</div>
</div>
</header>
<div class="toolbar">
<div class="tabs" role="tablist" aria-label="Filter by status">
<button
class="tab is-active"
role="tab"
aria-selected="true"
data-filter="all"
>
All <span class="count" id="count-all">0</span>
</button>
<button class="tab" role="tab" aria-selected="false" data-filter="low">
Low <span class="count" id="count-low">0</span>
</button>
<button class="tab" role="tab" aria-selected="false" data-filter="out">
Out <span class="count" id="count-out">0</span>
</button>
</div>
<div class="search">
<svg class="search-ico" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<line x1="16.5" y1="16.5" x2="21" y2="21" />
</svg>
<input
type="search"
id="search"
placeholder="Search item or SKU…"
aria-label="Search inventory by item name or SKU"
autocomplete="off"
/>
</div>
</div>
<section class="table-wrap" aria-label="Supplies inventory table">
<table class="inv-table">
<thead>
<tr>
<th scope="col" class="col-item">Item</th>
<th scope="col" class="col-onhand">On hand</th>
<th scope="col" class="col-par">Par</th>
<th scope="col" class="col-status">Status</th>
<th scope="col" class="col-restock">Last restock</th>
<th scope="col" class="col-supplier">Supplier</th>
<th scope="col" class="col-action"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody id="rows"><!-- rendered by script.js --></tbody>
</table>
<p class="empty" id="empty" hidden>
No items match your filters. Try clearing the search.
</p>
</section>
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Supplies Inventory
A stockroom admin screen for Northpoint Clinic. The table lists around a dozen real-feeling supplies — exam gloves, gauze, alcohol prep pads, syringes — each with its SKU, on-hand quantity, par level, last restock date and supplier. Every row carries a status pill that is computed, not stored: an item is Out at zero, Low when on-hand has fallen to or below its par level, and OK otherwise. Low and out rows pick up a coloured left accent so they read at a glance.
Inline + / − steppers adjust on-hand counts as items are used or restocked. Each change recomputes the status pill, repaints the row, decrements at zero, and shows or hides the per-row Reorder button live. The filter tabs (All / Low / Out) and the three summary cards stay in sync with the underlying data, and a search box narrows by item name or SKU. Reordering a low or out item fires a calm confirmation toast naming the supplier and a suggested quantity.
The whole screen is vanilla HTML, CSS and JavaScript — no frameworks, no build step. It is
keyboard-friendly, uses aria-live regions for the changing counts, collapses gracefully to a
card-style layout on narrow screens, and meets AA contrast throughout.
Illustrative UI only — not intended for real medical use.