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.
140 lines
8.9 KiB
HTML
140 lines
8.9 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Sales — customer 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>
|
||
: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; }
|
||
button { border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer; background:var(--accent); color:#fff; }
|
||
button.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); }
|
||
.kv { display:grid; grid-template-columns:160px 1fr; gap:.4rem .8rem; }
|
||
.kv dt { color:var(--muted); font-size:.82rem; }
|
||
.kv dd { margin:0; font-weight:600; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<a class="skip" href="#main">Skip to content</a>
|
||
<header class="app">
|
||
<div class="kicker">staff · sales</div>
|
||
<h1>Sales</h1>
|
||
<p>Review a customer’s balance, credits, usage, and payments. Open a quote with names and dollar amounts — not a JSON dump.</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_2" autocomplete="off" />
|
||
</div>
|
||
<button type="button" onclick="review()">Review account</button>
|
||
<button type="button" class="ghost" onclick="quote()">Show quote</button>
|
||
</div>
|
||
</div>
|
||
<div id="out"></div>
|
||
</main>
|
||
<script>
|
||
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
|
||
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>${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 paintStatement(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();
|
||
try {
|
||
const r = await fetch('/review/' + encodeURIComponent(id));
|
||
const st = await r.json();
|
||
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
|
||
paintStatement(st, id);
|
||
} catch (err) {
|
||
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
|
||
}
|
||
}
|
||
function pretty(v) {
|
||
if (v == null) return '—';
|
||
if (typeof v === 'number' && /cents|Cents/.test(String(v))) return fmt(v);
|
||
if (typeof v === 'object') return JSON.stringify(v);
|
||
return String(v);
|
||
}
|
||
async function quote() {
|
||
const id = document.getElementById('id').value.trim();
|
||
try {
|
||
const r = await fetch('/quotes/' + encodeURIComponent(id));
|
||
const q = await r.json();
|
||
if (!r.ok) throw new Error(q.error || ('HTTP ' + r.status));
|
||
const name = q.name || q.customerName || id;
|
||
const skip = new Set(['name', 'customerName']);
|
||
const rows = Object.entries(q)
|
||
.filter(([k, v]) => !skip.has(k) && v != null && typeof v !== 'object')
|
||
.map(([k, v]) => {
|
||
const label = k.replace(/Cents$/, ' (USD)');
|
||
const val = /cents|Cents$/i.test(k) && typeof v === 'number' ? fmt(v) : v;
|
||
return `<dt>${label}</dt><dd>${val}</dd>`;
|
||
}).join('');
|
||
const nested = Object.entries(q).filter(([, v]) => v && typeof v === 'object')
|
||
.map(([k, v]) => `<div class="card"><h3>${k}</h3><pre class="muted" style="white-space:pre-wrap">${JSON.stringify(v, null, 2)}</pre></div>`).join('');
|
||
document.getElementById('out').innerHTML = `
|
||
<div class="card">
|
||
<h3>Quote — ${name}</h3>
|
||
<dl class="kv">${rows || '<dt>Customer</dt><dd>' + name + '</dd>'}</dl>
|
||
</div>${nested}`;
|
||
} catch (err) {
|
||
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|