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
+546
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
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.
1<!-- =====================================================================
2 Admin: per-user inspection + control. All buttons that mutate
3 state POST to /admin_action.cgi -- never GET, never bare links.
4 The "View as user" submit starts impersonation; the wrapper-level
5 banner takes over from the next request.
6===================================================================== -->
7<style>
8 .au-grid {
9 display: grid;
10 grid-template-columns: 320px 1fr;
11 gap: 16px;
12 margin-top: 14px;
13 }
14 @media (max-width: 1000px) { .au-grid { grid-template-columns: 1fr; } }
15
16 .au-card {
17 background: var(--col-surface-1);
18 border: 1px solid var(--col-border);
19 border-radius: 12px;
20 padding: 18px;
21 }
22 .au-card-title {
23 font-size: 10px; font-weight: 700;
24 letter-spacing: 1.5px; text-transform: uppercase;
25 color: var(--col-text-3);
26 margin-bottom: 12px;
27 }
28 .au-row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; font-size: 13px; border-bottom: 1px solid var(--col-border); }
29 .au-row:last-child { border-bottom: none; }
30 .au-row .lbl { color: var(--col-text-3); }
31 .au-row .val { color: var(--col-text); font-weight: 600; text-align: right; word-break: break-word; }
32 .au-row .val.mono { font-family: var(--font-mono, monospace); font-size: 12px; }
33
34 .au-pill {
35 font-size: 9px; font-weight: 700;
36 padding: 3px 8px; border-radius: 6px;
37 letter-spacing: 1px; text-transform: uppercase;
38 }
39 .au-pill.ok { background: rgba(34,197,94,0.18); color: #4ade80; }
40 .au-pill.warn { background: rgba(245,158,11,0.20); color: #fbbf24; }
41 .au-pill.danger { background: rgba(239,68,68,0.20); color: #f87171; }
42 .au-pill.is-admin { background: rgba(124,58,237,0.18); color: #a78bfa; }
43
44 .au-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
45 .au-actions form { margin: 0; }
46 .au-actions button, .au-actions input[type=submit] {
47 width: 100%;
48 background: var(--col-surface-2);
49 border: 1px solid var(--col-border);
50 color: var(--col-text);
51 padding: 9px 12px;
52 border-radius: 8px;
53 cursor: pointer;
54 font-size: 13px;
55 font-weight: 600;
56 text-align: left;
57 transition: all 0.12s;
58 }
59 .au-actions button:hover { border-color: var(--col-accent); }
60 .au-actions .danger button { color: #fca5a5; border-color: rgba(239,68,68,0.30); }
61 .au-actions .danger button:hover { background: rgba(239,68,68,0.10); border-color: rgba(239,68,68,0.65); }
62 .au-actions .primary button { background: var(--grad-brand); border-color: transparent; color: #fff; }
63
64 .au-edit-grid {
65 display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
66 }
67 .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; }
68 .au-edit-grid input, .au-edit-grid select {
69 background: var(--col-surface-2);
70 border: 1px solid var(--col-border);
71 color: var(--col-text);
72 padding: 8px 10px; border-radius: 8px;
73 font-size: 13px; font-family: inherit;
74 }
75 .au-edit-grid input:focus, .au-edit-grid select:focus { outline: none; border-color: var(--col-accent); }
76 .au-edit-actions { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; }
77
78 .au-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
79 .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); }
80 .au-table td { padding: 9px 10px; border-bottom: 1px solid var(--col-border); color: var(--col-text-2); }
81 .au-table tr:last-child td { border-bottom: none; }
82 .au-table .mono { font-family: var(--font-mono, monospace); color: var(--col-text); }
83
84 .au-mp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
85 .au-mp-row { padding: 10px 12px; border: 1px solid var(--col-border); border-radius: 9px; background: var(--col-surface-2); }
86 .au-mp-row .nm { color: var(--col-text); font-weight: 700; font-size: 13px; }
87 .au-mp-row .meta { color: var(--col-text-3); font-size: 11px; margin-top: 3px; }
88
89 /* Credits card */
90 .au-credit-balance {
91 display: flex; align-items: baseline; gap: 10px;
92 padding: 14px 16px; margin-bottom: 12px;
93 background: var(--col-surface-2);
94 border: 1px solid var(--col-border);
95 border-radius: 10px;
96 }
97 .au-credit-balance .amt { font-size: 24px; font-weight: 800; color: var(--col-text); font-family: var(--font-mono, monospace); }
98 .au-credit-balance .amt.pos { color: #4ade80; }
99 .au-credit-balance .amt.neg { color: #f87171; }
100 .au-credit-balance .lbl { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); }
101
102 /* Full-width edit fields card -- 4 cols on wide, 2 on narrow */
103 .au-edit-wide { margin-top: 16px; }
104 .au-edit-wide .au-edit-grid { grid-template-columns: repeat(4, 1fr); }
105 @media (max-width: 900px) { .au-edit-wide .au-edit-grid { grid-template-columns: 1fr 1fr; } }
106 @media (max-width: 520px) { .au-edit-wide .au-edit-grid { grid-template-columns: 1fr; } }
107</style>
108
109<div class="page-head">
110 <div>
111 <span class="page-eyebrow"><span class="dot"></span> Admin &middot; User detail</span>
112 <h1 class="page-title">$target_display_name</h1>
113 <p class="page-subtitle">$target_email &middot; user #$target_id</p>
114 </div>
115 <div class="page-actions">
116 <a href="/admin.cgi" class="btn btn-secondary">
117 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
118 Back to search
119 </a>
120 </div>
121</div>
122
123<div class="au-grid">
124
125 <!-- LEFT COLUMN: account info + admin actions -->
126 <div>
127 <div class="au-card" data-kpi-detail='{
128 "eyebrow":"Account . user record",
129 "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.",
130 "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.",
131 "kpis":[
132 {"label":"Status","value":"$target_account_status","sub":"can sign in if active"},
133 {"label":"Plan","value":"$target_plan_tier","sub":"current tier"},
134 {"label":"2FA","value":"$target_2fa","sub":"two-factor auth"}
135 ],
136 "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."
137 }'>
138 <div class="au-card-title">Account</div>
139 <div class="au-row"><span class="lbl">Email</span><span class="val">$target_email</span></div>
140 <div class="au-row"><span class="lbl">Name</span><span class="val">$target_display_name</span></div>
141 <div class="au-row"><span class="lbl">Plan</span><span class="val">$target_plan_tier</span></div>
142 <div class="au-row"><span class="lbl">Status</span><span class="val">
143 [if:$is_active]<span class="au-pill ok">Active</span>[/if]
144 [if:$is_suspended]<span class="au-pill warn">Suspended</span>[/if]
145 [if:$is_closed]<span class="au-pill danger">Closed</span>[/if]
146 </span></div>
147 <div class="au-row"><span class="lbl">Role</span><span class="val">
148 [if:$target_is_admin]<span class="au-pill is-admin">Company admin</span>[/if]
149 [if:!$target_is_admin]Rep[/if]
150 </span></div>
151 <div class="au-row"><span class="lbl">Email verified</span><span class="val">$target_email_verified</span></div>
152 <div class="au-row"><span class="lbl">2FA</span><span class="val">$target_2fa</span></div>
153 <div class="au-row"><span class="lbl">Trust level</span><span class="val">$target_trust_level</span></div>
154 <div class="au-row"><span class="lbl">Currency</span><span class="val">$target_currency</span></div>
155 <div class="au-row"><span class="lbl">Timezone</span><span class="val">$target_timezone</span></div>
156 <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>
157 <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>
158 </div>
159
160 <div class="au-card" style="margin-top:14px">
161 <div class="au-card-title">Admin actions</div>
162 <div class="au-actions">
163
164 [if:!$is_self]
165 <form method="POST" action="/admin_action.cgi" class="primary">
166 <input type="hidden" name="act" value="impersonate">
167 <input type="hidden" name="u" value="$target_id">
168 <button type="submit">View as user (start impersonation)</button>
169 </form>
170 [/if]
171 [if:$is_self]
172 <div style="font-size:11px;color:var(--col-text-3);padding:8px 10px">This is your own account &mdash; nothing to impersonate.</div>
173 [/if]
174
175 [if:$is_active]
176 <form method="POST" action="/admin_action.cgi"
177 data-confirm-title="Suspend this account?"
178 data-confirm-message="They will be logged out immediately and unable to sign in until you reactivate the account."
179 data-confirm-label="Suspend account"
180 data-confirm-style="warning">
181 <input type="hidden" name="act" value="suspend">
182 <input type="hidden" name="u" value="$target_id">
183 <button type="submit">Suspend account</button>
184 </form>
185 [/if]
186
187 [if:$is_suspended]
188 <form method="POST" action="/admin_action.cgi">
189 <input type="hidden" name="act" value="reactivate">
190 <input type="hidden" name="u" value="$target_id">
191 <button type="submit">Reactivate account</button>
192 </form>
193 [/if]
194
195 [if:!$is_self]
196 <form method="POST" action="/admin_action.cgi" class="danger"
197 data-confirm-title="Close this account permanently?"
198 data-confirm-message="Their data stays in the database, but they can never sign in again. This cannot be undone from the UI."
199 data-confirm-label="Close account"
200 data-confirm-style="danger">
201 <input type="hidden" name="act" value="close">
202 <input type="hidden" name="u" value="$target_id">
203 <button type="submit">Close account</button>
204 </form>
205
206 <form method="POST" action="/admin_action.cgi"
207 data-confirm-title="Generate a password reset?"
208 data-confirm-message="A one-time reset link will be shown on the next screen. Share it directly with the user."
209 data-confirm-label="Generate reset link"
210 data-confirm-style="primary">
211 <input type="hidden" name="act" value="reset_password">
212 <input type="hidden" name="u" value="$target_id">
213 <button type="submit">Reset password (generate link)</button>
214 </form>
215
216 [if:!$target_is_admin]
217 <form method="POST" action="/admin_action.cgi"
218 data-confirm-title="Promote to company admin?"
219 data-confirm-message="They will see the Admin Console and can act on every account on the platform."
220 data-confirm-label="Promote to admin"
221 data-confirm-style="warning">
222 <input type="hidden" name="act" value="promote_admin">
223 <input type="hidden" name="u" value="$target_id">
224 <button type="submit">Promote to admin</button>
225 </form>
226 [/if]
227 [if:$target_is_admin]
228 <form method="POST" action="/admin_action.cgi"
229 data-confirm-title="Remove admin role?"
230 data-confirm-message="They will lose access to the Admin Console immediately."
231 data-confirm-label="Remove admin"
232 data-confirm-style="warning">
233 <input type="hidden" name="act" value="demote_admin">
234 <input type="hidden" name="u" value="$target_id">
235 <button type="submit">Remove admin role</button>
236 </form>
237 [/if]
238
239 [if:$viewer_is_super_admin]
240 [if:$target_not_super_admin]
241 <form method="POST" action="/admin_action.cgi"
242 data-confirm-title="Promote to SUPER admin?"
243 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."
244 data-confirm-label="Promote to super admin"
245 data-confirm-style="danger">
246 <input type="hidden" name="act" value="promote_super_admin">
247 <input type="hidden" name="u" value="$target_id">
248 <button type="submit" style="background:linear-gradient(135deg,#f59e0b,#b45309);color:#fff;border:0">Promote to super admin</button>
249 </form>
250 [/if]
251 [if:$target_is_super_admin]
252 [if:!$is_self]
253 <form method="POST" action="/admin_action.cgi"
254 data-confirm-title="Remove SUPER admin role?"
255 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."
256 data-confirm-label="Remove super admin"
257 data-confirm-style="warning">
258 <input type="hidden" name="act" value="demote_super_admin">
259 <input type="hidden" name="u" value="$target_id">
260 <button type="submit">Remove super admin</button>
261 </form>
262 [/if]
263 [if:$is_self]
264 <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>
265 [/if]
266 [/if]
267 [/if]
268 [/if]
269 </div>
270 </div>
271<div class="au-card" style="margin-top:14px">
272 <div class="au-card-title">Billing overrides</div>
273 <form method="POST" action="/admin_action.cgi" style="margin-bottom:14px">
274 <input type="hidden" name="act" value="grant_credit">
275 <input type="hidden" name="u" value="$target_id">
276 <div style="font-size:11px;color:var(--col-text-3);margin-bottom:6px">Grant credit (positive) or adjust (negative)</div>
277 <div style="display:flex;gap:6px">
278 <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>
279 <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">
280 </div>
281 <button type="submit" class="btn btn-primary btn-sm" style="margin-top:8px;width:100%">Apply credit</button>
282 </form>
283 <form method="POST" action="/admin_action.cgi">
284 <input type="hidden" name="act" value="queue_refund">
285 <input type="hidden" name="u" value="$target_id">
286 <div style="font-size:11px;color:var(--col-text-3);margin-bottom:6px">Queue refund (Stripe pending - soft credit applied immediately)</div>
287 <div style="display:flex;gap:6px">
288 <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">
289 <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>
290 <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">
291 </div>
292 <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>
293 </form>
294</div>
295
296
297 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
298 "eyebrow":"Account credits . goodwill + refunds",
299 "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.",
300 "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.",
301 "kpis":[
302 {"label":"Balance","value":"$target_credit_dollars","sub":"applied to next invoice"}
303 ],
304 "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."
305 }'>
306 <div class="au-card-title">Account credits</div>
307 <div class="au-credit-balance">
308 <span class="amt[if:$target_has_credit] pos[/if][if:$target_has_debit] neg[/if]">$target_credit_dollars</span>
309 <span class="lbl">balance</span>
310 </div>
311 <div class="au-actions">
312 <form class="abil-grant-form" method="POST" action="/admin_billing_action.cgi"
313 data-grant-user-label="$target_display_name">
314 <input type="hidden" name="act" value="grant_credit">
315 <input type="hidden" name="user_id" value="$target_id">
316 <input type="hidden" name="amount_cents" value="">
317 <input type="hidden" name="note" value="">
318 <input type="hidden" name="return_to" value="/admin_user.cgi?u=$target_id">
319 <button type="button" class="js-grant-credit">Grant credit</button>
320 </form>
321 <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>
322 </div>
323 </div>
324 </div>
325
326 <!-- RIGHT COLUMN: dashboards, models, orders, channels -->
327 <div>
328 <div class="au-card" data-kpi-detail='{
329 "eyebrow":"Dashboards . owned by this user",
330 "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.",
331 "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.",
332 "kpis":[
333 {"label":"Dashboards","value":"$dashboard_count","sub":"owned by this user"}
334 ],
335 "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."
336 }'>
337 <div class="au-card-title">Dashboards &middot; $dashboard_count</div>
338 [if:$has_dashboard]
339 <table class="au-table">
340 <thead><tr><th>Name</th><th>Subdomain</th><th>Created</th><th>Actions</th></tr></thead>
341 <tbody>
342 [loop:@dashboards]
343 <tr>
344 <td><strong>$loop1.name</strong></td>
345 <td class="mono">$loop1.subdomain</td>
346 <td><span class="ts" data-ts="$loop1.created_epoch" data-fmt="date">$loop1.created_at</span></td>
347 <td><a href="/store.cgi?id=$loop1.id" target="_blank">View store &rarr;</a></td>
348 </tr>
349 [/loop]
350 </tbody>
351 </table>
352 [/if]
353 [if:!$has_dashboard]
354 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No dashboards created yet.</div>
355 [/if]
356 </div>
357
358 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
359 "eyebrow":"Models . catalog snapshot",
360 "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.",
361 "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>.",
362 "kpis":[
363 {"label":"In catalog","value":"$m_total","sub":"non-purged total"},
364 {"label":"Published","value":"$m_published","sub":"contact-visible","color":"#4ade80"},
365 {"label":"In trash","value":"$m_purged","sub":"soft-deleted, recoverable"}
366 ],
367 "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."
368 }'>
369 <div class="au-card-title">Models &middot; $m_total ($m_published published &middot; $m_purged in trash)</div>
370 [if:$has_models]
371 <table class="au-table">
372 <thead><tr><th>Title</th><th>Status</th><th>Visibility</th><th>Price</th><th>Updated</th></tr></thead>
373 <tbody>
374 [loop:@models]
375 <tr>
376 <td><strong>$loop1.title</strong></td>
377 <td>$loop1.status</td>
378 <td>$loop1.visibility</td>
379 <td class="mono">$loop1.price</td>
380 <td><span class="ts" data-ts="$loop1.updated_epoch" data-fmt="datetime">$loop1.updated_at</span></td>
381 </tr>
382 [/loop]
383 </tbody>
384 </table>
385 [/if]
386 [if:!$has_models]
387 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No models in this user-s catalog.</div>
388 [/if]
389 </div>
390
391 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
392 "eyebrow":"Orders . revenue history",
393 "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.",
394 "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).",
395 "kpis":[
396 {"label":"Last 30 days","value":"$orders_30d_count orders","sub":"$orders_30d_revenue"},
397 {"label":"Lifetime","value":"$orders_all_revenue","sub":"gross since signup"}
398 ],
399 "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."
400 }'>
401 <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>
402 [if:$has_orders]
403 <table class="au-table">
404 <thead><tr><th>Order</th><th>Contact</th><th>Total</th><th>Status</th><th>Placed</th></tr></thead>
405 <tbody>
406 [loop:@recent_orders]
407 <tr>
408 <td class="mono">#$loop1.id</td>
409 <td>$loop1.contact_email</td>
410 <td class="mono">$loop1.total</td>
411 <td>$loop1.status</td>
412 <td><span class="ts" data-ts="$loop1.created_epoch" data-fmt="datetime">$loop1.created_at</span></td>
413 </tr>
414 [/loop]
415 </tbody>
416 </table>
417 [/if]
418 [if:!$has_orders]
419 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No orders yet on any of this user-s dashboards.</div>
420 [/if]
421 </div>
422
423 <div class="au-card" style="margin-top:14px" data-kpi-detail='{
424 "eyebrow":"Channel connections . OAuth + API keys",
425 "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.",
426 "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>.",
427 "kpis":[
428 {"label":"Connections","value":"see list","sub":"per-platform status below"}
429 ],
430 "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."
431 }'>
432 <div class="au-card-title">Channel connections</div>
433 [if:$has_channels]
434 <div class="au-mp-grid">
435 [loop:@channels]
436 <div class="au-mp-row">
437 <div class="nm">$loop1.name</div>
438 <div class="meta">Status: $loop1.status &middot; Handle: $loop1.account_handle</div>
439 <div class="meta">Connected: <span class="ts" data-ts="$loop1.connected_epoch" data-fmt="date">$loop1.connected_at</span></div>
440 <div class="meta">Last used: <span class="ts" data-ts="$loop1.last_used_epoch" data-fmt="relative">$loop1.last_used_at</span></div>
441 </div>
442 [/loop]
443 </div>
444 [/if]
445 [if:!$has_channels]
446 <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No channels connected for this user yet.</div>
447 [/if]
448 </div>
449 </div>
450
451</div>
452
453<!-- Full-width edit fields, moved out of the sidebar so the form has room to breathe -->
454<div class="au-card au-edit-wide">
455 <div class="au-card-title">Edit account fields</div>
456 <form method="POST" action="/admin_action.cgi">
457 <input type="hidden" name="act" value="edit_account">
458 <input type="hidden" name="u" value="$target_id">
459 <div class="au-edit-grid">
460 <label>Email
461 <input type="email" name="email" value="$target_email" required>
462 </label>
463 <label>Display name
464 <input type="text" name="display_name" value="$target_display_name" required>
465 </label>
466 <label>Plan
467 <select name="plan_tier">
468 <option value="pro">Pro</option>
469 <option value="business">Business</option>
470 </select>
471 </label>
472 <label>Status
473 <select name="account_status">
474 <option value="active">Active</option>
475 <option value="suspended">Suspended</option>
476 <option value="closed">Closed</option>
477 <option value="pending_verification">Pending verification</option>
478 </select>
479 </label>
480 </div>
481 <div class="au-edit-actions">
482 <button type="submit" class="btn btn-primary btn-sm">Save changes</button>
483 </div>
484 </form>
485 <script>
486 // Pre-select the dropdowns from the server values.
487 (function () {
488 var plan = "$target_plan_raw";
489 var status = "$target_account_status";
490 var planSel = document.querySelector('select[name=plan_tier]');
491 var statusSel = document.querySelector('select[name=account_status]');
492 if (planSel) planSel.value = plan;
493 if (statusSel) statusSel.value = status;
494 })();
495 </script>
496</div>
497
498<script>
499// Grant-credit flow: amount prompt -> note prompt -> confirm -> submit.
500// Mirrors the handler on admin_billing.cgi; pointed at the same action endpoint.
501document.addEventListener('click', function (e) {
502 var btn = e.target.closest('.js-grant-credit');
503 if (!btn) return;
504 e.preventDefault();
505 var form = btn.closest('.abil-grant-form');
506 if (!form) return;
507 var label = form.getAttribute('data-grant-user-label') || 'this user';
508 appPrompt({
509 title: 'Grant credit to ' + label,
510 message: 'Enter the amount in cents. Example: 500 for \$5.00.',
511 placeholder: '500',
512 input_type: 'number',
513 label: 'Next',
514 required: true,
515 validate: function (v) {
516 if (!/^[0-9]+\$/.test(v)) return 'Whole-cent integer required (no decimals, no dollar sign).';
517 if (parseInt(v, 10) <= 0) return 'Must be greater than zero.';
518 return '';
519 }
520 }).then(function (amt) {
521 if (amt === null) return;
522 appPrompt({
523 title: 'Note for ' + label,
524 message: 'Shown to the user on their credit history. Keep it short and clear.',
525 placeholder: 'Goodwill credit',
526 default_value: 'Goodwill credit',
527 label: 'Next',
528 required: true
529 }).then(function (note) {
530 if (note === null) return;
531 var dollars = (parseInt(amt, 10) / 100).toFixed(2);
532 appConfirm({
533 title: 'Grant \$' + dollars + ' credit to ' + label + '?',
534 message: 'Note: "' + note + '". Appends to the credit ledger immediately and applies on the next invoice.',
535 confirm_label: 'Grant credit',
536 confirm_style: 'success'
537 }).then(function (ok) {
538 if (!ok) return;
539 form.querySelector('input[name="amount_cents"]').value = amt;
540 form.querySelector('input[name="note"]').value = note;
541 form.submit();
542 });
543 });
544 });
545});
546</script>