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

O Operator
Diff

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

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

Added
+0
lines
Removed
-0
lines
Context
546
unchanged
Blobs
from 38ec948e7024
to 38ec948e7024
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11<!-- =====================================================================
22 Admin: per-user inspection + control. All buttons that mutate
33 state POST to /admin_action.cgi -- never GET, never bare links.
44 The "View as user" submit starts impersonation; the wrapper-level
55 banner takes over from the next request.
66===================================================================== -->
77<style>
88 .au-grid {
99 display: grid;
1010 grid-template-columns: 320px 1fr;
1111 gap: 16px;
1212 margin-top: 14px;
1313 }
1414 @media (max-width: 1000px) { .au-grid { grid-template-columns: 1fr; } }
1515
1616 .au-card {
1717 background: var(--col-surface-1);
1818 border: 1px solid var(--col-border);
1919 border-radius: 12px;
2020 padding: 18px;
2121 }
2222 .au-card-title {
2323 font-size: 10px; font-weight: 700;
2424 letter-spacing: 1.5px; text-transform: uppercase;
2525 color: var(--col-text-3);
2626 margin-bottom: 12px;
2727 }
2828 .au-row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; font-size: 13px; border-bottom: 1px solid var(--col-border); }
2929 .au-row:last-child { border-bottom: none; }
3030 .au-row .lbl { color: var(--col-text-3); }
3131 .au-row .val { color: var(--col-text); font-weight: 600; text-align: right; word-break: break-word; }
3232 .au-row .val.mono { font-family: var(--font-mono, monospace); font-size: 12px; }
3333
3434 .au-pill {
3535 font-size: 9px; font-weight: 700;
3636 padding: 3px 8px; border-radius: 6px;
3737 letter-spacing: 1px; text-transform: uppercase;
3838 }
3939 .au-pill.ok { background: rgba(34,197,94,0.18); color: #4ade80; }
4040 .au-pill.warn { background: rgba(245,158,11,0.20); color: #fbbf24; }
4141 .au-pill.danger { background: rgba(239,68,68,0.20); color: #f87171; }
4242 .au-pill.is-admin { background: rgba(124,58,237,0.18); color: #a78bfa; }
4343
4444 .au-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
4545 .au-actions form { margin: 0; }
4646 .au-actions button, .au-actions input[type=submit] {
4747 width: 100%;
4848 background: var(--col-surface-2);
4949 border: 1px solid var(--col-border);
5050 color: var(--col-text);
5151 padding: 9px 12px;
5252 border-radius: 8px;
5353 cursor: pointer;
5454 font-size: 13px;
5555 font-weight: 600;
5656 text-align: left;
5757 transition: all 0.12s;
5858 }
5959 .au-actions button:hover { border-color: var(--col-accent); }
6060 .au-actions .danger button { color: #fca5a5; border-color: rgba(239,68,68,0.30); }
6161 .au-actions .danger button:hover { background: rgba(239,68,68,0.10); border-color: rgba(239,68,68,0.65); }
6262 .au-actions .primary button { background: var(--grad-brand); border-color: transparent; color: #fff; }
6363
6464 .au-edit-grid {
6565 display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
6666 }
6767 .au-edit-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--col-text-3); letter-spacing: 1px; text-transform: uppercase; }
6868 .au-edit-grid input, .au-edit-grid select {
6969 background: var(--col-surface-2);
7070 border: 1px solid var(--col-border);
7171 color: var(--col-text);
7272 padding: 8px 10px; border-radius: 8px;
7373 font-size: 13px; font-family: inherit;
7474 }
7575 .au-edit-grid input:focus, .au-edit-grid select:focus { outline: none; border-color: var(--col-accent); }
7676 .au-edit-actions { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; }
7777
7878 .au-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
7979 .au-table th { text-align: left; font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); padding: 8px 10px; font-weight: 700; border-bottom: 1px solid var(--col-border); }
8080 .au-table td { padding: 9px 10px; border-bottom: 1px solid var(--col-border); color: var(--col-text-2); }
8181 .au-table tr:last-child td { border-bottom: none; }
8282 .au-table .mono { font-family: var(--font-mono, monospace); color: var(--col-text); }
8383
8484 .au-mp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
8585 .au-mp-row { padding: 10px 12px; border: 1px solid var(--col-border); border-radius: 9px; background: var(--col-surface-2); }
8686 .au-mp-row .nm { color: var(--col-text); font-weight: 700; font-size: 13px; }
8787 .au-mp-row .meta { color: var(--col-text-3); font-size: 11px; margin-top: 3px; }
8888
8989 /* Credits card */
9090 .au-credit-balance {
9191 display: flex; align-items: baseline; gap: 10px;
9292 padding: 14px 16px; margin-bottom: 12px;
9393 background: var(--col-surface-2);
9494 border: 1px solid var(--col-border);
9595 border-radius: 10px;
9696 }
9797 .au-credit-balance .amt { font-size: 24px; font-weight: 800; color: var(--col-text); font-family: var(--font-mono, monospace); }
9898 .au-credit-balance .amt.pos { color: #4ade80; }
9999 .au-credit-balance .amt.neg { color: #f87171; }
100100 .au-credit-balance .lbl { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); }
101101
102102 /* Full-width edit fields card -- 4 cols on wide, 2 on narrow */
103103 .au-edit-wide { margin-top: 16px; }
104104 .au-edit-wide .au-edit-grid { grid-template-columns: repeat(4, 1fr); }
105105 @media (max-width: 900px) { .au-edit-wide .au-edit-grid { grid-template-columns: 1fr 1fr; } }
106106 @media (max-width: 520px) { .au-edit-wide .au-edit-grid { grid-template-columns: 1fr; } }
107107</style>
108108
109109<div class="page-head">
110110 <div>
111111 <span class="page-eyebrow"><span class="dot"></span> Admin &middot; User detail</span>
112112 <h1 class="page-title">$target_display_name</h1>
113113 <p class="page-subtitle">$target_email &middot; user #$target_id</p>
114114 </div>
115115 <div class="page-actions">
116116 <a href="/admin.cgi" class="btn btn-secondary">
117117 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
118118 Back to search
119119 </a>
120120 </div>
121121</div>
122122
123123<div class="au-grid">
124124
125125 <!-- LEFT COLUMN: account info + admin actions -->
126126 <div>
127127 <div class="au-card" data-kpi-detail='{
128128 "eyebrow":"Account . user record",
129129 "description":"Identity, plan tier, status, and security posture for this user. Every field here maps 1-to-1 to a column on the <code>users</code> row -- the Edit account fields panel below writes back to those same columns.",
130130 "methodology":"<code>SELECT * FROM users WHERE id=this LIMIT 1</code>. account_status is the gate: only <code>active</code> can sign in (login_verify in MODS::Login enforces this). Email_verified_at being NULL blocks certain workflows like withdrawing payouts.",
131131 "kpis":[
132132 {"label":"Status","value":"$target_account_status","sub":"can sign in if active"},
133133 {"label":"Plan","value":"$target_plan_tier","sub":"current tier"},
134134 {"label":"2FA","value":"$target_2fa","sub":"two-factor auth"}
135135 ],
136136 "interpretation":"Three combinations to watch: <strong>active + 2FA off</strong> on a high-revenue account is a security risk (ask them to enable 2FA). <strong>active + email_unverified</strong> means signup half-completed -- they cannot get payouts. <strong>pending_verification</strong> for &gt; 7 days means the verification email never arrived; trigger a resend."
137137 }'>
138138 <div class="au-card-title">Account</div>
139139 <div class="au-row"><span class="lbl">Email</span><span class="val">$target_email</span></div>
140140 <div class="au-row"><span class="lbl">Name</span><span class="val">$target_display_name</span></div>
141141 <div class="au-row"><span class="lbl">Plan</span><span class="val">$target_plan_tier</span></div>
142142 <div class="au-row"><span class="lbl">Status</span><span class="val">
143143 [if:$is_active]<span class="au-pill ok">Active</span>[/if]
144144 [if:$is_suspended]<span class="au-pill warn">Suspended</span>[/if]
145145 [if:$is_closed]<span class="au-pill danger">Closed</span>[/if]
146146 </span></div>
147147 <div class="au-row"><span class="lbl">Role</span><span class="val">
148148 [if:$target_is_admin]<span class="au-pill is-admin">Company admin</span>[/if]
149149 [if:!$target_is_admin]Rep[/if]
150150 </span></div>
151151 <div class="au-row"><span class="lbl">Email verified</span><span class="val">$target_email_verified</span></div>
152152 <div class="au-row"><span class="lbl">2FA</span><span class="val">$target_2fa</span></div>
153153 <div class="au-row"><span class="lbl">Trust level</span><span class="val">$target_trust_level</span></div>
154154 <div class="au-row"><span class="lbl">Currency</span><span class="val">$target_currency</span></div>
155155 <div class="au-row"><span class="lbl">Timezone</span><span class="val">$target_timezone</span></div>
156156 <div class="au-row"><span class="lbl">Joined</span><span class="val mono"><span class="ts" data-ts="$target_created_epoch" data-fmt="date">$target_created_at</span></span></div>
157157 <div class="au-row"><span class="lbl">Last login</span><span class="val mono"><span class="ts" data-ts="$target_last_login_epoch" data-fmt="relative">$target_last_login_at</span></span></div>
158158 </div>
159159
160160 <div class="au-card" style="margin-top:14px">
161161 <div class="au-card-title">Admin actions</div>
162162 <div class="au-actions">
163163
164164 [if:!$is_self]
165165 <form method="POST" action="/admin_action.cgi" class="primary">
166166 <input type="hidden" name="act" value="impersonate">
167167 <input type="hidden" name="u" value="$target_id">
168168 <button type="submit">View as user (start impersonation)</button>
169169 </form>
170170 [/if]
171171 [if:$is_self]
172172 <div style="font-size:11px;color:var(--col-text-3);padding:8px 10px">This is your own account &mdash; nothing to impersonate.</div>
173173 [/if]
174174
175175 [if:$is_active]
176176 <form method="POST" action="/admin_action.cgi"
177177 data-confirm-title="Suspend this account?"
178178 data-confirm-message="They will be logged out immediately and unable to sign in until you reactivate the account."
179179 data-confirm-label="Suspend account"
180180 data-confirm-style="warning">
181181 <input type="hidden" name="act" value="suspend">
182182 <input type="hidden" name="u" value="$target_id">
183183 <button type="submit">Suspend account</button>
184184 </form>
185185 [/if]
186186
187187 [if:$is_suspended]
188188 <form method="POST" action="/admin_action.cgi">
189189 <input type="hidden" name="act" value="reactivate">
190190 <input type="hidden" name="u" value="$target_id">
191191 <button type="submit">Reactivate account</button>
192192 </form>
193193 [/if]
194194
195195 [if:!$is_self]
196196 <form method="POST" action="/admin_action.cgi" class="danger"
197197 data-confirm-title="Close this account permanently?"
198198 data-confirm-message="Their data stays in the database, but they can never sign in again. This cannot be undone from the UI."
199199 data-confirm-label="Close account"
200200 data-confirm-style="danger">
201201 <input type="hidden" name="act" value="close">
202202 <input type="hidden" name="u" value="$target_id">
203203 <button type="submit">Close account</button>
204204 </form>
205205
206206 <form method="POST" action="/admin_action.cgi"
207207 data-confirm-title="Generate a password reset?"
208208 data-confirm-message="A one-time reset link will be shown on the next screen. Share it directly with the user."
209209 data-confirm-label="Generate reset link"
210210 data-confirm-style="primary">
211211 <input type="hidden" name="act" value="reset_password">
212212 <input type="hidden" name="u" value="$target_id">
213213 <button type="submit">Reset password (generate link)</button>
214214 </form>
215215
216216 [if:!$target_is_admin]
217217 <form method="POST" action="/admin_action.cgi"
218218 data-confirm-title="Promote to company admin?"
219219 data-confirm-message="They will see the Admin Console and can act on every account on the platform."
220220 data-confirm-label="Promote to admin"
221221 data-confirm-style="warning">
222222 <input type="hidden" name="act" value="promote_admin">
223223 <input type="hidden" name="u" value="$target_id">
224224 <button type="submit">Promote to admin</button>
225225 </form>
226226 [/if]
227227 [if:$target_is_admin]
228228 <form method="POST" action="/admin_action.cgi"
229229 data-confirm-title="Remove admin role?"
230230 data-confirm-message="They will lose access to the Admin Console immediately."
231231 data-confirm-label="Remove admin"
232232 data-confirm-style="warning">
233233 <input type="hidden" name="act" value="demote_admin">
234234 <input type="hidden" name="u" value="$target_id">
235235 <button type="submit">Remove admin role</button>
236236 </form>
237237 [/if]
238238
239239 [if:$viewer_is_super_admin]
240240 [if:$target_not_super_admin]
241241 <form method="POST" action="/admin_action.cgi"
242242 data-confirm-title="Promote to SUPER admin?"
243243 data-confirm-message="They get the same powers as the platform owner: revenue dashboards, plan pricing, sandbox test cards, software configuration, and the ability to promote / demote other super admins. Use sparingly."
244244 data-confirm-label="Promote to super admin"
245245 data-confirm-style="danger">
246246 <input type="hidden" name="act" value="promote_super_admin">
247247 <input type="hidden" name="u" value="$target_id">
248248 <button type="submit" style="background:linear-gradient(135deg,#f59e0b,#b45309);color:#fff;border:0">Promote to super admin</button>
249249 </form>
250250 [/if]
251251 [if:$target_is_super_admin]
252252 [if:!$is_self]
253253 <form method="POST" action="/admin_action.cgi"
254254 data-confirm-title="Remove SUPER admin role?"
255255 data-confirm-message="They lose access to revenue dashboards, plan pricing, sandbox test cards, and software configuration. Their regular admin role stays in place -- demote that separately to fully revoke."
256256 data-confirm-label="Remove super admin"
257257 data-confirm-style="warning">
258258 <input type="hidden" name="act" value="demote_super_admin">
259259 <input type="hidden" name="u" value="$target_id">
260260 <button type="submit">Remove super admin</button>
261261 </form>
262262 [/if]
263263 [if:$is_self]
264264 <div class="text-xs text-dim" style="padding:8px 0">You are a super admin (self-demote disabled; have another super admin demote you).</div>
265265 [/if]
266266 [/if]
267267 [/if]
268268 [/if]
269269 </div>
270270 </div>
271271<div class="au-card" style="margin-top:14px">
272272 <div class="au-card-title">Billing overrides</div>
273273 <form method="POST" action="/admin_action.cgi" style="margin-bottom:14px">
274274 <input type="hidden" name="act" value="grant_credit">
275275 <input type="hidden" name="u" value="$target_id">
276276 <div style="font-size:11px;color:var(--col-text-3);margin-bottom:6px">Grant credit (positive) or adjust (negative)</div>
277277 <div style="display:flex;gap:6px">
278278 <input type="text" name="amount_dollars" placeholder="25.00" style="width:90px;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px" required>
279279 <input type="text" name="reason" placeholder="Reason (internal)" style="flex:1;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px">
280280 </div>
281281 <button type="submit" class="btn btn-primary btn-sm" style="margin-top:8px;width:100%">Apply credit</button>
282282 </form>
283283 <form method="POST" action="/admin_action.cgi">
284284 <input type="hidden" name="act" value="queue_refund">
285285 <input type="hidden" name="u" value="$target_id">
286286 <div style="font-size:11px;color:var(--col-text-3);margin-bottom:6px">Queue refund (Stripe pending - soft credit applied immediately)</div>
287287 <div style="display:flex;gap:6px">
288288 <input type="text" name="invoice_id" placeholder="Invoice #" style="width:90px;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px">
289289 <input type="text" name="amount_dollars" placeholder="25.00" style="width:90px;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px" required>
290290 <input type="text" name="reason" placeholder="Reason" style="flex:1;padding:7px 9px;border-radius:6px;border:1px solid var(--col-border);background:var(--col-surface-2);color:var(--col-text);font-size:13px">
291291 </div>
292292 <button type="submit" class="btn btn-warn btn-sm" style="margin-top:8px;width:100%;background:rgba(245,158,11,0.20);color:#fbbf24;border:1px solid rgba(245,158,11,0.40)">Queue refund</button>
293293 </form>
294294</div>
295295
296296
297297 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
298298 "eyebrow":"Account credits . goodwill + refunds",
299299 "description":"Running balance of this user-s credit_ledger. Positive balance is credit they have not yet consumed; the next invoice will apply it before charging the card. Negative is unusual -- it means more was consumed than granted, which usually points at a manual ledger entry that needs review.",
300300 "methodology":"<code>SUM(amount_cents) FROM credit_ledger WHERE user_id = this</code>. Append-only ledger; balance is always recomputed from the source rows. Grant Credit posts to /admin_billing_action.cgi with act=grant_credit and reason=manual_grant.",
301301 "kpis":[
302302 {"label":"Balance","value":"$target_credit_dollars","sub":"applied to next invoice"}
303303 ],
304304 "interpretation":"Use Grant credit for goodwill (apology for downtime, comp for a missed feature). For invoice-specific refunds prefer the per-invoice Refund-as-credit button on the billing page so the refund links back to the invoice id."
305305 }'>
306306 <div class="au-card-title">Account credits</div>
307307 <div class="au-credit-balance">
308308 <span class="amt[if:$target_has_credit] pos[/if][if:$target_has_debit] neg[/if]">$target_credit_dollars</span>
309309 <span class="lbl">balance</span>
310310 </div>
311311 <div class="au-actions">
312312 <form class="abil-grant-form" method="POST" action="/admin_billing_action.cgi"
313313 data-grant-user-label="$target_display_name">
314314 <input type="hidden" name="act" value="grant_credit">
315315 <input type="hidden" name="user_id" value="$target_id">
316316 <input type="hidden" name="amount_cents" value="">
317317 <input type="hidden" name="note" value="">
318318 <input type="hidden" name="return_to" value="/admin_user.cgi?u=$target_id">
319319 <button type="button" class="js-grant-credit">Grant credit</button>
320320 </form>
321321 <a href="/admin_billing.cgi" class="btn btn-secondary btn-sm" style="text-align:center;display:block;text-decoration:none;padding:9px 12px;font-size:13px;font-weight:600">View full billing &amp; credit history &rarr;</a>
322322 </div>
323323 </div>
324324 </div>
325325
326326 <!-- RIGHT COLUMN: dashboards, models, orders, channels -->
327327 <div>
328328 <div class="au-card" data-kpi-detail='{
329329 "eyebrow":"Dashboards . owned by this user",
330330 "description":"Every dashboard row in the DB owned by this user. Includes drafts and unpublished ones. The View store link opens the public view -- useful for spot-checking what contacts see right now.",
331331 "methodology":"<code>SELECT id, name, subdomain, created_at FROM dashboards WHERE user_id=this_user ORDER BY id ASC</code>. Multi-dashboard-per-user is permitted by the schema but the rep UI does not expose it yet, so most users have exactly one row here.",
332332 "kpis":[
333333 {"label":"Dashboards","value":"$dashboard_count","sub":"owned by this user"}
334334 ],
335335 "interpretation":"A user with zero contacts after a week is usually stuck during onboarding -- check if their CSV import errored or if they never confirmed email. Heavy contact counts on a non-staff user can indicate a power-rep worth keeping happy or, occasionally, an import gone wrong."
336336 }'>
337337 <div class="au-card-title">Dashboards &middot; $dashboard_count</div>
338338 [if:$has_dashboard]
339339 <table class="au-table">
340340 <thead><tr><th>Name</th><th>Subdomain</th><th>Created</th><th>Actions</th></tr></thead>
341341 <tbody>
342342 [loop:@dashboards]
343343 <tr>
344344 <td><strong>$loop1.name</strong></td>
345345 <td class="mono">$loop1.subdomain</td>
346346 <td><span class="ts" data-ts="$loop1.created_epoch" data-fmt="date">$loop1.created_at</span></td>
347347 <td><a href="/store.cgi?id=$loop1.id" target="_blank">View store &rarr;</a></td>
348348 </tr>
349349 [/loop]
350350 </tbody>
351351 </table>
352352 [/if]
353353 [if:!$has_dashboard]
354354 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No dashboards created yet.</div>
355355 [/if]
356356 </div>
357357
358358 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
359359 "eyebrow":"Models . catalog snapshot",
360360 "description":"Counts and the 10 most-recently-updated models for this user. Use this to spot inactive accounts (no models OR no recent updates) and to verify what a user has published before taking admin action.",
361361 "methodology":"Total: <code>COUNT(*) FROM models WHERE user_id=this AND purged_at IS NULL</code>. Published: <code>+ AND status=&#39;published&#39;</code>. Trashed: <code>WHERE purged_at IS NOT NULL</code>. Recent: <code>ORDER BY updated_at DESC LIMIT 10</code>.",
362362 "kpis":[
363363 {"label":"In catalog","value":"$m_total","sub":"non-purged total"},
364364 {"label":"Published","value":"$m_published","sub":"contact-visible","color":"#4ade80"},
365365 {"label":"In trash","value":"$m_purged","sub":"soft-deleted, recoverable"}
366366 ],
367367 "interpretation":"A high trash count can indicate a user who experiments a lot (healthy) or one who keeps abandoning the platform mid-upload (less healthy). Cross-reference with last_login_at on the account card -- if their last login is recent and they have many trash items, they may be wrestling with the upload UX."
368368 }'>
369369 <div class="au-card-title">Models &middot; $m_total ($m_published published &middot; $m_purged in trash)</div>
370370 [if:$has_models]
371371 <table class="au-table">
372372 <thead><tr><th>Title</th><th>Status</th><th>Visibility</th><th>Price</th><th>Updated</th></tr></thead>
373373 <tbody>
374374 [loop:@models]
375375 <tr>
376376 <td><strong>$loop1.title</strong></td>
377377 <td>$loop1.status</td>
378378 <td>$loop1.visibility</td>
379379 <td class="mono">$loop1.price</td>
380380 <td><span class="ts" data-ts="$loop1.updated_epoch" data-fmt="datetime">$loop1.updated_at</span></td>
381381 </tr>
382382 [/loop]
383383 </tbody>
384384 </table>
385385 [/if]
386386 [if:!$has_models]
387387 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No models in this user-s catalog.</div>
388388 [/if]
389389 </div>
390390
391391 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
392392 "eyebrow":"Orders . revenue history",
393393 "description":"Paid order metrics for this user. 30-day window for the headline numbers; lifetime gross is the cumulative total ever earned on their dashboards. The list below shows the 10 most recent orders regardless of status.",
394394 "methodology":"30-day revenue: <code>SUM(orders.total_amount_cents) WHERE dashboard_id IN (their dashboards) AND status=&#39;paid&#39; AND created_at &gt;= NOW() - INTERVAL 30 DAY</code>. Lifetime: same query without the date filter. Recent list: <code>ORDER BY created_at DESC LIMIT 10</code> (status not filtered).",
395395 "kpis":[
396396 {"label":"Last 30 days","value":"$orders_30d_count orders","sub":"$orders_30d_revenue"},
397397 {"label":"Lifetime","value":"$orders_all_revenue","sub":"gross since signup"}
398398 ],
399399 "interpretation":"A user with zero lifetime revenue and an old account is dormant; consider reaching out before suspending. A user with strong 30-day revenue is a power-rep worth keeping happy. The list below also shows non-paid orders (pending, refunded, failed) -- a cluster of failed orders for one contact email often means a payment-method problem worth flagging in support."
400400 }'>
401401 <div class="au-card-title">Orders &middot; $orders_30d_count in last 30d &middot; $orders_30d_revenue this month &middot; $orders_all_revenue lifetime</div>
402402 [if:$has_orders]
403403 <table class="au-table">
404404 <thead><tr><th>Order</th><th>Contact</th><th>Total</th><th>Status</th><th>Placed</th></tr></thead>
405405 <tbody>
406406 [loop:@recent_orders]
407407 <tr>
408408 <td class="mono">#$loop1.id</td>
409409 <td>$loop1.contact_email</td>
410410 <td class="mono">$loop1.total</td>
411411 <td>$loop1.status</td>
412412 <td><span class="ts" data-ts="$loop1.created_epoch" data-fmt="datetime">$loop1.created_at</span></td>
413413 </tr>
414414 [/loop]
415415 </tbody>
416416 </table>
417417 [/if]
418418 [if:!$has_orders]
419419 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No orders yet on any of this user-s dashboards.</div>
420420 [/if]
421421 </div>
422422
423423 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
424424 "eyebrow":"Channel connections . OAuth + API keys",
425425 "description":"Every external channel this user has linked. Status reflects the last health check; an expired token is functionally the same as disconnected (no publishes go out) until the user re-authenticates.",
426426 "methodology":"<code>SELECT platform, status, account_handle, connected_at, last_used_at FROM channel_accounts WHERE user_id=this</code>. status enum: <code>connected</code> | <code>expired</code> | <code>error</code> | <code>disconnected</code>.",
427427 "kpis":[
428428 {"label":"Connections","value":"see list","sub":"per-platform status below"}
429429 ],
430430 "interpretation":"Users with multiple healthy connections drive more revenue on average. If a user complains about publishing failing, check here first -- an expired or error status almost always explains it. Re-authenticate from their account (after they consent) by impersonating and going to /channels.cgi."
431431 }'>
432432 <div class="au-card-title">Channel connections</div>
433433 [if:$has_channels]
434434 <div class="au-mp-grid">
435435 [loop:@channels]
436436 <div class="au-mp-row">
437437 <div class="nm">$loop1.name</div>
438438 <div class="meta">Status: $loop1.status &middot; Handle: $loop1.account_handle</div>
439439 <div class="meta">Connected: <span class="ts" data-ts="$loop1.connected_epoch" data-fmt="date">$loop1.connected_at</span></div>
440440 <div class="meta">Last used: <span class="ts" data-ts="$loop1.last_used_epoch" data-fmt="relative">$loop1.last_used_at</span></div>
441441 </div>
442442 [/loop]
443443 </div>
444444 [/if]
445445 [if:!$has_channels]
446446 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No channels connected for this user yet.</div>
447447 [/if]
448448 </div>
449449 </div>
450450
451451</div>
452452
453453<!-- Full-width edit fields, moved out of the sidebar so the form has room to breathe -->
454454<div class="au-card au-edit-wide">
455455 <div class="au-card-title">Edit account fields</div>
456456 <form method="POST" action="/admin_action.cgi">
457457 <input type="hidden" name="act" value="edit_account">
458458 <input type="hidden" name="u" value="$target_id">
459459 <div class="au-edit-grid">
460460 <label>Email
461461 <input type="email" name="email" value="$target_email" required>
462462 </label>
463463 <label>Display name
464464 <input type="text" name="display_name" value="$target_display_name" required>
465465 </label>
466466 <label>Plan
467467 <select name="plan_tier">
468468 <option value="pro">Pro</option>
469469 <option value="business">Business</option>
470470 </select>
471471 </label>
472472 <label>Status
473473 <select name="account_status">
474474 <option value="active">Active</option>
475475 <option value="suspended">Suspended</option>
476476 <option value="closed">Closed</option>
477477 <option value="pending_verification">Pending verification</option>
478478 </select>
479479 </label>
480480 </div>
481481 <div class="au-edit-actions">
482482 <button type="submit" class="btn btn-primary btn-sm">Save changes</button>
483483 </div>
484484 </form>
485485 <script>
486486 // Pre-select the dropdowns from the server values.
487487 (function () {
488488 var plan = "$target_plan_raw";
489489 var status = "$target_account_status";
490490 var planSel = document.querySelector('select[name=plan_tier]');
491491 var statusSel = document.querySelector('select[name=account_status]');
492492 if (planSel) planSel.value = plan;
493493 if (statusSel) statusSel.value = status;
494494 })();
495495 </script>
496496</div>
497497
498498<script>
499499// Grant-credit flow: amount prompt -> note prompt -> confirm -> submit.
500500// Mirrors the handler on admin_billing.cgi; pointed at the same action endpoint.
501501document.addEventListener('click', function (e) {
502502 var btn = e.target.closest('.js-grant-credit');
503503 if (!btn) return;
504504 e.preventDefault();
505505 var form = btn.closest('.abil-grant-form');
506506 if (!form) return;
507507 var label = form.getAttribute('data-grant-user-label') || 'this user';
508508 appPrompt({
509509 title: 'Grant credit to ' + label,
510510 message: 'Enter the amount in cents. Example: 500 for \$5.00.',
511511 placeholder: '500',
512512 input_type: 'number',
513513 label: 'Next',
514514 required: true,
515515 validate: function (v) {
516516 if (!/^[0-9]+\$/.test(v)) return 'Whole-cent integer required (no decimals, no dollar sign).';
517517 if (parseInt(v, 10) <= 0) return 'Must be greater than zero.';
518518 return '';
519519 }
520520 }).then(function (amt) {
521521 if (amt === null) return;
522522 appPrompt({
523523 title: 'Note for ' + label,
524524 message: 'Shown to the user on their credit history. Keep it short and clear.',
525525 placeholder: 'Goodwill credit',
526526 default_value: 'Goodwill credit',
527527 label: 'Next',
528528 required: true
529529 }).then(function (note) {
530530 if (note === null) return;
531531 var dollars = (parseInt(amt, 10) / 100).toFixed(2);
532532 appConfirm({
533533 title: 'Grant \$' + dollars + ' credit to ' + label + '?',
534534 message: 'Note: "' + note + '". Appends to the credit ledger immediately and applies on the next invoice.',
535535 confirm_label: 'Grant credit',
536536 confirm_style: 'success'
537537 }).then(function (ok) {
538538 if (!ok) return;
539539 form.querySelector('input[name="amount_cents"]').value = amt;
540540 form.querySelector('input[name="note"]').value = note;
541541 form.submit();
542542 });
543543 });
544544 });
545545});
546546</script>