nxtgauge-admin-solid/src/routes/admin/report.tsx

195 lines
9.2 KiB
TypeScript

import { createMemo, createSignal, For, Show } from 'solid-js';
const API = '';
type UserReport = {
total_users?: number;
new_users?: number;
active_users?: number;
};
type RevenueReport = {
total_revenue?: number;
total_orders?: number;
total_tracecoins_sold?: number;
};
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
}
function authHeaders(): Record<string, string> {
const token = getToken();
return {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export default function ReportPage() {
const [from, setFrom] = createSignal('');
const [to, setTo] = createSignal('');
const [tab, setTab] = createSignal<'overview' | 'users' | 'revenue'>('overview');
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal('');
const [userReport, setUserReport] = createSignal<UserReport | null>(null);
const [revenueReport, setRevenueReport] = createSignal<RevenueReport | null>(null);
const rows = createMemo(() => {
if (tab() === 'users') {
return [
{ metric: 'Total Users', value: userReport()?.total_users ?? '—' },
{ metric: 'New Users', value: userReport()?.new_users ?? '—' },
{ metric: 'Active Users', value: userReport()?.active_users ?? '—' },
];
}
if (tab() === 'revenue') {
return [
{ metric: 'Total Revenue', value: revenueReport()?.total_revenue != null ? `${(revenueReport()!.total_revenue! / 100).toFixed(2)}` : '' },
{ metric: 'Total Orders', value: revenueReport()?.total_orders ?? '—' },
{ metric: 'TraceCoins Sold', value: revenueReport()?.total_tracecoins_sold ?? '—' },
];
}
return [
{ metric: 'Total Users', value: userReport()?.total_users ?? '—' },
{ metric: 'New Users', value: userReport()?.new_users ?? '—' },
{ metric: 'Active Users', value: userReport()?.active_users ?? '—' },
{ metric: 'Total Revenue', value: revenueReport()?.total_revenue != null ? `${(revenueReport()!.total_revenue! / 100).toFixed(2)}` : '' },
{ metric: 'Total Orders', value: revenueReport()?.total_orders ?? '—' },
{ metric: 'TraceCoins Sold', value: revenueReport()?.total_tracecoins_sold ?? '—' },
];
});
const exportCsv = () => {
const csv = ['Metric,Value', ...rows().map((r) => `"${r.metric.replace(/"/g, '""')}","${String(r.value).replace(/"/g, '""')}"`)].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'report-metrics.csv';
link.click();
URL.revokeObjectURL(url);
};
const handleLoad = async (e: Event) => {
e.preventDefault();
if (!from() || !to()) return;
try {
setLoading(true);
setError('');
const [usersRes, revenueRes] = await Promise.all([
fetch(`${API}/api/admin/reports/users?from=${from()}&to=${to()}`, { headers: authHeaders(), credentials: 'include' }),
fetch(`${API}/api/admin/reports/revenue?from=${from()}&to=${to()}`, { headers: authHeaders(), credentials: 'include' }),
]);
if (!usersRes.ok || !revenueRes.ok) throw new Error('Failed to load report data');
const [usersData, revenueData] = await Promise.all([usersRes.json(), revenueRes.json()]);
setUserReport(usersData);
setRevenueReport(revenueData);
} catch (err: any) {
setError(err.message || 'Failed to load reports');
} finally {
setLoading(false);
}
};
const statCards = createMemo(() => ([
{ label: 'Total Users', value: userReport()?.total_users ?? '—' },
{ label: 'New Users', value: userReport()?.new_users ?? '—' },
{ label: 'Revenue', value: revenueReport()?.total_revenue != null ? `${(revenueReport()!.total_revenue! / 100).toFixed(2)}` : '' },
{ label: 'Orders', value: revenueReport()?.total_orders ?? '—' },
]));
return (
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom:1.5rem">
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Report Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">View platform analytics and export reports.</p>
</div>
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button
type="button"
class={tab() === 'overview' ? '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={() => setTab('overview')}
>
Overview
</button>
<button
type="button"
class={tab() === 'users' ? '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={() => setTab('users')}
>
Users
</button>
<button
type="button"
class={tab() === 'revenue' ? '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={() => setTab('revenue')}
>
Revenue
</button>
</div>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
<form onSubmit={handleLoad} style="display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">From</label>
<input type="date" value={from()} onInput={(e) => setFrom(e.currentTarget.value)} required style="padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px" />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">To</label>
<input type="date" value={to()} onInput={(e) => setTo(e.currentTarget.value)} required style="padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px" />
</div>
<button class="btn-primary" type="submit" disabled={loading()}>{loading() ? 'Loading...' : 'Load Report'}</button>
</form>
<Show when={error()}>
<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:12px">{error()}</div>
</Show>
</section>
<Show when={userReport() || revenueReport()}>
<div style="display:flex;gap:12px;flex-wrap:wrap">
<For each={statCards()}>
{(card) => (
<div style="background:#f8f9fa;border:1px solid #e5e7eb;border-radius:8px;padding:12px 20px;text-align:center;min-width:140px">
<div style="font-size:22px;font-weight:700;color:#111827">{card.value}</div>
<div style="font-size:12px;color:#6b7280">{card.label}</div>
</div>
)}
</For>
</div>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">Export</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Metric</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">Value</th>
</tr>
</thead>
<tbody>
<For each={rows()}>
{(row) => (
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.metric}</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.value}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
<p style="font-size:13px;color:#6B7280">Showing <strong style="font-weight:600;color:#111827">{rows().length}</strong> metrics</p>
</div>
</div>
</Show>
</div>
);
}