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:
Tracewebstudio Dev 2026-06-14 20:28:07 +02:00
parent 8ad3f091aa
commit f511a3c31a
3 changed files with 427 additions and 0 deletions

View file

@ -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",

View file

@ -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>> {

View 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>
);
}