diff --git a/src/routes/admin/credit.tsx b/src/routes/admin/credit.tsx index de00738..6a52017 100644 --- a/src/routes/admin/credit.tsx +++ b/src/routes/admin/credit.tsx @@ -15,7 +15,36 @@ import { type ActiveTab = 'balance' | 'adjust' | 'reconcile' | 'platform'; export default function CreditPage() { - const [activeTab, setActiveTab] = createSignal('balance'); + 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(''); @@ -129,23 +158,135 @@ export default function CreditPage() { } }; - const tabs: { key: ActiveTab; label: string }[] = [ - { key: 'balance', label: 'Balance & Ledger' }, + 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: 'platform', label: 'Platform Ledger' }, + { key: 'ai_credits', label: 'AI Credits' }, ]; - const exportLedgerCsv = (entries: AdminLedgerEntry[], filename: string) => { - const headers = ['Type', 'Amount', 'Balance After', 'Reason', 'Ref ID', 'Actor', 'Date']; - const rows = entries.map((entry) => [ - entry.entry_type, - `${entry.amount ?? 0}`, - entry.balance_after != null ? String(entry.balance_after) : '', - entry.reason ?? '', - entry.reference_id ?? '', - entry.actor_user_id ?? 'system', - entry.created_at, + // 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(); + if (q) { + data = data.filter((entry) => + String(entry.referenceId || '').toLowerCase().includes(q) + || String(entry.transactionType || '').toLowerCase().includes(q) + ); + } + if (ledgerTypeFilter() !== 'all') { + data = data.filter((entry) => entry.transactionType === ledgerTypeFilter()); + } + const sorted = [...data]; + sorted.sort((a, b) => { + const aCreated = new Date(a.createdAt || 0).getTime(); + const bCreated = new Date(b.createdAt || 0).getTime(); + const aAmt = Number(a.amount ?? 0); + const bAmt = Number(b.amount ?? 0); + if (ledgerSortBy() === 'oldest') return aCreated - bCreated; + if (ledgerSortBy() === 'amount_desc') return bAmt - aAmt; + if (ledgerSortBy() === 'amount_asc') return aAmt - bAmt; + return bCreated - aCreated; + }); + return sorted; + }); + + const exportLedgerCsv = () => { + const headers = ['Type', 'Amount', 'Ref ID', 'Expires At', 'Date']; + const rows = filteredLedger().map((entry) => [ + entry.transactionType, + `${entry.transactionType === 'ADD' ? '+' : '-'}${entry.amount ?? 0}`, + entry.referenceId || '—', + entry.expiresAt ? new Date(entry.expiresAt).toLocaleDateString() : '—', + entry.createdAt ? new Date(entry.createdAt).toLocaleString() : '—', ]); const csv = [headers, ...rows] .map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) @@ -479,9 +620,309 @@ 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 56a619f..b84e26a 100644 --- a/src/routes/admin/pricing.tsx +++ b/src/routes/admin/pricing.tsx @@ -10,6 +10,17 @@ import { deleteAdminPackage, } from '~/lib/api'; +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)' }, @@ -62,13 +73,10 @@ const SORT_LABELS: Record = { }; export default function PricingPage() { - const [packages, { refetch }] = createResource(async () => { - const res = await listAdminPackages(); - const raw = res.data; - if (Array.isArray(raw)) return raw; - return (raw as any)?.packages ?? []; - }); - const [view, setView] = createSignal<'packages' | 'create'>('packages'); + const [rows, setRows] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [loadError, setLoadError] = createSignal(""); + const [view, setView] = createSignal<"packages" | "create" | "ai_packages">("packages"); const [search, setSearch] = createSignal(''); const [typeFilter, setTypeFilter] = createSignal('all'); @@ -100,6 +108,156 @@ 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(""); + try { + const res = await fetch(`${API}/api/packages`, { + headers: authHeaders(), + credentials: "include", + }); + if (!res.ok) throw new Error(`Request failed (${res.status})`); + const data = await res.json(); + setRows(Array.isArray(data) ? data : (data.data ?? [])); + } catch (err: any) { + setLoadError(err.message || "Could not load packages."); + setRows([]); + } finally { + setLoading(false); + } + }; + + // 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(() => { let r = packages() ?? []; const q = search().toLowerCase(); @@ -263,28 +421,19 @@ export default function PricingPage() {
- - + {(["packages", "create", "ai_packages"] as const).map((t) => ( + + ))}
@@ -772,6 +921,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 +
+
+
+
+
);