Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_user.html
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
to 38ec948e7024
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 · User detail</span> | |
| 112 | <h1 class="page-title">$target_display_name</h1> | |
| 113 | <p class="page-subtitle">$target_email · 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 > 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 — 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 & credit history →</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 · $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 →</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='published'</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 · $m_total ($m_published published · $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='paid' AND created_at >= 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 · $orders_30d_count in last 30d · $orders_30d_revenue this month · $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 · 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. | |
| 501 | document.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> |