- Add AiCreditsAdmin component for managing AI credits - View user balance with detailed credit breakdown - Transaction history (ledger) viewer with pagination - Manual credit adjustment (ADD/DEDUCT) with audit reasons - Reconcile tab for generating reports - Integrate with backend /admin/ai-credits endpoints
This commit is contained in:
parent
a0cfbe15eb
commit
f55a2dad77
8 changed files with 887 additions and 53 deletions
|
|
@ -3,7 +3,7 @@
|
|||
* Used for pages that need actual backend connectivity
|
||||
* (My Profile, My Portfolio, Verification) instead of the preview mock.
|
||||
*/
|
||||
import { For, JSX, createMemo } from "solid-js";
|
||||
import { For, JSX, Show, createMemo } from "solid-js";
|
||||
import { AiChatWidget } from "./AiChatWidget";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
import {
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
LogOut,
|
||||
Bell,
|
||||
ChevronRight,
|
||||
Lock,
|
||||
} from "lucide-solid";
|
||||
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
|
|
@ -85,6 +86,7 @@ interface Props {
|
|||
onSidebarSelect: (item: string) => void;
|
||||
roleKey: string;
|
||||
userName?: string;
|
||||
isAdmin?: boolean;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +202,41 @@ export default function DashboardShell(props: Props) {
|
|||
</For>
|
||||
</nav>
|
||||
|
||||
{/* Admin section (only for admins) */}
|
||||
<Show when={props.isAdmin}>
|
||||
<div style={{ padding: "8px", "border-top": "1px solid #E5E7EB", "margin-top": "auto" }}>
|
||||
<p style={{ margin: "4px 8px", "font-size": "10px", "letter-spacing": "0.08em", "text-transform": "uppercase", color: "#9CA3AF" }}>
|
||||
Admin Tools
|
||||
</p>
|
||||
<a
|
||||
href="/admin/ai-credits"
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "9px",
|
||||
width: "100%",
|
||||
"text-align": "left",
|
||||
height: "34px",
|
||||
padding: "0 10px",
|
||||
"border-radius": "8px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
"font-size": "12px",
|
||||
"font-weight": "600",
|
||||
"margin-top": "4px",
|
||||
background: "transparent",
|
||||
color: "#6B7280",
|
||||
"text-decoration": "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ "flex-shrink": "0", color: "#9CA3AF" }}>
|
||||
<Lock size={16} />
|
||||
</span>
|
||||
AI Credits Admin
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* User footer */}
|
||||
<div style={{ padding: "12px 16px", "border-top": "1px solid #E5E7EB" }}>
|
||||
<p
|
||||
|
|
|
|||
705
src/components/admin/AiCreditsAdmin.tsx
Normal file
705
src/components/admin/AiCreditsAdmin.tsx
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (credits: number) => {
|
||||
return credits.toLocaleString();
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return 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'
|
||||
}}>
|
||||
{[
|
||||
{ key: 'balance', label: 'View Balance' },
|
||||
{ key: 'ledger', label: 'Transaction History' },
|
||||
{ key: 'adjust', label: 'Adjust Credits' },
|
||||
{ key: 'reconcile', label: 'Reconcile' },
|
||||
].map(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>
|
||||
))}
|
||||
</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 < 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -194,24 +194,18 @@ export default function CompanyJobsPage() {
|
|||
const context = form().title || form().description || "job posting";
|
||||
|
||||
const token = window.sessionStorage.getItem("nxtgauge_access_token") || "";
|
||||
const endpoint =
|
||||
field === "description"
|
||||
? "/api/ai/company/jobs/generate-description"
|
||||
: field === "skills"
|
||||
? "/api/ai/company/jobs/extract-skills"
|
||||
: "/api/ai/generate-job-field";
|
||||
const body =
|
||||
endpoint === "/api/ai/generate-job-field"
|
||||
? { field, context }
|
||||
: { context };
|
||||
// All four fields are generated by the same backend endpoint
|
||||
// (apps/users/src/handlers/ai.rs::ai_generate_job_field), which
|
||||
// branches on `field` itself -- there is no separate
|
||||
// generate-description/extract-skills endpoint.
|
||||
try {
|
||||
const res = await fetch(`${API}${endpoint}`, {
|
||||
const res = await fetch(`${API}/api/ai/generate-job-field`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ field, context }),
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { For, Show, createSignal, onMount } from "solid-js";
|
|||
import { Portal } from "solid-js/web";
|
||||
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, ORANGE, NAVY, PKG_CARD } from "~/components/DashboardShell";
|
||||
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from "./RoleDashboardShared";
|
||||
import { openPayuCheckout } from "~/lib/payu";
|
||||
import { openPayuCheckout, submitPayuCheckout } from "~/lib/payu";
|
||||
|
||||
const API = "/api";
|
||||
|
||||
|
|
@ -329,11 +329,25 @@ export default function CreditsPage(props: Props) {
|
|||
return;
|
||||
}
|
||||
|
||||
const paymentResult = await openPayuCheckout({
|
||||
// Submit the order /api/ai-credits/order already created, rather than
|
||||
// going through openPayuCheckout (which would create a SECOND,
|
||||
// different order via /api/payments/create-order, using this order's
|
||||
// txnid as if it were a package_id -- wrong endpoint, wrong id, and
|
||||
// the resulting hash wouldn't match this order's key/txnid anyway).
|
||||
const paymentResult = await submitPayuCheckout({
|
||||
key: orderData.key,
|
||||
txnid: orderData.txnid,
|
||||
amount: orderData.amount,
|
||||
currency: orderData.currency,
|
||||
txnId: orderData.txnid || orderData.order_id,
|
||||
description: `${aiPkg.name} - ${aiPkg.credits} AI Credits`,
|
||||
productinfo: orderData.productinfo,
|
||||
firstname: orderData.firstname,
|
||||
email: orderData.email,
|
||||
phone: orderData.phone,
|
||||
surl: orderData.surl,
|
||||
furl: orderData.furl,
|
||||
hash: orderData.hash,
|
||||
payu_base_url: orderData.payu_base_url,
|
||||
udf1: orderData.udf1,
|
||||
udf2: orderData.udf2,
|
||||
});
|
||||
|
||||
const verifyRes = await apiFetch("/api/ai-credits/verify", {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type AuthUser = {
|
|||
full_name: string;
|
||||
active_role: string;
|
||||
email_verified: boolean;
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
type AuthState = {
|
||||
|
|
@ -99,6 +100,7 @@ async function fetchSession(): Promise<AuthUser | null> {
|
|||
full_name: data.full_name || data.name || '',
|
||||
active_role: resolvedActiveRole,
|
||||
email_verified: data.email_verified || false,
|
||||
roles: data.roles || [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -183,4 +185,41 @@ export function RequireAuth(props: ParentProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function RequireAdmin(props: ParentProps) {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const [checked, setChecked] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
if (checked()) {
|
||||
const user = auth.user();
|
||||
if (!user || !user.roles?.some(r => r === 'ADMIN' || r === 'SUPER_ADMIN')) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
setChecked(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={checked() && auth.user()?.roles?.some(r => r === 'ADMIN' || r === 'SUPER_ADMIN')} fallback={
|
||||
<div style={{ "min-height": "100vh", display: "flex", "align-items": "center", "justify-content": "center", background: "#F3F4F6", "font-family": "Inter, system-ui, sans-serif" }}>
|
||||
<div style={{ "text-align": "center" }}>
|
||||
<div style={{ width: "40px", height: "40px", border: "3px solid #FF5E13", "border-top-color": "transparent", "border-radius": "50%", animation: "spin 0.8s linear infinite", margin: "0 auto 12px" }} />
|
||||
<p style={{ color: "#6B7280", "font-size": "14px", margin: "0" }}>Checking admin access...</p>
|
||||
</div>
|
||||
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
}>
|
||||
{props.children}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export function isAdmin(user: AuthUser | null): boolean {
|
||||
return user?.roles?.some(r => r === 'ADMIN' || r === 'SUPER_ADMIN') ?? false;
|
||||
}
|
||||
|
||||
export { getToken, clearAuthStorage };
|
||||
|
|
|
|||
|
|
@ -82,49 +82,26 @@ function readQueryFromCurrentUrl(): Record<string, string> {
|
|||
return Object.fromEntries(new URLSearchParams(search));
|
||||
}
|
||||
|
||||
export async function openPayuCheckout(
|
||||
options: PayuCheckoutOptions
|
||||
// Submit an already-built PayU order (key/txnid/hash/etc. obtained from
|
||||
// whichever backend endpoint created it -- `/api/payments/create-order`
|
||||
// for TraceCoins, `/api/ai-credits/order` for AI credits) as a form POST
|
||||
// to PayU, and resolve/reject based on the callback result. This is the
|
||||
// shared checkout mechanics; it does NOT create an order itself -- the
|
||||
// caller must have already done that. Use `openPayuCheckout` below if you
|
||||
// need both steps for the TraceCoins flow specifically.
|
||||
export async function submitPayuCheckout(
|
||||
rawParams: PayuCheckoutParams
|
||||
): Promise<PayuSuccessPayload> {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("PayU checkout is only available in the browser.");
|
||||
}
|
||||
|
||||
const orderRes = await fetch("/api/payments/create-order", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
amount: options.amount,
|
||||
currency: options.currency,
|
||||
package_id: options.txnId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!orderRes.ok) {
|
||||
throw new Error("Failed to create PayU order.");
|
||||
}
|
||||
|
||||
const orderData = await orderRes.json();
|
||||
const params: PayuCheckoutParams = {
|
||||
key: orderData.key,
|
||||
txnid: orderData.txnid,
|
||||
amount: orderData.amount,
|
||||
productinfo: orderData.productinfo,
|
||||
firstname: orderData.firstname,
|
||||
email: orderData.email,
|
||||
phone: orderData.phone,
|
||||
surl: ensureAbsoluteUrl(orderData.surl),
|
||||
furl: ensureAbsoluteUrl(orderData.furl),
|
||||
hash: orderData.hash,
|
||||
payu_base_url: orderData.payu_base_url,
|
||||
udf1: orderData.udf1,
|
||||
udf2: orderData.udf2,
|
||||
udf3: orderData.udf3,
|
||||
udf4: orderData.udf4,
|
||||
udf5: orderData.udf5,
|
||||
...rawParams,
|
||||
surl: ensureAbsoluteUrl(rawParams.surl),
|
||||
furl: ensureAbsoluteUrl(rawParams.furl),
|
||||
};
|
||||
|
||||
|
||||
const { url, inputs } = PAYU_CHECKOUT_BUILD(params);
|
||||
|
||||
return new Promise<PayuSuccessPayload>((resolve, reject) => {
|
||||
|
|
@ -202,4 +179,53 @@ export async function openPayuCheckout(
|
|||
});
|
||||
}
|
||||
|
||||
// TraceCoins checkout: creates the order via `/api/payments/create-order`
|
||||
// (using `options.txnId` as the tracecoin package id, per that endpoint's
|
||||
// contract) and then submits it. For AI credits, do NOT use this --
|
||||
// create the order via `/api/ai-credits/order` first and pass its
|
||||
// response straight to `submitPayuCheckout` instead, since this function
|
||||
// would otherwise create a second, mismatched order (see CreditsPage.tsx).
|
||||
export async function openPayuCheckout(
|
||||
options: PayuCheckoutOptions
|
||||
): Promise<PayuSuccessPayload> {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("PayU checkout is only available in the browser.");
|
||||
}
|
||||
|
||||
const orderRes = await fetch("/api/payments/create-order", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
amount: options.amount,
|
||||
currency: options.currency,
|
||||
package_id: options.txnId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!orderRes.ok) {
|
||||
throw new Error("Failed to create PayU order.");
|
||||
}
|
||||
|
||||
const orderData = await orderRes.json();
|
||||
return submitPayuCheckout({
|
||||
key: orderData.key,
|
||||
txnid: orderData.txnid,
|
||||
amount: orderData.amount,
|
||||
productinfo: orderData.productinfo,
|
||||
firstname: orderData.firstname,
|
||||
email: orderData.email,
|
||||
phone: orderData.phone,
|
||||
surl: orderData.surl,
|
||||
furl: orderData.furl,
|
||||
hash: orderData.hash,
|
||||
payu_base_url: orderData.payu_base_url,
|
||||
udf1: orderData.udf1,
|
||||
udf2: orderData.udf2,
|
||||
udf3: orderData.udf3,
|
||||
udf4: orderData.udf4,
|
||||
udf5: orderData.udf5,
|
||||
});
|
||||
}
|
||||
|
||||
export type { PayuSuccessPayload, PayuCheckoutParams, PayuCheckoutOptions };
|
||||
|
|
|
|||
13
src/routes/admin/ai-credits.tsx
Normal file
13
src/routes/admin/ai-credits.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { lazy } from 'solid-js';
|
||||
import { RouteDefinition } from '@solidjs/router';
|
||||
import { RequireAdmin } from '~/lib/auth';
|
||||
|
||||
const AiCreditsAdmin = lazy(() => import('~/components/admin/AiCreditsAdmin'));
|
||||
|
||||
export default function AdminAiCreditsPage() {
|
||||
return (
|
||||
<RequireAdmin>
|
||||
<AiCreditsAdmin />
|
||||
</RequireAdmin>
|
||||
);
|
||||
}
|
||||
|
|
@ -607,6 +607,11 @@ export default function RuntimeDashboardPage() {
|
|||
const [roleReconcileAttempted, setRoleReconcileAttempted] = createSignal(false);
|
||||
const [verificationStatusOverride, setVerificationStatusOverride] = createSignal<string | undefined>(undefined);
|
||||
|
||||
const isAdmin = createMemo(() => {
|
||||
const user = auth.user();
|
||||
return user?.roles?.some(r => r === 'ADMIN' || r === 'SUPER_ADMIN') ?? false;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
setHydrated(true);
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
|
@ -826,6 +831,7 @@ export default function RuntimeDashboardPage() {
|
|||
onSidebarSelect={setActiveSidebar}
|
||||
roleKey={role()}
|
||||
userName={userName()}
|
||||
isAdmin={isAdmin()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={activeSidebarKey() === "my dashboard"}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue