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