Restyle staff and console UIs; add UI-Docs walkthrough and review PDF.
Some checks are pending
offline / test (push) Waiting to run

Match CS/sales/accounting/access-staff to the portal indigo system with
dollar amounts, skip links, and empty states. Fleet replica actions move
into overflow menus, roles become chips, Docs become cards, and the
header copy reflects the 0.0.0.0 bind. Simulator uses the same shell
(orange only for faults). Portal API keys are masked; admin customers
edit in a drawer. Catalog uses system-ui. New UI-Docs repo holds
screenshots, usage notes, and UI-REVIEW.pdf.
This commit is contained in:
George Lambert 2026-09-11 18:01:33 -04:00
parent ddf772454b
commit cb07f5b321
72 changed files with 3284 additions and 242 deletions

View file

@ -4,62 +4,139 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Customer service — account review</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
body { font-family: -apple-system, sans-serif; margin: 2rem; max-width: 960px; color: #171a26; }
h1 { font-size: 1.3rem; }
label { font-size: 0.8rem; font-weight: 600; display: block; margin-top: 0.6rem; }
input { padding: 0.4rem 0.55rem; }
button { margin-top: 0.8rem; padding: 0.4rem 0.8rem; }
table { border-collapse: collapse; width: 100%; margin-top: 0.6rem; }
th, td { border-bottom: 1px solid #e5e7f0; padding: 0.4rem 0.5rem; text-align: left; font-size: 0.88rem; }
.muted { color: #6b7186; }
.card { border: 1px solid #e5e7f0; border-radius: 10px; padding: 1rem; margin: 1rem 0; }
.stat { font-size: 1.4rem; font-weight: 800; }
:root {
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe; --danger:#dc2626; --ok:#047857;
--radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
.skip { position:absolute; left:-999px; }
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.15rem 1.5rem 1.25rem; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
header.app h1 { margin:.2rem 0 .25rem; font-size:1.25rem; }
header.app p { margin:0; font-size:13px; opacity:.88; max-width:42rem; }
main { max-width:1080px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
.card h3 { margin:0 0 .75rem; font-size:.95rem; }
label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.7rem 0 .25rem; }
input { width:100%; max-width:22rem; padding:.5rem .65rem; border:1px solid var(--line); border-radius:8px; font-size:.95rem; }
input:focus-visible, button:focus-visible, a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
.row > div { min-width:10rem; }
button, a.btn {
border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer;
background:var(--accent); color:#fff; text-decoration:none; display:inline-block;
}
button.ghost, a.btn.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
table { border-collapse:collapse; width:100%; }
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.45rem .55rem; }
td { border-bottom:1px solid var(--line); padding:.5rem .55rem; }
.money { font-variant-numeric:tabular-nums; }
.muted { color:var(--muted); }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin-bottom:1rem; }
.stat { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:.9rem 1.1rem; }
.stat .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
.stat .v { font-size:1.35rem; font-weight:800; margin-top:.2rem; font-variant-numeric:tabular-nums; }
.empty { text-align:center; padding:1.5rem 1rem; color:var(--muted); }
.empty svg { display:block; margin:0 auto .6rem; }
.err { color:var(--danger); }
.who { font-weight:750; }
</style>
</head>
<body>
<h1>Customer service</h1>
<p class="muted">Review prepaid balance, credits, usage, and payments. Credits go to account-balance over NATS.</p>
<div class="card">
<label>Customer id</label>
<input id="id" value="cust_1" />
<button onclick="review()">Review</button>
</div>
<div class="card">
<h3>Add credit</h3>
<label>Cents</label><input id="cents" type="number" value="500" />
<label>Reason</label><input id="reason" value="goodwill" />
<label>Agent</label><input id="agent" value="cs" />
<button onclick="credit()">Apply credit</button>
</div>
<div id="out"></div>
<a class="skip" href="#main">Skip to content</a>
<header class="app">
<div class="kicker">staff · customer service</div>
<h1>Customer service</h1>
<p>Look up a customer by name or id. Review prepaid balance, credits, usage, and payments. Credit amounts are in dollars.</p>
</header>
<main id="main">
<div class="card">
<div class="row">
<div>
<label for="id">Customer id or name</label>
<input id="id" value="cust_1" autocomplete="off" />
</div>
<button type="button" onclick="review()">Review account</button>
</div>
</div>
<div class="card">
<h3>Apply credit</h3>
<div class="row">
<div>
<label for="dollars">Amount (USD)</label>
<input id="dollars" type="number" min="0.01" step="0.01" value="5.00" />
</div>
<div>
<label for="reason">Reason</label>
<input id="reason" value="goodwill" />
</div>
<div>
<label for="agent">Agent name</label>
<input id="agent" value="cs" />
</div>
<button type="button" onclick="credit()">Apply credit</button>
</div>
<p class="muted" style="margin:.7rem 0 0;font-size:12px">Credits write to account-balance. The customer sees dollars, not cents.</p>
</div>
<div id="out"></div>
</main>
<script>
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
const rows = (list, cols) =>
(list || [])
.map((r) => '<tr>' + cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('') + '</tr>')
.join('') || '<tr><td colspan="8" class="muted">none</td></tr>';
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
const empty = (title, hint) =>
`<div class="empty"><svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg><div class="who">${title}</div><p>${hint}</p></div>`;
function moneyTable(list, cols, emptyTitle) {
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
const body = list.map((r) => '<tr>' + cols.map((c) => {
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
}).join('') + '</tr>').join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
function paint(st, id) {
const name = st.name || st.customerName || id;
document.getElementById('out').innerHTML = `
<div class="stat-grid">
<div class="stat"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
<div class="stat"><div class="k">Credits</div><div class="v">${(st.credits||[]).length}</div></div>
<div class="stat"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
</div>
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
}
async function review() {
const id = document.getElementById('id').value.trim();
const st = await (await fetch('/review/' + encodeURIComponent(id))).json();
document.getElementById('out').innerHTML = `
<div class="card"><div class="muted">Prepaid</div><div class="stat">${fmt(st.prepaidCents)}</div>
<p class="muted">source ${st.source || 'unknown'}</p></div>
<div class="card"><h3>Credits</h3><table><thead><tr><th>cents</th><th>reason</th><th>agent</th><th>at</th></tr></thead>
<tbody>${rows(st.credits, ['cents','reason','agent','at'])}</tbody></table></div>
<div class="card"><h3>Usage</h3><table><thead><tr><th>endpoint</th><th>cents</th><th>at</th></tr></thead>
<tbody>${rows(st.usage, ['endpointId','cents','at'])}</tbody></table></div>
<div class="card"><h3>Payments</h3><table><thead><tr><th>cents</th><th>kind</th><th>reason</th><th>at</th></tr></thead>
<tbody>${rows(st.payments, ['cents','kind','reason','at'])}</tbody></table></div>`;
try {
const r = await fetch('/review/' + encodeURIComponent(id));
const st = await r.json();
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
paint(st, id);
} catch (err) {
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
}
}
async function credit() {
const dollars = Number(document.getElementById('dollars').value);
const body = {
customerId: document.getElementById('id').value.trim(),
cents: Number(document.getElementById('cents').value),
cents: Math.round(dollars * 100),
reason: document.getElementById('reason').value,
agent: document.getElementById('agent').value,
};
await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const r = await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) {
const st = await r.json().catch(() => ({}));
document.getElementById('out').innerHTML = `<div class="card err">${st.error || r.status}</div>`;
return;
}
await review();
}
</script>