feat: add AI management page to admin panel
New AI Management page (/admin/ai-management): - Overview tab: AI users, daily/monthly generations, active plans - User Usage tab: searchable table with plan, usage, addon balance - Plans tab: grid of available AI plans Sidebar: - Added AI Management nav item with Sparkles icon Dashboard: - Added kpi_ai_users widget - Added kpi_ai_generations_today widget Files: - src/routes/admin/ai-management.tsx (new)
This commit is contained in:
parent
8ad3f091aa
commit
f511a3c31a
3 changed files with 427 additions and 0 deletions
|
|
@ -37,6 +37,7 @@ import {
|
|||
Megaphone,
|
||||
Bell,
|
||||
Video,
|
||||
Sparkles,
|
||||
} from "lucide-solid";
|
||||
|
||||
type NavItem = {
|
||||
|
|
@ -246,6 +247,12 @@ const GROUPS: NavItem[][] = [
|
|||
icon: CreditCard,
|
||||
moduleKeys: ["CREDIT_MANAGEMENT", "CREDITS"],
|
||||
},
|
||||
{
|
||||
href: "/admin/ai-management",
|
||||
label: "AI Management",
|
||||
icon: Sparkles,
|
||||
moduleKeys: ["AI_MANAGEMENT", "AI"],
|
||||
},
|
||||
{
|
||||
href: "/admin/coupon",
|
||||
label: "Coupon Management",
|
||||
|
|
|
|||
|
|
@ -95,6 +95,62 @@ const adapterMap: Record<string, () => Promise<WidgetDataResult<AdminWidgetPaylo
|
|||
kpi_credits_purchased: async () => ({ state: 'success', payload: { kind: 'kpi', data: kpiData.creditsPurchased } }),
|
||||
chart_leads_trend: async () => ({ state: 'success', payload: { kind: 'line_chart', data: lineChartData } }),
|
||||
chart_revenue_overview: async () => ({ state: 'success', payload: { kind: 'bar_chart', data: barChartData } }),
|
||||
kpi_ai_users: async () => {
|
||||
try {
|
||||
const token = typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
const res = await fetch('/api/admin/ai/stats', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const data = await res.json();
|
||||
return {
|
||||
state: 'success',
|
||||
payload: {
|
||||
kind: 'kpi',
|
||||
data: {
|
||||
value: String(data.total_users_with_ai ?? 0),
|
||||
delta: '',
|
||||
note: `${data.total_generations_today ?? 0} today`,
|
||||
tone: 'up' as const,
|
||||
icon: 'users' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { state: 'error', message: 'Failed to load AI stats' };
|
||||
}
|
||||
},
|
||||
kpi_ai_generations_today: async () => {
|
||||
try {
|
||||
const token = typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
const res = await fetch('/api/admin/ai/stats', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const data = await res.json();
|
||||
return {
|
||||
state: 'success',
|
||||
payload: {
|
||||
kind: 'kpi',
|
||||
data: {
|
||||
value: String(data.total_generations_today ?? 0),
|
||||
delta: '',
|
||||
note: `${data.total_generations_month ?? 0} this month`,
|
||||
tone: 'up' as const,
|
||||
icon: 'trend' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { state: 'error', message: 'Failed to load AI usage' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const ADMIN_DASHBOARD_WIDGETS: DashboardWidgetDefinition[] = [
|
||||
|
|
@ -180,6 +236,28 @@ export const ADMIN_DASHBOARD_WIDGETS: DashboardWidgetDefinition[] = [
|
|||
readiness: 'ready',
|
||||
description: 'Weekly revenue overview',
|
||||
},
|
||||
{
|
||||
widgetKey: 'kpi_ai_users',
|
||||
title: 'AI Users',
|
||||
moduleKey: 'AI_MANAGEMENT',
|
||||
defaultSize: 'S',
|
||||
defaultVisible: true,
|
||||
order: 9,
|
||||
dataAdapterKey: 'kpi_ai_users',
|
||||
readiness: 'ready',
|
||||
description: 'Total users with active AI plans',
|
||||
},
|
||||
{
|
||||
widgetKey: 'kpi_ai_generations_today',
|
||||
title: 'AI Generations Today',
|
||||
moduleKey: 'AI_MANAGEMENT',
|
||||
defaultSize: 'S',
|
||||
defaultVisible: true,
|
||||
order: 10,
|
||||
dataAdapterKey: 'kpi_ai_generations_today',
|
||||
readiness: 'ready',
|
||||
description: 'AI generations used today',
|
||||
},
|
||||
];
|
||||
|
||||
export function loadWidgetData(definition: DashboardWidgetDefinition): Promise<WidgetDataResult<AdminWidgetPayload>> {
|
||||
|
|
|
|||
342
src/routes/admin/ai-management.tsx
Normal file
342
src/routes/admin/ai-management.tsx
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import { createMemo, createSignal, Show, For, onMount } from "solid-js";
|
||||
import { Sparkles, Users, TrendingUp, Calendar, Zap, BarChart3 } from "lucide-solid";
|
||||
|
||||
const API = "";
|
||||
|
||||
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",
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type AiStats = {
|
||||
total_users_with_ai: number;
|
||||
total_generations_today: number;
|
||||
total_generations_month: number;
|
||||
by_plan: PlanUsage[];
|
||||
};
|
||||
|
||||
type PlanUsage = {
|
||||
plan_name: string;
|
||||
user_count: number;
|
||||
monthly_limit: number;
|
||||
total_monthly_usage: number;
|
||||
};
|
||||
|
||||
type UserAiUsage = {
|
||||
user_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
plan_name: string;
|
||||
monthly_limit: number;
|
||||
monthly_used: number;
|
||||
addon_balance: number;
|
||||
renewal_date: string | null;
|
||||
};
|
||||
|
||||
type AiPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
monthly_action_limit: number;
|
||||
daily_action_limit: number | null;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export default function AiManagementPage() {
|
||||
const [activeTab, setActiveTab] = createSignal<"overview" | "users" | "plans">("overview");
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [stats, setStats] = createSignal<AiStats | null>(null);
|
||||
const [users, setUsers] = createSignal<UserAiUsage[]>([]);
|
||||
const [plans, setPlans] = createSignal<AiPlan[]>([]);
|
||||
const [error, setError] = createSignal("");
|
||||
|
||||
const [userPage, setUserPage] = createSignal(1);
|
||||
const [userSearch, setUserSearch] = createSignal("");
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/ai/stats`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to load AI stats");
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async (page: number) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/ai/users?page=${page}&limit=20`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to load users");
|
||||
const data = await res.json();
|
||||
setUsers(data);
|
||||
setUserPage(page);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/ai/plans`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to load plans");
|
||||
const data = await res.json();
|
||||
setPlans(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
loadStats();
|
||||
if (activeTab() === "users") loadUsers(1);
|
||||
if (activeTab() === "plans") loadPlans();
|
||||
});
|
||||
|
||||
const handleTabChange = (tab: "overview" | "users" | "plans") => {
|
||||
setActiveTab(tab);
|
||||
if (tab === "users") loadUsers(1);
|
||||
if (tab === "plans") loadPlans();
|
||||
};
|
||||
|
||||
const filteredUsers = createMemo(() => {
|
||||
const search = userSearch().toLowerCase();
|
||||
if (!search) return users();
|
||||
return users().filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(search) ||
|
||||
u.user_id.toLowerCase().includes(search) ||
|
||||
u.role.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div style="padding:24px">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:24px">
|
||||
<Sparkles size={28} style="color:#8B5CF6" />
|
||||
<h1 style="margin:0;font-size:24px;font-weight:700">AI Management</h1>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-bottom:24px;border-bottom:1px solid #E5E7EB">
|
||||
<button
|
||||
onClick={() => handleTabChange("overview")}
|
||||
style={`padding:8px 16px;border:none;background:${activeTab() === "overview" ? "#8B5CF6" : "transparent"};color:${activeTab() === "overview" ? "white" : "#6B7280"};border-radius:8px 8px 0 0;cursor:pointer;font-weight:600`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange("users")}
|
||||
style={`padding:8px 16px;border:none;background:${activeTab() === "users" ? "#8B5CF6" : "transparent"};color:${activeTab() === "users" ? "white" : "#6B7280"};border-radius:8px 8px 0 0;cursor:pointer;font-weight:600`}
|
||||
>
|
||||
User Usage
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange("plans")}
|
||||
style={`padding:8px 16px;border:none;background:${activeTab() === "plans" ? "#8B5CF6" : "transparent"};color:${activeTab() === "plans" ? "white" : "#6B7280"};border-radius:8px 8px 0 0;cursor:pointer;font-weight:600`}
|
||||
>
|
||||
Plans
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div style="padding:12px;background:#FEE2E2;color:#991B1B;border-radius:8px;margin-bottom:16px">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "overview"}>
|
||||
<Show when={loading()}>
|
||||
<div style="text-align:center;padding:40px;color:#6B7280">Loading...</div>
|
||||
</Show>
|
||||
<Show when={!loading() && stats()}>
|
||||
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:16px;margin-bottom:24px">
|
||||
<div style="background:white;padding:20px;border-radius:12px;border:1px solid #E5E7EB">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<Users size={20} style="color:#8B5CF6" />
|
||||
<span style="font-size:14px;color:#6B7280">AI Users</span>
|
||||
</div>
|
||||
<p style="margin:0;font-size:28px;font-weight:700">{stats()?.total_users_with_ai ?? 0}</p>
|
||||
</div>
|
||||
<div style="background:white;padding:20px;border-radius:12px;border:1px solid #E5E7EB">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<TrendingUp size={20} style="color:#10B981" />
|
||||
<span style="font-size:14px;color:#6B7280">Today</span>
|
||||
</div>
|
||||
<p style="margin:0;font-size:28px;font-weight:700">{stats()?.total_generations_today ?? 0}</p>
|
||||
</div>
|
||||
<div style="background:white;padding:20px;border-radius:12px;border:1px solid #E5E7EB">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<Calendar size={20} style="color:#F59E0B" />
|
||||
<span style="font-size:14px;color:#6B7280">This Month</span>
|
||||
</div>
|
||||
<p style="margin:0;font-size:28px;font-weight:700">{stats()?.total_generations_month ?? 0}</p>
|
||||
</div>
|
||||
<div style="background:white;padding:20px;border-radius:12px;border:1px solid #E5E7EB">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<Sparkles size={20} style="color:#EC4899" />
|
||||
<span style="font-size:14px;color:#6B7280">Active Plans</span>
|
||||
</div>
|
||||
<p style="margin:0;font-size:28px;font-weight:700">{stats()?.by_plan?.length ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:white;padding:20px;border-radius:12px;border:1px solid #E5E7EB">
|
||||
<h3 style="margin:0 0 16px 0;font-size:16px;font-weight:600">Usage by Plan</h3>
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid #E5E7EB">
|
||||
<th style="text-align:left;padding:8px 0;color:#6B7280;font-size:12px">Plan</th>
|
||||
<th style="text-align:right;padding:8px 0;color:#6B7280;font-size:12px">Users</th>
|
||||
<th style="text-align:right;padding:8px 0;color:#6B7280;font-size:12px">Monthly Limit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={stats()?.by_plan ?? []}>
|
||||
{(plan) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6">
|
||||
<td style="padding:12px 0;font-weight:500">{plan.plan_name}</td>
|
||||
<td style="text-align:right;padding:12px 0">{plan.user_count}</td>
|
||||
<td style="text-align:right;padding:12px 0">{plan.monthly_limit}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "users"}>
|
||||
<div style="margin-bottom:16px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email, user ID, or role..."
|
||||
value={userSearch()}
|
||||
onInput={(e) => setUserSearch(e.currentTarget.value)}
|
||||
style="width:100%;padding:10px 12px;border:1px solid #E5E7EB;border-radius:8px;font-size:14px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="background:white;border-radius:12px;border:1px solid #E5E7EB;overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="background:#F9FAFB">
|
||||
<th style="text-align:left;padding:12px 16px;color:#6B7280;font-size:12px;font-weight:600">User</th>
|
||||
<th style="text-align:left;padding:12px 16px;color:#6B7280;font-size:12px;font-weight:600">Role</th>
|
||||
<th style="text-align:left;padding:12px 16px;color:#6B7280;font-size:12px;font-weight:600">Plan</th>
|
||||
<th style="text-align:right;padding:12px 16px;color:#6B7280;font-size:12px;font-weight:600">Monthly Used</th>
|
||||
<th style="text-align:right;padding:12px 16px;color:#6B7280;font-size:12px;font-weight:600">Limit</th>
|
||||
<th style="text-align:right;padding:12px 16px;color:#6B7280;font-size:12px;font-weight:600">Add-on</th>
|
||||
<th style="text-align:left;padding:12px 16px;color:#6B7280;font-size:12px;font-weight:600">Renews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredUsers()}>
|
||||
{(user) => (
|
||||
<tr style="border-bottom:1px solid #F3F4F6">
|
||||
<td style="padding:12px 16px">
|
||||
<div style="font-weight:500">{user.email}</div>
|
||||
<div style="font-size:12px;color:#9CA3AF">{user.user_id.slice(0, 8)}...</div>
|
||||
</td>
|
||||
<td style="padding:12px 16px">
|
||||
<span style={`display:inline-block;padding:2px 8px;background:#EEF2FF;color:#4F46E5;border-radius:4px;font-size:12px;font-weight:500`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:12px 16px;font-weight:500">{user.plan_name}</td>
|
||||
<td style="text-align:right;padding:12px 16px">{user.monthly_used}</td>
|
||||
<td style="text-align:right;padding:12px 16px">{user.monthly_limit}</td>
|
||||
<td style="text-align:right;padding:12px 16px">
|
||||
<span style={`display:inline-block;padding:2px 8px;background:${user.addon_balance > 0 ? "#FEF3C7" : "#F3F4F6"};color:${user.addon_balance > 0 ? "#D97706" : "#6B7280"};border-radius:4px;font-size:12px;font-weight:500`}>
|
||||
{user.addon_balance}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:12px 16px;font-size:13px;color:#6B7280">
|
||||
{user.renewal_date || "N/A"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;margin-top:16px">
|
||||
<button
|
||||
onClick={() => loadUsers(userPage() - 1)}
|
||||
disabled={userPage() === 1}
|
||||
style={`padding:8px 16px;border:1px solid #E5E7EB;background:white;border-radius:8px;cursor:${userPage() === 1 ? "not-allowed" : "pointer"};opacity:${userPage() === 1 ? 0.5 : 1}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style="color:#6B7280;font-size:14px">Page {userPage()}</span>
|
||||
<button
|
||||
onClick={() => loadUsers(userPage() + 1)}
|
||||
disabled={users().length < 20}
|
||||
style={`padding:8px 16px;border:1px solid #E5E7EB;background:white;border-radius:8px;cursor:${users().length < 20 ? "not-allowed" : "pointer"};opacity:${users().length < 20 ? 0.5 : 1}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "plans"}>
|
||||
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:16px">
|
||||
<For each={plans()}>
|
||||
{(plan) => (
|
||||
<div style="background:white;padding:20px;border-radius:12px;border:1px solid #E5E7EB">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<h3 style="margin:0;font-size:18px;font-weight:700">{plan.name}</h3>
|
||||
<span style={`padding:4px 8px;background:${plan.is_active ? "#D1FAE5" : "#FEE2E2"};color:${plan.is_active ? "#065F46" : "#991B1B"};border-radius:4px;font-size:12px;font-weight:500`}>
|
||||
{plan.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<Zap size={16} style="color:#8B5CF6" />
|
||||
<span style="font-size:14px;color:#6B7280">Monthly Limit</span>
|
||||
</div>
|
||||
<p style="margin:0 0 16px 24px;font-size:24px;font-weight:700">{plan.monthly_action_limit}</p>
|
||||
<Show when={plan.daily_action_limit}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<BarChart3 size={16} style="color:#6B7280" />
|
||||
<span style="font-size:14px;color:#6B7280">Daily Limit: {plan.daily_action_limit}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue