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