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.
770 lines
32 KiB
JavaScript
770 lines
32 KiB
JavaScript
const state = { pricing: null, customers: [], invoices: [], report: null, trend: null, system: null, users: [] };
|
|
const TOKEN_KEY = 'zappier-admin-token';
|
|
|
|
/* ---------------- auth ---------------- */
|
|
|
|
function token() {
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
|
|
function showLogin(message = '') {
|
|
document.getElementById('shell').classList.remove('on');
|
|
document.getElementById('login').style.display = 'grid';
|
|
document.getElementById('login-error').textContent = message;
|
|
}
|
|
|
|
function showShell() {
|
|
document.getElementById('login').style.display = 'none';
|
|
document.getElementById('shell').classList.add('on');
|
|
}
|
|
|
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById('login-username').value.trim();
|
|
const password = document.getElementById('login-password').value;
|
|
try {
|
|
const res = await fetch('/admin/api/login', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error || 'Login failed');
|
|
const { token: t } = await res.json();
|
|
localStorage.setItem(TOKEN_KEY, t);
|
|
showShell();
|
|
load().catch((err) => say(err.message, true));
|
|
} catch (err) {
|
|
showLogin(err.message);
|
|
}
|
|
});
|
|
|
|
document.getElementById('logout').addEventListener('click', () => {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
location.reload();
|
|
});
|
|
|
|
/* ---------------- api + status ---------------- */
|
|
|
|
async function api(path, options = {}) {
|
|
const res = await fetch(`/admin/api${path}`, {
|
|
...options,
|
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token()}` },
|
|
});
|
|
if (res.status === 403) {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
showLogin('Session expired — sign in again.');
|
|
throw new Error('Session expired.');
|
|
}
|
|
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
|
|
return res.json();
|
|
}
|
|
|
|
let statusTimer;
|
|
function say(msg, isError = false) {
|
|
const el = document.getElementById('status');
|
|
el.textContent = msg;
|
|
el.classList.toggle('error', isError);
|
|
el.classList.add('show');
|
|
clearTimeout(statusTimer);
|
|
statusTimer = setTimeout(() => el.classList.remove('show'), 4000);
|
|
}
|
|
|
|
/* ---------------- shared helpers ---------------- */
|
|
|
|
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>` : '';
|
|
return (
|
|
all +
|
|
state.customers
|
|
.map((c) => `<option value="${c.id}" ${c.id === selected ? 'selected' : ''}>${c.name} (${c.id})</option>`)
|
|
.join('')
|
|
);
|
|
}
|
|
|
|
function currentPeriod() {
|
|
return new Date().toISOString().slice(0, 7);
|
|
}
|
|
|
|
async function load() {
|
|
state.pricing = await api('/pricing');
|
|
state.customers = (await api('/customers')).customers;
|
|
state.invoices = (await api('/invoices')).invoices;
|
|
state.users = (await api('/users')).users;
|
|
renderEndpoints();
|
|
renderTiers();
|
|
renderCustomers();
|
|
renderStatement();
|
|
renderInvoices();
|
|
renderReports();
|
|
renderSystem();
|
|
renderUsers();
|
|
}
|
|
|
|
/* ---------------- rate card ---------------- */
|
|
|
|
function ruleInputs(id, rule) {
|
|
const fields =
|
|
rule.kind === 'fixed'
|
|
? { fixedCents: rule.fixedCents }
|
|
: rule.kind === 'variable'
|
|
? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents }
|
|
: {};
|
|
return Object.entries(fields)
|
|
.map(
|
|
([k, v]) =>
|
|
`<label class="field"><span>${k}</span><input data-endpoint="${id}" data-field="${k}" type="number" step="any" value="${v}" size="6"></label>`,
|
|
)
|
|
.join('');
|
|
}
|
|
|
|
function renderEndpoints() {
|
|
const rows = Object.entries(state.pricing.rateCard.endpoints)
|
|
.map(
|
|
([id, rule]) => `<tr>
|
|
<td class="id">${id}</td>
|
|
<td><span class="pill ${rule.kind}">${rule.kind}</span></td>
|
|
<td><select data-endpoint-kind="${id}">
|
|
${['free', 'fixed', 'variable'].map((k) => `<option ${k === rule.kind ? 'selected' : ''}>${k}</option>`).join('')}
|
|
</select></td>
|
|
<td>${ruleInputs(id, rule)}</td>
|
|
<td class="row-actions">
|
|
<button class="btn" onclick="saveEndpoint('${id}')">Save</button>
|
|
<button class="btn ghost" onclick="deleteEndpoint('${id}')">Delete</button>
|
|
</td>
|
|
</tr>`,
|
|
)
|
|
.join('');
|
|
document.getElementById('endpoints').innerHTML = `
|
|
<h2>Rate card</h2>
|
|
<p class="lede">Per-endpoint list prices, in cents. Changes apply to the next API call — no restart.</p>
|
|
<div class="card"><table>
|
|
<thead><tr><th>Endpoint (operationId)</th><th>Kind</th><th>Set kind</th><th>Prices (cents)</th><th></th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table></div>
|
|
<div class="card">
|
|
<h3>Add endpoint</h3>
|
|
<input id="new-endpoint-id" placeholder="operationId">
|
|
<select id="new-endpoint-kind"><option>free</option><option selected>fixed</option><option>variable</option></select>
|
|
<button class="btn" onclick="addEndpoint()">Add</button>
|
|
<p class="hint">The operationId must match an operation in openapi.yaml.</p>
|
|
</div>`;
|
|
}
|
|
|
|
async function saveEndpoint(id) {
|
|
const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value;
|
|
const rule = { kind };
|
|
document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => {
|
|
rule[el.dataset.field] = Number(el.value);
|
|
});
|
|
if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0;
|
|
if (kind === 'variable') {
|
|
rule.baseCents = rule.baseCents ?? 0;
|
|
rule.perKbCents = rule.perKbCents ?? 0;
|
|
rule.perMbCents = rule.perMbCents ?? 0;
|
|
}
|
|
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
|
|
say(`Saved ${id}.`);
|
|
await load();
|
|
}
|
|
|
|
async function deleteEndpoint(id) {
|
|
await api(`/endpoints/${id}`, { method: 'DELETE' });
|
|
say(`Deleted ${id} — calls to it now get 403 unless a tier has a default rule.`);
|
|
await load();
|
|
}
|
|
|
|
async function addEndpoint() {
|
|
const id = document.getElementById('new-endpoint-id').value.trim();
|
|
const kind = document.getElementById('new-endpoint-kind').value;
|
|
if (!id) return say('Endpoint id required.', true);
|
|
const rule =
|
|
kind === 'free'
|
|
? { kind }
|
|
: kind === 'fixed'
|
|
? { kind, fixedCents: 0 }
|
|
: { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 };
|
|
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
|
|
say(`Added ${id}.`);
|
|
await load();
|
|
}
|
|
|
|
/* ---------------- customer types ---------------- */
|
|
|
|
function renderTiers() {
|
|
const rows = state.pricing.tiers
|
|
.map(
|
|
(t) => `<tr>
|
|
<td class="id">${t.id}</td>
|
|
<td><input data-tier="${t.id}" data-field="name" value="${t.name}"></td>
|
|
<td><input data-tier="${t.id}" data-field="multiplier" type="number" step="any" value="${t.multiplier}" size="5"></td>
|
|
<td><input data-tier="${t.id}" data-field="monthlyCreditCents" type="number" value="${t.monthlyCreditCents}" size="8"></td>
|
|
<td class="row-actions">
|
|
<button class="btn" onclick="saveTier('${t.id}')">Save</button>
|
|
<button class="btn ghost" onclick="deleteTier('${t.id}')">Delete</button>
|
|
</td>
|
|
</tr>`,
|
|
)
|
|
.join('');
|
|
document.getElementById('tiers').innerHTML = `
|
|
<h2>Customer types</h2>
|
|
<p class="lede">Multiplier scales every list price (0.5 = 50%). Monthly credit is free included usage, in cents.</p>
|
|
<div class="card"><table>
|
|
<thead><tr><th>Id</th><th>Name</th><th>Multiplier</th><th>Monthly credit (cents)</th><th></th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table></div>
|
|
<div class="card">
|
|
<h3>Add customer type</h3>
|
|
<input id="new-tier-id" placeholder="id">
|
|
<input id="new-tier-name" placeholder="name">
|
|
<input id="new-tier-multiplier" type="number" step="any" value="1" size="5"> multiplier
|
|
<button class="btn" onclick="addTier()">Add</button>
|
|
<p class="hint">New types start with 0 monthly credit — edit after adding.</p>
|
|
</div>`;
|
|
}
|
|
|
|
async function saveTier(id) {
|
|
const body = { id };
|
|
document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => {
|
|
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
|
|
});
|
|
const existing = state.pricing.tiers.find((t) => t.id === id);
|
|
if (existing?.defaultRule) body.defaultRule = existing.defaultRule;
|
|
await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
say(`Saved tier ${id}.`);
|
|
await load();
|
|
}
|
|
|
|
async function deleteTier(id) {
|
|
await api(`/tiers/${id}`, { method: 'DELETE' });
|
|
say(`Deleted tier ${id}.`);
|
|
await load();
|
|
}
|
|
|
|
async function addTier() {
|
|
const id = document.getElementById('new-tier-id').value.trim();
|
|
const name = document.getElementById('new-tier-name').value.trim();
|
|
const multiplier = Number(document.getElementById('new-tier-multiplier').value);
|
|
if (!id || !name) return say('Tier id and name required.', true);
|
|
await api(`/tiers/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }),
|
|
});
|
|
say(`Added tier ${id}.`);
|
|
await load();
|
|
}
|
|
|
|
/* ---------------- customers ---------------- */
|
|
|
|
function renderCustomers() {
|
|
const tierOptions = (selected) =>
|
|
state.pricing.tiers
|
|
.map((t) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
|
|
.join('');
|
|
const rows = state.customers
|
|
.map(
|
|
(c) => `<tr>
|
|
<td class="id">${c.id}</td>
|
|
<td>${c.name}</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">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 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 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 = {
|
|
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 ${customerName(id)}.`);
|
|
closeCustomerDrawer();
|
|
await load();
|
|
}
|
|
|
|
function renderStatement() {
|
|
const options = customerOptions(state.customers[0]?.id || '');
|
|
document.getElementById('statement').innerHTML = `
|
|
<h2>Customer statement</h2>
|
|
<p class="lede">Credits, prepaid balance, usage, and payments. Prefers NATS account-balance.</p>
|
|
<div class="card">
|
|
<div class="filterbar">
|
|
<label><span>Customer</span><select id="stmt-customer">${options}</select></label>
|
|
<button class="btn" onclick="reviewCustomer(document.getElementById('stmt-customer').value)">Load</button>
|
|
</div>
|
|
<div id="stmt-out"></div>
|
|
</div>`;
|
|
}
|
|
|
|
async function reviewCustomer(id) {
|
|
if (!id) return;
|
|
const st = await api(`/statement/${id}`);
|
|
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 || 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>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>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;
|
|
else {
|
|
document.getElementById('statement').innerHTML = `<h2>Customer statement</h2>${html}`;
|
|
document.querySelectorAll('nav button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'statement'));
|
|
document.querySelectorAll('main section').forEach((s) => (s.hidden = s.id !== 'statement'));
|
|
}
|
|
const sel = document.getElementById('stmt-customer');
|
|
if (sel) sel.value = id;
|
|
say(`Loaded statement for ${id}.`);
|
|
}
|
|
|
|
async function addCustomer() {
|
|
const name = document.getElementById('new-customer-name').value.trim();
|
|
const tierId = document.getElementById('new-customer-tier').value;
|
|
if (!name) return say('Customer name required.', true);
|
|
const created = await api('/customers', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, tierId }),
|
|
});
|
|
say(`Created ${created.id} — API key: ${created.apiKey}`);
|
|
await load();
|
|
}
|
|
|
|
/* ---------------- invoices ---------------- */
|
|
|
|
function renderInvoices() {
|
|
const rows = state.invoices
|
|
.slice()
|
|
.sort((a, b) => b.id.localeCompare(a.id))
|
|
.map((inv) => {
|
|
const actions = [];
|
|
actions.push(`<button class="btn ghost" onclick="viewInvoice('${inv.id}')">View</button>`);
|
|
if (inv.status === 'draft')
|
|
actions.push(`<button class="btn" onclick="invoiceAction('${inv.id}','issue')">Issue</button>`);
|
|
if (inv.status === 'issued')
|
|
actions.push(`<button class="btn" onclick="invoiceAction('${inv.id}','paid')">Mark paid</button>`);
|
|
return `<tr>
|
|
<td class="id">${inv.id}</td>
|
|
<td>${customerName(inv.customerId)}</td>
|
|
<td>${inv.period}</td>
|
|
<td><span class="pill ${inv.status}">${inv.status}</span></td>
|
|
<td><span class="pill ${inv.billingType}">${inv.billingType === 'stripe' ? 'Stripe' : 'PO'}</span>${inv.poNumber ? ` <span class="id">${inv.poNumber}</span>` : ''}</td>
|
|
<td class="money">${fmt(inv.totalCents)}</td>
|
|
<td class="money">${fmt(inv.creditCents)}</td>
|
|
<td class="money"><b>${fmt(inv.billableCents)}</b></td>
|
|
<td>${fmtDate(inv.dueAtMs)}</td>
|
|
<td class="row-actions">${actions.join('')}</td>
|
|
</tr>`;
|
|
})
|
|
.join('');
|
|
document.getElementById('invoices').innerHTML = `
|
|
<h2>Invoices</h2>
|
|
<p class="lede">Generate monthly invoices from metered usage, then issue and collect. Regenerating a period replaces drafts and skips issued/paid invoices.</p>
|
|
<div class="card">
|
|
<h3>Generate invoices</h3>
|
|
<div class="filterbar">
|
|
<label><span>Period</span><input id="gen-period" type="month" value="${currentPeriod()}"></label>
|
|
<label><span>Customer</span><select id="gen-customer">${customerOptions('', true)}</select></label>
|
|
<label><span>PO number (optional)</span><input id="gen-po" placeholder="PO-1234" size="12"></label>
|
|
<button class="btn" onclick="generateInvoices()">Generate</button>
|
|
</div>
|
|
<div id="gen-result"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="filterbar">
|
|
<label><span>Customer</span><select id="inv-filter-customer" onchange="refreshInvoices()">${customerOptions('', true)}</select></label>
|
|
<label><span>Period</span><input id="inv-filter-period" type="month" onchange="refreshInvoices()"></label>
|
|
<label><span>Status</span><select id="inv-filter-status" onchange="refreshInvoices()">
|
|
<option value="">Any</option><option>draft</option><option>issued</option><option>paid</option>
|
|
</select></label>
|
|
<button class="btn ghost" onclick="refreshInvoices()">Refresh</button>
|
|
</div>
|
|
${
|
|
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}</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>`;
|
|
}
|
|
|
|
async function generateInvoices() {
|
|
const period = document.getElementById('gen-period').value;
|
|
const customerId = document.getElementById('gen-customer').value;
|
|
const poNumber = document.getElementById('gen-po').value.trim();
|
|
if (!period) return say('Pick a period first.', true);
|
|
const result = await api('/invoices/generate', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
period,
|
|
...(customerId ? { customerId } : {}),
|
|
...(poNumber ? { poNumber } : {}),
|
|
}),
|
|
});
|
|
const skips = result.skipped
|
|
.map((s) => `<li>${customerName(s.customerId)}: ${s.reason}</li>`)
|
|
.join('');
|
|
document.getElementById('gen-result').innerHTML =
|
|
`<p class="hint">Generated ${result.generated.length}: ${result.generated.join(', ') || '—'}</p>` +
|
|
(skips ? `<ul class="skip-list">${skips}</ul>` : '');
|
|
say(`Generated ${result.generated.length} invoice(s), skipped ${result.skipped.length}.`);
|
|
await load();
|
|
}
|
|
|
|
async function refreshInvoices() {
|
|
const params = new URLSearchParams();
|
|
const customerId = document.getElementById('inv-filter-customer').value;
|
|
const period = document.getElementById('inv-filter-period').value;
|
|
const status = document.getElementById('inv-filter-status').value;
|
|
if (customerId) params.set('customerId', customerId);
|
|
if (period) params.set('period', period);
|
|
if (status) params.set('status', status);
|
|
state.invoices = (await api(`/invoices?${params}`)).invoices;
|
|
renderInvoices();
|
|
}
|
|
|
|
async function invoiceAction(id, action) {
|
|
await api(`/invoices/${id}/${action}`, { method: 'POST', body: '{}' });
|
|
say(action === 'issue' ? `Issued ${id}.` : `Marked ${id} paid.`);
|
|
await load();
|
|
}
|
|
|
|
async function viewInvoice(id) {
|
|
const res = await fetch(`/admin/api/invoices/${id}?format=html`, {
|
|
headers: { authorization: `Bearer ${token()}` },
|
|
});
|
|
if (!res.ok) return say(`Could not load ${id}.`, true);
|
|
const blob = await res.blob();
|
|
window.open(URL.createObjectURL(blob), '_blank');
|
|
}
|
|
|
|
/* ---------------- reports ---------------- */
|
|
|
|
function renderReports() {
|
|
const periodStart = `${currentPeriod()}-01`;
|
|
document.getElementById('reports').innerHTML = `
|
|
<h2>Reports</h2>
|
|
<p class="lede">Billing and usage analytics across customers. All amounts in USD, converted from integer cents.</p>
|
|
<div class="card">
|
|
<h3>Billing report</h3>
|
|
<div class="filterbar">
|
|
<label><span>From</span><input id="rep-from" type="date" value="${periodStart}"></label>
|
|
<label><span>To</span><input id="rep-to" type="date"></label>
|
|
<label><span>Customer</span><select id="rep-customer">${customerOptions('', true)}</select></label>
|
|
<label><span>Billing type</span><select id="rep-billing-type">
|
|
<option value="">Any</option><option value="stripe">Stripe</option><option value="purchase_order">Purchase order</option>
|
|
</select></label>
|
|
<button class="btn" onclick="runReport()">Run</button>
|
|
<button class="btn ghost" onclick="downloadCsv()">Download CSV</button>
|
|
</div>
|
|
<div id="rep-summary"></div>
|
|
<table id="rep-table"></table>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Usage trend</h3>
|
|
<div class="filterbar">
|
|
<label><span>Bucket</span><select id="trend-bucket" onchange="runTrend()">
|
|
<option value="day">Daily</option><option value="week">Weekly</option>
|
|
</select></label>
|
|
</div>
|
|
<div id="trend-chart"></div>
|
|
</div>`;
|
|
runReport().catch((err) => say(err.message, true));
|
|
runTrend().catch((err) => say(err.message, true));
|
|
}
|
|
|
|
function reportQuery() {
|
|
const params = new URLSearchParams();
|
|
const from = document.getElementById('rep-from').value;
|
|
const to = document.getElementById('rep-to').value;
|
|
const customerId = document.getElementById('rep-customer').value;
|
|
const billingType = document.getElementById('rep-billing-type').value;
|
|
if (from) params.set('from', from);
|
|
if (to) params.set('to', to);
|
|
if (customerId) params.set('customerId', customerId);
|
|
if (billingType) params.set('billingType', billingType);
|
|
return params;
|
|
}
|
|
|
|
async function runReport() {
|
|
const { rows } = await api(`/reports/billing?${reportQuery()}`);
|
|
state.report = rows;
|
|
const totals = rows.reduce(
|
|
(acc, r) => ({
|
|
calls: acc.calls + r.calls,
|
|
totalCents: acc.totalCents + r.totalCents,
|
|
creditCents: acc.creditCents + r.creditCents,
|
|
billableCents: acc.billableCents + r.billableCents,
|
|
}),
|
|
{ calls: 0, totalCents: 0, creditCents: 0, billableCents: 0 },
|
|
);
|
|
document.getElementById('rep-summary').innerHTML = `
|
|
<div class="stat-grid">
|
|
<div class="stat"><div class="k">Calls</div><div class="v">${totals.calls.toLocaleString()}</div></div>
|
|
<div class="stat"><div class="k">Gross usage</div><div class="v">${fmt(totals.totalCents)}</div></div>
|
|
<div class="stat"><div class="k">Credits applied</div><div class="v">${fmt(totals.creditCents)}</div></div>
|
|
<div class="stat"><div class="k">Billable</div><div class="v">${fmt(totals.billableCents)}</div></div>
|
|
</div>`;
|
|
document.getElementById('rep-table').innerHTML = `
|
|
<thead><tr><th>Customer</th><th>Billing</th><th>Calls</th><th>Gross</th><th>Credit</th><th>Billable</th></tr></thead>
|
|
<tbody>${
|
|
rows
|
|
.map(
|
|
(r) => `<tr>
|
|
<td>${r.name} <span class="id">${r.customerId}</span></td>
|
|
<td><span class="pill ${r.billingType}">${r.billingType === 'stripe' ? 'Stripe' : 'PO'}</span></td>
|
|
<td class="money">${r.calls.toLocaleString()}</td>
|
|
<td class="money">${fmt(r.totalCents)}</td>
|
|
<td class="money">${fmt(r.creditCents)}</td>
|
|
<td class="money"><b>${fmt(r.billableCents)}</b></td>
|
|
</tr>`,
|
|
)
|
|
.join('') || '<tr><td colspan="6" style="color:var(--muted)">No usage in range.</td></tr>'
|
|
}</tbody>`;
|
|
}
|
|
|
|
async function downloadCsv() {
|
|
const params = reportQuery();
|
|
params.set('format', 'csv');
|
|
const res = await fetch(`/admin/api/reports/billing?${params}`, {
|
|
headers: { authorization: `Bearer ${token()}` },
|
|
});
|
|
if (!res.ok) return say('CSV download failed.', true);
|
|
const url = URL.createObjectURL(await res.blob());
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'billing-report.csv';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
say('CSV downloaded.');
|
|
}
|
|
|
|
async function runTrend() {
|
|
const bucket = document.getElementById('trend-bucket').value;
|
|
const params = reportQuery();
|
|
params.delete('billingType');
|
|
params.set('bucket', bucket);
|
|
const { points } = await api(`/reports/usage-trend?${params}`);
|
|
state.trend = points;
|
|
document.getElementById('trend-chart').innerHTML = points.length
|
|
? trendChart(points)
|
|
: '<p class="hint">No usage in range.</p>';
|
|
}
|
|
|
|
function trendChart(points) {
|
|
const W = 920;
|
|
const H = 220;
|
|
const padL = 8;
|
|
const padB = 34;
|
|
const padT = 10;
|
|
const max = Math.max(...points.map((p) => p.cents), 1);
|
|
const band = (W - padL) / points.length;
|
|
const barW = Math.max(4, Math.min(48, band * 0.62));
|
|
const bars = points
|
|
.map((p, i) => {
|
|
const h = ((H - padB - padT) * p.cents) / max;
|
|
const x = padL + i * band + (band - barW) / 2;
|
|
const y = H - padB - h;
|
|
const label =
|
|
points.length <= 31 || i % Math.ceil(points.length / 31) === 0
|
|
? `<text x="${x + barW / 2}" y="${H - padB + 13}" text-anchor="middle">${p.bucket.slice(5)}</text>`
|
|
: '';
|
|
return `<rect class="bar" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(h, p.cents > 0 ? 2 : 0).toFixed(1)}"><title>${p.bucket}: ${p.calls} calls, ${fmt(p.cents)}</title></rect>${label}`;
|
|
})
|
|
.join('');
|
|
return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Usage trend">${bars}</svg>
|
|
<p class="hint">Hover a bar for exact calls and amount. Peak: ${fmt(max)}.</p>`;
|
|
}
|
|
|
|
/* ---------------- system ---------------- */
|
|
|
|
function renderSystem() {
|
|
document.getElementById('system').innerHTML = `
|
|
<h2>System</h2>
|
|
<p class="lede">Integration health and current-period billing snapshot.</p>
|
|
<div class="card"><h3>Zapier integration</h3><div id="sys-zapier"><p class="hint">Loading…</p></div></div>
|
|
<div class="card"><h3>Current period (${currentPeriod()})</h3><div id="sys-period"><p class="hint">Loading…</p></div></div>`;
|
|
loadSystem().catch((err) => say(err.message, true));
|
|
}
|
|
|
|
async function loadSystem() {
|
|
const status = await api('/zapier/status');
|
|
state.system = status;
|
|
document.getElementById('sys-zapier').innerHTML = `
|
|
<dl class="kv">
|
|
<dt>App directory</dt><dd>${status.appDirPresent ? '✓ zapier-app/ found' : '✗ not found'}</dd>
|
|
<dt>Version</dt><dd>${status.version ?? '—'}</dd>
|
|
<dt>Triggers</dt><dd>${status.triggers.length ? status.triggers.join(', ') : '—'}</dd>
|
|
<dt>Creates</dt><dd>${status.creates.length ? status.creates.join(', ') : '—'}</dd>
|
|
</dl>`;
|
|
|
|
const params = new URLSearchParams({ from: `${currentPeriod()}-01` });
|
|
const { rows } = await api(`/reports/billing?${params}`);
|
|
const totals = rows.reduce(
|
|
(acc, r) => ({ calls: acc.calls + r.calls, billableCents: acc.billableCents + r.billableCents }),
|
|
{ calls: 0, billableCents: 0 },
|
|
);
|
|
const unpaid = state.invoices.filter((i) => i.status === 'issued');
|
|
document.getElementById('sys-period').innerHTML = `
|
|
<div class="stat-grid">
|
|
<div class="stat"><div class="k">Calls this period</div><div class="v">${totals.calls.toLocaleString()}</div></div>
|
|
<div class="stat"><div class="k">Billable this period</div><div class="v">${fmt(totals.billableCents)}</div></div>
|
|
<div class="stat"><div class="k">Open invoices</div><div class="v">${unpaid.length}</div></div>
|
|
<div class="stat"><div class="k">Open amount</div><div class="v">${fmt(unpaid.reduce((s, i) => s + i.billableCents, 0))}</div></div>
|
|
</div>`;
|
|
}
|
|
|
|
/* ---------------- admin users ---------------- */
|
|
|
|
function renderUsers() {
|
|
const rows = state.users
|
|
.slice()
|
|
.sort((a, b) => a.username.localeCompare(b.username))
|
|
.map(
|
|
(u) => `<tr>
|
|
<td class="id">${u.username}</td>
|
|
<td><span class="pill ${u.active ? 'paid' : 'draft'}">${u.active ? 'active' : 'inactive'}</span></td>
|
|
<td>${fmtDate(u.createdMs)}</td>
|
|
<td class="row-actions">
|
|
<button class="btn ${u.active ? 'ghost' : ''}" onclick="toggleUser('${u.username}', ${!u.active})">${u.active ? 'Deactivate' : 'Activate'}</button>
|
|
</td>
|
|
</tr>`,
|
|
)
|
|
.join('');
|
|
document.getElementById('users').innerHTML = `
|
|
<h2>Admin users</h2>
|
|
<p class="lede">Accounts that can sign in to this console. Passwords are stored as scrypt hashes — never in plain text. The last active admin cannot be deactivated.</p>
|
|
<div class="card"><table>
|
|
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th></th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table></div>
|
|
<div class="card">
|
|
<h3>Add admin user</h3>
|
|
<input id="new-user-name" placeholder="username" autocomplete="off">
|
|
<input id="new-user-password" type="password" placeholder="password (min 8 chars)" autocomplete="new-password">
|
|
<button class="btn" onclick="addUser()">Create</button>
|
|
<p class="hint">Usernames may contain letters, digits, dots, dashes, and underscores. Deactivated users are blocked from signing in immediately.</p>
|
|
</div>`;
|
|
}
|
|
|
|
async function addUser() {
|
|
const username = document.getElementById('new-user-name').value.trim();
|
|
const password = document.getElementById('new-user-password').value;
|
|
if (!username || !password) return say('Username and password required.', true);
|
|
await api('/users', { method: 'POST', body: JSON.stringify({ username, password }) });
|
|
say(`Created admin user ${username}.`);
|
|
await load();
|
|
}
|
|
|
|
async function toggleUser(username, activate) {
|
|
await api(`/users/${username}/${activate ? 'activate' : 'deactivate'}`, {
|
|
method: 'POST',
|
|
body: '{}',
|
|
});
|
|
say(`${activate ? 'Activated' : 'Deactivated'} ${username}.`);
|
|
await load();
|
|
}
|
|
|
|
/* ---------------- tabs + boot ---------------- */
|
|
|
|
document.querySelectorAll('nav button').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.querySelectorAll('main section').forEach((s) => (s.hidden = true));
|
|
document.getElementById(btn.dataset.tab).hidden = false;
|
|
}),
|
|
);
|
|
|
|
if (token()) {
|
|
showShell();
|
|
load().catch((err) => say(err.message, true));
|
|
} else {
|
|
showLogin();
|
|
}
|