2026-07-06 01:58:29 +05:30
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
fix: ESLint, SolidJS reactivity bugs, role workflow bug fixes
ESLint:
- Downgrade eslint v10 → v8.57.1 (compatible with @typescript-eslint v7 + eslint-plugin-solid)
- Fix .eslintrc.cjs: remove invalid require() calls, update to valid rule set
- Add npm run lint script
SolidJS solid/prefer-for (18 fixes across 3 files):
- OpportunityGraph.tsx: EDGES.map() → <For> with reactive visible/drawing signals
- PortfolioPage.tsx: services.map() and experience.map() → <For>
- DashboardDesignPreview.tsx: 15 .map() calls → <For> (stats, packages, timeline,
testimonials, quick actions, step tabs, pills, form fields, status lists, buttons,
counters, filter tabs)
Role workflow bug fixes (from full role audit):
- cover_letter → cover_note in job applications (field name canonical fix)
- applicant_user_id field name fix in shortlisted candidates
- CompanyApplicationsPage: GET → POST for contact unlock endpoint
- CreditsPage: /payments/history → /payments/invoices; response key data.data
- CreditsPage: holds response key data.data (not data.holds)
- ProfessionalResponsesPage: requirement_id → lead_id, decision_at → resolved_at
- PortfolioPage: data.items → data.data; fix vacuous-truth in form completion check;
saveProfessionalForm: check res.ok before showing success
- CustomerBrowseProfessionalsPage: professional_role_code → profession_key
- MyDashboardPage: professional prefix split('_')[0] not replace('_','')
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 13:38:38 +02:00
|
|
|
const formatCurrency = (credits: number) => credits.toLocaleString();
|
2026-07-06 01:58:29 +05:30
|
|
|
|
fix: ESLint, SolidJS reactivity bugs, role workflow bug fixes
ESLint:
- Downgrade eslint v10 → v8.57.1 (compatible with @typescript-eslint v7 + eslint-plugin-solid)
- Fix .eslintrc.cjs: remove invalid require() calls, update to valid rule set
- Add npm run lint script
SolidJS solid/prefer-for (18 fixes across 3 files):
- OpportunityGraph.tsx: EDGES.map() → <For> with reactive visible/drawing signals
- PortfolioPage.tsx: services.map() and experience.map() → <For>
- DashboardDesignPreview.tsx: 15 .map() calls → <For> (stats, packages, timeline,
testimonials, quick actions, step tabs, pills, form fields, status lists, buttons,
counters, filter tabs)
Role workflow bug fixes (from full role audit):
- cover_letter → cover_note in job applications (field name canonical fix)
- applicant_user_id field name fix in shortlisted candidates
- CompanyApplicationsPage: GET → POST for contact unlock endpoint
- CreditsPage: /payments/history → /payments/invoices; response key data.data
- CreditsPage: holds response key data.data (not data.holds)
- ProfessionalResponsesPage: requirement_id → lead_id, decision_at → resolved_at
- PortfolioPage: data.items → data.data; fix vacuous-truth in form completion check;
saveProfessionalForm: check res.ok before showing success
- CustomerBrowseProfessionalsPage: professional_role_code → profession_key
- MyDashboardPage: professional prefix split('_')[0] not replace('_','')
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 13:38:38 +02:00
|
|
|
const formatDate = (dateStr: string) => new Date(dateStr).toLocaleString();
|
2026-07-06 01:58:29 +05:30
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
}}>
|
fix: ESLint, SolidJS reactivity bugs, role workflow bug fixes
ESLint:
- Downgrade eslint v10 → v8.57.1 (compatible with @typescript-eslint v7 + eslint-plugin-solid)
- Fix .eslintrc.cjs: remove invalid require() calls, update to valid rule set
- Add npm run lint script
SolidJS solid/prefer-for (18 fixes across 3 files):
- OpportunityGraph.tsx: EDGES.map() → <For> with reactive visible/drawing signals
- PortfolioPage.tsx: services.map() and experience.map() → <For>
- DashboardDesignPreview.tsx: 15 .map() calls → <For> (stats, packages, timeline,
testimonials, quick actions, step tabs, pills, form fields, status lists, buttons,
counters, filter tabs)
Role workflow bug fixes (from full role audit):
- cover_letter → cover_note in job applications (field name canonical fix)
- applicant_user_id field name fix in shortlisted candidates
- CompanyApplicationsPage: GET → POST for contact unlock endpoint
- CreditsPage: /payments/history → /payments/invoices; response key data.data
- CreditsPage: holds response key data.data (not data.holds)
- ProfessionalResponsesPage: requirement_id → lead_id, decision_at → resolved_at
- PortfolioPage: data.items → data.data; fix vacuous-truth in form completion check;
saveProfessionalForm: check res.ok before showing success
- CustomerBrowseProfessionalsPage: professional_role_code → profession_key
- MyDashboardPage: professional prefix split('_')[0] not replace('_','')
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 13:38:38 +02:00
|
|
|
<For each={[
|
2026-07-06 01:58:29 +05:30
|
|
|
{ key: 'balance', label: 'View Balance' },
|
|
|
|
|
{ key: 'ledger', label: 'Transaction History' },
|
|
|
|
|
{ key: 'adjust', label: 'Adjust Credits' },
|
|
|
|
|
{ key: 'reconcile', label: 'Reconcile' },
|
fix: ESLint, SolidJS reactivity bugs, role workflow bug fixes
ESLint:
- Downgrade eslint v10 → v8.57.1 (compatible with @typescript-eslint v7 + eslint-plugin-solid)
- Fix .eslintrc.cjs: remove invalid require() calls, update to valid rule set
- Add npm run lint script
SolidJS solid/prefer-for (18 fixes across 3 files):
- OpportunityGraph.tsx: EDGES.map() → <For> with reactive visible/drawing signals
- PortfolioPage.tsx: services.map() and experience.map() → <For>
- DashboardDesignPreview.tsx: 15 .map() calls → <For> (stats, packages, timeline,
testimonials, quick actions, step tabs, pills, form fields, status lists, buttons,
counters, filter tabs)
Role workflow bug fixes (from full role audit):
- cover_letter → cover_note in job applications (field name canonical fix)
- applicant_user_id field name fix in shortlisted candidates
- CompanyApplicationsPage: GET → POST for contact unlock endpoint
- CreditsPage: /payments/history → /payments/invoices; response key data.data
- CreditsPage: holds response key data.data (not data.holds)
- ProfessionalResponsesPage: requirement_id → lead_id, decision_at → resolved_at
- PortfolioPage: data.items → data.data; fix vacuous-truth in form completion check;
saveProfessionalForm: check res.ok before showing success
- CustomerBrowseProfessionalsPage: professional_role_code → profession_key
- MyDashboardPage: professional prefix split('_')[0] not replace('_','')
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 13:38:38 +02:00
|
|
|
]}>{tab => (
|
2026-07-06 01:58:29 +05:30
|
|
|
<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>
|
fix: ESLint, SolidJS reactivity bugs, role workflow bug fixes
ESLint:
- Downgrade eslint v10 → v8.57.1 (compatible with @typescript-eslint v7 + eslint-plugin-solid)
- Fix .eslintrc.cjs: remove invalid require() calls, update to valid rule set
- Add npm run lint script
SolidJS solid/prefer-for (18 fixes across 3 files):
- OpportunityGraph.tsx: EDGES.map() → <For> with reactive visible/drawing signals
- PortfolioPage.tsx: services.map() and experience.map() → <For>
- DashboardDesignPreview.tsx: 15 .map() calls → <For> (stats, packages, timeline,
testimonials, quick actions, step tabs, pills, form fields, status lists, buttons,
counters, filter tabs)
Role workflow bug fixes (from full role audit):
- cover_letter → cover_note in job applications (field name canonical fix)
- applicant_user_id field name fix in shortlisted candidates
- CompanyApplicationsPage: GET → POST for contact unlock endpoint
- CreditsPage: /payments/history → /payments/invoices; response key data.data
- CreditsPage: holds response key data.data (not data.holds)
- ProfessionalResponsesPage: requirement_id → lead_id, decision_at → resolved_at
- PortfolioPage: data.items → data.data; fix vacuous-truth in form completion check;
saveProfessionalForm: check res.ok before showing success
- CustomerBrowseProfessionalsPage: professional_role_code → profession_key
- MyDashboardPage: professional prefix split('_')[0] not replace('_','')
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 13:38:38 +02:00
|
|
|
)}</For>
|
2026-07-06 01:58:29 +05:30
|
|
|
</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();
|
|
|
|
|
}}
|
2026-07-17 00:47:32 +02:00
|
|
|
disabled={ledgerData()?.data.length === 0 || (ledgerData()?.data.length ?? 0) < 20}
|
2026-07-06 01:58:29 +05:30
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|