feat: AI Credits Admin Panel
Some checks failed
build-and-release / build (push) Failing after 1m6s

- 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:
Ashwin Kumar Sivakumar 2026-07-06 01:58:29 +05:30
parent a0cfbe15eb
commit f55a2dad77
8 changed files with 887 additions and 53 deletions

View file

@ -3,7 +3,7 @@
* Used for pages that need actual backend connectivity * Used for pages that need actual backend connectivity
* (My Profile, My Portfolio, Verification) instead of the preview mock. * (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 { AiChatWidget } from "./AiChatWidget";
import NotificationBell from "./NotificationBell"; import NotificationBell from "./NotificationBell";
import { import {
@ -22,6 +22,7 @@ import {
LogOut, LogOut,
Bell, Bell,
ChevronRight, ChevronRight,
Lock,
} from "lucide-solid"; } from "lucide-solid";
const ICON_MAP: Record<string, any> = { const ICON_MAP: Record<string, any> = {
@ -85,6 +86,7 @@ interface Props {
onSidebarSelect: (item: string) => void; onSidebarSelect: (item: string) => void;
roleKey: string; roleKey: string;
userName?: string; userName?: string;
isAdmin?: boolean;
children: JSX.Element; children: JSX.Element;
} }
@ -200,6 +202,41 @@ export default function DashboardShell(props: Props) {
</For> </For>
</nav> </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 */} {/* User footer */}
<div style={{ padding: "12px 16px", "border-top": "1px solid #E5E7EB" }}> <div style={{ padding: "12px 16px", "border-top": "1px solid #E5E7EB" }}>
<p <p

View 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>
);
}

View file

@ -194,24 +194,18 @@ export default function CompanyJobsPage() {
const context = form().title || form().description || "job posting"; const context = form().title || form().description || "job posting";
const token = window.sessionStorage.getItem("nxtgauge_access_token") || ""; const token = window.sessionStorage.getItem("nxtgauge_access_token") || "";
const endpoint = // All four fields are generated by the same backend endpoint
field === "description" // (apps/users/src/handlers/ai.rs::ai_generate_job_field), which
? "/api/ai/company/jobs/generate-description" // branches on `field` itself -- there is no separate
: field === "skills" // generate-description/extract-skills endpoint.
? "/api/ai/company/jobs/extract-skills"
: "/api/ai/generate-job-field";
const body =
endpoint === "/api/ai/generate-job-field"
? { field, context }
: { context };
try { try {
const res = await fetch(`${API}${endpoint}`, { const res = await fetch(`${API}/api/ai/generate-job-field`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(body), body: JSON.stringify({ field, context }),
}); });
if (res.status === 429) { if (res.status === 429) {

View file

@ -2,7 +2,7 @@ import { For, Show, createSignal, onMount } from "solid-js";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, ORANGE, NAVY, PKG_CARD } from "~/components/DashboardShell"; 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 { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from "./RoleDashboardShared";
import { openPayuCheckout } from "~/lib/payu"; import { openPayuCheckout, submitPayuCheckout } from "~/lib/payu";
const API = "/api"; const API = "/api";
@ -329,11 +329,25 @@ export default function CreditsPage(props: Props) {
return; 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, amount: orderData.amount,
currency: orderData.currency, productinfo: orderData.productinfo,
txnId: orderData.txnid || orderData.order_id, firstname: orderData.firstname,
description: `${aiPkg.name} - ${aiPkg.credits} AI Credits`, 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", { const verifyRes = await apiFetch("/api/ai-credits/verify", {

View file

@ -9,6 +9,7 @@ type AuthUser = {
full_name: string; full_name: string;
active_role: string; active_role: string;
email_verified: boolean; email_verified: boolean;
roles?: string[];
}; };
type AuthState = { type AuthState = {
@ -99,6 +100,7 @@ async function fetchSession(): Promise<AuthUser | null> {
full_name: data.full_name || data.name || '', full_name: data.full_name || data.name || '',
active_role: resolvedActiveRole, active_role: resolvedActiveRole,
email_verified: data.email_verified || false, email_verified: data.email_verified || false,
roles: data.roles || [],
}; };
} catch { } catch {
return null; 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 }; export { getToken, clearAuthStorage };

View file

@ -82,49 +82,26 @@ function readQueryFromCurrentUrl(): Record<string, string> {
return Object.fromEntries(new URLSearchParams(search)); return Object.fromEntries(new URLSearchParams(search));
} }
export async function openPayuCheckout( // Submit an already-built PayU order (key/txnid/hash/etc. obtained from
options: PayuCheckoutOptions // 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> { ): Promise<PayuSuccessPayload> {
if (typeof window === "undefined") { if (typeof window === "undefined") {
throw new Error("PayU checkout is only available in the browser."); 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 = { const params: PayuCheckoutParams = {
key: orderData.key, ...rawParams,
txnid: orderData.txnid, surl: ensureAbsoluteUrl(rawParams.surl),
amount: orderData.amount, furl: ensureAbsoluteUrl(rawParams.furl),
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,
}; };
const { url, inputs } = PAYU_CHECKOUT_BUILD(params); const { url, inputs } = PAYU_CHECKOUT_BUILD(params);
return new Promise<PayuSuccessPayload>((resolve, reject) => { 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 }; export type { PayuSuccessPayload, PayuCheckoutParams, PayuCheckoutOptions };

View 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>
);
}

View file

@ -607,6 +607,11 @@ export default function RuntimeDashboardPage() {
const [roleReconcileAttempted, setRoleReconcileAttempted] = createSignal(false); const [roleReconcileAttempted, setRoleReconcileAttempted] = createSignal(false);
const [verificationStatusOverride, setVerificationStatusOverride] = createSignal<string | undefined>(undefined); 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(() => { onMount(() => {
setHydrated(true); setHydrated(true);
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -826,6 +831,7 @@ export default function RuntimeDashboardPage() {
onSidebarSelect={setActiveSidebar} onSidebarSelect={setActiveSidebar}
roleKey={role()} roleKey={role()}
userName={userName()} userName={userName()}
isAdmin={isAdmin()}
> >
<Switch> <Switch>
<Match when={activeSidebarKey() === "my dashboard"}> <Match when={activeSidebarKey() === "my dashboard"}>