Initial import of zappier-edge from zapier monorepo
This commit is contained in:
commit
2e035b1e80
128 changed files with 20545 additions and 0 deletions
715
admin/app.js
Normal file
715
admin/app.js
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
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;
|
||||
|
||||
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 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>
|
||||
</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>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
say(`Saved customer ${id}.`);
|
||||
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) =>
|
||||
(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 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue