Clinic — In-clinic Dispense Queue
An in-clinic dispensing queue for handing medications to patients at the visit. Each prescription row shows the patient, drug and strength, quantity, prescriber and a status pill that advances To verify to Ready to Dispensed. Filter tabs carry live counts, a search box filters rows instantly by patient, drug or prescriber, and every primary action moves a row forward while recounting tabs and confirming with a calm toast.
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;
}
/* ── Layout ── */
.queue {
max-width: 960px;
margin: 0 auto;
padding: 32px 20px 64px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Header ── */
.queue-head {
display: flex;
flex-direction: column;
gap: 18px;
}
.head-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--teal-d);
}
.queue-head h1 {
font-size: 1.6rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-top: 4px;
}
.pill-clock {
display: inline-flex;
align-items: center;
gap: 7px;
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 13px;
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-2);
box-shadow: var(--shadow-1);
white-space: nowrap;
}
.pill-clock .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 4px rgba(47, 158, 111, 0.16);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
50% {
box-shadow: 0 0 0 7px rgba(47, 158, 111, 0.04);
}
}
/* ── Controls ── */
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.search {
position: relative;
flex: 1;
min-width: 240px;
}
.search-ico {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
fill: none;
stroke: var(--muted);
stroke-width: 2;
stroke-linecap: round;
pointer-events: none;
}
.search input {
width: 100%;
font: inherit;
font-size: 0.92rem;
color: var(--ink);
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 11px 16px 11px 42px;
box-shadow: var(--shadow-1);
transition: border-color 0.15s, box-shadow 0.15s;
}
.search input::placeholder {
color: var(--muted);
}
.search input:focus-visible {
outline: none;
border-color: var(--teal);
box-shadow: 0 0 0 3px rgba(18, 156, 147, 0.18);
}
/* ── Tabs ── */
.tabs {
display: inline-flex;
gap: 4px;
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px;
box-shadow: var(--shadow-1);
flex-wrap: wrap;
}
.tab {
border: none;
background: transparent;
border-radius: 999px;
padding: 8px 15px;
font: inherit;
font-weight: 600;
font-size: 0.86rem;
color: var(--muted);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 7px;
transition: background 0.15s, color 0.15s;
}
.tab:hover {
color: var(--ink-2);
}
.tab:focus-visible {
outline: 2px solid var(--teal);
outline-offset: 2px;
}
.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;
}
/* ── Rows ── */
.rows {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.row {
display: grid;
grid-template-columns: 40px minmax(104px, 1.1fr) minmax(132px, 1.6fr) 56px minmax(104px, 1fr) auto auto;
align-items: center;
gap: 16px;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 18px;
box-shadow: var(--shadow-1);
transition: border-color 0.15s, box-shadow 0.15s, opacity 0.25s, transform 0.12s;
}
.row > * { min-width: 0; }
.row .status, .row .row-action { min-width: max-content; }
.row:hover {
border-color: var(--line-2);
box-shadow: 0 2px 4px rgba(16, 50, 47, 0.05), 0 8px 22px rgba(16, 50, 47, 0.08);
}
.row[hidden] {
display: none;
}
.row.is-dispensed {
background: #fafdfc;
box-shadow: none;
}
.row.just-advanced {
animation: flash 0.6s ease;
}
@keyframes flash {
0% {
background: var(--teal-50);
}
100% {
background: var(--white);
}
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
background: linear-gradient(150deg, var(--teal-50), #d9efec);
color: var(--teal-700);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.who .name,
.pres-name {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--ink);
}
.sub {
font-size: 0.78rem;
color: var(--muted);
margin-top: 2px;
}
.drug {
font-size: 0.95rem;
font-weight: 700;
color: var(--ink);
}
.strength {
font-weight: 700;
color: var(--teal-d);
}
.qty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.qty-num {
font-size: 1.15rem;
font-weight: 800;
color: var(--ink);
line-height: 1;
}
.qty-unit {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
margin-top: 3px;
}
.prescriber .sub {
margin-top: 0;
}
.prescriber .pres-name {
font-size: 0.88rem;
margin-top: 1px;
}
/* ── Status pill ── */
.status {
justify-self: start;
font-size: 0.74rem;
font-weight: 700;
padding: 5px 11px;
border-radius: 999px;
white-space: nowrap;
}
.status-verify {
background: rgba(217, 138, 43, 0.16);
color: var(--warn);
}
.status-ready {
background: rgba(18, 156, 147, 0.14);
color: var(--teal-d);
}
.status-dispensed {
background: rgba(47, 158, 111, 0.14);
color: var(--ok);
}
/* ── Action ── */
.row-action {
justify-self: end;
}
.btn {
border: none;
border-radius: 10px;
padding: 9px 16px;
font: inherit;
font-weight: 600;
font-size: 0.84rem;
cursor: pointer;
white-space: nowrap;
transition: transform 0.12s, background 0.15s, box-shadow 0.15s;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--teal);
outline-offset: 2px;
}
.btn-verify {
background: var(--white);
border: 1px solid var(--line-2);
color: var(--warn);
}
.btn-verify:hover {
background: rgba(217, 138, 43, 0.1);
border-color: var(--warn);
}
.btn-dispense {
background: var(--teal-d);
color: #fff;
box-shadow: 0 2px 8px rgba(12, 122, 115, 0.24);
}
.btn-dispense:hover {
background: var(--teal-700);
}
.done-mark {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.8rem;
font-weight: 600;
color: var(--ok);
white-space: nowrap;
}
.done-mark svg {
width: 16px;
height: 16px;
fill: none;
stroke: var(--ok);
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ── Empty ── */
.empty {
text-align: center;
color: var(--muted);
font-size: 0.92rem;
padding: 36px 0;
}
/* ── 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) {
.row {
grid-template-columns: 40px 1fr auto;
grid-template-areas:
"avatar who status"
"avatar rx rx"
"avatar meta action";
row-gap: 10px;
column-gap: 14px;
}
.avatar {
grid-area: avatar;
align-self: start;
}
.who {
grid-area: who;
}
.rx {
grid-area: rx;
}
.qty {
grid-area: meta;
flex-direction: row;
align-items: baseline;
gap: 6px;
}
.qty-unit {
margin-top: 0;
}
.prescriber {
display: none;
}
.status {
grid-area: status;
justify-self: end;
}
.row-action {
grid-area: action;
justify-self: end;
}
}
@media (max-width: 520px) {
.queue {
padding: 24px 14px 56px;
}
.head-top {
flex-direction: column;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.tabs {
justify-content: flex-start;
}
.tab {
padding: 8px 12px;
font-size: 0.82rem;
}
}// ── Toast ──────────────────────────────────────────────────────────────────
const toast = document.getElementById("toast");
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2600);
}
const rows = document.getElementById("rows");
const empty = document.getElementById("empty");
const search = document.getElementById("search");
const tabs = [...document.querySelectorAll(".tab")];
let activeFilter = "all";
// ── Status flow ──────────────────────────────────────────────────────────────
// Each status carries its display label, the action button to show, and the
// status it advances to on click.
const FLOW = {
verify: { label: "To verify", next: "ready", action: "Verify", cls: "btn-verify" },
ready: { label: "Ready", next: "dispensed", action: "Dispense", cls: "btn-dispense" },
dispensed: { label: "Dispensed", next: null },
};
// ── Live counts from current DOM state ───────────────────────────────────────
function refreshCounts() {
const all = rows.querySelectorAll(".row");
const tally = { all: all.length, verify: 0, ready: 0, dispensed: 0 };
all.forEach((r) => (tally[r.dataset.status] += 1));
document.querySelectorAll(".count").forEach((c) => {
c.textContent = tally[c.dataset.count];
});
}
// ── Apply active filter + search query ───────────────────────────────────────
function applyView() {
const q = search.value.trim().toLowerCase();
let visible = 0;
rows.querySelectorAll(".row").forEach((row) => {
const matchesFilter = activeFilter === "all" || row.dataset.status === activeFilter;
const matchesQuery = !q || row.dataset.search.includes(q);
const show = matchesFilter && matchesQuery;
row.hidden = !show;
if (show) visible += 1;
});
empty.hidden = visible !== 0;
}
// ── Tab switching ────────────────────────────────────────────────────────────
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
activeFilter = tab.dataset.filter;
tabs.forEach((t) => {
const active = t === tab;
t.classList.toggle("is-active", active);
t.setAttribute("aria-selected", String(active));
});
applyView();
});
});
// ── Advance a row's status (Verify → Dispense) ───────────────────────────────
function advance(row) {
const current = FLOW[row.dataset.status];
if (!current || !current.next) return;
const name = row.querySelector(".name").textContent;
const drug = row.querySelector(".drug").firstChild.textContent.trim();
const nextKey = current.next;
const next = FLOW[nextKey];
row.dataset.status = nextKey;
const status = row.querySelector(".status");
status.className = "status status-" + nextKey;
status.textContent = next.label;
status.setAttribute("aria-label", "Status: " + next.label.toLowerCase());
const slot = row.querySelector(".row-action");
if (next.next) {
slot.innerHTML =
'<button class="btn ' + next.cls + '" data-action="advance">' + next.action + "</button>";
showToast(name + " · " + drug + " verified — ready to dispense.");
} else {
row.classList.add("is-dispensed");
slot.innerHTML =
'<span class="done-mark" aria-hidden="true">' +
'<svg viewBox="0 0 24 24"><polyline points="4 12.5 9.5 18 20 6" /></svg>' +
"Handed to patient</span>";
showToast(name + " · " + drug + " dispensed and logged.");
}
row.classList.remove("just-advanced");
void row.offsetWidth;
row.classList.add("just-advanced");
refreshCounts();
applyView();
}
// ── Wiring ───────────────────────────────────────────────────────────────────
rows.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action="advance"]');
if (!btn) return;
advance(btn.closest(".row"));
});
search.addEventListener("input", applyView);
refreshCounts();
applyView();<!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>Dispense Queue · Northpoint Clinic</title>
</head>
<body>
<main class="queue">
<header class="queue-head">
<div class="head-top">
<div>
<p class="eyebrow">Northpoint Clinic · Dispensary</p>
<h1>In-clinic dispense queue</h1>
</div>
<span class="pill-clock" aria-hidden="true">
<span class="dot"></span> Live · clinic floor
</span>
</div>
<div class="controls">
<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 patient, drug or prescriber…"
aria-label="Search prescriptions"
autocomplete="off"
/>
</div>
<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" data-count="all">5</span>
</button>
<button class="tab" role="tab" aria-selected="false" data-filter="verify">
To verify <span class="count" data-count="verify">2</span>
</button>
<button class="tab" role="tab" aria-selected="false" data-filter="ready">
Ready <span class="count" data-count="ready">2</span>
</button>
<button class="tab" role="tab" aria-selected="false" data-filter="dispensed">
Dispensed <span class="count" data-count="dispensed">1</span>
</button>
</div>
</div>
</header>
<ul class="rows" id="rows" role="list" aria-label="Prescriptions ready to dispense">
<li class="row" data-status="verify" data-search="aisha bello amoxicillin clarke">
<span class="avatar" aria-hidden="true">AB</span>
<div class="who">
<p class="name">Aisha Bello</p>
<p class="sub">MRN 40921 · 34y</p>
</div>
<div class="rx">
<p class="drug">Amoxicillin <span class="strength">500 mg</span></p>
<p class="sub">Capsule · 1 cap, 3× daily, 7 days</p>
</div>
<div class="qty"><span class="qty-num">21</span><span class="qty-unit">caps</span></div>
<div class="prescriber">
<p class="sub">Prescriber</p>
<p class="pres-name">Dr. Lena Okafor</p>
</div>
<span class="status status-verify" aria-label="Status: to verify">To verify</span>
<div class="row-action">
<button class="btn btn-verify" data-action="advance">Verify</button>
</div>
</li>
<li class="row" data-status="verify" data-search="marcus reynolds metformin patel">
<span class="avatar" aria-hidden="true">MR</span>
<div class="who">
<p class="name">Marcus Reynolds</p>
<p class="sub">MRN 38114 · 58y</p>
</div>
<div class="rx">
<p class="drug">Metformin <span class="strength">850 mg</span></p>
<p class="sub">Tablet · 1 tab, 2× daily with meals</p>
</div>
<div class="qty"><span class="qty-num">60</span><span class="qty-unit">tabs</span></div>
<div class="prescriber">
<p class="sub">Prescriber</p>
<p class="pres-name">Dr. Ravi Patel</p>
</div>
<span class="status status-verify" aria-label="Status: to verify">To verify</span>
<div class="row-action">
<button class="btn btn-verify" data-action="advance">Verify</button>
</div>
</li>
<li class="row" data-status="ready" data-search="sofia lindgren salbutamol patel inhaler">
<span class="avatar" aria-hidden="true">SL</span>
<div class="who">
<p class="name">Sofia Lindgren</p>
<p class="sub">MRN 41388 · 27y</p>
</div>
<div class="rx">
<p class="drug">Salbutamol <span class="strength">100 mcg</span></p>
<p class="sub">Inhaler · 2 puffs as needed</p>
</div>
<div class="qty"><span class="qty-num">1</span><span class="qty-unit">inhaler</span></div>
<div class="prescriber">
<p class="sub">Prescriber</p>
<p class="pres-name">Dr. Ravi Patel</p>
</div>
<span class="status status-ready" aria-label="Status: ready to dispense">Ready</span>
<div class="row-action">
<button class="btn btn-dispense" data-action="advance">Dispense</button>
</div>
</li>
<li class="row" data-status="ready" data-search="omar haddad atorvastatin bloom">
<span class="avatar" aria-hidden="true">OH</span>
<div class="who">
<p class="name">Omar Haddad</p>
<p class="sub">MRN 39007 · 61y</p>
</div>
<div class="rx">
<p class="drug">Atorvastatin <span class="strength">20 mg</span></p>
<p class="sub">Tablet · 1 tab at night</p>
</div>
<div class="qty"><span class="qty-num">30</span><span class="qty-unit">tabs</span></div>
<div class="prescriber">
<p class="sub">Prescriber</p>
<p class="pres-name">Dr. Maya Bloom</p>
</div>
<span class="status status-ready" aria-label="Status: ready to dispense">Ready</span>
<div class="row-action">
<button class="btn btn-dispense" data-action="advance">Dispense</button>
</div>
</li>
<li class="row" data-status="dispensed" data-search="priya nair levothyroxine okafor">
<span class="avatar" aria-hidden="true">PN</span>
<div class="who">
<p class="name">Priya Nair</p>
<p class="sub">MRN 40553 · 45y</p>
</div>
<div class="rx">
<p class="drug">Levothyroxine <span class="strength">75 mcg</span></p>
<p class="sub">Tablet · 1 tab each morning</p>
</div>
<div class="qty"><span class="qty-num">28</span><span class="qty-unit">tabs</span></div>
<div class="prescriber">
<p class="sub">Prescriber</p>
<p class="pres-name">Dr. Lena Okafor</p>
</div>
<span class="status status-dispensed" aria-label="Status: dispensed">Dispensed</span>
<div class="row-action">
<span class="done-mark" aria-hidden="true">
<svg viewBox="0 0 24 24"><polyline points="4 12.5 9.5 18 20 6" /></svg>
Handed to patient
</span>
</div>
</li>
</ul>
<p class="empty" id="empty" hidden>No prescriptions match this view.</p>
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>In-clinic Dispense Queue
A focused dispensary screen for the moment a clinician hands medication to a patient at the visit. Each row is a table-and-card hybrid: a patient avatar and MRN, the drug with its strength and dosing line, the quantity to count out, the prescriber, and a coloured status pill that reads To verify, Ready or Dispensed. The header carries a live “clinic floor” indicator and a search field.
Filter tabs across the top — All, To verify, Ready and Dispensed — each show a live count and narrow the list to that status. The search box filters rows instantly across patient name, drug and prescriber. Every active row has one clear primary action: Verify promotes a script from To verify to Ready, then Dispense marks it handed to the patient. Each step recolours the status pill, swaps in the next action, gently flashes the row, recalculates the tab counts and confirms with a brief toast.
The layout reflows from a wide multi-column row into a stacked card under ~760px and stays usable down to 360px, with focus-visible outlines and aria labels on the interactive controls. All state lives in the DOM and every interaction is plain vanilla JS — no frameworks, no build step.
Illustrative UI only — not intended for real medical use.