nxtgauge-admin-solid/src/routes/admin/credit.tsx
Ashwin Kumar 0ec64be905 feat: unify API paths and upgrade table UIs
- Replace all /api/gateway/* with /api/* to match gateway routing
- Fix AdminShell.tsx: update UGC route to singular and fix logout URL
- Remove Applications and Responses from sidebar (unused)
- Move conflicting route files into folders (company, approval, verification, users, jobs, kb, leads, photographer) as index.tsx to avoid catch-all interference
- Upgrade ProfessionAdminListPage to match Department Management UI:
  • Dark headers with white text
  • Icons on Sort/Filters/Export buttons
  • Pagination UI
  • Improved empty state with Create button
  • Hover effects and consistent spacing
- Update all pages using ProfessionAdminListPage to benefit from new UI
- Fix jobs admin endpoint to use /api/admin/companies/jobs with auth
- Add authentication headers to jobs and leads fetch calls

These changes unify the API architecture and bring a consistent, professional look to all management tables.
2026-04-07 22:12:52 +02:00

405 lines
19 KiB
TypeScript

import { createSignal, createResource, Show, For } from 'solid-js';
const API = '';
type LedgerEntry = {
id: string;
transactionType: 'ADD' | 'DEDUCT';
amount: number;
referenceId?: string;
expiresAt?: string;
createdAt?: string;
};
type ReconcileRow = {
userId: string;
expectedBalance: number;
actualBalance: number;
discrepancy: number;
};
export default function CreditPage() {
const [activeTab, setActiveTab] = createSignal<'ledger' | 'adjust' | 'reconcile'>('ledger');
// Balance & Ledger tab state
const [userId, setUserId] = createSignal('');
const [searchedUserId, setSearchedUserId] = createSignal('');
const [searchTrigger, setSearchTrigger] = createSignal(0);
const [balance, setBalance] = createSignal<number | null>(null);
const [ledger, setLedger] = createSignal<LedgerEntry[]>([]);
const [searchLoading, setSearchLoading] = createSignal(false);
const [searchError, setSearchError] = createSignal('');
// Reward/Deduct tab state
const [adjUserId, setAdjUserId] = createSignal('');
const [adjAmount, setAdjAmount] = createSignal(1);
const [adjType, setAdjType] = createSignal<'ADD' | 'DEDUCT'>('ADD');
const [adjReason, setAdjReason] = createSignal('');
const [adjRefId, setAdjRefId] = createSignal('');
const [adjLoading, setAdjLoading] = createSignal(false);
const [adjSuccess, setAdjSuccess] = createSignal('');
const [adjError, setAdjError] = createSignal('');
// Reconcile tab state
const [reconFrom, setReconFrom] = createSignal('');
const [reconTo, setReconTo] = createSignal('');
const [reconLoading, setReconLoading] = createSignal(false);
const [reconResults, setReconResults] = createSignal<ReconcileRow[] | null>(null);
const [reconError, setReconError] = createSignal('');
const handleSearch = async () => {
const uid = userId().trim();
if (!uid) return;
setSearchLoading(true);
setSearchError('');
setBalance(null);
setLedger([]);
setSearchedUserId(uid);
try {
const [balRes, ledRes] = await Promise.all([
fetch(`${API}/api/admin/credits/balance?userId=${encodeURIComponent(uid)}`),
fetch(`${API}/api/admin/credits/ledger?userId=${encodeURIComponent(uid)}`),
]);
if (!balRes.ok || !ledRes.ok) throw new Error('Failed to fetch');
const balData = await balRes.json();
const ledData = await ledRes.json();
setBalance(balData.balance ?? 0);
setLedger(Array.isArray(ledData.entries) ? ledData.entries : []);
} catch {
setSearchError('Failed to fetch TraceCoin data for this user ID.');
setBalance(null);
setLedger([]);
} finally {
setSearchLoading(false);
}
};
const handleAdjust = async (e: Event) => {
e.preventDefault();
setAdjLoading(true);
setAdjSuccess('');
setAdjError('');
try {
const res = await fetch(`${API}/api/admin/credits/adjust`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: adjUserId(),
amount: adjAmount(),
type: adjType(),
reason: adjReason(),
reference_id: adjRefId() || undefined,
}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error((d as any).message || 'Failed to adjust credits');
}
setAdjSuccess('Credit adjusted successfully!');
setAdjUserId('');
setAdjAmount(1);
setAdjType('ADD');
setAdjReason('');
setAdjRefId('');
} catch (err: any) {
setAdjError(err.message || 'Failed to adjust credits');
} finally {
setAdjLoading(false);
}
};
const handleReconcile = async (e: Event) => {
e.preventDefault();
setReconLoading(true);
setReconError('');
setReconResults(null);
try {
const res = await fetch(
`${API}/api/admin/credits/reconcile?from=${encodeURIComponent(reconFrom())}&to=${encodeURIComponent(reconTo())}`
);
if (!res.ok) throw new Error('Failed to reconcile');
const data = await res.json();
setReconResults(Array.isArray(data.results) ? data.results : []);
} catch (err: any) {
setReconError(err.message || 'Failed to reconcile');
} finally {
setReconLoading(false);
}
};
const tabs: { key: 'ledger' | 'adjust' | 'reconcile'; label: string }[] = [
{ key: 'ledger', label: 'Balance & Ledger' },
{ key: 'adjust', label: 'Reward / Deduct' },
{ key: 'reconcile', label: 'Reconcile' },
];
return (
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Credit Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Audit TraceCoin balances and adjust credits</p>
</div>
{/* Tabs */}
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<For each={tabs}>
{(tab) => (
<button
type="button"
class={activeTab() === tab.key ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
)}
</For>
</div>
<div class="flex-1 p-6">
{/* Balance & Ledger Tab */}
<Show when={activeTab() === 'ledger'}>
<div style="display:flex;flex-direction:column;gap:24px">
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">Search Account Balance</h2>
<div style="display:flex;gap:10px">
<input
type="text"
placeholder="Enter User ID..."
value={userId()}
onInput={(e) => setUserId(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm"
style="flex:1"
/>
<button
class="btn-primary"
onClick={handleSearch}
disabled={searchLoading()}
>
{searchLoading() ? 'Searching...' : 'Search'}
</button>
</div>
<Show when={searchError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-top:10px">{searchError()}</div>
</Show>
</section>
<Show when={balance() !== null}>
<div style="display:grid;grid-template-columns:1fr 2fr;gap:20px">
<div style="background:#2563eb;border-radius:12px;padding:24px;color:#fff">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#bfdbfe;margin:0 0 8px">Current Balance</p>
<p style="font-size:36px;font-weight:900;margin:0">{balance()} TraceCoins</p>
<p style="font-size:12px;color:#bfdbfe;margin:8px 0 0">User: {searchedUserId()}</p>
</div>
<div class="table-card" style="overflow:hidden">
<h3 style="margin:0 0 16px;font-size:15px;font-weight:700;color:#0f172a">TraceCoin Ledger</h3>
<Show when={ledger().length === 0}>
<p style="text-align:center;padding:32px;color:#94a3b8;font-style:italic">No transactions found for this account.</p>
</Show>
<Show when={ledger().length > 0}>
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>Type</th>
<th>Amount</th>
<th>Ref ID</th>
<th>Expires At</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<For each={ledger()}>
{(entry) => (
<tr class="hover:bg-slate-50">
<td>
<span
style={`display:inline-block;padding:2px 8px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase;background:${entry.transactionType === 'ADD' ? '#dcfce7' : '#fee2e2'};color:${entry.transactionType === 'ADD' ? '#15803d' : '#b91c1c'}`}
>
{entry.transactionType}
</span>
</td>
<td class={entry.transactionType === 'ADD' ? 'font-semibold text-slate-900' : 'font-semibold text-slate-900'} style={entry.transactionType === 'ADD' ? 'color:#16a34a' : 'color:#dc2626'}>
{entry.transactionType === 'ADD' ? '+' : '-'}{entry.amount}
</td>
<td class="text-slate-500" style="font-size:12px;font-family:monospace">
{entry.referenceId ? entry.referenceId.substring(0, 18) : '—'}
</td>
<td class="text-slate-500" style="font-size:12px">
{entry.expiresAt ? new Date(entry.expiresAt).toLocaleDateString() : '—'}
</td>
<td class="text-slate-500" style="font-size:12px">
{entry.createdAt ? new Date(entry.createdAt).toLocaleString() : '—'}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</div>
</Show>
</div>
</Show>
{/* Reward / Deduct Tab */}
<Show when={activeTab() === 'adjust'}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="max-width:460px">
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">Reward or Adjust TraceCoins</h2>
<p style="margin:0 0 20px;font-size:13px;color:#64748b">
Use this to reward TraceCoins for a valid support case, process a refund, or correct a balance manually.
</p>
<Show when={adjSuccess()}>
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:14px;color:#15803d;font-weight:600">
{adjSuccess()}
</div>
</Show>
<Show when={adjError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:14px">{adjError()}</div>
</Show>
<form onSubmit={handleAdjust} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">User ID</label>
<input
type="text"
required
value={adjUserId()}
onInput={(e) => setAdjUserId(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Amount</label>
<input
type="number"
min="1"
required
value={adjAmount()}
onInput={(e) => setAdjAmount(Number(e.currentTarget.value))}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Type</label>
<select
value={adjType()}
onChange={(e) => setAdjType(e.currentTarget.value as 'ADD' | 'DEDUCT')}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
>
<option value="ADD">ADD</option>
<option value="DEDUCT">DEDUCT</option>
</select>
</div>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Reason</label>
<input
type="text"
required
value={adjReason()}
onInput={(e) => setAdjReason(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Reference ID <span style="font-weight:400;color:#94a3b8">(optional)</span></label>
<input
type="text"
value={adjRefId()}
onInput={(e) => setAdjRefId(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div>
<button class="btn-primary" type="submit" disabled={adjLoading()}>
{adjLoading() ? 'Adjusting...' : 'Apply Adjustment'}
</button>
</div>
</form>
</section>
</Show>
{/* Reconcile Tab */}
<Show when={activeTab() === 'reconcile'}>
<div style="display:flex;flex-direction:column;gap:20px">
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="max-width:460px">
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">Ledger Reconciliation</h2>
<Show when={reconError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:14px">{reconError()}</div>
</Show>
<form onSubmit={handleReconcile} style="display:flex;flex-direction:column;gap:14px">
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Start Date</label>
<input
type="date"
required
value={reconFrom()}
onInput={(e) => setReconFrom(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">End Date</label>
<input
type="date"
required
value={reconTo()}
onInput={(e) => setReconTo(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
</div>
<div>
<button class="btn-primary" type="submit" disabled={reconLoading()}>
{reconLoading() ? 'Running...' : 'Run Reconciliation'}
</button>
</div>
</form>
</section>
<Show when={reconResults() !== null}>
<Show when={(reconResults()?.length ?? 0) === 0}>
<p style="color:#16a34a;font-weight:600;font-size:14px">No discrepancies found.</p>
</Show>
<Show when={(reconResults()?.length ?? 0) > 0}>
<div class="table-card">
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>User ID</th>
<th>Expected Balance</th>
<th>Actual Balance</th>
<th>Discrepancy</th>
</tr>
</thead>
<tbody>
<For each={reconResults()!}>
{(row) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900" style="font-family:monospace;font-size:13px">{row.userId}</td>
<td class="text-slate-500">{row.expectedBalance}</td>
<td class="text-slate-500">{row.actualBalance}</td>
<td style={row.discrepancy !== 0 ? 'color:#dc2626;font-weight:700' : 'color:#16a34a'}>
{row.discrepancy}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
</Show>
</div>
</Show>
</div>
</div>
);
}