134 lines
8.3 KiB
HTML
134 lines
8.3 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Staff access — 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>
|
|
: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 { border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer; background:var(--accent); color:#fff; }
|
|
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); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<a class="skip" href="#main">Skip to content</a>
|
|
<header class="app">
|
|
<div class="kicker">staff plane · after authz</div>
|
|
<h1>Staff access</h1>
|
|
<p>CS / sales / accounting door. Review and credit go through <code>verae.access.authz.check</code>, then account-balance. Amounts are dollars; the customer name is shown when the ledger has it.</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>
|
|
</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>`;
|
|
}
|
|
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));
|
|
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>`;
|
|
} catch (err) {
|
|
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
|
|
}
|
|
}
|
|
async function credit() {
|
|
const body = {
|
|
customerId: document.getElementById('id').value.trim(),
|
|
cents: Math.round(Number(document.getElementById('dollars').value) * 100),
|
|
reason: document.getElementById('reason').value,
|
|
agent: document.getElementById('agent').value,
|
|
};
|
|
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>
|
|
</body>
|
|
</html>
|