performance improvments
This commit is contained in:
1 parent
912981e3f3
commit
02c9681aca
13 files changed
+805
-474
No files matched your search
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { successResponse } from "@/lib/api/responses";
|
||||
import { db as rawDb, sql } from "@fitai/database";
|
||||
|
||||
interface UserGrowthPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface MembershipDistributionPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface RevenuePoint {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface AnalyticsData {
|
||||
userGrowth: UserGrowthPoint[];
|
||||
membershipDistribution: MembershipDistributionPoint[];
|
||||
revenue: RevenuePoint[];
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const database = await getDatabase();
|
||||
const user = await ensureUserSynced(userId, database);
|
||||
|
||||
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const months = parseInt(url.searchParams.get("months") || "6");
|
||||
|
||||
const allUsers = await database.getAllUsers();
|
||||
const allClients = await database.getAllClients();
|
||||
|
||||
const paymentsRaw = await rawDb.all(
|
||||
sql`SELECT * FROM payments WHERE status = 'completed' AND paid_at IS NOT NULL`,
|
||||
);
|
||||
const payments: any[] = paymentsRaw || [];
|
||||
|
||||
const now = new Date();
|
||||
const userGrowth: UserGrowthPoint[] = [];
|
||||
|
||||
for (let i = months - 1; i >= 0; i--) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const monthLabel = date.toLocaleDateString("en-US", { month: "short" });
|
||||
|
||||
const usersCreatedByMonth = allUsers.filter((u: any) => {
|
||||
const createdAt = new Date(u.createdAt);
|
||||
return (
|
||||
createdAt.getFullYear() === date.getFullYear() &&
|
||||
createdAt.getMonth() === date.getMonth()
|
||||
);
|
||||
});
|
||||
|
||||
userGrowth.push({
|
||||
label: monthLabel,
|
||||
value: usersCreatedByMonth.length,
|
||||
});
|
||||
}
|
||||
|
||||
let runningTotal = 0;
|
||||
for (const point of userGrowth) {
|
||||
runningTotal += point.value;
|
||||
point.value = runningTotal;
|
||||
}
|
||||
|
||||
const membershipCounts: Record<string, number> = {
|
||||
basic: 0,
|
||||
premium: 0,
|
||||
vip: 0,
|
||||
};
|
||||
|
||||
for (const client of allClients) {
|
||||
const membershipType = (client as any).membershipType || "basic";
|
||||
if (membershipCounts[membershipType] !== undefined) {
|
||||
membershipCounts[membershipType]++;
|
||||
}
|
||||
}
|
||||
|
||||
const membershipDistribution: MembershipDistributionPoint[] = [
|
||||
{ label: "Basic", value: membershipCounts.basic, color: "#6b7280" },
|
||||
{ label: "Premium", value: membershipCounts.premium, color: "#3b82f6" },
|
||||
{ label: "VIP", value: membershipCounts.vip, color: "#f59e0b" },
|
||||
];
|
||||
|
||||
const revenue: RevenuePoint[] = [];
|
||||
|
||||
for (let i = months - 1; i >= 0; i--) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const monthLabel = date.toLocaleDateString("en-US", { month: "short" });
|
||||
const nextMonthDate = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - i + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
const monthlyRevenue = payments
|
||||
.filter((p: any) => {
|
||||
const paidAt = p.paid_at ? new Date(p.paid_at) : null;
|
||||
if (!paidAt) return false;
|
||||
return paidAt >= date && paidAt < nextMonthDate;
|
||||
})
|
||||
.reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
|
||||
|
||||
revenue.push({
|
||||
label: monthLabel,
|
||||
value: monthlyRevenue,
|
||||
color: "#10b981",
|
||||
});
|
||||
}
|
||||
|
||||
const analyticsData: AnalyticsData = {
|
||||
userGrowth,
|
||||
membershipDistribution,
|
||||
revenue,
|
||||
};
|
||||
|
||||
return successResponse({ analytics: analyticsData });
|
||||
} catch (error) {
|
||||
console.error("Analytics error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { successResponse } from "@/lib/api/responses";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
@@ -51,7 +52,7 @@ export async function GET(req: Request) {
|
||||
revenueGrowth: 0,
|
||||
};
|
||||
|
||||
return NextResponse.json(stats);
|
||||
return successResponse({ stats });
|
||||
} catch (error) {
|
||||
console.error("Dashboard stats error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@clerk/nextjs";
|
||||
import { Sidebar } from "@/components/ui/Sidebar";
|
||||
import { Toaster } from "sonner";
|
||||
import { QueryProvider } from "@/components/providers/QueryProvider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -25,15 +26,17 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<div className="flex min-h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-20 p-8">{children}</main>
|
||||
</div>
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
<QueryProvider>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<div className="flex min-h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-20 p-8">{children}</main>
|
||||
</div>
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
</QueryProvider>
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
+56
-67
@@ -1,99 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Users, CreditCard, CalendarCheck, TrendingUp } from "lucide-react";
|
||||
import { StatsCard } from "@/components/ui/StatsCard";
|
||||
import { StatsCardSkeleton } from "@/components/ui/skeleton";
|
||||
import { UserManagement } from "@/components/users/UserManagement";
|
||||
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
|
||||
import axios from "axios";
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
activeClients: number;
|
||||
totalRevenue: number;
|
||||
revenueGrowth: number;
|
||||
}
|
||||
import { useDashboardStats } from "@/hooks/use-api";
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalUsers: 0,
|
||||
activeClients: 0,
|
||||
totalRevenue: 0,
|
||||
revenueGrowth: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await axios.get("/api/admin/stats");
|
||||
setStats(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard stats:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
const { data: stats, isLoading } = useDashboardStats();
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(value);
|
||||
}).format(value || 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-slate-900">Dashboard</h2>
|
||||
<p className="text-slate-500 mt-2">Welcome back, here's what's happening today.</p>
|
||||
<p className="text-slate-500 mt-2">
|
||||
Welcome back, here's what's happening today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatsCard
|
||||
title="Total Users"
|
||||
value={loading ? "..." : stats.totalUsers}
|
||||
change="+12%" // Placeholder for now as we don't track historical growth yet
|
||||
trend="up"
|
||||
icon={Users}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Clients"
|
||||
value={loading ? "..." : stats.activeClients}
|
||||
change="+5%"
|
||||
trend="up"
|
||||
icon={CalendarCheck}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Revenue"
|
||||
value={loading ? "..." : formatCurrency(stats.totalRevenue)}
|
||||
change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}%`}
|
||||
trend={stats.revenueGrowth >= 0 ? "up" : "down"}
|
||||
icon={CreditCard}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Growth"
|
||||
value="24%" // Placeholder
|
||||
change="-2%"
|
||||
trend="down"
|
||||
icon={TrendingUp}
|
||||
color="orange"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<StatsCardSkeleton />
|
||||
<StatsCardSkeleton />
|
||||
<StatsCardSkeleton />
|
||||
<StatsCardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StatsCard
|
||||
title="Total Users"
|
||||
value={stats?.totalUsers ?? 0}
|
||||
change="+12%"
|
||||
trend="up"
|
||||
icon={Users}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Clients"
|
||||
value={stats?.activeClients ?? 0}
|
||||
change="+5%"
|
||||
trend="up"
|
||||
icon={CalendarCheck}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Revenue"
|
||||
value={formatCurrency(stats?.totalRevenue ?? 0)}
|
||||
change={`${(stats?.revenueGrowth ?? 0) > 0 ? "+" : ""}${stats?.revenueGrowth ?? 0}%`}
|
||||
trend={(stats?.revenueGrowth ?? 0) >= 0 ? "up" : "down"}
|
||||
icon={CreditCard}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Growth"
|
||||
value="24%"
|
||||
change="-2%"
|
||||
trend="down"
|
||||
icon={TrendingUp}
|
||||
color="orange"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-6">Recent Activity</h3>
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-6">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<UserManagement />
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-6">Quick Analytics</h3>
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-6">
|
||||
Quick Analytics
|
||||
</h3>
|
||||
<AnalyticsDashboard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,86 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import log from "@/lib/logger";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
recommendationText: string;
|
||||
activityPlan: string;
|
||||
dietPlan: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
import {
|
||||
useUsers,
|
||||
useRecommendations,
|
||||
useGenerateRecommendations,
|
||||
useApproveRecommendation,
|
||||
useUpdateRecommendation,
|
||||
type Recommendation,
|
||||
} from "@/hooks/use-api";
|
||||
|
||||
export default function RecommendationsPage() {
|
||||
const { user } = useUser();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [pendingRecommendations, setPendingRecommendations] = useState<
|
||||
Recommendation[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState<string | null>(null);
|
||||
const [useExternalModel, setUseExternalModel] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
const { data: users = [], isLoading: usersLoading } = useUsers();
|
||||
const { data: allRecommendations = [], isLoading: recsLoading } =
|
||||
useRecommendations();
|
||||
const pendingRecommendations = allRecommendations.filter(
|
||||
(r) => r.status === "pending",
|
||||
);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch users - API returns { success: true, data: { users: [...] }, meta: {...} }
|
||||
const usersRes = await fetch("/api/users");
|
||||
const usersResult = await usersRes.json();
|
||||
const usersArray = usersResult.data?.users || usersResult.users || [];
|
||||
setUsers(usersArray);
|
||||
|
||||
// Fetch pending recommendations - API returns { success: true, data: { recommendations: [...] }, meta: {...} }
|
||||
const recsRes = await fetch("/api/recommendations");
|
||||
const recsResult = await recsRes.json();
|
||||
const allRecs =
|
||||
recsResult.data?.recommendations || recsResult.recommendations || [];
|
||||
setPendingRecommendations(
|
||||
allRecs.filter((r: Recommendation) => r.status === "pending"),
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const generateRec = useGenerateRecommendations();
|
||||
const approveRec = useApproveRecommendation();
|
||||
const updateRec = useUpdateRecommendation();
|
||||
|
||||
const handleGenerate = async (userId: string) => {
|
||||
setGenerating(userId);
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, useExternalModel }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
toast.error(`Error: ${error.error}`);
|
||||
} else {
|
||||
toast.success("Recommendation generated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
await generateRec.mutateAsync(userId);
|
||||
toast.success("Recommendation generated successfully!");
|
||||
} catch (error) {
|
||||
log.error("Failed to generate recommendation", error);
|
||||
toast.error("Failed to generate recommendation.");
|
||||
} finally {
|
||||
setGenerating(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,25 +43,11 @@ export default function RecommendationsPage() {
|
||||
status: "approved" | "rejected",
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch("/api/recommendations/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
recommendationId,
|
||||
status,
|
||||
approvedBy: user?.id || "admin",
|
||||
}),
|
||||
await approveRec.mutateAsync({
|
||||
recommendationId,
|
||||
approved: status === "approved",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
toast.error(
|
||||
`Failed to update status: ${errorData.error || "Unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
toast.success("Recommendation status updated");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
toast.success("Recommendation status updated");
|
||||
} catch (error) {
|
||||
log.error("Failed to approve recommendation", error);
|
||||
toast.error("Error processing request");
|
||||
@@ -124,38 +64,24 @@ export default function RecommendationsPage() {
|
||||
newActivityPlan === null ||
|
||||
newDietPlan === null
|
||||
) {
|
||||
// User cancelled one of the prompts
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/recommendations", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: rec.id,
|
||||
content: newContent,
|
||||
activityPlan: newActivityPlan,
|
||||
dietPlan: newDietPlan,
|
||||
}),
|
||||
await updateRec.mutateAsync({
|
||||
id: rec.id,
|
||||
content: newContent,
|
||||
activityPlan: newActivityPlan,
|
||||
dietPlan: newDietPlan,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
toast.error(
|
||||
`Failed to update recommendation: ${errorData.error || "Unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
toast.success("Recommendation updated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
toast.success("Recommendation updated successfully!");
|
||||
} catch (error) {
|
||||
log.error("Failed to update recommendation", error);
|
||||
toast.error("Failed to update recommendation.");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (usersLoading || recsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-xl">Loading...</div>
|
||||
@@ -215,10 +141,10 @@ export default function RecommendationsPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleGenerate(user.id)}
|
||||
disabled={generating === user.id}
|
||||
disabled={generateRec.isPending}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{generating === user.id ? "Generating..." : "Generate"}
|
||||
{generateRec.isPending ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user