Restyle staff and console UIs; add UI-Docs walkthrough and review PDF.
Some checks are pending
offline / test (push) Waiting to run
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:
parent
ddf772454b
commit
cb07f5b321
72 changed files with 3284 additions and 242 deletions
|
|
@ -75,6 +75,8 @@ const fmt = (cents) =>
|
|||
(cents < 0 ? '-$' : '$') + (Math.abs(cents) / 100).toFixed(2);
|
||||
const fmtDate = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—');
|
||||
const customerName = (id) => state.customers.find((c) => c.id === id)?.name ?? id;
|
||||
const EMPTY_SVG =
|
||||
'<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>';
|
||||
|
||||
function customerOptions(selected, includeAll = false) {
|
||||
const all = includeAll ? `<option value="">All customers</option>` : '';
|
||||
|
|
@ -265,48 +267,85 @@ function renderCustomers() {
|
|||
state.pricing.tiers
|
||||
.map((t) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
|
||||
.join('');
|
||||
const btOptions = (selected) =>
|
||||
['stripe', 'purchase_order']
|
||||
.map((b) => `<option value="${b}" ${b === (selected ?? 'stripe') ? 'selected' : ''}>${b === 'stripe' ? 'Stripe' : 'Purchase order'}</option>`)
|
||||
.join('');
|
||||
const rows = state.customers
|
||||
.map(
|
||||
(c) => `<tr>
|
||||
<td class="id">${c.id}</td>
|
||||
<td>${c.name}</td>
|
||||
<td><input data-customer="${c.id}" data-field="email" type="email" size="18" value="${c.email ?? ''}" placeholder="—"></td>
|
||||
<td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
|
||||
<td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
|
||||
<td><select data-customer="${c.id}" data-field="billingType">${btOptions(c.billingType)}</select></td>
|
||||
<td class="row-actions"><button class="btn ghost" onclick="reviewCustomer('${c.id}')">Statement</button>
|
||||
<button class="btn" onclick="saveCustomer('${c.id}')">Save</button></td>
|
||||
<td>${c.email || '—'}</td>
|
||||
<td><span class="pill ${c.tierId}">${c.tierId}</span></td>
|
||||
<td><span class="pill ${c.billingType}">${c.billingType === 'stripe' ? 'Stripe' : 'PO'}</span></td>
|
||||
<td class="row-actions">
|
||||
<button class="btn ghost" type="button" onclick="reviewCustomer('${c.id}')">Statement</button>
|
||||
<button class="btn" type="button" onclick="openCustomerDrawer('${c.id}')">Edit</button>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
document.getElementById('customers').innerHTML = `
|
||||
<h2>Customers</h2>
|
||||
<p class="lede">Assign types, billing method, and per-customer deals. A multiplier override replaces the type multiplier for that customer.</p>
|
||||
<div class="card"><table>
|
||||
<thead><tr><th>Id</th><th>Name</th><th>Email</th><th>Type</th><th>Multiplier override</th><th>Billing</th><th></th></tr></thead>
|
||||
<p class="lede">List is read-only. Open Edit to change type, billing, email, or a per-customer multiplier override.</p>
|
||||
<div class="card">${
|
||||
rows
|
||||
? `<table>
|
||||
<thead><tr><th>Id</th><th>Name</th><th>Email</th><th>Type</th><th>Billing</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></div>
|
||||
</table>`
|
||||
: `<div class="empty">${EMPTY_SVG}<h3>No customers yet</h3><p>Create one below. The API key is shown once.</p></div>`
|
||||
}</div>
|
||||
<div class="card">
|
||||
<h3>Add customer</h3>
|
||||
<input id="new-customer-name" placeholder="name">
|
||||
<select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
|
||||
<button class="btn" onclick="addCustomer()">Create</button>
|
||||
<p class="hint">The new customer's API key is shown once in the notification — copy it immediately. Set email and billing method after creating.</p>
|
||||
<p class="hint">The new customer's API key is shown once in the notification — copy it immediately. Set email and billing method in the edit drawer.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closeCustomerDrawer() {
|
||||
document.getElementById('drawer-root').innerHTML = '';
|
||||
}
|
||||
|
||||
function openCustomerDrawer(id) {
|
||||
const c = state.customers.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
const tierOptions = state.pricing.tiers
|
||||
.map((t) => `<option value="${t.id}" ${t.id === c.tierId ? 'selected' : ''}>${t.name} (${t.id})</option>`)
|
||||
.join('');
|
||||
document.getElementById('drawer-root').innerHTML = `
|
||||
<div class="drawer-backdrop" onclick="closeCustomerDrawer()"></div>
|
||||
<aside class="drawer" role="dialog" aria-labelledby="edit-cust-title">
|
||||
<h2 id="edit-cust-title">Edit ${c.name}</h2>
|
||||
<p class="lede">${c.id}</p>
|
||||
<label for="edit-email">Email</label>
|
||||
<input id="edit-email" type="email" value="${c.email ?? ''}" placeholder="billing@example.com">
|
||||
<label for="edit-tier">Customer type</label>
|
||||
<select id="edit-tier">${tierOptions}</select>
|
||||
<label for="edit-mult">Multiplier override</label>
|
||||
<input id="edit-mult" type="number" step="any" value="${c.multiplierOverride ?? ''}" placeholder="use type default">
|
||||
<label for="edit-billing">Billing</label>
|
||||
<select id="edit-billing">
|
||||
<option value="stripe" ${c.billingType === 'stripe' ? 'selected' : ''}>Stripe</option>
|
||||
<option value="purchase_order" ${c.billingType === 'purchase_order' ? 'selected' : ''}>Purchase order</option>
|
||||
</select>
|
||||
<div class="filterbar" style="margin-top:1.2rem">
|
||||
<button class="btn" type="button" onclick="saveCustomer('${c.id}')">Save</button>
|
||||
<button class="btn ghost" type="button" onclick="closeCustomerDrawer()">Cancel</button>
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
async function saveCustomer(id) {
|
||||
const body = {};
|
||||
document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => {
|
||||
if (el.value === '') return;
|
||||
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
|
||||
});
|
||||
const body = {
|
||||
email: document.getElementById('edit-email').value.trim(),
|
||||
tierId: document.getElementById('edit-tier').value,
|
||||
billingType: document.getElementById('edit-billing').value,
|
||||
};
|
||||
const mult = document.getElementById('edit-mult').value;
|
||||
if (mult !== '') body.multiplierOverride = Number(mult);
|
||||
await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
say(`Saved customer ${id}.`);
|
||||
say(`Saved ${customerName(id)}.`);
|
||||
closeCustomerDrawer();
|
||||
await load();
|
||||
}
|
||||
|
||||
|
|
@ -327,17 +366,29 @@ function renderStatement() {
|
|||
async function reviewCustomer(id) {
|
||||
if (!id) return;
|
||||
const st = await api(`/statement/${id}`);
|
||||
const row = (list, cols) =>
|
||||
(list || [])
|
||||
.map((r) => `<tr>${cols.map((c) => `<td>${r[c] ?? ''}</td>`).join('')}</tr>`)
|
||||
.join('') || `<tr><td colspan="${cols.length}" style="color:var(--muted)">None.</td></tr>`;
|
||||
const row = (list, cols) => {
|
||||
if (!list || !list.length) {
|
||||
return `<tr><td colspan="${cols.length}"><div class="empty" style="padding:1rem">${EMPTY_SVG}<h3>Nothing here yet</h3></div></td></tr>`;
|
||||
}
|
||||
return list
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr>${cols
|
||||
.map((c) => {
|
||||
const v = c === 'cents' ? fmt(r.cents) : c === 'at' ? fmtDate(r.at) : (r[c] ?? '—');
|
||||
return `<td class="${c === 'cents' ? 'money' : ''}">${v}</td>`;
|
||||
})
|
||||
.join('')}</tr>`,
|
||||
)
|
||||
.join('');
|
||||
};
|
||||
const html = `
|
||||
<p class="lede">${st.name || id} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}</p>
|
||||
<div class="card"><h3>Credits</h3><table><thead><tr><th>Cents</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
|
||||
<p class="lede">${st.name || customerName(id)} · prepaid ${fmt(st.prepaidCents || 0)} · source ${st.source || 'local'}</p>
|
||||
<div class="card"><h3>Credits</h3><table><thead><tr><th>Amount</th><th>Reason</th><th>Agent</th><th>When</th></tr></thead>
|
||||
<tbody>${row(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>When</th></tr></thead>
|
||||
<div class="card"><h3>Usage</h3><table><thead><tr><th>Endpoint</th><th>Amount</th><th>When</th></tr></thead>
|
||||
<tbody>${row(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>When</th></tr></thead>
|
||||
<div class="card"><h3>Payments</h3><table><thead><tr><th>Amount</th><th>Kind</th><th>Reason</th><th>When</th></tr></thead>
|
||||
<tbody>${row(st.payments, ['cents', 'kind', 'reason', 'at'])}</tbody></table></div>`;
|
||||
const out = document.getElementById('stmt-out');
|
||||
if (out) out.innerHTML = html;
|
||||
|
|
@ -412,10 +463,14 @@ function renderInvoices() {
|
|||
</select></label>
|
||||
<button class="btn ghost" onclick="refreshInvoices()">Refresh</button>
|
||||
</div>
|
||||
<table id="inv-table">
|
||||
${
|
||||
rows
|
||||
? `<table id="inv-table">
|
||||
<thead><tr><th>Invoice</th><th>Customer</th><th>Period</th><th>Status</th><th>Billing</th><th>Total</th><th>Credit</th><th>Due amount</th><th>Due date</th><th></th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="10" style="color:var(--muted)">No invoices yet — generate a period above.</td></tr>'}</tbody>
|
||||
</table>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`
|
||||
: `<div class="empty">${EMPTY_SVG}<h3>No invoices yet</h3><p>Generate a period above. Issued invoices keep their numbers; drafts can be replaced.</p></div>`
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Zappier Admin</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;
|
||||
|
|
@ -100,8 +101,26 @@
|
|||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
nav button { min-height: 40px; }
|
||||
nav button:hover { background: var(--bg); color: var(--ink); }
|
||||
nav button.active { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
|
||||
nav button:focus-visible, #logout:focus-visible, button.btn:focus-visible, a:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||
}
|
||||
.skip { position:absolute; left:-999px; }
|
||||
.skip:focus { left:1rem; top:1rem; z-index:40; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
|
||||
.empty { text-align:center; padding:1.6rem 1rem; color:var(--muted); }
|
||||
.empty svg { display:block; margin:0 auto .6rem; }
|
||||
.empty h3 { margin:0 0 .25rem; color:var(--ink); font-size:1rem; }
|
||||
.drawer-backdrop { position:fixed; inset:0; background:rgba(23,26,38,.35); z-index:20; }
|
||||
.drawer {
|
||||
position:fixed; top:0; right:0; height:100vh; width:min(420px,100%);
|
||||
background:#fff; box-shadow:-8px 0 32px rgba(23,26,38,.12); z-index:21;
|
||||
padding:1.4rem 1.35rem 2rem; overflow:auto;
|
||||
}
|
||||
.drawer h2 { margin:0 0 .2rem; }
|
||||
.drawer label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.85rem 0 .25rem; }
|
||||
.drawer input, .drawer select { width:100%; }
|
||||
aside .spacer { flex: 1; }
|
||||
#logout {
|
||||
border: 1px solid var(--line); background: none; border-radius: 8px;
|
||||
|
|
@ -216,6 +235,7 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<section id="login">
|
||||
<form class="card" id="login-form">
|
||||
<h1>Zappier Admin</h1>
|
||||
|
|
@ -245,7 +265,7 @@
|
|||
<div class="spacer"></div>
|
||||
<button id="logout">Sign out</button>
|
||||
</aside>
|
||||
<main>
|
||||
<main id="main">
|
||||
<section id="endpoints"></section>
|
||||
<section id="tiers" hidden></section>
|
||||
<section id="customers" hidden></section>
|
||||
|
|
@ -256,6 +276,7 @@
|
|||
<section id="users" hidden></section>
|
||||
</main>
|
||||
</div>
|
||||
<div id="drawer-root"></div>
|
||||
<p id="status"></p>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue