Add admin dashboard frontend page
- Overview tab: stat cards (users, tasks, goals, projects, AI msgs, errors) + daily activity bar chart - Users tab: user list with task/goal counts and delete capability - Activity tab: recent activity feed with type badges - Tailwind-based responsive design matching existing app style Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
282
apps/tasks/app/admin/page.tsx
Normal file
282
apps/tasks/app/admin/page.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
language: string;
|
||||
auth_provider: string;
|
||||
created_at: string;
|
||||
task_count: number;
|
||||
goal_count: number;
|
||||
}
|
||||
|
||||
interface Analytics {
|
||||
overview: {
|
||||
total_users: number;
|
||||
new_users_7d: number;
|
||||
total_tasks: number;
|
||||
completed_tasks: number;
|
||||
tasks_today: number;
|
||||
total_goals: number;
|
||||
accepted_invites: number;
|
||||
ai_messages: number;
|
||||
errors_24h: number;
|
||||
total_projects: number;
|
||||
};
|
||||
daily: { day: string; tasks_created: number }[];
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
type: string;
|
||||
detail: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const API_BASE = typeof window !== "undefined" ? "" : "http://localhost:3000";
|
||||
|
||||
async function adminFetch<T>(path: string, opts: { method?: string; token?: string } = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (opts.token) headers["Authorization"] = `Bearer ${opts.token}`;
|
||||
const res = await fetch(`${API_BASE}${path}`, { method: opts.method || "GET", headers, cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [analytics, setAnalytics] = useState<Analytics | null>(null);
|
||||
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"overview" | "users" | "activity">("overview");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [usersRes, analyticsRes, activityRes] = await Promise.all([
|
||||
adminFetch<{ data: AdminUser[] }>("/api/v1/admin/users", { token }),
|
||||
adminFetch<{ data: Analytics }>("/api/v1/admin/analytics", { token }),
|
||||
adminFetch<{ data: ActivityItem[] }>("/api/v1/admin/activity", { token }),
|
||||
]);
|
||||
setUsers(usersRes.data || []);
|
||||
setAnalytics(analyticsRes.data || null);
|
||||
setActivity(activityRes.data || []);
|
||||
} catch (err) {
|
||||
console.error("Admin load error:", err);
|
||||
setError("Failed to load admin data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { router.replace("/login"); return; }
|
||||
loadData();
|
||||
}, [token, router, loadData]);
|
||||
|
||||
async function handleDeleteUser(userId: string) {
|
||||
if (!token || !confirm("Delete this user and all their data?")) return;
|
||||
try {
|
||||
await adminFetch("/api/v1/admin/users/" + userId, { method: "DELETE", token });
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error("Delete error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearErrors() {
|
||||
if (!token) return;
|
||||
try {
|
||||
await adminFetch("/api/v1/admin/errors/clear", { method: "DELETE", token });
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error("Clear errors:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const o = analytics?.overview;
|
||||
const maxTasks = analytics?.daily ? Math.max(...analytics.daily.map(d => Number(d.tasks_created)), 1) : 1;
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function typeLabel(t: string) {
|
||||
const map: Record<string, string> = {
|
||||
task_created: "New task",
|
||||
user_registered: "New user",
|
||||
goal_created: "New goal",
|
||||
invite_sent: "Invitation",
|
||||
};
|
||||
return map[t] || t;
|
||||
}
|
||||
|
||||
function typeColor(t: string) {
|
||||
const map: Record<string, string> = {
|
||||
task_created: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
user_registered: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
goal_created: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300",
|
||||
invite_sent: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
|
||||
};
|
||||
return map[t] || "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto" />
|
||||
<p className="mt-3 text-sm text-muted">Loading admin data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
<button onClick={loadData} className="mt-3 text-sm text-blue-500 underline">Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-24">
|
||||
<h1 className="text-xl font-bold mb-4">Admin Dashboard</h1>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 mb-5 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
{(["overview", "users", "activity"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
tab === t
|
||||
? "bg-white dark:bg-gray-700 shadow-sm text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{t === "overview" ? "Overview" : t === "users" ? "Users" : "Activity"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview tab */}
|
||||
{tab === "overview" && o && (
|
||||
<div className="space-y-5">
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Users", value: o.total_users, sub: `+${o.new_users_7d} this week`, color: "text-blue-600 dark:text-blue-400" },
|
||||
{ label: "Tasks", value: o.total_tasks, sub: `${o.completed_tasks} completed`, color: "text-green-600 dark:text-green-400" },
|
||||
{ label: "Today", value: o.tasks_today, sub: "tasks created", color: "text-purple-600 dark:text-purple-400" },
|
||||
{ label: "Goals", value: o.total_goals, sub: "total", color: "text-orange-600 dark:text-orange-400" },
|
||||
{ label: "Projects", value: o.total_projects, sub: "active", color: "text-indigo-600 dark:text-indigo-400" },
|
||||
{ label: "AI msgs", value: o.ai_messages, sub: "chat responses", color: "text-pink-600 dark:text-pink-400" },
|
||||
{ label: "Invites", value: o.accepted_invites, sub: "accepted", color: "text-teal-600 dark:text-teal-400" },
|
||||
{ label: "Errors", value: o.errors_24h, sub: "last 24h", color: o.errors_24h > 0 ? "text-red-600 dark:text-red-400" : "text-green-600 dark:text-green-400" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{card.label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${card.color}`}>{card.value}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{card.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Daily activity chart */}
|
||||
{analytics?.daily && analytics.daily.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Tasks Created (last 7 days)</h3>
|
||||
<div className="flex items-end gap-2 h-32">
|
||||
{analytics.daily.map((d) => (
|
||||
<div key={d.day} className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-xs text-gray-500">{Number(d.tasks_created)}</span>
|
||||
<div
|
||||
className="w-full bg-blue-500 dark:bg-blue-600 rounded-t-md transition-all min-h-[4px]"
|
||||
style={{ height: `${(Number(d.tasks_created) / maxTasks) * 100}%` }}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400 whitespace-nowrap">
|
||||
{new Date(d.day).toLocaleDateString("cs-CZ", { day: "numeric", month: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear errors button */}
|
||||
{o.errors_24h > 0 && (
|
||||
<button
|
||||
onClick={handleClearErrors}
|
||||
className="text-sm text-red-500 hover:text-red-600 underline"
|
||||
>
|
||||
Clear errors older than 7 days
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users tab */}
|
||||
{tab === "users" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500 mb-3">{users.length} registered users</p>
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
{(u.name || u.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{u.name || "No name"}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{u.email}</p>
|
||||
<div className="flex gap-3 mt-1">
|
||||
<span className="text-xs text-gray-400">{u.task_count} tasks</span>
|
||||
<span className="text-xs text-gray-400">{u.goal_count} goals</span>
|
||||
<span className="text-xs text-gray-400">{u.auth_provider || "email"}</span>
|
||||
<span className="text-xs text-gray-400">{u.language || "cs"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(u.id)}
|
||||
className="p-2 text-red-400 hover:text-red-600 transition-colors flex-shrink-0"
|
||||
title="Delete user"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity tab */}
|
||||
{tab === "activity" && (
|
||||
<div className="space-y-2">
|
||||
{activity.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-8">No recent activity</p>
|
||||
) : (
|
||||
activity.map((a, i) => (
|
||||
<div key={i} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-3 flex items-center gap-3">
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap ${typeColor(a.type)}`}>
|
||||
{typeLabel(a.type)}
|
||||
</span>
|
||||
<span className="text-sm truncate flex-1">{a.detail}</span>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap flex-shrink-0">{formatDate(a.created_at)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user