Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_billing.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_billing.html

added on local at 2026-07-11 18:31:52

Added
+728
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 9fbcbf729cea
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!-- =====================================================================
2 Admin Billing Console (company-staff only).
3 1. Page head + tutorial button
4 2. Cash / credit / outstanding / MRR tiles
5 3. Plan breakdown + subscription status counts
6 4. Recent invoices (all users) with refund-as-credit action
7 5. Accounts with non-zero credit balance
8 Cash revenue and credit consumption are reported as two separate
9 tiles -- they NEVER mix on this page.
10===================================================================== -->
11
12<style>
13 .abil-banner-row {
14 display: grid; grid-template-columns: 1fr 1fr 1fr 1fr;
15 gap: 14px; margin-bottom: 22px;
16 }
17 @media (max-width: 1100px) { .abil-banner-row { grid-template-columns: 1fr 1fr; } }
18
19 .abil-tile {
20 background: var(--col-surface-1);
21 border: 1px solid var(--col-border);
22 border-radius: 14px;
23 padding: 16px 18px;
24 position: relative;
25 overflow: hidden;
26 }
27 .abil-tile.tile-cash { border-color: rgba(34,197,94,0.40); }
28 .abil-tile.tile-credit { border-color: rgba(124,58,237,0.40); }
29 .abil-tile.tile-debt { border-color: rgba(245,158,11,0.40); }
30 .abil-tile.tile-mrr { border-color: rgba(59,130,246,0.40); }
31
32 .abil-tile .lbl { font-size:10px; letter-spacing:1.5px; text-transform:uppercase; color:var(--col-text-3); font-weight:700; margin-bottom:6px; }
33 .abil-tile .val { font-size:26px; font-weight:700; color:#fff; font-family: var(--font-mono, monospace); }
34 .abil-tile .sub { font-size:11px; color:var(--col-text-3); margin-top:4px; }
35 .abil-tile.tile-cash .val { color:#4ade80; }
36 .abil-tile.tile-credit .val { color:#a78bfa; }
37 .abil-tile.tile-debt .val { color:#fbbf24; }
38
39 .abil-twocol {
40 display:grid; grid-template-columns: 1.4fr 1fr; gap:14px;
41 margin-bottom: 22px;
42 }
43 @media (max-width: 1000px) { .abil-twocol { grid-template-columns: 1fr; } }
44
45 .abil-plan-bar {
46 display: flex; flex-direction: column; gap: 8px;
47 }
48 .abil-plan-row {
49 display: flex; align-items: center; gap: 10px;
50 }
51 .abil-plan-name {
52 width: 80px; font-size: 12px; color: var(--col-text-2); font-weight: 600;
53 }
54 .abil-plan-track {
55 flex: 1; height: 8px; background: var(--col-surface-3);
56 border-radius: 999px; overflow: hidden;
57 }
58 .abil-plan-fill { height: 100%; border-radius: 999px; }
59 .abil-plan-meta { font-size: 12px; color: var(--col-text-3); width: 110px; text-align: right; font-family: var(--font-mono, monospace); }
60
61 .abil-table { width:100%; border-collapse: separate; border-spacing:0;
62 background: var(--col-surface-1);
63 border: 1px solid var(--col-border);
64 border-radius: 12px; overflow: hidden; }
65 .abil-table thead th {
66 background: var(--col-surface-2); padding:11px 14px;
67 text-align: left; font-size: 10px; letter-spacing: 1.5px;
68 text-transform: uppercase; color: var(--col-text-3); font-weight:700;
69 border-bottom: 1px solid var(--col-border);
70 }
71 .abil-table tbody td {
72 padding: 12px 14px; border-bottom: 1px solid var(--col-border);
73 font-size: 13px; color: var(--col-text-2); vertical-align: middle;
74 }
75 .abil-table tbody tr:last-child td { border-bottom: none; }
76 .abil-table .num { text-align: right; font-family: var(--font-mono, monospace); }
77 .abil-table .num.cash { color: #4ade80; }
78 .abil-table .num.credit { color: #a78bfa; }
79 .abil-table a { color: var(--col-accent-bright); text-decoration: none; }
80 .abil-table .pill { font-size:9px; font-weight:700; padding:3px 8px; border-radius:6px; letter-spacing:1px; text-transform:uppercase; }
81 .abil-table .pill.paid { background: rgba(34,197,94,0.18); color: #4ade80; }
82 .abil-table .pill.open { background: rgba(245,158,11,0.18); color: #fbbf24; }
83 .abil-table .pill.failed { background: rgba(239,68,68,0.20); color: #f87171; }
84 .abil-table .pill.refunded { background: rgba(124,58,237,0.18); color: #a78bfa; }
85 .abil-table .pill.void { background: rgba(100,116,139,0.20); color: #94a3b8; }
86
87 .abil-refund-form { display: inline; margin: 0; }
88 .abil-refund-form button {
89 background: none; border: none; padding: 0; font: inherit; cursor: pointer;
90 color: #a78bfa; font-weight: 700; font-size: 12px;
91 }
92 .abil-refund-form button:hover { text-decoration: underline; }
93</style>
94
95<div class="page-head">
96 <div>
97 <span class="page-eyebrow"><span class="dot"></span> Admin &middot; Billing</span>
98 <h1 class="page-title">Billing console</h1>
99 <p class="page-subtitle">Platform-wide subscription revenue, credit liability, and invoice activity. <strong>Cash revenue and credit consumption are reported separately</strong> -- credit applications never inflate the cash numbers.</p>
100 </div>
101 <div class="page-actions">
102 [if:$not_tutorial_mode]<a href="/admin_billing.cgi?tutorial=1" class="btn btn-secondary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Sample Data View</a>[/if]
103 [if:$tutorial_mode]<a href="/admin_billing.cgi" class="btn btn-secondary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Exit sample view</a>[/if]
104 </div>
105</div>
106
107[if:$tutorial_mode]
108<div class="tutorial-banner">
109 <div class="tutorial-banner-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></div>
110 <div class="tutorial-banner-body">
111 <div class="tutorial-banner-title">Tutorial &middot; Sample data only</div>
112 <div class="tutorial-banner-text">Every tile, plan distribution bar, invoice row and credit-holder entry below is example data. None of it reflects the real platform &mdash; it is here so staff can see how Admin &middot; Billing reads on a populated platform.</div>
113 </div>
114 <a href="/admin_billing.cgi" class="btn btn-secondary btn-sm">Exit sample view</a>
115</div>
116
117<div class="tutorial-intro">
118 <div class="tutorial-intro-eyebrow">A walkthrough of the Billing Console</div>
119 <h2 class="tutorial-intro-title">This is the admin view of subscription revenue and the credit ledger.</h2>
120 <p class="tutorial-intro-text">The tiles at the top split the platform's money into the categories that matter most: <strong>money received</strong> (real cash received this calendar month), <strong>credit consumed</strong> (services delivered against existing credit, no cash involved), and <strong>outstanding credit</strong> (the liability we owe users on their accounts). These tiles are intentionally separated so a credit application never inflates the cash line &mdash; the cash tile only counts <code>SUM(invoices.cash_paid_cents)</code>; the credit tile only counts <code>SUM(invoices.credit_applied_cents)</code>; the liability tile is <code>SUM(credit_ledger.amount_cents)</code> over the whole table.</p>
121 <p class="tutorial-intro-text">Below the tiles you see the plan-tier distribution, the active / trialing / past-due / churn counts, the recent-invoices feed, and the accounts that currently hold credit. Any paid invoice in the table has a <strong>Refund as credit</strong> action &mdash; clicking it adds the cash amount back to the user's credit balance and marks the invoice as <em>Refunded as credit</em>. Money never moves back to the card; the user's next invoice consumes the credit first.</p>
122 <p class="tutorial-intro-text tutorial-intro-foot">Click <a href="/admin_billing.cgi" class="text-brand fw-700">Exit sample view</a> to return to the live admin billing console.</p>
123</div>
124[/if]
125
126[if:$schema_missing]
127<div class="an-banner" style="background:rgba(245,158,11,0.10);border-color:rgba(245,158,11,0.30)">
128 <strong>Billing schema not yet installed.</strong> Run the latest <code>db_schema.sql</code> against the database to bring the billing tables online; once installed, this page populates with real subscription, credit, and invoice data.
129</div>
130[/if]
131
132[if:$schema_ready]
133
134[if:$is_super_admin]
135<!-- Month/year selector -- scopes every tile below to that calendar
136 month. Submits with native browser navigation so the URL is
137 shareable and the back button works. Wrapped in the super-admin
138 gate because the widgets it scopes are owner-only. -->
139<div class="abil-period-bar" style="display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:14px">
140 <div>
141 <div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.06em;font-weight:600">Reporting period</div>
142 <div class="fw-700" style="color:#fff;font-size:18px;line-height:1.2;margin-top:2px">$sel_period_label[if:$is_current_month] <span class="text-xs text-dim" style="font-weight:500;letter-spacing:0">(current month)</span>[/if]</div>
143 </div>
144 <form method="get" action="/admin_billing.cgi" style="display:flex;gap:8px;align-items:center;margin:0">
145 <label class="text-xs text-dim" for="abil-month-select" style="font-weight:600">View month</label>
146 <select id="abil-month-select" name="month" class="select" style="min-width:170px" onchange="this.form.submit()">
147 [loop:@month_options]<option value="$loop1.ym"$loop1.selected_attr>$loop1.label</option>[/loop]
148 </select>
149 <noscript><button type="submit" class="btn btn-secondary btn-sm">Go</button></noscript>
150 </form>
151</div>
152
153<!-- Cash / credit / liability tiles -- all scoped to $sel_period_label.
154 Platform-owner only. -->
155[if:$has_daily_billing]<script>window.BILLING_DAILY_SERIES = $daily_billing_json;</script>[/if]
156<div class="abil-banner-row">
157 <div class="abil-tile tile-cash" data-kpi-detail='{"eyebrow":"Money received . selected month (platform)","description":"Real cash received from paid invoices during the selected calendar month. Credit applications are not counted -- this is the true cash line.","methodology":"<code>SUM(invoices.cash_paid_cents) WHERE status=&#39;paid&#39; AND paid_at WITHIN selected_month</code>. Each invoice carries cash_paid_cents and credit_applied_cents separately; only the cash column is summed here.","interpretation":"Primary platform health metric. Use the month selector above to compare against the same month last year or against the prior month to read growth trends.","seriesGlobal":"BILLING_DAILY_SERIES","seriesKey":"cash","chartColor":"#22c55e","chartLabel":"Cash ($)","chartTitle":"Daily cash received in selected month"}'>
158 <div class="lbl">Money received</div>
159 <div class="val">$cash_mtd</div>
160 <div class="sub">Actual cash received in $sel_period_label. Excludes credit applications.</div>
161 </div>
162 <div class="abil-tile tile-credit" data-kpi-detail='{"eyebrow":"Credit consumed . selected month (platform)","description":"Total dollar amount of services delivered against user credit balances during the selected calendar month. No cash changes hands when credit is applied -- this metric tracks how fast we are burning down our liability to users.","methodology":"<code>SUM(invoices.credit_applied_cents) WHERE paid_at WITHIN selected_month</code>. Each invoice can be partially cash and partially credit; the cash side feeds the Cash Revenue tile and the credit side feeds this one.","interpretation":"High credit consumption with low cash revenue means the platform is delivering value users have already paid for. That is fine in moderation but means we are reducing the Outstanding Credit liability without earning new cash.","seriesGlobal":"BILLING_DAILY_SERIES","seriesKey":"credit","chartColor":"#a78bfa","chartLabel":"Credit ($)","chartTitle":"Daily credit consumed in selected month"}'>
163 <div class="lbl">Credit consumed</div>
164 <div class="val">$credit_mtd</div>
165 <div class="sub">Services delivered against credit in $sel_period_label. No cash involved.</div>
166 </div>
167 <div class="abil-tile tile-debt" data-kpi-detail='{"eyebrow":"Outstanding credit . end of selected month (platform)","description":"Total dollar value of credit balances held across every user account. This is a liability -- the platform owes users this much in future service or refunds. For past months we show the balance as it stood at month-end; for the current month we show the live balance.","methodology":"<code>SUM(credit_ledger.amount_cents)</code>. Live for the current month, snapshotted at month-end for past months. Positive entries are credits granted; negative entries are credits consumed when invoices apply them.","interpretation":"Watch the trend month-over-month. Rising outstanding credit with steady cash revenue means we are granting refunds faster than users are spending them. Falling outstanding credit means users are converting their balances into shipped services.","seriesGlobal":"BILLING_DAILY_SERIES","seriesKey":"credit","chartColor":"#fbbf24","chartLabel":"Credit consumed ($)","chartTitle":"Daily credit consumption (drives liability changes)"}'>
168 <div class="lbl">Outstanding credit[if:$not_current_month] (month-end)[/if]</div>
169 <div class="val">$outstanding_credit</div>
170 <div class="sub">[if:$is_current_month]Liability owed to users right now.[/if][if:$not_current_month]Liability as of end of $sel_period_label.[/if] Will reduce future cash revenue as it is consumed.</div>
171 </div>
172 <div class="abil-tile tile-cash" style="border-left:3px solid var(--col-accent)" data-kpi-detail='{"eyebrow":"Projected month-end cash (platform)","description":"Best estimate of where cash revenue will land at month-end. For the current month: MTD pace extrapolated linearly + scheduled subscription renewals. For past months: the actual cash that landed.","methodology":"Current month: <code>(cash_mtd / days_elapsed) * days_in_month + scheduled_renewals_in_remaining_days</code>. Past month: equals Money Received for that month.","interpretation":"For the current month this is the number that drives mid-month decisions. For past months it is the closed total -- compare across months in the selector to read growth.","seriesGlobal":"BILLING_DAILY_SERIES","seriesKey":"cash","chartColor":"#3b82f6","chartLabel":"Cash ($)","chartTitle":"Daily cash revenue (the pace driving this projection)"}'>
173 <div class="lbl">[if:$is_current_month]Projected (month-end)[/if][if:$not_current_month]Total received (month-end)[/if]</div>
174 <div class="val">$projected_month</div>
175 <div class="sub">
176 [if:$is_current_month]
177 $projection_days_elap of $projection_days_in_m days in. Pace alone: $projected_pace_only.
178 Scheduled renewals in the remaining $projection_days_left day(s): $projected_subs_due.
179 [/if]
180 [if:$not_current_month]
181 Final cash total for $sel_period_label across $projection_days_in_m days.
182 [/if]
183 </div>
184 </div>
185</div>
186
187<!-- Signups (free vs paid) -- by day and by month -->
188[if:$has_signups]
189<script>
190 window.SIGNUPS_DAILY = $signups_daily_json;
191 window.SIGNUPS_MONTHLY = $signups_monthly_json;
192</script>
193<div class="module mt-3" style="margin-bottom:18px">
194 <div class="module-head" style="flex-wrap:wrap;gap:10px">
195 <div class="left">
196 <div class="module-icon" style="background:rgba(34,197,94,0.15);color:#4ade80"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg></div>
197 <div>
198 <div class="module-title">Signups (free + paid)</div>
199 <div class="module-sub"><span class="font-mono">$signups_total_30d</span> total in last 30 days &middot; <span class="font-mono" style="color:#4ade80">$signups_paid_30d paid</span> &middot; <span class="font-mono" style="color:#94a3b8">$signups_free_30d free</span></div>
200 </div>
201 </div>
202 <div class="signups-toggle" style="display:flex;gap:4px;background:rgba(255,255,255,0.04);padding:4px;border-radius:8px;border:1px solid var(--col-border)">
203 <button type="button" class="btn btn-sm signups-tab is-active" data-signups-view="daily" style="background:rgba(59,130,246,0.18);color:#fff">Day (30d)</button>
204 <button type="button" class="btn btn-sm signups-tab" data-signups-view="monthly" style="background:transparent;color:var(--col-text-dim)">Month (12mo)</button>
205 </div>
206 </div>
207 <div class="module-body">
208 <div id="signups-chart-wrap" style="position:relative">
209 <canvas id="signups-chart" style="display:block;width:100%;height:240px;cursor:pointer"></canvas>
210 <!-- Floating tooltip; positioned by JS on mousemove. position:fixed
211 anchors it to the viewport so it escapes every parent stacking
212 context (the modules below were layering on top at z-index:auto).
213 Pointer-events off so it never blocks the canvas hit-testing. -->
214 <div id="signups-tooltip" style="position:fixed;display:none;pointer-events:none;background:rgba(8,12,24,0.96);border:1px solid var(--col-border);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--col-text-2);box-shadow:0 12px 32px rgba(0,0,0,0.55);min-width:170px;z-index:900"></div>
215 <div id="signups-legend" style="display:flex;gap:18px;align-items:center;margin-top:10px;font-size:12px;color:var(--col-text-dim);flex-wrap:wrap">
216 <span style="display:inline-flex;align-items:center;gap:6px"><span style="display:inline-block;width:12px;height:12px;background:#4ade80;border-radius:2px"></span>Paid (starter / pro / studio)</span>
217 <span style="display:inline-flex;align-items:center;gap:6px"><span style="display:inline-block;width:12px;height:12px;background:#94a3b8;border-radius:2px"></span>Free</span>
218 <span style="margin-left:auto;color:var(--col-text-dim);font-style:italic">Hover a bar for stats . click for the full breakdown</span>
219 </div>
220 </div>
221 </div>
222</div>
223
224<!-- Click-modal overlay. Hidden by default; JS swaps display=flex and
225 populates #signups-modal-body with the per-tier breakdown for the
226 bar that was clicked. Click outside the inner card OR the X button
227 to dismiss. -->
228<div id="signups-modal" style="display:none;position:fixed;inset:0;background:rgba(2,4,12,0.72);backdrop-filter:blur(4px);z-index:1000;align-items:center;justify-content:center;padding:24px">
229 <div id="signups-modal-card" style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:14px;max-width:520px;width:100%;max-height:90vh;overflow:auto;box-shadow:0 24px 60px rgba(0,0,0,0.6)">
230 <div style="display:flex;align-items:flex-start;justify-content:space-between;padding:18px 20px;border-bottom:1px solid var(--col-border);gap:12px">
231 <div>
232 <div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.08em;font-weight:600">Signups</div>
233 <div id="signups-modal-title" class="fw-700" style="color:#fff;font-size:20px;line-height:1.25;margin-top:2px"></div>
234 </div>
235 <button type="button" id="signups-modal-close" aria-label="Close" style="background:none;border:0;color:var(--col-text-dim);cursor:pointer;padding:4px;line-height:0">
236 <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
237 </button>
238 </div>
239 <div id="signups-modal-body" style="padding:20px"></div>
240 </div>
241</div>
242<script>
243(function () {
244 var canvas = document.getElementById('signups-chart');
245 if (!canvas || !window.SIGNUPS_DAILY || !window.SIGNUPS_MONTHLY) return;
246 var ctx = canvas.getContext('2d');
247 var view = 'daily';
248
249 function rows() { return view === 'daily' ? window.SIGNUPS_DAILY : window.SIGNUPS_MONTHLY; }
250 function xKey() { return view === 'daily' ? 'date' : 'month'; }
251 function shortLabel(s) {
252 if (view === 'monthly') {
253 var p = s.split('-'); var mo = parseInt(p[1], 10);
254 var names = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
255 return names[mo - 1] + ' ' + p[0].slice(2);
256 }
257 var p = s.split('-');
258 return p[1] + '/' + p[2];
259 }
260
261 function draw() {
262 var data = rows();
263 var dpr = window.devicePixelRatio || 1;
264 var w = canvas.clientWidth || canvas.offsetWidth || 600;
265 var h = 240;
266 canvas.width = Math.floor(w * dpr);
267 canvas.height = Math.floor(h * dpr);
268 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
269 ctx.clearRect(0, 0, w, h);
270
271 var padL = 36, padR = 12, padT = 14, padB = 28;
272 var plotW = w - padL - padR;
273 var plotH = h - padT - padB;
274 var maxTotal = 1;
275 for (var i = 0; i < data.length; i++) {
276 if (data[i].total > maxTotal) maxTotal = data[i].total;
277 }
278 // Round max up to next nice number for the y-axis.
279 var step = Math.pow(10, Math.floor(Math.log10(maxTotal)));
280 var yMax = Math.ceil(maxTotal / step) * step;
281 if (yMax < 4) yMax = 4;
282
283 // Gridlines + y labels.
284 ctx.strokeStyle = 'rgba(255,255,255,0.06)';
285 ctx.fillStyle = 'rgba(255,255,255,0.45)';
286 ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
287 ctx.textBaseline = 'middle';
288 for (var g = 0; g <= 4; g++) {
289 var y = padT + plotH * (1 - g / 4);
290 ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y); ctx.stroke();
291 ctx.textAlign = 'right';
292 ctx.fillText(String(Math.round(yMax * g / 4)), padL - 6, y);
293 }
294
295 // Bars -- stacked paid (bottom) + free (top).
296 var n = data.length;
297 var bw = Math.max(2, Math.floor(plotW / n) - 2);
298 var step_x = plotW / n;
299 for (var j = 0; j < n; j++) {
300 var x = padL + j * step_x + (step_x - bw) / 2;
301 var paid = data[j].paid;
302 var free = data[j].free;
303 var paidH = (paid / yMax) * plotH;
304 var freeH = (free / yMax) * plotH;
305 var yPaid = padT + plotH - paidH;
306 var yFree = yPaid - freeH;
307 if (paidH > 0) {
308 ctx.fillStyle = '#4ade80';
309 ctx.fillRect(x, yPaid, bw, paidH);
310 }
311 if (freeH > 0) {
312 ctx.fillStyle = '#94a3b8';
313 ctx.fillRect(x, yFree, bw, freeH);
314 }
315 }
316
317 // X labels -- show roughly 6 evenly.
318 ctx.fillStyle = 'rgba(255,255,255,0.55)';
319 ctx.textAlign = 'center';
320 ctx.textBaseline = 'top';
321 var labelEvery = Math.max(1, Math.ceil(n / 6));
322 for (var k = 0; k < n; k++) {
323 if (k % labelEvery !== 0 && k !== n - 1) continue;
324 var lx = padL + k * step_x + step_x / 2;
325 ctx.fillText(shortLabel(data[k][xKey()]), lx, padT + plotH + 6);
326 }
327 }
328
329 // Map a pointer event to a chart-bar index, or -1 outside the plot.
330 function indexFromEvent(e) {
331 var rect = canvas.getBoundingClientRect();
332 var x = e.clientX - rect.left;
333 var w = canvas.clientWidth;
334 var padL = 36, padR = 12;
335 var plotW = w - padL - padR;
336 var data = rows();
337 var n = data.length;
338 if (x < padL || x > padL + plotW || n === 0) return -1;
339 var idx = Math.floor((x - padL) / (plotW / n));
340 if (idx < 0) idx = 0; else if (idx >= n) idx = n - 1;
341 return idx;
342 }
343
344 // Build the long-form label for a row -- used by the tooltip header
345 // and the modal title. "May 12, 2026" for daily, "May 2026" for monthly.
346 var MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
347 function longLabel(row) {
348 if (view === 'monthly') {
349 var p = row.month.split('-');
350 return MONTH_NAMES[parseInt(p[1], 10) - 1] + ' ' + p[0];
351 }
352 var p = row.date.split('-');
353 return MONTH_NAMES[parseInt(p[1], 10) - 1] + ' ' + parseInt(p[2], 10) + ', ' + p[0];
354 }
355
356 // Floating tooltip. Mirrors the bar's row counts; positioned next
357 // to the cursor with a flip when it would clip the viewport edge.
358 var tooltip = document.getElementById('signups-tooltip');
359 function showTooltip(e, row) {
360 var paidParts = [];
361 if (row.starter) paidParts.push(row.starter + ' starter');
362 if (row.pro) paidParts.push(row.pro + ' pro');
363 if (row.studio) paidParts.push(row.studio + ' studio');
364 var paidSubLine = paidParts.length
365 ? '<div style="font-size:11px;margin:-2px 0 4px;text-align:right;color:var(--col-text-dim)">(' + paidParts.join(', ') + ')</div>'
366 : '';
367 tooltip.innerHTML =
368 '<div style="color:#fff;font-weight:700;margin-bottom:6px">' + longLabel(row) + '</div>' +
369 '<div style="display:flex;justify-content:space-between;gap:14px"><span>Total</span><span style="color:#fff;font-family:var(--font-mono)">' + row.total + '</span></div>' +
370 '<div style="display:flex;justify-content:space-between;gap:14px;color:#4ade80"><span>Paid</span><span style="font-family:var(--font-mono)">' + row.paid + '</span></div>' +
371 paidSubLine +
372 '<div style="display:flex;justify-content:space-between;gap:14px;color:#94a3b8"><span>Free</span><span style="font-family:var(--font-mono)">' + row.free + '</span></div>';
373 tooltip.style.display = 'block';
374 // Tooltip is position:fixed -- coords are viewport-relative.
375 // Offset slightly from the cursor, flip to the left if we'd
376 // overflow the right edge, clamp the bottom edge too.
377 var tw = tooltip.offsetWidth || 200;
378 var th = tooltip.offsetHeight || 100;
379 var tx = e.clientX + 14;
380 var ty = e.clientY + 14;
381 if (tx + tw + 8 > window.innerWidth) tx = e.clientX - tw - 14;
382 if (ty + th + 8 > window.innerHeight) ty = e.clientY - th - 14;
383 if (tx < 4) tx = 4;
384 if (ty < 4) ty = 4;
385 tooltip.style.left = tx + 'px';
386 tooltip.style.top = ty + 'px';
387 }
388 function hideTooltip() { tooltip.style.display = 'none'; }
389
390 canvas.addEventListener('mousemove', function (e) {
391 var idx = indexFromEvent(e);
392 if (idx < 0) { hideTooltip(); return; }
393 showTooltip(e, rows()[idx]);
394 });
395 canvas.addEventListener('mouseleave', hideTooltip);
396
397 // Click -> modal. Reads from the same preloaded data so there's no
398 // round-trip; per-tier counts live in every row.
399 var modal = document.getElementById('signups-modal');
400 var modalTitle = document.getElementById('signups-modal-title');
401 var modalBody = document.getElementById('signups-modal-body');
402 var modalCard = document.getElementById('signups-modal-card');
403 function openModal(row) {
404 modalTitle.textContent = longLabel(row);
405 var pct = function (n) { return row.total ? Math.round((n / row.total) * 100) : 0; };
406 var tierRow = function (label, n, color) {
407 var p = pct(n);
408 return '' +
409 '<div style="display:flex;align-items:center;gap:12px;margin:0 0 10px">' +
410 '<div style="width:64px;color:var(--col-text-2);font-size:13px">' + label + '</div>' +
411 '<div style="flex:1;height:10px;background:rgba(255,255,255,0.06);border-radius:5px;overflow:hidden">' +
412 '<div style="width:' + p + '%;height:100%;background:' + color + ';transition:width 0.25s ease"></div>' +
413 '</div>' +
414 '<div style="min-width:78px;text-align:right;font-family:var(--font-mono);color:#fff">' + n + ' <span style="color:var(--col-text-dim);font-size:11px">(' + p + '%)</span></div>' +
415 '</div>';
416 };
417 modalBody.innerHTML =
418 '<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:18px">' +
419 '<div style="font-size:34px;font-weight:700;color:#fff;line-height:1">' + row.total + '</div>' +
420 '<div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.08em">Total signups</div>' +
421 '</div>' +
422 '<div style="display:flex;gap:14px;margin-bottom:18px">' +
423 '<div style="flex:1;padding:12px 14px;background:rgba(74,222,128,0.08);border:1px solid rgba(74,222,128,0.2);border-radius:10px">' +
424 '<div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.06em;font-weight:600;color:#4ade80">Paid</div>' +
425 '<div style="font-size:22px;font-weight:700;color:#fff;margin-top:2px">' + row.paid + '</div>' +
426 '</div>' +
427 '<div style="flex:1;padding:12px 14px;background:rgba(148,163,184,0.06);border:1px solid rgba(148,163,184,0.18);border-radius:10px">' +
428 '<div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.06em;font-weight:600">Free</div>' +
429 '<div style="font-size:22px;font-weight:700;color:#fff;margin-top:2px">' + row.free + '</div>' +
430 '</div>' +
431 '</div>' +
432 '<div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:0.08em;font-weight:600;margin:0 0 10px">Breakdown by tier</div>' +
433 tierRow('Free', row.free, '#94a3b8') +
434 tierRow('Starter', row.starter, '#60a5fa') +
435 tierRow('Pro', row.pro, '#a78bfa') +
436 tierRow('Studio', row.studio, '#fbbf24');
437 modal.style.display = 'flex';
438 }
439 function closeModal() { modal.style.display = 'none'; }
440
441 canvas.addEventListener('click', function (e) {
442 var idx = indexFromEvent(e);
443 if (idx < 0) return;
444 openModal(rows()[idx]);
445 });
446 document.getElementById('signups-modal-close').addEventListener('click', closeModal);
447 modal.addEventListener('click', function (e) {
448 if (e.target === modal) closeModal();
449 });
450 document.addEventListener('keydown', function (e) {
451 if (e.key === 'Escape' && modal.style.display === 'flex') closeModal();
452 });
453
454 // Tab toggle.
455 var tabs = document.querySelectorAll('[data-signups-view]');
456 tabs.forEach(function (btn) {
457 btn.addEventListener('click', function () {
458 view = btn.getAttribute('data-signups-view');
459 tabs.forEach(function (b) {
460 var on = b === btn;
461 b.classList.toggle('is-active', on);
462 b.style.background = on ? 'rgba(59,130,246,0.18)' : 'transparent';
463 b.style.color = on ? '#fff' : 'var(--col-text-dim)';
464 });
465 draw();
466 });
467 });
468
469 window.addEventListener('resize', draw);
470 draw();
471})();
472</script>
473[/if]
474[if:!$has_signups]
475<div class="module mt-3">
476 <div class="module-head">
477 <div class="left">
478 <div class="module-icon" style="background:rgba(34,197,94,0.15);color:#4ade80"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg></div>
479 <div>
480 <div class="module-title">Signups (free + paid)</div>
481 <div class="module-sub">No signups yet &mdash; chart appears once the first user account is created.</div>
482 </div>
483 </div>
484 </div>
485</div>
486[/if]
487
488<!-- Plan breakdown + subscription status counts -->
489<div class="abil-twocol">
490 <div class="module">
491 <div class="module-head">
492 <div class="left">
493 <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="9" y2="21"/></svg></div>
494 <div>
495 <div class="module-title">Plan distribution</div>
496 <div class="module-sub">Active + trialing + past_due across all tiers</div>
497 </div>
498 </div>
499 </div>
500 <div class="module-body">
501 [if:$has_plan_breakdown]
502 <div class="abil-plan-bar">
503 [loop:@plan_breakdown]
504 <div class="abil-plan-row">
505 <div class="abil-plan-name">$loop1.tier_label</div>
506 <div class="abil-plan-track">
507 <div class="abil-plan-fill" style="width:$loop1.pct%;background:$loop1.color"></div>
508 </div>
509 <div class="abil-plan-meta">$loop1.count &middot; $loop1.pct%</div>
510 </div>
511 [/loop]
512 </div>
513 [/if]
514 [if:!$has_plan_breakdown]
515 <div class="text-xs text-dim" style="text-align:center;padding:18px 12px">No active subscriptions yet.</div>
516 [/if]
517 </div>
518 </div>
519
520 <div class="module">
521 <div class="module-head">
522 <div class="left">
523 <div class="module-icon" style="background:rgba(59,130,246,0.15);color:#60a5fa"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m7 14 4-4 4 4 5-5"/></svg></div>
524 <div>
525 <div class="module-title">Recurring revenue</div>
526 <div class="module-sub">MRR / ARR + subscription status</div>
527 </div>
528 </div>
529 </div>
530 <div class="module-body" style="display:flex;flex-direction:column;gap:10px">
531 <div class="flex justify-between" style="padding:8px 0">
532 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">MRR</span>
533 <span class="font-mono fw-700" style="color:#fff">$mrr_display</span>
534 </div>
535 <div class="flex justify-between" style="padding:8px 0;border-top:1px dashed var(--col-border)">
536 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">ARR</span>
537 <span class="font-mono fw-700" style="color:#fff">$arr_display</span>
538 </div>
539 <div class="flex justify-between" style="padding:8px 0;border-top:1px dashed var(--col-border)">
540 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">Active</span>
541 <span class="font-mono fw-700" style="color:#4ade80">$active_subs</span>
542 </div>
543 <div class="flex justify-between" style="padding:8px 0">
544 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">Trialing</span>
545 <span class="font-mono fw-700" style="color:#a78bfa">$trialing_subs</span>
546 </div>
547 <div class="flex justify-between" style="padding:8px 0">
548 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">Past due</span>
549 <span class="font-mono fw-700" style="color:#fbbf24">$past_due_subs</span>
550 </div>
551 <div class="flex justify-between" style="padding:8px 0;border-top:1px dashed var(--col-border)">
552 <span class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase">Canceled (30d)</span>
553 <span class="font-mono fw-700" style="color:#f87171">$canceled_subs_30d</span>
554 </div>
555 </div>
556 </div>
557</div>
558[/if]
559<!-- end super-admin-only platform overview -->
560
561[if:$not_super_admin]
562<!-- Manager / Editor banner: explains why the revenue dashboards are
563 hidden from this role. The user-support panels (refunds + credit
564 grants) below still work as before. -->
565<div class="banner mb-3" style="padding:12px 14px;border-radius:10px;background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.25);display:flex;gap:10px;align-items:flex-start">
566 <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#a5b4fc" stroke-width="2" style="flex:0 0 auto;margin-top:1px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
567 <div style="flex:1">
568 <div class="fw-700" style="color:#fff;font-size:13px">User support view</div>
569 <div class="text-xs text-dim" style="margin-top:2px">You can refund invoices and grant credit from the panels below. Platform-wide revenue, MRR, and signup dashboards are visible only to the platform owner.</div>
570 </div>
571</div>
572[/if]
573
574<!-- Recent invoices across all users -->
575<h2 style="font-size:18px;margin:18px 0 14px;color:var(--col-text)">Recent invoices</h2>
576[if:$has_recent_invoices]
577<table class="abil-table">
578 <thead>
579 <tr>
580 <th>Invoice</th>
581 <th>Customer</th>
582 <th class="num">Due</th>
583 <th class="num">Credit</th>
584 <th class="num">Cash</th>
585 <th>Status</th>
586 <th>Action</th>
587 </tr>
588 </thead>
589 <tbody>
590 [loop:@recent_invoices]
591 <tr>
592 <td>
593 <div style="color:var(--col-text);font-weight:600">$loop1.invoice_number</div>
594 <div class="text-xs text-dim"><span class="ts" data-ts="$loop1.issued_epoch" data-fmt="date">$loop1.issued_at</span></div>
595 </td>
596 <td>
597 <a href="/admin_user.cgi?u=$loop1.user_id">$loop1.user_label</a>
598 <div class="text-xs text-dim">$loop1.user_email</div>
599 </td>
600 <td class="num">$loop1.amount_due_display</td>
601 <td class="num credit">$loop1.credit_applied_display</td>
602 <td class="num cash">$loop1.cash_paid_display</td>
603 <td>
604 [if:$loop1.is_paid]<span class="pill paid">Paid</span>[/if]
605 [if:$loop1.is_open]<span class="pill open">Open</span>[/if]
606 [if:$loop1.is_failed]<span class="pill failed">Failed</span>[/if]
607 [if:$loop1.is_refunded]<span class="pill refunded">Refunded as credit</span>[/if]
608 [if:$loop1.is_void]<span class="pill void">Void</span>[/if]
609 </td>
610 <td>
611 [if:$loop1.can_refund]
612 <form class="abil-refund-form" method="POST" action="/admin_billing_action.cgi"
613 data-confirm-title="Refund $loop1.cash_paid_display as account credit?"
614 data-confirm-message="Money does NOT leave Stripe. It becomes credit on $loop1.user_label and applies to their next invoice."
615 data-confirm-label="Refund as credit"
616 data-confirm-style="warning">
617 <input type="hidden" name="act" value="refund_as_credit">
618 <input type="hidden" name="invoice_id" value="$loop1.id">
619 <button type="submit">Refund as credit</button>
620 </form>
621 [/if]
622 [if:!$loop1.can_refund]<span class="text-xs text-dim">-</span>[/if]
623 </td>
624 </tr>
625 [/loop]
626 </tbody>
627</table>
628[/if]
629[if:!$has_recent_invoices]
630<div style="text-align:center;padding:36px 18px;background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px">
631 <div class="text-sm fw-600" style="color:#fff;margin-bottom:6px">No invoices yet</div>
632 <div class="text-xs text-dim">Invoices appear here as soon as the billing worker generates the first one.</div>
633</div>
634[/if]
635
636<!-- Credit holders -->
637<h2 style="font-size:18px;margin:24px 0 14px;color:var(--col-text)">Accounts with credit on file</h2>
638[if:$has_credit_holders]
639<table class="abil-table">
640 <thead>
641 <tr>
642 <th>User</th>
643 <th>Email</th>
644 <th class="num">Credit balance</th>
645 <th>Action</th>
646 </tr>
647 </thead>
648 <tbody>
649 [loop:@credit_holders]
650 <tr>
651 <td><a href="/admin_user.cgi?u=$loop1.user_id">$loop1.user_label</a></td>
652 <td>$loop1.user_email</td>
653 <td class="num credit">$loop1.balance_display</td>
654 <td>
655 <form class="abil-refund-form abil-grant-form" method="POST" action="/admin_billing_action.cgi"
656 data-grant-user-label="$loop1.user_label">
657 <input type="hidden" name="act" value="grant_credit">
658 <input type="hidden" name="user_id" value="$loop1.user_id">
659 <input type="hidden" name="amount_cents" value="">
660 <input type="hidden" name="note" value="">
661 <button type="button" class="js-grant-credit">Grant credit</button>
662 </form>
663 </td>
664 </tr>
665 [/loop]
666 </tbody>
667</table>
668[/if]
669[if:!$has_credit_holders]
670<div style="text-align:center;padding:36px 18px;background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px">
671 <div class="text-sm fw-600" style="color:#fff;margin-bottom:6px">No accounts hold credit right now</div>
672 <div class="text-xs text-dim">Accounts appear here as soon as the first credit_ledger row lands.</div>
673</div>
674[/if]
675[/if]
676
677<script>
678// Credit-grant flow: amount prompt -> note prompt -> confirm -> submit.
679// Single delegated handler so the credit-holders loop body stays
680// thin (just markup; no per-row script).
681document.addEventListener('click', function (e) {
682 var btn = e.target.closest('.js-grant-credit');
683 if (!btn) return;
684 e.preventDefault();
685 var form = btn.closest('.abil-grant-form');
686 if (!form) return;
687 var label = form.getAttribute('data-grant-user-label') || 'this user';
688 appPrompt({
689 title: 'Grant credit to ' + label,
690 message: 'Enter the amount in cents. Example: 500 for \$5.00.',
691 placeholder: '500',
692 input_type: 'number',
693 label: 'Next',
694 required: true,
695 validate: function (v) {
696 if (!/^\d+\$/.test(v)) return 'Whole-cent integer required (no decimals, no dollar sign).';
697 if (parseInt(v, 10) <= 0) return 'Must be greater than zero.';
698 return '';
699 }
700 }).then(function (amt) {
701 if (amt === null) return;
702 appPrompt({
703 title: 'Note for ' + label,
704 message: 'Shown to the user on their credit history. Keep it short and clear.',
705 placeholder: 'Goodwill credit',
706 default_value: 'Goodwill credit',
707 label: 'Next',
708 required: true
709 }).then(function (note) {
710 if (note === null) return;
711 var dollars = (parseInt(amt, 10) / 100).toFixed(2);
712 appConfirm({
713 title: 'Grant \$' + dollars + ' credit to ' + label + '?',
714 message: 'Note: "' + note + '". This appends a row to the credit ledger immediately and shows up on their next invoice.',
715 confirm_label: 'Grant credit',
716 confirm_style: 'success'
717 }).then(function (ok) {
718 if (!ok) return;
719 form.querySelector('input[name="amount_cents"]').value = amt;
720 form.querySelector('input[name="note"]').value = note;
721 form.submit();
722 });
723 });
724 });
725});
726</script>
727
728<div class="adm-foot-spacer" data-end="admin-billing">&nbsp;</div>