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:
2026-03-30 00:55:26 +00:00
parent 42881b1f5a
commit 83febef040

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