nxtgauge-frontend-solid/src/components/admin/AiCreditsAdmin.tsx

702 lines
27 KiB
TypeScript
Raw Normal View History

/**
* AI Credits Admin Panel
*
* Admin UI for managing AI credits:
* - View any user's balance
* - View transaction history (ledger)
* - Manually adjust credits (ADD/DEDUCT)
* - Reconcile reports
*/
import { createSignal, createResource, createMemo, Show, For, Suspense } from "solid-js";
import { useAuth } from "~/lib/auth";
const API = '/api';
// Types
interface Wallet {
available_credits: number;
monthly_credits_total: number;
monthly_credits_used: number;
purchased_credits_total: number;
purchased_credits_used: number;
bonus_credits_total: number;
bonus_credits_used: number;
reserved_credits: number;
locked_credits: number;
daily_actions_used: number;
daily_credits_used: number;
}
interface LedgerEntry {
id: string;
entry_type: string;
credits: number;
balance_after: number;
description: string | null;
idempotency_key: string | null;
created_at: string;
}
interface AdjustRequest {
user_id: string;
amount: number;
type: 'ADD' | 'DEDUCT';
reason: string;
}
// API Functions
async function fetchUserBalance(userId: string): Promise<Wallet> {
const token = sessionStorage.getItem('nxtgauge_access_token');
const res = await fetch(`${API}/admin/ai-credits/balance?userId=${encodeURIComponent(userId)}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function fetchUserLedger(userId: string, page: number = 1, limit: number = 20): Promise<{ data: LedgerEntry[]; total: number }> {
const token = sessionStorage.getItem('nxtgauge_access_token');
const res = await fetch(
`${API}/admin/ai-credits/ledger?userId=${encodeURIComponent(userId)}&page=${page}&limit=${limit}`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
credentials: 'include',
}
);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function adjustCredits(request: AdjustRequest): Promise<{ wallet: Wallet; success: boolean }> {
const token = sessionStorage.getItem('nxtgauge_access_token');
const res = await fetch(`${API}/admin/ai-credits/adjust`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(request),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export default function AiCreditsAdmin() {
const auth = useAuth();
const [activeTab, setActiveTab] = createSignal<'balance' | 'ledger' | 'adjust' | 'reconcile'>('balance');
// Balance tab state
const [userIdInput, setUserIdInput] = createSignal('');
const [balanceData, setBalanceData] = createSignal<Wallet | null>(null);
const [balanceError, setBalanceError] = createSignal('');
const [balanceLoading, setBalanceLoading] = createSignal(false);
// Ledger tab state
const [ledgerUserId, setLedgerUserId] = createSignal('');
const [ledgerPage, setLedgerPage] = createSignal(1);
const [ledgerData, setLedgerData] = createSignal<{ data: LedgerEntry[]; total: number } | null>(null);
const [ledgerError, setLedgerError] = createSignal('');
const [ledgerLoading, setLedgerLoading] = createSignal(false);
// Adjust tab state
const [adjustForm, setAdjustForm] = createSignal<AdjustRequest>({
user_id: '',
amount: 100,
type: 'ADD',
reason: '',
});
const [adjustResult, setAdjustResult] = createSignal<{ wallet: Wallet; success: boolean } | null>(null);
const [adjustError, setAdjustError] = createSignal('');
const [adjustLoading, setAdjustLoading] = createSignal(false);
// Reconcile tab state
const [reconcileFrom, setReconcileFrom] = createSignal('');
const [reconcileTo, setReconcileTo] = createSignal('');
const handleFetchBalance = async () => {
if (!userIdInput()) {
setBalanceError('Please enter a User ID');
return;
}
setBalanceLoading(true);
setBalanceError('');
try {
const data = await fetchUserBalance(userIdInput());
setBalanceData(data);
} catch (e: any) {
setBalanceError(e.message || 'Failed to fetch balance');
} finally {
setBalanceLoading(false);
}
};
const handleFetchLedger = async () => {
if (!ledgerUserId()) {
setLedgerError('Please enter a User ID');
return;
}
setLedgerLoading(true);
setLedgerError('');
try {
const data = await fetchUserLedger(ledgerUserId(), ledgerPage());
setLedgerData(data);
} catch (e: any) {
setLedgerError(e.message || 'Failed to fetch ledger');
} finally {
setLedgerLoading(false);
}
};
const handleAdjust = async () => {
if (!adjustForm().user_id || !adjustForm().amount || !adjustForm().reason) {
setAdjustError('Please fill in all fields');
return;
}
if (adjustForm().amount <= 0) {
setAdjustError('Amount must be positive');
return;
}
if (adjustForm().reason.trim().length < 5) {
setAdjustError('Please provide a detailed reason (at least 5 characters)');
return;
}
setAdjustLoading(true);
setAdjustError('');
try {
const result = await adjustCredits(adjustForm());
setAdjustResult(result);
} catch (e: any) {
setAdjustError(e.message || 'Failed to adjust credits');
} finally {
setAdjustLoading(false);
}
};
2026-08-12 13:38:38 +02:00
const formatCurrency = (credits: number) => credits.toLocaleString();
2026-08-12 13:38:38 +02:00
const formatDate = (dateStr: string) => new Date(dateStr).toLocaleString();
return (
<div style={{ padding: '24px', 'max-width': '1200px', margin: '0 auto' }}>
<h1 style={{ 'font-size': '28px', 'font-weight': '600', margin: '0 0 8px 0' }}>
AI Credits Admin
</h1>
<p style={{ color: '#6B7280', margin: '0 0 24px 0' }}>
Manage AI credits for users across the platform
</p>
{/* Tab Navigation */}
<div style={{
display: 'flex',
'border-bottom': '1px solid #E5E7EB',
margin: '0 0 24px 0'
}}>
2026-08-12 13:38:38 +02:00
<For each={[
{ key: 'balance', label: 'View Balance' },
{ key: 'ledger', label: 'Transaction History' },
{ key: 'adjust', label: 'Adjust Credits' },
{ key: 'reconcile', label: 'Reconcile' },
2026-08-12 13:38:38 +02:00
]}>{tab => (
<button
onClick={() => setActiveTab(tab.key as any)}
style={{
padding: '12px 20px',
'font-size': '14px',
'font-weight': activeTab() === tab.key ? '600' : '400',
color: activeTab() === tab.key ? '#FF5E13' : '#6B7280',
border: 'none',
'border-bottom': activeTab() === tab.key ? '2px solid #FF5E13' : '2px solid transparent',
background: 'transparent',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
{tab.label}
</button>
2026-08-12 13:38:38 +02:00
)}</For>
</div>
{/* Balance Tab */}
<Show when={activeTab() === 'balance'}>
<div style={{ 'background-color': 'white', padding: '24px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', gap: '12px', 'margin-bottom': '24px' }}>
<input
type="text"
value={userIdInput()}
onInput={(e) => setUserIdInput(e.currentTarget.value)}
placeholder="Enter User ID (UUID)"
style={{
flex: '1',
padding: '10px 14px',
'font-size': '14px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
}}
/>
<button
onClick={handleFetchBalance}
disabled={balanceLoading()}
style={{
padding: '10px 20px',
'font-size': '14px',
'font-weight': '500',
color: 'white',
background: balanceLoading() ? '#9CA3AF' : '#FF5E13',
border: 'none',
'border-radius': '6px',
cursor: balanceLoading() ? 'not-allowed' : 'pointer',
}}
>
{balanceLoading() ? 'Loading...' : 'View Balance'}
</button>
</div>
<Show when={balanceError()}>
<div style={{
padding: '12px 16px',
'background-color': '#FEE2E2',
color: '#991B1B',
'border-radius': '6px',
'margin-bottom': '16px'
}}>
{balanceError()}
</div>
</Show>
<Show when={balanceData()}>
{(wallet) => (
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div style={{
padding: '20px',
'background-color': '#FEF2F0',
'border-radius': '8px',
border: '1px solid #FECACA'
}}>
<div style={{ 'font-size': '12px', 'text-transform': 'uppercase', 'letter-spacing': '0.5px', color: '#991B1B' }}>
Available Credits
</div>
<div style={{ 'font-size': '32px', 'font-weight': '700', color: '#FF5E13' }}>
{formatCurrency(wallet().available_credits)}
</div>
</div>
<div style={{ padding: '20px', 'background-color': '#F3F4F6', 'border-radius': '8px' }}>
<div style={{ 'font-size': '12px', 'text-transform': 'uppercase', 'letter-spacing': '0.5px', color: '#6B7280' }}>
Monthly Credits
</div>
<div style={{ 'font-size': '24px', 'font-weight': '600' }}>
{formatCurrency(wallet().monthly_credits_total - wallet().monthly_credits_used)} / {formatCurrency(wallet().monthly_credits_total)}
</div>
<div style={{ 'font-size': '12px', color: '#6B7280' }}>
Used: {formatCurrency(wallet().monthly_credits_used)}
</div>
</div>
<div style={{ padding: '20px', 'background-color': '#F3F4F6', 'border-radius': '8px' }}>
<div style={{ 'font-size': '12px', 'text-transform': 'uppercase', 'letter-spacing': '0.5px', color: '#6B7280' }}>
Purchased Credits
</div>
<div style={{ 'font-size': '24px', 'font-weight': '600' }}>
{formatCurrency(wallet().purchased_credits_total - wallet().purchased_credits_used)} / {formatCurrency(wallet().purchased_credits_total)}
</div>
<div style={{ 'font-size': '12px', color: '#6B7280' }}>
Used: {formatCurrency(wallet().purchased_credits_used)}
</div>
</div>
<div style={{ padding: '20px', 'background-color': '#F3F4F6', 'border-radius': '8px' }}>
<div style={{ 'font-size': '12px', 'text-transform': 'uppercase', 'letter-spacing': '0.5px', color: '#6B7280' }}>
Bonus Credits
</div>
<div style={{ 'font-size': '24px', 'font-weight': '600' }}>
{formatCurrency(wallet().bonus_credits_total - wallet().bonus_credits_used)} / {formatCurrency(wallet().bonus_credits_total)}
</div>
<div style={{ 'font-size': '12px', color: '#6B7280' }}>
Used: {formatCurrency(wallet().bonus_credits_used)}
</div>
</div>
<div style={{ padding: '20px', 'background-color': '#FEF3C7', 'border-radius': '8px' }}>
<div style={{ 'font-size': '12px', 'text-transform': 'uppercase', 'letter-spacing': '0.5px', color: '#92400E' }}>
Reserved
</div>
<div style={{ 'font-size': '24px', 'font-weight': '600', color: '#B45309' }}>
{formatCurrency(wallet().reserved_credits)}
</div>
</div>
<div style={{ padding: '20px', 'background-color': '#E0E7FF', 'border-radius': '8px' }}>
<div style={{ 'font-size': '12px', 'text-transform': 'uppercase', 'letter-spacing': '0.5px', color: '#3730A3' }}>
Locked
</div>
<div style={{ 'font-size': '24px', 'font-weight': '600', color: '#4338CA' }}>
{formatCurrency(wallet().locked_credits)}
</div>
</div>
</div>
)}
</Show>
</div>
</Show>
{/* Ledger Tab */}
<Show when={activeTab() === 'ledger'}>
<div style={{ 'background-color': 'white', padding: '24px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', gap: '12px', 'margin-bottom': '24px' }}>
<input
type="text"
value={ledgerUserId()}
onInput={(e) => setLedgerUserId(e.currentTarget.value)}
placeholder="Enter User ID (UUID)"
style={{
flex: '1',
padding: '10px 14px',
'font-size': '14px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
}}
/>
<button
onClick={handleFetchLedger}
disabled={ledgerLoading()}
style={{
padding: '10px 20px',
'font-size': '14px',
'font-weight': '500',
color: 'white',
background: ledgerLoading() ? '#9CA3AF' : '#FF5E13',
border: 'none',
'border-radius': '6px',
cursor: ledgerLoading() ? 'not-allowed' : 'pointer',
}}
>
{ledgerLoading() ? 'Loading...' : 'View History'}
</button>
</div>
<Show when={ledgerError()}>
<div style={{
padding: '12px 16px',
'background-color': '#FEE2E2',
color: '#991B1B',
'border-radius': '6px',
'margin-bottom': '16px'
}}>
{ledgerError()}
</div>
</Show>
<Show when={ledgerData()}>
{(data) => (
<div>
<div style={{ 'margin-bottom': '16px', color: '#6B7280', 'font-size': '14px' }}>
Showing {data().data.length} of {data().total} entries
</div>
<div style={{ overflow: 'auto' }}>
<table style={{ width: '100%', 'border-collapse': 'collapse' }}>
<thead>
<tr style={{ 'background-color': '#F9FAFB', 'border-bottom': '1px solid #E5E7EB' }}>
<th style={{ padding: '12px', 'text-align': 'left', 'font-size': '12px', 'font-weight': '600', color: '#6B7280' }}>Date</th>
<th style={{ padding: '12px', 'text-align': 'left', 'font-size': '12px', 'font-weight': '600', color: '#6B7280' }}>Type</th>
<th style={{ padding: '12px', 'text-align': 'right', 'font-size': '12px', 'font-weight': '600', color: '#6B7280' }}>Credits</th>
<th style={{ padding: '12px', 'text-align': 'right', 'font-size': '12px', 'font-weight': '600', color: '#6B7280' }}>Balance After</th>
<th style={{ padding: '12px', 'text-align': 'left', 'font-size': '12px', 'font-weight': '600', color: '#6B7280' }}>Description</th>
</tr>
</thead>
<tbody>
<For each={data().data}>
{(entry) => (
<tr style={{ 'border-bottom': '1px solid #E5E7EB' }}>
<td style={{ padding: '12px', 'font-size': '14px' }}>
{formatDate(entry.created_at)}
</td>
<td style={{ padding: '12px' }}>
<span style={{
padding: '4px 8px',
'border-radius': '4px',
'font-size': '12px',
'font-weight': '500',
'background-color': entry.entry_type.includes('credit') ? '#D1FAE5' : entry.entry_type.includes('debit') ? '#FEE2E2' : '#F3F4F6',
color: entry.entry_type.includes('credit') ? '#065F46' : entry.entry_type.includes('debit') ? '#991B1B' : '#374151'
}}>
{entry.entry_type}
</span>
</td>
<td style={{ padding: '12px', 'text-align': 'right', 'font-size': '14px', 'font-weight': '500' }}>
{entry.credits > 0 ? '+' : ''}{formatCurrency(entry.credits)}
</td>
<td style={{ padding: '12px', 'text-align': 'right', 'font-size': '14px' }}>
{formatCurrency(entry.balance_after)}
</td>
<td style={{ padding: '12px', 'font-size': '14px', color: '#6B7280' }}>
{entry.description || '-'}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<div style={{ display: 'flex', 'justify-content': 'center', gap: '8px', 'margin-top': '16px' }}>
<button
onClick={() => {
setLedgerPage(p => Math.max(1, p - 1));
handleFetchLedger();
}}
disabled={ledgerPage() === 1}
style={{
padding: '8px 16px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
background: ledgerPage() === 1 ? '#F3F4F6' : 'white',
cursor: ledgerPage() === 1 ? 'not-allowed' : 'pointer',
}}
>
Previous
</button>
<span style={{ padding: '8px 16px', color: '#6B7280' }}>
Page {ledgerPage()}
</span>
<button
onClick={() => {
setLedgerPage(p => p + 1);
handleFetchLedger();
}}
disabled={ledgerData()?.data.length === 0 || (ledgerData()?.data.length ?? 0) < 20}
style={{
padding: '8px 16px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
background: 'white',
cursor: 'pointer',
}}
>
Next
</button>
</div>
</div>
)}
</Show>
</div>
</Show>
{/* Adjust Tab */}
<Show when={activeTab() === 'adjust'}>
<div style={{ 'background-color': 'white', padding: '24px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}>
<div>
<label style={{ display: 'block', 'margin-bottom': '6px', 'font-size': '14px', 'font-weight': '500' }}>
User ID
</label>
<input
type="text"
value={adjustForm().user_id}
onInput={(e) => setAdjustForm(f => ({ ...f, user_id: e.currentTarget.value }))}
placeholder="Enter User ID (UUID)"
style={{
width: '100%',
padding: '10px 14px',
'font-size': '14px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
}}
/>
</div>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '16px' }}>
<div>
<label style={{ display: 'block', 'margin-bottom': '6px', 'font-size': '14px', 'font-weight': '500' }}>
Operation Type
</label>
<select
value={adjustForm().type}
onChange={(e) => setAdjustForm(f => ({ ...f, type: e.currentTarget.value as 'ADD' | 'DEDUCT' }))}
style={{
width: '100%',
padding: '10px 14px',
'font-size': '14px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
}}
>
<option value="ADD">Add Credits (+)</option>
<option value="DEDUCT">Deduct Credits (-)</option>
</select>
</div>
<div>
<label style={{ display: 'block', 'margin-bottom': '6px', 'font-size': '14px', 'font-weight': '500' }}>
Amount
</label>
<input
type="number"
min="1"
value={adjustForm().amount}
onInput={(e) => setAdjustForm(f => ({ ...f, amount: parseInt(e.currentTarget.value) || 0 }))}
style={{
width: '100%',
padding: '10px 14px',
'font-size': '14px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', 'margin-bottom': '6px', 'font-size': '14px', 'font-weight': '500' }}>
Reason (Required)
</label>
<textarea
value={adjustForm().reason}
onInput={(e) => setAdjustForm(f => ({ ...f, reason: e.currentTarget.value }))}
placeholder="Explain why you're adjusting these credits (for audit purposes)"
rows={3}
style={{
width: '100%',
padding: '10px 14px',
'font-size': '14px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
resize: 'vertical',
}}
/>
</div>
<button
onClick={handleAdjust}
disabled={adjustLoading()}
style={{
padding: '12px 24px',
'font-size': '14px',
'font-weight': '500',
color: 'white',
background: adjustLoading() ? '#9CA3AF' : '#FF5E13',
border: 'none',
'border-radius': '6px',
cursor: adjustLoading() ? 'not-allowed' : 'pointer',
'margin-top': '8px',
}}
>
{adjustLoading() ? 'Processing...' : adjustForm().type === 'ADD' ? 'Add Credits' : 'Deduct Credits'}
</button>
</div>
<Show when={adjustError()}>
<div style={{
padding: '12px 16px',
'background-color': '#FEE2E2',
color: '#991B1B',
'border-radius': '6px',
'margin-top': '16px'
}}>
{adjustError()}
</div>
</Show>
<Show when={adjustResult()}>
{(result) => (
<div style={{
padding: '16px',
'background-color': '#D1FAE5',
color: '#065F46',
'border-radius': '6px',
'margin-top': '16px'
}}>
<div style={{ 'font-weight': '600', 'margin-bottom': '8px' }}>
Credits adjusted successfully!
</div>
<div>New available balance: {formatCurrency(result().wallet.available_credits)} credits</div>
</div>
)}
</Show>
</div>
</Show>
{/* Reconcile Tab */}
<Show when={activeTab() === 'reconcile'}>
<div style={{ 'background-color': 'white', padding: '24px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}>
<div>
<p style={{ color: '#6B7280', 'margin-bottom': '16px' }}>
Generate a reconciliation report to verify ledger entries match wallet balances.
This helps identify any drift or discrepancies in the system.
</p>
</div>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '16px' }}>
<div>
<label style={{ display: 'block', 'margin-bottom': '6px', 'font-size': '14px', 'font-weight': '500' }}>
From Date
</label>
<input
type="datetime-local"
value={reconcileFrom()}
onInput={(e) => setReconcileFrom(e.currentTarget.value)}
style={{
width: '100%',
padding: '10px 14px',
'font-size': '14px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
}}
/>
</div>
<div>
<label style={{ display: 'block', 'margin-bottom': '6px', 'font-size': '14px', 'font-weight': '500' }}>
To Date
</label>
<input
type="datetime-local"
value={reconcileTo()}
onInput={(e) => setReconcileTo(e.currentTarget.value)}
style={{
width: '100%',
padding: '10px 14px',
'font-size': '14px',
border: '1px solid #D1D5DB',
'border-radius': '6px',
}}
/>
</div>
</div>
<button
onClick={() => {
// This would call the reconcile API endpoint
alert('Reconcile endpoint to be implemented in backend');
}}
style={{
padding: '12px 24px',
'font-size': '14px',
'font-weight': '500',
color: 'white',
background: '#374151',
border: 'none',
'border-radius': '6px',
cursor: 'pointer',
'margin-top': '8px',
}}
>
Generate Reconcile Report
</button>
</div>
</div>
</Show>
</div>
);
}