Pages Hard
POS — Void & Refund Manager
Manager screen for voiding items or full tickets, issuing refunds, and logging comp reasons — searchable ticket list, line-item selection, approval PIN, and audit trail.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
/* ============================================================
POS Void & Refund Manager — Phase 27 Restaurant Theme
============================================================ */
:root {
--cream: #FAF7F1;
--ink: #2C1A0E;
--forest: #345F40;
--forest-d: #213D29;
--forest-l: #3d7049;
--terracotta: #C4622D;
--terracotta-d: #a34e22;
--gold: #D4A853;
--gold-d: #b88a3a;
--warm-gray: #8A7D72;
--bone: #F0EBE0;
--bone-d: #ddd6c8;
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'Inter', system-ui, sans-serif;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 3px rgba(44, 26, 14, 0.12);
--shadow-md: 0 4px 12px rgba(44, 26, 14, 0.15);
--shadow-lg: 0 8px 32px rgba(44, 26, 14, 0.2);
--transition: 150ms ease;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* ── Utilities ─────────────────────────────────────────────── */
.hidden { display: none !important; }
/* ── App Shell ──────────────────────────────────────────────── */
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ============================================================
LEFT PANEL
============================================================ */
.panel-left {
width: 35%;
min-width: 280px;
max-width: 380px;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--forest);
color: var(--bone);
flex-shrink: 0;
overflow: hidden;
}
.panel-left__header {
padding: 24px 20px 16px;
border-bottom: 1px solid rgba(240, 235, 224, 0.12);
flex-shrink: 0;
}
.panel-left__title {
font-family: var(--font-display);
font-size: 1.375rem;
font-weight: 700;
color: var(--bone);
margin-bottom: 14px;
letter-spacing: -0.01em;
}
/* Search */
.search-wrap {
position: relative;
margin-bottom: 12px;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 0.875rem;
pointer-events: none;
opacity: 0.6;
}
.search-input {
width: 100%;
padding: 9px 12px 9px 34px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(240, 235, 224, 0.2);
border-radius: var(--radius-md);
color: var(--bone);
font-family: var(--font-body);
font-size: 0.875rem;
outline: none;
transition: border-color var(--transition), background var(--transition);
}
.search-input::placeholder {
color: rgba(240, 235, 224, 0.5);
}
.search-input:focus {
background: rgba(255, 255, 255, 0.15);
border-color: var(--gold);
}
/* Filter chips */
.filter-chips {
display: flex;
gap: 6px;
}
.chip {
padding: 5px 14px;
border-radius: 20px;
border: 1px solid rgba(240, 235, 224, 0.25);
background: transparent;
color: rgba(240, 235, 224, 0.7);
font-family: var(--font-body);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.chip:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--bone);
}
.chip--active {
background: var(--gold);
border-color: var(--gold);
color: var(--ink);
font-weight: 600;
}
/* Ticket List */
.ticket-list {
flex: 1;
overflow-y: auto;
list-style: none;
padding: 8px 0;
scrollbar-width: thin;
scrollbar-color: rgba(240, 235, 224, 0.2) transparent;
}
.ticket-list::-webkit-scrollbar {
width: 4px;
}
.ticket-list::-webkit-scrollbar-thumb {
background: rgba(240, 235, 224, 0.2);
border-radius: 2px;
}
.ticket-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
cursor: pointer;
transition: background var(--transition);
border-left: 3px solid transparent;
user-select: none;
}
.ticket-row:hover {
background: var(--forest-d);
}
.ticket-row--selected {
background: var(--forest-d);
border-left-color: var(--gold);
}
.ticket-row__icon {
font-size: 1.1rem;
flex-shrink: 0;
opacity: 0.8;
}
.ticket-row__body {
flex: 1;
min-width: 0;
}
.ticket-row__table {
font-weight: 600;
font-size: 0.9rem;
color: var(--bone);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ticket-row__meta {
font-size: 0.78rem;
color: rgba(240, 235, 224, 0.6);
margin-top: 2px;
}
.ticket-row__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.ticket-row__total {
font-weight: 700;
font-size: 0.9rem;
color: var(--bone);
}
/* Status chips */
.status-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.status-chip--open {
background: var(--gold);
color: var(--ink);
}
.status-chip--closed {
background: var(--bone-d);
color: var(--warm-gray);
}
.status-chip--voided {
background: var(--terracotta);
color: var(--bone);
}
/* ============================================================
RIGHT PANEL
============================================================ */
.panel-right {
flex: 1;
height: 100vh;
overflow-y: auto;
background: var(--cream);
display: flex;
flex-direction: column;
}
/* Empty state */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px;
}
.empty-state__icon {
font-size: 3rem;
opacity: 0.35;
}
.empty-state__text {
font-size: 1rem;
color: var(--warm-gray);
text-align: center;
max-width: 260px;
line-height: 1.5;
}
/* Detail view */
.detail-view {
flex: 1;
display: flex;
flex-direction: column;
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 2px solid var(--bone-d);
padding: 0 28px;
background: var(--cream);
flex-shrink: 0;
}
.tab {
position: relative;
padding: 16px 4px;
margin-right: 28px;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
font-family: var(--font-body);
font-size: 0.9rem;
font-weight: 500;
color: var(--warm-gray);
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
display: flex;
align-items: center;
gap: 6px;
}
.tab:hover {
color: var(--ink);
}
.tab--active {
color: var(--forest);
border-bottom-color: var(--forest);
font-weight: 600;
}
.audit-badge {
background: var(--terracotta);
color: var(--bone);
font-size: 0.68rem;
font-weight: 700;
border-radius: 10px;
padding: 1px 6px;
min-width: 18px;
text-align: center;
}
/* Ticket header */
.ticket-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 24px 28px 16px;
border-bottom: 1px solid var(--bone-d);
flex-shrink: 0;
}
.ticket-header__title {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
color: var(--ink);
line-height: 1.2;
}
.ticket-header__meta {
display: block;
font-size: 0.83rem;
color: var(--warm-gray);
margin-top: 4px;
}
/* Items section */
.items-section {
padding: 20px 28px 0;
flex-shrink: 0;
}
.items-section__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.items-section__label {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--warm-gray);
}
.select-all-btn {
background: none;
border: 1px solid var(--bone-d);
border-radius: var(--radius-sm);
padding: 4px 10px;
font-family: var(--font-body);
font-size: 0.78rem;
font-weight: 500;
color: var(--warm-gray);
cursor: pointer;
transition: border-color var(--transition), color var(--transition);
}
.select-all-btn:hover {
border-color: var(--forest);
color: var(--forest);
}
.items-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 280px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--bone-d) transparent;
}
.items-list::-webkit-scrollbar {
width: 4px;
}
.items-list::-webkit-scrollbar-thumb {
background: var(--bone-d);
border-radius: 2px;
}
/* Item row */
.item-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid transparent;
transition: background var(--transition), border-color var(--transition);
cursor: pointer;
}
.item-row:hover:not(.item-row--voided) {
background: var(--bone);
border-color: var(--bone-d);
}
.item-row--selected:not(.item-row--voided) {
background: rgba(196, 98, 45, 0.07);
border-color: rgba(196, 98, 45, 0.25);
}
.item-row--voided {
opacity: 0.55;
cursor: default;
}
/* Custom checkbox */
.item-checkbox {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border: 2px solid var(--bone-d);
border-radius: var(--radius-sm);
background: #fff;
cursor: pointer;
flex-shrink: 0;
position: relative;
transition: background var(--transition), border-color var(--transition);
}
.item-checkbox:checked {
background: var(--terracotta);
border-color: var(--terracotta);
}
.item-checkbox:checked::after {
content: '';
position: absolute;
top: 2px;
left: 5px;
width: 6px;
height: 10px;
border: 2px solid #fff;
border-top: none;
border-left: none;
transform: rotate(42deg);
}
.item-checkbox:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.item-row__name {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
color: var(--ink);
min-width: 0;
}
.item-row__name--voided {
text-decoration: line-through;
color: var(--warm-gray);
}
.item-row__qty {
font-size: 0.83rem;
color: var(--warm-gray);
white-space: nowrap;
}
.item-row__price {
font-size: 0.9rem;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
min-width: 56px;
text-align: right;
}
.item-row__price--voided {
text-decoration: line-through;
color: var(--warm-gray);
}
.item-row__voided-tag {
font-size: 0.72rem;
font-weight: 600;
color: var(--terracotta);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
/* Void summary */
.void-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 14px 28px;
background: var(--bone);
border-top: 1px solid var(--bone-d);
border-bottom: 1px solid var(--bone-d);
margin-top: 12px;
font-size: 0.9rem;
}
.void-summary__label {
color: var(--warm-gray);
font-weight: 500;
}
.void-summary__amount {
font-weight: 700;
font-size: 1.05rem;
color: var(--terracotta);
min-width: 64px;
}
.void-summary__from {
color: var(--warm-gray);
}
.void-summary__total {
font-weight: 600;
color: var(--ink);
}
/* Void controls */
.void-controls {
padding: 20px 28px 24px;
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
flex-shrink: 0;
}
.reason-wrap {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 6px;
}
.reason-label {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--warm-gray);
}
.reason-select {
padding: 10px 14px;
background: #fff;
border: 1.5px solid var(--bone-d);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--ink);
cursor: pointer;
outline: none;
transition: border-color var(--transition);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238A7D72' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.reason-select:focus {
border-color: var(--forest);
}
.void-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 24px;
background: var(--terracotta);
color: #fff;
border: none;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), opacity var(--transition), transform var(--transition);
white-space: nowrap;
min-height: 44px;
}
.void-btn:hover:not(:disabled) {
background: var(--terracotta-d);
transform: translateY(-1px);
}
.void-btn:active:not(:disabled) {
transform: translateY(0);
}
.void-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.void-btn__arrow {
font-size: 1rem;
}
/* ============================================================
AUDIT TAB
============================================================ */
.audit-header {
padding: 20px 28px 12px;
display: flex;
align-items: baseline;
gap: 12px;
border-bottom: 1px solid var(--bone-d);
}
.audit-header__title {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
color: var(--ink);
}
.audit-header__sub {
font-size: 0.8rem;
color: var(--warm-gray);
}
.audit-list-wrap {
padding: 16px 28px 28px;
overflow-x: auto;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.audit-table th {
text-align: left;
padding: 8px 10px;
color: var(--warm-gray);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 2px solid var(--bone-d);
white-space: nowrap;
}
.audit-table td {
padding: 9px 10px;
color: var(--ink);
vertical-align: top;
}
.audit-table tbody tr:nth-child(even) {
background: var(--bone);
}
.audit-table tbody tr:hover {
background: rgba(52, 95, 64, 0.06);
}
.audit-table .col-amount {
font-weight: 600;
color: var(--terracotta);
}
.audit-empty {
text-align: center;
color: var(--warm-gray);
font-size: 0.9rem;
padding: 32px 0;
}
/* ============================================================
PIN OVERLAY
============================================================ */
.pin-overlay {
position: fixed;
inset: 0;
background: rgba(44, 26, 14, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(2px);
}
.pin-card {
background: var(--cream);
border-radius: var(--radius-xl);
padding: 32px 28px 28px;
width: 100%;
max-width: 340px;
box-shadow: var(--shadow-lg);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pin-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--bone-d);
background: none;
cursor: pointer;
font-size: 0.8rem;
color: var(--warm-gray);
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition), color var(--transition);
}
.pin-close:hover {
background: var(--bone);
color: var(--ink);
}
.pin-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: var(--ink);
text-align: center;
}
.pin-subtitle {
font-size: 0.83rem;
color: var(--warm-gray);
text-align: center;
margin-top: -8px;
line-height: 1.4;
}
.pin-summary {
background: var(--bone);
border-radius: var(--radius-md);
padding: 10px 16px;
font-size: 0.83rem;
color: var(--ink);
text-align: center;
width: 100%;
line-height: 1.5;
}
.pin-display {
display: flex;
gap: 12px;
justify-content: center;
align-items: center;
padding: 8px 0;
}
.pin-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--bone-d);
background: transparent;
transition: background var(--transition), border-color var(--transition);
}
.pin-dot--filled {
background: var(--forest);
border-color: var(--forest);
}
.pin-dot--error {
border-color: var(--terracotta);
background: var(--terracotta);
}
.pin-error {
font-size: 0.83rem;
color: var(--terracotta);
font-weight: 500;
text-align: center;
}
/* Keypad */
.pin-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
width: 100%;
max-width: 260px;
}
.key {
padding: 0;
height: 56px;
border-radius: var(--radius-md);
border: 1.5px solid var(--bone-d);
background: #fff;
font-family: var(--font-body);
font-size: 1.2rem;
font-weight: 600;
color: var(--ink);
cursor: pointer;
transition: background var(--transition), border-color var(--transition), transform var(--transition);
display: flex;
align-items: center;
justify-content: center;
}
.key:hover {
background: var(--bone);
border-color: var(--forest);
}
.key:active {
transform: scale(0.95);
background: var(--bone-d);
}
.key--clear {
background: var(--bone);
color: var(--warm-gray);
font-size: 0.9rem;
}
.key--back {
background: var(--bone);
color: var(--ink);
font-size: 1rem;
}
.pin-approve-btn {
width: 100%;
max-width: 260px;
padding: 13px;
background: var(--forest);
color: var(--bone);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), opacity var(--transition);
min-height: 48px;
}
.pin-approve-btn:hover:not(:disabled) {
background: var(--forest-d);
}
.pin-approve-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ============================================================
TOAST
============================================================ */
.toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
background: var(--ink);
color: var(--bone);
padding: 12px 24px;
border-radius: var(--radius-lg);
font-size: 0.9rem;
font-weight: 500;
box-shadow: var(--shadow-lg);
z-index: 200;
pointer-events: none;
opacity: 0;
transition: opacity 300ms ease;
}
.toast--visible {
opacity: 1;
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 768px) {
.app {
flex-direction: column;
height: auto;
overflow: auto;
}
.panel-left {
width: 100%;
max-width: 100%;
height: auto;
min-height: 220px;
max-height: 45vh;
}
.panel-right {
height: auto;
overflow-y: visible;
}
html, body {
height: auto;
overflow: auto;
}
}/* ============================================================
POS Void & Refund Manager — script.js
Phase 27 Restaurant Theme
============================================================ */
'use strict';
/* ── Data ─────────────────────────────────────────────────── */
const TICKETS = [
{
id: 'T001',
table: 'Table 3',
time: '7:14 PM',
server: 'Maria R.',
status: 'open',
items: [
{ id: 'i1', name: 'Ribeye Steak', qty: 1, price: 42.00, voided: false },
{ id: 'i2', name: 'Truffle Fries', qty: 2, price: 9.50, voided: false },
{ id: 'i3', name: 'House Salad', qty: 1, price: 7.00, voided: false },
{ id: 'i4', name: 'Sparkling Water', qty: 2, price: 4.00, voided: false },
],
},
{
id: 'T002',
table: 'Table 7',
time: '6:52 PM',
server: 'James T.',
status: 'closed',
items: [
{ id: 'i5', name: 'Pan-Seared Salmon', qty: 2, price: 28.00, voided: false },
{ id: 'i6', name: 'Mushroom Risotto', qty: 1, price: 18.50, voided: false },
{ id: 'i7', name: 'Crème Brûlée', qty: 2, price: 8.00, voided: false },
{ id: 'i8', name: 'Sauvignon Blanc (glass)', qty: 3, price: 12.00, voided: false },
],
},
{
id: 'T003',
table: 'Table 12',
time: '7:33 PM',
server: 'Sofia D.',
status: 'open',
items: [
{ id: 'i9', name: 'Duck Confit', qty: 1, price: 34.00, voided: false },
{ id: 'i10', name: 'Burrata & Heirloom Tomato', qty: 1, price: 16.00, voided: false },
{ id: 'i11', name: 'Tiramisu', qty: 1, price: 9.00, voided: false },
],
},
{
id: 'T004',
table: 'Bar 2',
time: '8:05 PM',
server: 'Alex M.',
status: 'open',
items: [
{ id: 'i12', name: 'Wagyu Slider (x3)', qty: 1, price: 22.00, voided: false },
{ id: 'i13', name: 'Craft Lager', qty: 2, price: 7.50, voided: false },
{ id: 'i14', name: 'Old Fashioned', qty: 1, price: 14.00, voided: false },
],
},
{
id: 'T005',
table: 'Table 5',
time: '6:15 PM',
server: 'Maria R.',
status: 'voided',
items: [
{ id: 'i15', name: 'Lobster Bisque', qty: 2, price: 14.00, voided: true },
{ id: 'i16', name: 'Caesar Salad', qty: 1, price: 10.00, voided: true },
],
},
{
id: 'T006',
table: 'Table 9',
time: '7:48 PM',
server: 'James T.',
status: 'closed',
items: [
{ id: 'i17', name: 'Foie Gras Terrine', qty: 1, price: 24.00, voided: false },
{ id: 'i18', name: 'Rack of Lamb', qty: 2, price: 46.00, voided: false },
{ id: 'i19', name: 'Molten Chocolate Cake', qty: 2, price: 10.00, voided: false },
{ id: 'i20', name: 'Champagne (bottle)', qty: 1, price: 85.00, voided: false },
],
},
{
id: 'T007',
table: 'Patio 1',
time: '8:22 PM',
server: 'Sofia D.',
status: 'open',
items: [
{ id: 'i21', name: 'Margherita Flatbread', qty: 1, price: 15.00, voided: false },
{ id: 'i22', name: 'Burrata Bowl', qty: 1, price: 17.00, voided: false },
{ id: 'i23', name: 'Lemonade (pitcher)', qty: 1, price: 11.00, voided: false },
],
},
{
id: 'T008',
table: 'Table 1',
time: '5:58 PM',
server: 'Alex M.',
status: 'closed',
items: [
{ id: 'i24', name: 'Charcuterie Board', qty: 1, price: 28.00, voided: false },
{ id: 'i25', name: 'Grilled Branzino', qty: 1, price: 32.00, voided: false },
{ id: 'i26', name: 'Roasted Vegetables', qty: 1, price: 11.00, voided: false },
{ id: 'i27', name: 'Dessert Trio', qty: 1, price: 18.00, voided: false },
],
},
];
/* Audit log */
const auditLog = [];
const CORRECT_PIN = '1234';
/* ── State ────────────────────────────────────────────────── */
let selectedTicketId = null;
let selectedItemIds = new Set();
let currentFilter = 'all';
let searchQuery = '';
let pinBuffer = '';
let pendingVoidReason = '';
/* ── Helpers ──────────────────────────────────────────────── */
function getTicket(id) {
return TICKETS.find(t => t.id === id) || null;
}
function ticketTotal(ticket) {
return ticket.items.reduce((sum, it) => sum + it.qty * it.price, 0);
}
function ticketVoidableTotal(ticket) {
return ticket.items
.filter(it => !it.voided)
.reduce((sum, it) => sum + it.qty * it.price, 0);
}
function selectedVoidAmount() {
const ticket = getTicket(selectedTicketId);
if (!ticket) return 0;
return ticket.items
.filter(it => selectedItemIds.has(it.id))
.reduce((sum, it) => sum + it.qty * it.price, 0);
}
function fmt(n) {
return '$' + n.toFixed(2);
}
function now() {
return new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
}
function statusClass(status) {
return `status-chip status-chip--${status}`;
}
function statusLabel(status) {
return status.charAt(0).toUpperCase() + status.slice(1);
}
/* ── Render: Ticket List ──────────────────────────────────── */
function renderList() {
const list = document.getElementById('ticket-list');
const q = searchQuery.trim().toLowerCase();
const filtered = TICKETS.filter(t => {
if (currentFilter !== 'all' && t.status !== currentFilter) return false;
if (q && !t.table.toLowerCase().includes(q)) return false;
return true;
});
if (filtered.length === 0) {
list.innerHTML = `<li class="ticket-row" style="cursor:default;opacity:0.6;font-size:0.85rem;color:rgba(240,235,224,0.6)">No tickets found</li>`;
return;
}
list.innerHTML = filtered.map(t => {
const icon = t.status === 'open' ? '🟡' : t.status === 'voided' ? '🔴' : '⚪';
const isSelected = t.id === selectedTicketId;
return `
<li
class="ticket-row${isSelected ? ' ticket-row--selected' : ''}"
data-id="${t.id}"
role="listitem"
tabindex="0"
aria-selected="${isSelected}"
>
<span class="ticket-row__icon">${icon}</span>
<div class="ticket-row__body">
<div class="ticket-row__table">${t.table}</div>
<div class="ticket-row__meta">${t.time} · ${t.server}</div>
</div>
<div class="ticket-row__right">
<span class="ticket-row__total">${fmt(ticketTotal(t))}</span>
<span class="${statusClass(t.status)}">${statusLabel(t.status)}</span>
</div>
</li>
`;
}).join('');
/* Event delegation */
list.querySelectorAll('.ticket-row[data-id]').forEach(row => {
row.addEventListener('click', () => selectTicket(row.dataset.id));
row.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectTicket(row.dataset.id);
}
});
});
}
/* ── Render: Ticket Detail ────────────────────────────────── */
function renderDetail() {
const emptyState = document.getElementById('empty-state');
const detailView = document.getElementById('detail-view');
if (!selectedTicketId) {
emptyState.classList.remove('hidden');
detailView.classList.add('hidden');
return;
}
const ticket = getTicket(selectedTicketId);
if (!ticket) return;
emptyState.classList.add('hidden');
detailView.classList.remove('hidden');
/* Header */
document.getElementById('detail-title').textContent = ticket.table;
document.getElementById('detail-meta').textContent =
`${ticket.time} · ${ticket.server} · ${ticket.items.length} items`;
const statusEl = document.getElementById('detail-status');
statusEl.className = statusClass(ticket.status);
statusEl.textContent = statusLabel(ticket.status);
/* Items */
const itemsList = document.getElementById('items-list');
const isVoidedTicket = ticket.status === 'voided';
itemsList.innerHTML = ticket.items.map(it => {
const isChecked = selectedItemIds.has(it.id);
const isVoided = it.voided;
const disabled = isVoided || isVoidedTicket ? 'disabled' : '';
return `
<li
class="item-row${isChecked && !isVoided ? ' item-row--selected' : ''}${isVoided ? ' item-row--voided' : ''}"
data-item-id="${it.id}"
>
<input
type="checkbox"
class="item-checkbox"
id="cb-${it.id}"
${isChecked && !isVoided ? 'checked' : ''}
${disabled}
aria-label="Select ${it.name} for void"
/>
<label for="cb-${it.id}" class="item-row__name${isVoided ? ' item-row__name--voided' : ''}">${it.name}</label>
<span class="item-row__qty">x${it.qty}</span>
<span class="item-row__price${isVoided ? ' item-row__price--voided' : ''}">${fmt(it.qty * it.price)}</span>
${isVoided ? '<span class="item-row__voided-tag">Voided</span>' : ''}
</li>
`;
}).join('');
/* Item checkbox events */
itemsList.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const itemId = cb.id.replace('cb-', '');
if (cb.checked) {
selectedItemIds.add(itemId);
} else {
selectedItemIds.delete(itemId);
}
renderDetail();
});
});
/* Item row click (row click toggles checkbox) */
itemsList.querySelectorAll('.item-row').forEach(row => {
row.addEventListener('click', e => {
if (e.target.classList.contains('item-checkbox') || e.target.tagName === 'LABEL') return;
const itemId = row.dataset.itemId;
const it = ticket.items.find(i => i.id === itemId);
if (!it || it.voided || isVoidedTicket) return;
const cb = document.getElementById(`cb-${itemId}`);
if (!cb || cb.disabled) return;
cb.checked = !cb.checked;
if (cb.checked) {
selectedItemIds.add(itemId);
} else {
selectedItemIds.delete(itemId);
}
renderDetail();
});
});
/* Select all button */
const selectAllBtn = document.getElementById('select-all-btn');
const voidableItems = ticket.items.filter(it => !it.voided);
const allSelected = voidableItems.length > 0 && voidableItems.every(it => selectedItemIds.has(it.id));
selectAllBtn.textContent = allSelected ? 'Deselect all' : 'Select all';
selectAllBtn.disabled = isVoidedTicket || voidableItems.length === 0;
selectAllBtn.onclick = () => {
if (allSelected) {
voidableItems.forEach(it => selectedItemIds.delete(it.id));
} else {
voidableItems.forEach(it => selectedItemIds.add(it.id));
}
renderDetail();
};
/* Summary */
const voidAmt = selectedVoidAmount();
document.getElementById('void-amount').textContent = fmt(voidAmt);
document.getElementById('ticket-total').textContent = fmt(ticketTotal(ticket));
/* Void button state */
const voidBtn = document.getElementById('void-btn');
const reasonSelect = document.getElementById('void-reason');
const hasSelection = selectedItemIds.size > 0;
const hasReason = reasonSelect.value !== '';
voidBtn.disabled = !hasSelection || !hasReason || isVoidedTicket;
}
/* ── Render: Audit Trail ──────────────────────────────────── */
function renderAudit() {
const tbody = document.getElementById('audit-tbody');
const emptyMsg = document.getElementById('audit-empty');
const badge = document.getElementById('audit-badge');
const entries = auditLog.slice(-10).reverse();
if (entries.length === 0) {
tbody.innerHTML = '';
emptyMsg.classList.remove('hidden');
badge.classList.add('hidden');
} else {
emptyMsg.classList.add('hidden');
badge.classList.remove('hidden');
badge.textContent = Math.min(auditLog.length, 10);
tbody.innerHTML = entries.map(entry => `
<tr>
<td>${entry.time}</td>
<td>${entry.table}</td>
<td>${entry.items.join(', ')}</td>
<td>${entry.reason}</td>
<td>${entry.approver}</td>
<td class="col-amount">${fmt(entry.amount)}</td>
</tr>
`).join('');
}
}
/* ── Select Ticket ────────────────────────────────────────── */
function selectTicket(id) {
if (selectedTicketId === id) return;
selectedTicketId = id;
selectedItemIds.clear();
document.getElementById('void-reason').value = '';
switchTab('detail');
renderList();
renderDetail();
}
/* ── Filter + Search ──────────────────────────────────────── */
function initFilters() {
document.querySelectorAll('.chip').forEach(chip => {
chip.addEventListener('click', () => {
currentFilter = chip.dataset.filter;
document.querySelectorAll('.chip').forEach(c => c.classList.remove('chip--active'));
chip.classList.add('chip--active');
renderList();
});
});
document.getElementById('search-input').addEventListener('input', e => {
searchQuery = e.target.value;
renderList();
});
}
/* ── Tabs ─────────────────────────────────────────────────── */
function switchTab(tab) {
const tabs = document.querySelectorAll('.tab');
const panels = { detail: 'panel-detail', audit: 'panel-audit' };
tabs.forEach(t => {
const isActive = t.dataset.tab === tab;
t.classList.toggle('tab--active', isActive);
t.setAttribute('aria-selected', isActive);
});
Object.entries(panels).forEach(([key, panelId]) => {
const panel = document.getElementById(panelId);
if (key === tab) {
panel.classList.remove('hidden');
} else {
panel.classList.add('hidden');
}
});
if (tab === 'audit') {
renderAudit();
}
}
function initTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
}
/* ── Void Button ──────────────────────────────────────────── */
function initVoidBtn() {
const voidBtn = document.getElementById('void-btn');
const reasonSelect = document.getElementById('void-reason');
reasonSelect.addEventListener('change', () => renderDetail());
voidBtn.addEventListener('click', () => {
if (voidBtn.disabled) return;
const reason = reasonSelect.value;
if (!reason) {
showToast('Please select a void reason.');
return;
}
if (selectedItemIds.size === 0) {
showToast('Please select at least one item to void.');
return;
}
pendingVoidReason = reason;
openPinOverlay();
});
}
/* ── PIN Overlay ──────────────────────────────────────────── */
function openPinOverlay() {
pinBuffer = '';
updatePinDisplay();
/* Populate summary */
const ticket = getTicket(selectedTicketId);
const voidAmt = selectedVoidAmount();
const itemNames = ticket.items
.filter(it => selectedItemIds.has(it.id))
.map(it => it.name)
.join(', ');
document.getElementById('pin-summary').innerHTML =
`Voiding <strong>${fmt(voidAmt)}</strong> from ${ticket.table}<br><span style="font-size:0.78rem;color:var(--warm-gray)">${itemNames}</span>`;
document.getElementById('pin-error').classList.add('hidden');
document.getElementById('pin-overlay').classList.remove('hidden');
document.getElementById('pin-approve-btn').disabled = true;
}
function closePinOverlay() {
pinBuffer = '';
updatePinDisplay();
document.getElementById('pin-error').classList.add('hidden');
document.getElementById('pin-overlay').classList.add('hidden');
}
function updatePinDisplay() {
for (let i = 0; i < 4; i++) {
const dot = document.getElementById(`dot-${i}`);
dot.classList.remove('pin-dot--filled', 'pin-dot--error');
if (i < pinBuffer.length) {
dot.classList.add('pin-dot--filled');
}
}
document.getElementById('pin-approve-btn').disabled = pinBuffer.length < 4;
}
function showPinError() {
for (let i = 0; i < 4; i++) {
const dot = document.getElementById(`dot-${i}`);
dot.classList.remove('pin-dot--filled');
dot.classList.add('pin-dot--error');
}
document.getElementById('pin-error').classList.remove('hidden');
pinBuffer = '';
document.getElementById('pin-approve-btn').disabled = true;
setTimeout(() => {
for (let i = 0; i < 4; i++) {
document.getElementById(`dot-${i}`).classList.remove('pin-dot--error');
}
document.getElementById('pin-error').classList.add('hidden');
}, 1600);
}
function handlePin(key) {
if (key === 'back') {
pinBuffer = pinBuffer.slice(0, -1);
} else if (key === 'clear') {
pinBuffer = '';
} else if (pinBuffer.length < 4) {
pinBuffer += key;
}
updatePinDisplay();
}
function approveVoid() {
if (pinBuffer !== CORRECT_PIN) {
showPinError();
return;
}
performVoid();
closePinOverlay();
}
function performVoid() {
const ticket = getTicket(selectedTicketId);
if (!ticket) return;
const voidedItems = [];
let voidedAmount = 0;
ticket.items.forEach(it => {
if (selectedItemIds.has(it.id) && !it.voided) {
it.voided = true;
voidedItems.push(it.name);
voidedAmount += it.qty * it.price;
}
});
/* Check if all items are voided → update ticket status */
const allVoided = ticket.items.every(it => it.voided);
if (allVoided) {
ticket.status = 'voided';
}
/* Add audit entry */
auditLog.push({
time: now(),
table: ticket.table,
items: voidedItems,
reason: pendingVoidReason,
approver: 'Manager',
amount: voidedAmount,
});
/* Reset selection */
selectedItemIds.clear();
document.getElementById('void-reason').value = '';
pendingVoidReason = '';
/* Re-render */
renderList();
renderDetail();
renderAudit();
showToast(`Void approved — ${fmt(voidedAmount)} refunded from ${ticket.table}`);
}
function initPinOverlay() {
document.getElementById('pin-close').addEventListener('click', closePinOverlay);
document.getElementById('pin-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closePinOverlay();
});
document.querySelectorAll('.key').forEach(key => {
key.addEventListener('click', () => handlePin(key.dataset.key));
});
document.getElementById('pin-approve-btn').addEventListener('click', approveVoid);
document.addEventListener('keydown', e => {
const overlay = document.getElementById('pin-overlay');
if (overlay.classList.contains('hidden')) return;
if (e.key >= '0' && e.key <= '9') {
handlePin(e.key);
} else if (e.key === 'Backspace') {
handlePin('back');
} else if (e.key === 'Escape') {
closePinOverlay();
} else if (e.key === 'Enter' && pinBuffer.length === 4) {
approveVoid();
}
});
}
/* ── Toast ────────────────────────────────────────────────── */
let toastTimeout;
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.remove('hidden');
requestAnimationFrame(() => toast.classList.add('toast--visible'));
clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => {
toast.classList.remove('toast--visible');
setTimeout(() => toast.classList.add('hidden'), 300);
}, 3000);
}
/* ── Init ─────────────────────────────────────────────────── */
function init() {
renderList();
renderDetail();
renderAudit();
initFilters();
initTabs();
initVoidBtn();
initPinOverlay();
}
document.addEventListener('DOMContentLoaded', init);<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>POS — Void & Refund Manager</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Inter:wght@400;500;600;700&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<!-- LEFT PANEL: Ticket List -->
<aside class="panel-left">
<div class="panel-left__header">
<h1 class="panel-left__title">Void & Refunds</h1>
<div class="search-wrap">
<span class="search-icon">🔍</span>
<input
id="search-input"
class="search-input"
type="text"
placeholder="Search table…"
aria-label="Search tickets by table number"
/>
</div>
<div class="filter-chips" role="group" aria-label="Filter tickets">
<button class="chip chip--active" data-filter="all">All</button>
<button class="chip" data-filter="open">Open</button>
<button class="chip" data-filter="closed">Closed</button>
</div>
</div>
<ul id="ticket-list" class="ticket-list" role="list" aria-label="Ticket list"></ul>
</aside>
<!-- RIGHT PANEL: Ticket Detail -->
<main class="panel-right">
<!-- Empty state -->
<div id="empty-state" class="empty-state">
<div class="empty-state__icon">💳</div>
<p class="empty-state__text">Select a ticket to review and void items</p>
</div>
<!-- Ticket detail (hidden until ticket selected) -->
<div id="detail-view" class="detail-view hidden">
<!-- Tabs -->
<div class="tabs" role="tablist">
<button
class="tab tab--active"
id="tab-detail"
role="tab"
aria-selected="true"
aria-controls="panel-detail"
data-tab="detail"
>Ticket Detail</button>
<button
class="tab"
id="tab-audit"
role="tab"
aria-selected="false"
aria-controls="panel-audit"
data-tab="audit"
>
Audit Trail
<span id="audit-badge" class="audit-badge hidden">0</span>
</button>
</div>
<!-- Detail Tab Panel -->
<div id="panel-detail" role="tabpanel" aria-labelledby="tab-detail">
<div class="ticket-header">
<div class="ticket-header__info">
<h2 id="detail-title" class="ticket-header__title"></h2>
<span id="detail-meta" class="ticket-header__meta"></span>
</div>
<span id="detail-status" class="status-chip"></span>
</div>
<div class="items-section">
<div class="items-section__header">
<span class="items-section__label">Line Items</span>
<button id="select-all-btn" class="select-all-btn">Select all</button>
</div>
<ul id="items-list" class="items-list" role="list" aria-label="Ticket line items"></ul>
</div>
<div class="void-summary" id="void-summary">
<span class="void-summary__label">Voiding:</span>
<span id="void-amount" class="void-summary__amount">$0.00</span>
<span class="void-summary__from">from</span>
<span id="ticket-total" class="void-summary__total">$0.00</span>
<span class="void-summary__label">total</span>
</div>
<div class="void-controls">
<div class="reason-wrap">
<label class="reason-label" for="void-reason">Void reason</label>
<select id="void-reason" class="reason-select" aria-label="Select void reason">
<option value="">— Select reason —</option>
<option value="Mistake">Mistake</option>
<option value="Comp · Manager">Comp · Manager</option>
<option value="Quality issue">Quality issue</option>
<option value="Allergy">Allergy</option>
<option value="Other">Other</option>
</select>
</div>
<button id="void-btn" class="void-btn" disabled>
<span>Void selected</span>
<span class="void-btn__arrow">→</span>
</button>
</div>
</div>
<!-- Audit Tab Panel -->
<div id="panel-audit" role="tabpanel" aria-labelledby="tab-audit" class="hidden">
<div class="audit-header">
<h3 class="audit-header__title">Recent Void Events</h3>
<span class="audit-header__sub">Last 10 entries</span>
</div>
<div id="audit-list-wrap" class="audit-list-wrap">
<table class="audit-table" aria-label="Void audit trail">
<thead>
<tr>
<th>Time</th>
<th>Table</th>
<th>Items voided</th>
<th>Reason</th>
<th>Approver</th>
<th>Amount</th>
</tr>
</thead>
<tbody id="audit-tbody"></tbody>
</table>
<p id="audit-empty" class="audit-empty">No void events recorded yet.</p>
</div>
</div>
</div>
</main>
</div>
<!-- PIN Overlay -->
<div id="pin-overlay" class="pin-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="pin-title">
<div class="pin-card">
<button class="pin-close" id="pin-close" aria-label="Cancel PIN entry">✕</button>
<h2 class="pin-title" id="pin-title">Manager Approval</h2>
<p class="pin-subtitle">Enter 4-digit manager PIN to confirm void</p>
<div class="pin-summary" id="pin-summary"></div>
<div class="pin-display" aria-live="polite" aria-label="PIN entry">
<span class="pin-dot" id="dot-0"></span>
<span class="pin-dot" id="dot-1"></span>
<span class="pin-dot" id="dot-2"></span>
<span class="pin-dot" id="dot-3"></span>
</div>
<p class="pin-error hidden" id="pin-error">⚠ Incorrect PIN. Try again.</p>
<div class="pin-keypad" aria-label="PIN keypad">
<button class="key" data-key="1">1</button>
<button class="key" data-key="2">2</button>
<button class="key" data-key="3">3</button>
<button class="key" data-key="4">4</button>
<button class="key" data-key="5">5</button>
<button class="key" data-key="6">6</button>
<button class="key" data-key="7">7</button>
<button class="key" data-key="8">8</button>
<button class="key" data-key="9">9</button>
<button class="key key--clear" data-key="clear">C</button>
<button class="key" data-key="0">0</button>
<button class="key key--back" data-key="back">⌫</button>
</div>
<button class="pin-approve-btn" id="pin-approve-btn" disabled>Approve Void</button>
</div>
</div>
<!-- Toast notification -->
<div id="toast" class="toast hidden" role="alert" aria-live="assertive"></div>
<script src="script.js"></script>
</body>
</html>POS Void & Refund Manager
Manager-only screen for post-service corrections. Left panel: searchable ticket list (table number, total, status: open/closed/voided). Right panel: ticket detail with line items — each item has a checkbox for void selection. Void reasons dropdown (Mistake, Comp, Quality issue, Allergy, Manager discretion). PIN approval gate (4-digit). On confirm: items move to “voided” state in the ticket with a strikethrough, refund amount shown. Audit trail tab shows last 10 void events.