203 lines
5.9 KiB
TypeScript
203 lines
5.9 KiB
TypeScript
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import { getServerSession } from "next-auth";
|
|
import { authOptions } from "@/lib/auth";
|
|
import { isAdminEmail } from "@/lib/admin";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
interface WeeklyBucket {
|
|
weekStart: Date;
|
|
label: string;
|
|
count: number;
|
|
}
|
|
|
|
function thirtyDaysAgoDate(): Date {
|
|
return new Date(Date.now() - 30 * DAY_MS);
|
|
}
|
|
|
|
async function getSignupsPerWeek(weeks = 8): Promise<WeeklyBucket[]> {
|
|
const now = new Date();
|
|
const startMs = now.getTime() - weeks * 7 * DAY_MS;
|
|
const startDate = new Date(startMs);
|
|
|
|
const users = await prisma.user.findMany({
|
|
where: { createdAt: { gte: startDate } },
|
|
select: { createdAt: true },
|
|
});
|
|
|
|
// Bucketize per week (Senin sebagai start, supaya konsisten dengan kalender Indonesia).
|
|
const buckets: WeeklyBucket[] = [];
|
|
for (let i = weeks - 1; i >= 0; i--) {
|
|
const bucketStart = new Date(now.getTime() - (i + 1) * 7 * DAY_MS);
|
|
bucketStart.setUTCHours(0, 0, 0, 0);
|
|
const bucketEnd = new Date(bucketStart.getTime() + 7 * DAY_MS);
|
|
const count = users.filter(
|
|
(u) => u.createdAt >= bucketStart && u.createdAt < bucketEnd
|
|
).length;
|
|
buckets.push({
|
|
weekStart: bucketStart,
|
|
label: bucketStart.toLocaleDateString("id-ID", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
}),
|
|
count,
|
|
});
|
|
}
|
|
return buckets;
|
|
}
|
|
|
|
export default async function AdminUserStatsPage() {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session?.user) redirect("/login?callbackUrl=/admin/users/stats");
|
|
if (!isAdminEmail(session.user.email)) {
|
|
return (
|
|
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
|
<p className="text-sm text-neutral-600">
|
|
Halaman ini hanya untuk admin SeTrip.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const thirtyDaysAgo = thirtyDaysAgoDate();
|
|
|
|
const [
|
|
totalUsers,
|
|
suspendedUsers,
|
|
verifiedOrganizers,
|
|
activeOrganizers30d,
|
|
paidParticipants30d,
|
|
weekly,
|
|
] = await Promise.all([
|
|
prisma.user.count(),
|
|
prisma.user.count({ where: { suspended: true } }),
|
|
prisma.organizerVerification.count({ where: { status: "APPROVED" } }),
|
|
prisma.user.count({
|
|
where: {
|
|
trips: { some: { createdAt: { gte: thirtyDaysAgo } } },
|
|
},
|
|
}),
|
|
prisma.user.count({
|
|
where: {
|
|
bookings: {
|
|
some: {
|
|
status: "PAID",
|
|
createdAt: { gte: thirtyDaysAgo },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
getSignupsPerWeek(8),
|
|
]);
|
|
|
|
const maxWeeklyCount = Math.max(1, ...weekly.map((w) => w.count));
|
|
|
|
return (
|
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
|
<div className="mb-4 text-xs text-neutral-500">
|
|
<Link href="/admin/users" className="hover:text-primary-600">
|
|
← Kembali ke list users
|
|
</Link>
|
|
</div>
|
|
|
|
<header className="mb-6">
|
|
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
|
User Analytics
|
|
</h1>
|
|
<p className="mt-1 text-sm text-neutral-500">
|
|
Snapshot pertumbuhan user. Real-time read langsung dari DB — tidak
|
|
ada cache, refresh halaman untuk angka terbaru.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="mb-8 grid gap-3 sm:grid-cols-3">
|
|
<StatCard label="Total Users" value={totalUsers} />
|
|
<StatCard
|
|
label="Suspended"
|
|
value={suspendedUsers}
|
|
accent="red"
|
|
/>
|
|
<StatCard
|
|
label="Verified Organizers"
|
|
value={verifiedOrganizers}
|
|
accent="emerald"
|
|
/>
|
|
<StatCard
|
|
label="Organizer Aktif (30 hari)"
|
|
value={activeOrganizers30d}
|
|
accent="secondary"
|
|
sub="Bikin trip baru"
|
|
/>
|
|
<StatCard
|
|
label="Peserta Aktif (30 hari)"
|
|
value={paidParticipants30d}
|
|
accent="primary"
|
|
sub="Booking PAID"
|
|
/>
|
|
</section>
|
|
|
|
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
|
|
<h2 className="mb-1 text-sm font-bold text-neutral-900">
|
|
Signup per Minggu (8 minggu terakhir)
|
|
</h2>
|
|
<p className="mb-4 text-xs text-neutral-500">
|
|
Tiap bar = 1 minggu (mulai hari ini mundur). Angka di atas bar = total
|
|
signup minggu itu.
|
|
</p>
|
|
<div className="flex h-48 items-end gap-2">
|
|
{weekly.map((w) => {
|
|
const heightPct = (w.count / maxWeeklyCount) * 100;
|
|
return (
|
|
<div
|
|
key={w.weekStart.toISOString()}
|
|
className="flex flex-1 flex-col items-center gap-1"
|
|
>
|
|
<span className="text-[10px] font-semibold text-neutral-700">
|
|
{w.count}
|
|
</span>
|
|
<div
|
|
className="w-full rounded-t-md bg-primary-500/80"
|
|
style={{ height: `${Math.max(heightPct, 2)}%` }}
|
|
title={`${w.count} signup minggu ${w.label}`}
|
|
/>
|
|
<span className="text-[10px] text-neutral-500">{w.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
sub,
|
|
accent = "neutral",
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
sub?: string;
|
|
accent?: "neutral" | "primary" | "secondary" | "emerald" | "red";
|
|
}) {
|
|
const map = {
|
|
neutral: "text-neutral-800",
|
|
primary: "text-primary-700",
|
|
secondary: "text-secondary-700",
|
|
emerald: "text-emerald-700",
|
|
red: "text-red-700",
|
|
};
|
|
return (
|
|
<div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
|
{label}
|
|
</p>
|
|
<p className={`mt-0.5 text-2xl font-bold ${map[accent]}`}>{value}</p>
|
|
{sub && <p className="text-[11px] text-neutral-500">{sub}</p>}
|
|
</div>
|
|
);
|
|
}
|