Skip to content

Commit e67d499

Browse files
committed
Add comprehensive admin dashboard with users, quotes, workspaces, and support management
- Dashboard overview page with KPI metrics: total users, workspaces, quotes, images generated, paid subscriptions, conversion rate, success rate, support tickets - Users management: search, view auth type/workspaces, toggle admin, delete users - Quotes management: search/filter by status, image preview, quote details dialog, delete - Workspaces management: search/filter by tier, view stats, change subscription plan (including granting Enterprise), add bonus credits - Support tickets: manage contact form submissions with status workflow (new -> in progress -> resolved -> closed) - Updated admin sidebar with grouped navigation (Overview, Management, Support) - Added shadcn table, dropdown-menu, and avatar UI components - All API routes protected with admin auth guard (requireAdmin) https://claude.ai/code/session_01KApDGzxdnkvyosHHEL4TaZ
1 parent 1ed149f commit e67d499

File tree

23 files changed

+3653
-27
lines changed

23 files changed

+3653
-27
lines changed

src/app/(admin)/admin/page.tsx

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,145 @@
1-
import { redirect } from "next/navigation";
1+
import prisma from "@/lib/prisma";
2+
import { AdminDashboard } from "@/components/admin/admin-dashboard";
23

3-
export default function AdminPage() {
4-
redirect("/admin/styles");
4+
export default async function AdminPage() {
5+
const now = new Date();
6+
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
7+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
8+
9+
const [
10+
totalUsers,
11+
newUsers30d,
12+
totalWorkspaces,
13+
activeWorkspaces,
14+
totalQuotes,
15+
quotes30d,
16+
completedQuotes,
17+
failedQuotes,
18+
totalImages,
19+
images30d,
20+
subscriptionsByTier,
21+
totalChannels,
22+
activeChannels,
23+
newContactSubmissions,
24+
recentQuotes,
25+
recentUsers,
26+
tokenPurchaseRevenue,
27+
weeklyActiveWorkspaceIds,
28+
] = await Promise.all([
29+
prisma.user.count(),
30+
prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
31+
prisma.workspace.count(),
32+
prisma.workspace.count({ where: { isActive: true } }),
33+
prisma.quote.count(),
34+
prisma.quote.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
35+
prisma.quote.count({ where: { status: "COMPLETED" } }),
36+
prisma.quote.count({ where: { status: "FAILED" } }),
37+
prisma.imageGeneration.count({ where: { status: "COMPLETED" } }),
38+
prisma.imageGeneration.count({
39+
where: { status: "COMPLETED", createdAt: { gte: thirtyDaysAgo } },
40+
}),
41+
prisma.subscription.groupBy({
42+
by: ["tier"],
43+
_count: { id: true },
44+
}),
45+
prisma.channel.count(),
46+
prisma.channel.count({ where: { isActive: true } }),
47+
prisma.contactFormSubmission.count({ where: { status: "new" } }),
48+
prisma.quote.findMany({
49+
where: { status: "COMPLETED" },
50+
orderBy: { createdAt: "desc" },
51+
take: 5,
52+
select: {
53+
id: true,
54+
quoteText: true,
55+
imageUrl: true,
56+
slackUserName: true,
57+
createdAt: true,
58+
workspace: { select: { slackTeamName: true } },
59+
},
60+
}),
61+
prisma.user.findMany({
62+
orderBy: { createdAt: "desc" },
63+
take: 5,
64+
select: {
65+
id: true,
66+
name: true,
67+
email: true,
68+
createdAt: true,
69+
},
70+
}),
71+
prisma.tokenPurchase.aggregate({
72+
_sum: { amountPaid: true },
73+
}),
74+
prisma.quote.findMany({
75+
where: { createdAt: { gte: sevenDaysAgo } },
76+
select: { workspaceId: true },
77+
distinct: ["workspaceId"],
78+
}),
79+
]);
80+
81+
const tierBreakdown: Record<string, number> = {};
82+
for (const tier of subscriptionsByTier) {
83+
tierBreakdown[tier.tier] = tier._count.id;
84+
}
85+
86+
const paidSubscriptions =
87+
(tierBreakdown["STARTER"] || 0) +
88+
(tierBreakdown["TEAM"] || 0) +
89+
(tierBreakdown["BUSINESS"] || 0) +
90+
(tierBreakdown["ENTERPRISE"] || 0);
91+
92+
const totalSubscriptions = Object.values(tierBreakdown).reduce(
93+
(a, b) => a + b,
94+
0,
95+
);
96+
97+
const conversionRate =
98+
totalSubscriptions > 0
99+
? parseFloat(((paidSubscriptions / totalSubscriptions) * 100).toFixed(1))
100+
: 0;
101+
102+
const quoteSuccessRate =
103+
completedQuotes + failedQuotes > 0
104+
? parseFloat(
105+
((completedQuotes / (completedQuotes + failedQuotes)) * 100).toFixed(
106+
1,
107+
),
108+
)
109+
: 100;
110+
111+
return (
112+
<AdminDashboard
113+
stats={{
114+
totalUsers,
115+
newUsers30d,
116+
totalWorkspaces,
117+
activeWorkspaces,
118+
weeklyActiveWorkspaces: weeklyActiveWorkspaceIds.length,
119+
totalQuotes,
120+
quotes30d,
121+
totalImages,
122+
images30d,
123+
totalChannels,
124+
activeChannels,
125+
newContactSubmissions,
126+
paidSubscriptions,
127+
freeSubscriptions: tierBreakdown["FREE"] || 0,
128+
conversionRate,
129+
quoteSuccessRate,
130+
completedQuotes,
131+
failedQuotes,
132+
totalTokenRevenue: (tokenPurchaseRevenue._sum.amountPaid || 0) / 100,
133+
tierBreakdown,
134+
}}
135+
recentQuotes={recentQuotes.map((q) => ({
136+
...q,
137+
createdAt: q.createdAt.toISOString(),
138+
}))}
139+
recentUsers={recentUsers.map((u) => ({
140+
...u,
141+
createdAt: u.createdAt.toISOString(),
142+
}))}
143+
/>
144+
);
5145
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { AdminQuotesManager } from "@/components/admin/admin-quotes";
2+
3+
export default function AdminQuotesPage() {
4+
return (
5+
<div className="space-y-8">
6+
<div>
7+
<h1 className="text-2xl font-bold">Quotes</h1>
8+
<p className="text-muted-foreground mt-1 text-sm">
9+
View and manage all quotes across workspaces
10+
</p>
11+
</div>
12+
<AdminQuotesManager />
13+
</div>
14+
);
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { AdminSupportManager } from "@/components/admin/admin-support";
2+
3+
export default function AdminSupportPage() {
4+
return (
5+
<div className="space-y-8">
6+
<div>
7+
<h1 className="text-2xl font-bold">Support</h1>
8+
<p className="text-muted-foreground mt-1 text-sm">
9+
Manage contact form submissions and support tickets
10+
</p>
11+
</div>
12+
<AdminSupportManager />
13+
</div>
14+
);
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { AdminUsersManager } from "@/components/admin/admin-users";
2+
3+
export default function AdminUsersPage() {
4+
return (
5+
<div className="space-y-8">
6+
<div>
7+
<h1 className="text-2xl font-bold">Users</h1>
8+
<p className="text-muted-foreground mt-1 text-sm">
9+
Manage all platform users
10+
</p>
11+
</div>
12+
<AdminUsersManager />
13+
</div>
14+
);
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { AdminWorkspacesManager } from "@/components/admin/admin-workspaces";
2+
3+
export default function AdminWorkspacesPage() {
4+
return (
5+
<div className="space-y-8">
6+
<div>
7+
<h1 className="text-2xl font-bold">Workspaces</h1>
8+
<p className="text-muted-foreground mt-1 text-sm">
9+
Manage workspaces, subscriptions, and grant plans
10+
</p>
11+
</div>
12+
<AdminWorkspacesManager />
13+
</div>
14+
);
15+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { auth } from "@/lib/auth";
3+
import prisma from "@/lib/prisma";
4+
5+
async function requireAdmin() {
6+
const session = await auth();
7+
if (!session?.user?.id || !session.user.isAdmin) {
8+
return null;
9+
}
10+
return session;
11+
}
12+
13+
export async function DELETE(
14+
_request: NextRequest,
15+
{ params }: { params: Promise<{ id: string }> },
16+
) {
17+
const session = await requireAdmin();
18+
if (!session) {
19+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
20+
}
21+
22+
const { id } = await params;
23+
24+
const quote = await prisma.quote.findUnique({ where: { id } });
25+
if (!quote) {
26+
return NextResponse.json({ error: "Quote not found" }, { status: 404 });
27+
}
28+
29+
await prisma.quote.delete({ where: { id } });
30+
31+
return NextResponse.json({ ok: true });
32+
}

src/app/api/admin/quotes/route.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { auth } from "@/lib/auth";
3+
import prisma from "@/lib/prisma";
4+
5+
async function requireAdmin() {
6+
const session = await auth();
7+
if (!session?.user?.id || !session.user.isAdmin) {
8+
return null;
9+
}
10+
return session;
11+
}
12+
13+
export async function GET(request: NextRequest) {
14+
const session = await requireAdmin();
15+
if (!session) {
16+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
17+
}
18+
19+
const searchParams = request.nextUrl.searchParams;
20+
const page = parseInt(searchParams.get("page") || "1", 10);
21+
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 100);
22+
const search = searchParams.get("search") || "";
23+
const status = searchParams.get("status") || "";
24+
const workspaceId = searchParams.get("workspaceId") || "";
25+
26+
const where: Record<string, unknown> = {};
27+
28+
if (search) {
29+
where.OR = [
30+
{ quoteText: { contains: search, mode: "insensitive" } },
31+
{ slackUserName: { contains: search, mode: "insensitive" } },
32+
{ attributedTo: { contains: search, mode: "insensitive" } },
33+
];
34+
}
35+
36+
if (status) {
37+
where.status = status;
38+
}
39+
40+
if (workspaceId) {
41+
where.workspaceId = workspaceId;
42+
}
43+
44+
const [quotes, total] = await Promise.all([
45+
prisma.quote.findMany({
46+
where,
47+
orderBy: { createdAt: "desc" },
48+
skip: (page - 1) * limit,
49+
take: limit,
50+
select: {
51+
id: true,
52+
quoteText: true,
53+
attributedTo: true,
54+
slackUserName: true,
55+
imageUrl: true,
56+
status: true,
57+
styleId: true,
58+
isFavorited: true,
59+
createdAt: true,
60+
workspace: {
61+
select: { slackTeamName: true, slug: true },
62+
},
63+
channel: {
64+
select: { channelName: true },
65+
},
66+
},
67+
}),
68+
prisma.quote.count({ where }),
69+
]);
70+
71+
return NextResponse.json({
72+
quotes,
73+
pagination: {
74+
page,
75+
limit,
76+
total,
77+
totalPages: Math.ceil(total / limit),
78+
},
79+
});
80+
}

0 commit comments

Comments
 (0)