From c383382f93c8f9485a878738df08cf8d1ffd1c1e Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Mon, 6 Jul 2026 01:48:13 +0530 Subject: [PATCH] feat: Add AI Credits admin UI (Task 2) - pricing.tsx: AI Credit Packages tab with list, create, edit, toggle - credit.tsx: AI Credits panel with Balance/Ledger/Adjust/Reconcile sub-tabs - Coupon code support in order creation - Integration with backend admin endpoints --- src/routes/admin/credit.tsx | 423 ++++++++++++++++++++++++++++++++++- src/routes/admin/pricing.tsx | 386 +++++++++++++++++++++++++++++++- 2 files changed, 804 insertions(+), 5 deletions(-) diff --git a/src/routes/admin/credit.tsx b/src/routes/admin/credit.tsx index f1b27de..a594cdd 100644 --- a/src/routes/admin/credit.tsx +++ b/src/routes/admin/credit.tsx @@ -34,7 +34,36 @@ type ReconcileRow = { }; export default function CreditPage() { - const [activeTab, setActiveTab] = createSignal<'ledger' | 'adjust' | 'reconcile'>('ledger'); + const [activeTab, setActiveTab] = createSignal<'ledger' | 'adjust' | 'reconcile' | 'ai_credits'>('ledger'); + + // AI Credits sub-tab state + const [aiCreditsSubTab, setAiCreditsSubTab] = createSignal<'balance' | 'adjust' | 'reconcile'>('balance'); + const [aiUserId, setAiUserId] = createSignal(''); + const [aiSearchedUserId, setAiSearchedUserId] = createSignal(''); + const [aiBalance, setAiBalance] = createSignal(null); + const [aiLedger, setAiLedger] = createSignal([]); + const [aiSearchLoading, setAiSearchLoading] = createSignal(false); + const [aiSearchError, setAiSearchError] = createSignal(''); + const [aiLedgerPage, setAiLedgerPage] = createSignal(1); + const [aiLedgerLimit, setAiLedgerLimit] = createSignal(20); + const [aiLedgerTotal, setAiLedgerTotal] = createSignal(0); + + // AI Adjust state + const [aiAdjUserId, setAiAdjUserId] = createSignal(''); + const [aiAdjAmount, setAiAdjAmount] = createSignal(1); + const [aiAdjType, setAiAdjType] = createSignal<'ADD' | 'DEDUCT'>('ADD'); + const [aiAdjReason, setAiAdjReason] = createSignal(''); + const [aiAdjIdempotencyKey, setAiAdjIdempotencyKey] = createSignal(''); + const [aiAdjLoading, setAiAdjLoading] = createSignal(false); + const [aiAdjSuccess, setAiAdjSuccess] = createSignal(''); + const [aiAdjError, setAiAdjError] = createSignal(''); + + // AI Reconcile state + const [aiReconFrom, setAiReconFrom] = createSignal(''); + const [aiReconTo, setAiReconTo] = createSignal(''); + const [aiReconLoading, setAiReconLoading] = createSignal(false); + const [aiReconResults, setAiReconResults] = createSignal(null); + const [aiReconError, setAiReconError] = createSignal(''); // Balance & Ledger tab state const [userId, setUserId] = createSignal(''); @@ -149,12 +178,101 @@ export default function CreditPage() { } }; - const tabs: { key: 'ledger' | 'adjust' | 'reconcile'; label: string }[] = [ + const tabs: { key: 'ledger' | 'adjust' | 'reconcile' | 'ai_credits'; label: string }[] = [ { key: 'ledger', label: 'Balance & Ledger' }, { key: 'adjust', label: 'Reward / Deduct' }, { key: 'reconcile', label: 'Reconcile' }, + { key: 'ai_credits', label: 'AI Credits' }, ]; + // AI Credits handlers + const handleAiSearch = async () => { + const uid = aiUserId().trim(); + if (!uid) return; + setAiSearchLoading(true); + setAiSearchError(''); + setAiBalance(null); + setAiLedger([]); + setAiSearchedUserId(uid); + try { + const [balRes, ledRes] = await Promise.all([ + fetch(`${API}/api/admin/ai-credits/balance?userId=${encodeURIComponent(uid)}`, { headers: authHeaders(), credentials: 'include' }), + fetch(`${API}/api/admin/ai-credits/ledger?userId=${encodeURIComponent(uid)}&page=${aiLedgerPage()}&limit=${aiLedgerLimit()}`, { headers: authHeaders(), credentials: 'include' }), + ]); + if (!balRes.ok || !ledRes.ok) throw new Error('Failed to fetch'); + const balData = await balRes.json(); + const ledData = await ledRes.json(); + setAiBalance(balData); + setAiLedger(ledData.data ?? []); + setAiLedgerTotal(ledData.total ?? 0); + } catch { + setAiSearchError('Failed to fetch AI credits data for this user ID.'); + setAiBalance(null); + setAiLedger([]); + } finally { + setAiSearchLoading(false); + } + }; + + const handleAiAdjust = async (e: Event) => { + e.preventDefault(); + setAiAdjLoading(true); + setAiAdjSuccess(''); + setAiAdjError(''); + try { + // Generate idempotency key if not provided + const idempotencyKey = aiAdjIdempotencyKey().trim() || crypto.randomUUID(); + const res = await fetch(`${API}/api/admin/ai-credits/adjust`, { + method: 'POST', + headers: authHeaders(), + credentials: 'include', + body: JSON.stringify({ + user_id: aiAdjUserId(), + amount: aiAdjAmount(), + type: aiAdjType(), + reason: aiAdjReason(), + idempotency_key: idempotencyKey, + }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error((d as any).error || 'Failed to adjust credits'); + } + setAiAdjSuccess('AI credits adjusted successfully!'); + setAiAdjUserId(''); + setAiAdjAmount(1); + setAiAdjType('ADD'); + setAiAdjReason(''); + setAiAdjIdempotencyKey(''); + } catch (err: any) { + setAiAdjError(err.message || 'Failed to adjust credits'); + } finally { + setAiAdjLoading(false); + } + }; + + const handleAiReconcile = async (e: Event) => { + e.preventDefault(); + setAiReconLoading(true); + setAiReconError(''); + setAiReconResults(null); + try { + const fromDate = new Date(aiReconFrom()).toISOString(); + const toDate = new Date(aiReconTo()).toISOString(); + const res = await fetch( + `${API}/api/admin/ai-credits/reconcile?from=${encodeURIComponent(fromDate)}&to=${encodeURIComponent(toDate)}`, + { headers: authHeaders(), credentials: 'include' } + ); + if (!res.ok) throw new Error('Failed to reconcile'); + const data = await res.json(); + setAiReconResults(data); + } catch (err: any) { + setAiReconError(err.message || 'Failed to reconcile'); + } finally { + setAiReconLoading(false); + } + }; + const filteredLedger = createMemo(() => { let data = ledger(); const q = ledgerSearch().toLowerCase().trim(); @@ -518,6 +636,307 @@ export default function CreditPage() { + + {/* AI Credits Tab */} + +
+ {/* AI Credits Sub-tabs */} +
+ {(['balance', 'adjust', 'reconcile'] as const).map((t) => ( + + ))} +
+ + {/* AI Balance & Ledger */} + +
+
+

Search AI Credits Balance

+
+ setAiUserId(e.currentTarget.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAiSearch()} + class="rounded-lg border border-gray-200 px-3 py-2 text-sm" + style="flex:1" + /> + +
+ +
{aiSearchError()}
+
+
+ + +
+
+

Available AI Credits

+

{aiBalance().available_credits ?? 0}

+

User: {aiSearchedUserId()}

+
+

Monthly: {aiBalance().monthly_credits_used ?? 0} / {aiBalance().monthly_credits_total ?? 0}

+

Purchased: {aiBalance().purchased_credits_used ?? 0} / {aiBalance().purchased_credits_total ?? 0}

+

Bonus: {aiBalance().bonus_credits_used ?? 0} / {aiBalance().bonus_credits_total ?? 0}

+
+
+ +
+

AI Credits Ledger

+ +

No transactions found for this account.

+
+ 0}> +
+ + + + + + + + + + + + + {(entry) => ( + + + + + + + + )} + + +
TypeCreditsBalance AfterDescriptionDate
+ 0 ? '#dcfce7' : '#fee2e2'};color:${entry.credits > 0 ? '#15803d' : '#b91c1c'}`} + > + {entry.credits > 0 ? 'CREDIT' : 'DEBIT'} + + 0 ? 'color:#16a34a' : 'color:#dc2626'}> + {entry.credits > 0 ? '+' : ''}{entry.credits} + {entry.balance_after}{entry.description || '—'} + {entry.created_at ? new Date(entry.created_at).toLocaleString() : '—'} +
+
+
+ Showing {aiLedger().length} of {aiLedgerTotal()} entries (Page {aiLedgerPage()}) +
+
+
+
+
+
+
+ + {/* AI Reward / Deduct */} + +
+

Reward or Adjust AI Credits

+

+ Use this to reward AI credits for a valid support case, process a refund, or correct a balance manually. +

+ +
+ {aiAdjSuccess()} +
+
+ +
{aiAdjError()}
+
+
+
+ + setAiAdjUserId(e.currentTarget.value)} + style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" + /> +
+
+
+ + setAiAdjAmount(Number(e.currentTarget.value))} + style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" + /> +
+
+ + +
+
+
+ + setAiAdjReason(e.currentTarget.value)} + placeholder="Required: Explain why credits are being adjusted" + style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" + /> +
+
+ + setAiAdjIdempotencyKey(e.currentTarget.value)} + placeholder="Leave empty to auto-generate" + style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" + /> +

Prevents duplicate adjustments if resubmitted

+
+
+ +
+
+
+
+ + {/* AI Reconcile */} + +
+
+

AI Credits Reconciliation

+ +
{aiReconError()}
+
+
+
+
+ + setAiReconFrom(e.currentTarget.value)} + style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" + /> +
+
+ + setAiReconTo(e.currentTarget.value)} + style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" + /> +
+
+
+ +
+
+
+ + +
+

Summary by Entry Type

+
+ + + + + + + + + + + {(row) => ( + + + + + + )} + + +
Entry TypeCountTotal Credits
{row.entry_type}{row.count}{row.total_credits}
+
+ +

Drift Check

+ +

No discrepancies found. All wallets reconciled correctly.

+
+ 0}> +
+ + + + + + + + + + + + {(row) => ( + + + + + + + )} + + +
UserLedger SumComputedDrift
+
{row.user_email}
+
{row.user_id}
+
{row.ledger_sum}{row.computed_available} + {row.drift > 0 ? '+' : ''}{row.drift} +
+
+
+
+
+
+
+
+
); diff --git a/src/routes/admin/pricing.tsx b/src/routes/admin/pricing.tsx index 299ff8d..ff8be21 100644 --- a/src/routes/admin/pricing.tsx +++ b/src/routes/admin/pricing.tsx @@ -37,6 +37,17 @@ type Package = { is_expired?: boolean; }; +type AiCreditPackage = { + id: string; + name: string; + description?: string; + credits: number; + price_inr: number; + is_active: boolean; + created_at: string; + updated_at: string; +}; + const PACKAGE_TYPES = [ { value: "TRACECOIN_BUNDLE", label: "Tracecoin Bundle" }, { value: "CONTACT_VIEWS", label: "Contact Views (Company)" }, @@ -92,7 +103,7 @@ export default function PricingPage() { const [rows, setRows] = createSignal([]); const [loading, setLoading] = createSignal(true); const [loadError, setLoadError] = createSignal(""); - const [view, setView] = createSignal<"packages" | "create">("packages"); + const [view, setView] = createSignal<"packages" | "create" | "ai_packages">("packages"); // Filters const [search, setSearch] = createSignal(""); @@ -125,6 +136,27 @@ export default function PricingPage() { const [cError, setCError] = createSignal(""); const [roleDropdownOpen, setRoleDropdownOpen] = createSignal(false); + // AI Credit Packages state + const [aiPackages, setAiPackages] = createSignal([]); + const [aiPackagesLoading, setAiPackagesLoading] = createSignal(false); + const [aiPackagesError, setAiPackagesError] = createSignal(""); + const [aiEditingId, setAiEditingId] = createSignal(""); + const [aiEditName, setAiEditName] = createSignal(""); + const [aiEditDescription, setAiEditDescription] = createSignal(""); + const [aiEditCredits, setAiEditCredits] = createSignal(""); + const [aiEditPrice, setAiEditPrice] = createSignal(""); + const [aiEditSaving, setAiEditSaving] = createSignal(false); + const [aiEditError, setAiEditError] = createSignal(""); + const [aiTogglingId, setAiTogglingId] = createSignal(""); + // AI Create form + const [aiCName, setAiCName] = createSignal(""); + const [aiCDescription, setAiCDescription] = createSignal(""); + const [aiCCredits, setAiCCredits] = createSignal(""); + const [aiCPrice, setAiCPrice] = createSignal(""); + const [aiCSaving, setAiCSaving] = createSignal(false); + const [aiCError, setAiCError] = createSignal(""); + const [aiCreateView, setAiCreateView] = createSignal(false); + const load = async () => { setLoading(true); setLoadError(""); @@ -144,6 +176,114 @@ export default function PricingPage() { } }; + // AI Credit Packages functions + const loadAiPackages = async () => { + setAiPackagesLoading(true); + setAiPackagesError(""); + try { + const res = await fetch(`${API}/api/admin/ai-credits/packages`, { + headers: authHeaders(), + credentials: "include", + }); + if (!res.ok) throw new Error(`Request failed (${res.status})`); + const data = await res.json(); + setAiPackages(Array.isArray(data.packages) ? data.packages : []); + } catch (err: any) { + setAiPackagesError(err.message || "Could not load AI credit packages."); + setAiPackages([]); + } finally { + setAiPackagesLoading(false); + } + }; + + const startAiEdit = (pkg: AiCreditPackage) => { + setAiEditingId(pkg.id); + setAiEditName(pkg.name); + setAiEditDescription(pkg.description || ""); + setAiEditCredits(String(pkg.credits)); + setAiEditPrice(String(pkg.price_inr / 100)); // Convert paise to rupees for display + setAiEditError(""); + }; + + const cancelAiEdit = () => { + setAiEditingId(""); + setAiEditError(""); + }; + + const saveAiEdit = async (id: string) => { + try { + setAiEditSaving(true); + setAiEditError(""); + const priceInPaise = Math.round(Number(aiEditPrice()) * 100); + const res = await fetch(`${API}/api/admin/ai-credits/packages/${id}`, { + method: "PATCH", + headers: authHeaders(), + credentials: "include", + body: JSON.stringify({ + name: aiEditName(), + description: aiEditDescription() || undefined, + credits: Number(aiEditCredits()), + price_inr: priceInPaise, + }), + }); + if (!res.ok) throw new Error("Failed to save"); + setAiEditingId(""); + await loadAiPackages(); + } catch (err: any) { + setAiEditError(err.message || "Failed to save"); + } finally { + setAiEditSaving(false); + } + }; + + const toggleAiActive = async (pkg: AiCreditPackage) => { + try { + setAiTogglingId(pkg.id); + await fetch(`${API}/api/admin/ai-credits/packages/${pkg.id}`, { + method: "PATCH", + headers: authHeaders(), + credentials: "include", + body: JSON.stringify({ is_active: !pkg.is_active }), + }); + await loadAiPackages(); + } catch { + /* ignore */ + } finally { + setAiTogglingId(""); + } + }; + + const handleAiCreate = async (e: Event) => { + e.preventDefault(); + try { + setAiCSaving(true); + setAiCError(""); + const priceInPaise = Math.round(Number(aiCPrice()) * 100); + const res = await fetch(`${API}/api/admin/ai-credits/packages`, { + method: "POST", + headers: authHeaders(), + credentials: "include", + body: JSON.stringify({ + name: aiCName(), + description: aiCDescription() || undefined, + credits: Number(aiCCredits()), + price_inr: priceInPaise, + }), + }); + if (!res.ok) throw new Error("Failed to create package"); + setAiCName(""); + setAiCDescription(""); + setAiCCredits(""); + setAiCPrice(""); + setAiCreateView(false); + await loadAiPackages(); + } catch (err: any) { + setAiCError(err.message || "Failed to create"); + } finally { + setAiCSaving(false); + } + }; + onMount(() => void load()); const filteredRows = createMemo(() => { @@ -302,7 +442,7 @@ export default function PricingPage() { {/* Tabs */}
- {(["packages", "create"] as const).map((t) => ( + {(["packages", "create", "ai_packages"] as const).map((t) => ( ))}
@@ -774,6 +914,246 @@ export default function PricingPage() { + + {/* AI Credit Packages Tab */} + +
loadAiPackages()}> +
+

AI Credit Packages

+ +
+ + +
+ {aiPackagesError()} +
+
+ + +
+ +
+ {aiCError()} +
+
+

Create AI Credit Package

+
+
+ + setAiCName(e.currentTarget.value)} + required + placeholder="e.g. AI Starter Pack" + style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px" + /> +
+
+ + setAiCDescription(e.currentTarget.value)} + placeholder="Optional" + style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:200px" + /> +
+
+ + setAiCCredits(e.currentTarget.value)} + required + min="1" + placeholder="e.g. 100" + style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px" + /> +
+
+ + setAiCPrice(e.currentTarget.value)} + required + min="1" + placeholder="e.g. 499" + style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px" + /> +
+
+ + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + 0}> + + {(pkg) => ( + <> + + + + + + + + + + + + + + )} + + + +
NameCreditsPrice (₹)StatusActions
+ Loading... +
+ No AI credit packages found. +
+ {pkg.name} + {pkg.credits}₹{(pkg.price_inr / 100).toLocaleString("en-IN")} + + + {pkg.is_active ? "Active" : "Inactive"} + + +
+ + +
+
+ +
+ {aiEditError()} +
+
+
+
+ + setAiEditName(e.currentTarget.value)} + style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px" + /> +
+
+ + setAiEditDescription(e.currentTarget.value)} + style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px" + /> +
+
+ + setAiEditCredits(e.currentTarget.value)} + style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px" + /> +
+
+ + setAiEditPrice(e.currentTarget.value)} + style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px" + /> +
+
+ + +
+
+
+
+ +
+ {aiPackages().length} AI credit packages +
+
+
+
+
);