admin roadmap done, reupload request, submission history, manual override
This commit is contained in:
@@ -6,6 +6,23 @@ import { organizerService } from "@/server/services/organizer.service";
|
||||
import { VerifyForm } from "@/features/organizer/components/verify-form";
|
||||
import { VerifiedBadge } from "@/components/shared/verified-badge";
|
||||
|
||||
function reuploadFieldLabel(field: string): string {
|
||||
switch (field) {
|
||||
case "ktpImage":
|
||||
return "Foto KTP";
|
||||
case "liveness":
|
||||
return "Foto liveness (pegang kertas SETRIP)";
|
||||
case "nik":
|
||||
return "NIK";
|
||||
case "bankInfo":
|
||||
return "Info rekening";
|
||||
case "address":
|
||||
return "Alamat";
|
||||
default:
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function VerifyPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
@@ -53,7 +70,7 @@ export default async function VerifyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status === "PENDING" && (
|
||||
{verification?.status === "PENDING" && !verification.reuploadRequested && (
|
||||
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
|
||||
<p className="mb-1 text-sm font-bold text-amber-800">
|
||||
⏳ Menunggu review admin
|
||||
@@ -64,6 +81,40 @@ export default async function VerifyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.reuploadRequested && (
|
||||
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
|
||||
<p className="mb-1 text-sm font-bold text-amber-900">
|
||||
🔄 Admin minta kamu upload ulang
|
||||
</p>
|
||||
{verification.reuploadNote && (
|
||||
<p className="mb-3 text-sm text-neutral-700">
|
||||
<span className="font-semibold">Catatan admin:</span>{" "}
|
||||
{verification.reuploadNote}
|
||||
</p>
|
||||
)}
|
||||
{verification.reuploadFields.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="mb-1 text-xs font-semibold text-amber-900">
|
||||
Field yang perlu di-upload ulang:
|
||||
</p>
|
||||
<ul className="ml-4 list-disc text-xs text-neutral-700">
|
||||
{verification.reuploadFields.map((f) => (
|
||||
<li key={f}>
|
||||
<span className="font-semibold">
|
||||
{reuploadFieldLabel(f)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-neutral-700">
|
||||
Submit ulang form di bawah dengan data/foto yang sudah diperbaiki.
|
||||
Setelah submit, banner ini hilang otomatis.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status === "REJECTED" && (
|
||||
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
|
||||
<p className="mb-1 text-sm font-bold text-red-800">❌ Pengajuan ditolak</p>
|
||||
@@ -79,9 +130,9 @@ export default async function VerifyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && (
|
||||
<VerifyForm initial={initial} />
|
||||
)}
|
||||
{(verification?.status !== "APPROVED" &&
|
||||
(verification?.status !== "PENDING" ||
|
||||
verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
|
||||
|
||||
<p className="mt-6 text-center text-sm text-neutral-500">
|
||||
<Link href="/profile" className="hover:text-primary-600">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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";
|
||||
import { systemHealthService } from "@/server/services/system-health.service";
|
||||
|
||||
interface JobSummary {
|
||||
jobName: string;
|
||||
@@ -78,11 +80,20 @@ export default async function AdminSystemPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const summaries = await Promise.all(TRACKED_JOBS.map(getJobSummary));
|
||||
const recentRuns = await prisma.cronRun.findMany({
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
const [summaries, recentRuns, stale] = await Promise.all([
|
||||
Promise.all(TRACKED_JOBS.map(getJobSummary)),
|
||||
prisma.cronRun.findMany({
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
systemHealthService.detectStale(),
|
||||
]);
|
||||
|
||||
const hasAnyStale =
|
||||
stale.stalePaymentsCount > 0 ||
|
||||
stale.awaitingPayPastDepartureCount > 0 ||
|
||||
stale.overduePayoutsCount > 0 ||
|
||||
stale.stuckRefundsCount > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
@@ -96,6 +107,55 @@ export default async function AdminSystemPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{hasAnyStale && (
|
||||
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
|
||||
<h2 className="mb-2 text-sm font-bold text-amber-900">
|
||||
⚠️ Stale State Alerts
|
||||
</h2>
|
||||
<ul className="space-y-1 text-xs text-amber-900">
|
||||
{stale.stalePaymentsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.stalePaymentsCount}</strong> Payment MIDTRANS
|
||||
AWAITING > 25 jam — webhook mungkin tertunda. Cek manual lalu
|
||||
reconcile.
|
||||
</li>
|
||||
)}
|
||||
{stale.awaitingPayPastDepartureCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.awaitingPayPastDepartureCount}</strong> Booking
|
||||
AWAITING_PAY tapi trip sudah lewat tanggal berangkat — peserta
|
||||
lupa bayar, butuh cleanup.
|
||||
</li>
|
||||
)}
|
||||
{stale.overduePayoutsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.overduePayoutsCount}</strong> Payout HELD lewat
|
||||
heldUntil > 1 hari — cron release mungkin tidak jalan, cek
|
||||
cron history di bawah.{" "}
|
||||
<Link
|
||||
href="/admin/payouts?tab=HELD"
|
||||
className="font-semibold text-amber-700 hover:underline"
|
||||
>
|
||||
Lihat HELD →
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{stale.stuckRefundsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.stuckRefundsCount}</strong> Refund APPROVED
|
||||
> 7 hari belum di-process.{" "}
|
||||
<Link
|
||||
href="/admin/refunds?tab=APPROVED"
|
||||
className="font-semibold text-amber-700 hover:underline"
|
||||
>
|
||||
Lihat APPROVED →
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Cron Jobs
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isAdminEmail } from "@/lib/admin";
|
||||
import { userRepo } from "@/server/repositories/user.repo";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { SuspendUserButton } from "@/features/admin/components/suspend-user-button";
|
||||
import { ManualVerifyButton } from "@/features/admin/components/manual-verify-button";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -154,10 +155,18 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
|
||||
</h2>
|
||||
{isSelf ? (
|
||||
<p className="text-xs text-neutral-500">
|
||||
Tidak bisa suspend akun sendiri.
|
||||
Tidak bisa suspend / modifikasi akun sendiri.
|
||||
</p>
|
||||
) : (
|
||||
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
|
||||
{!user.organizerVerification && (
|
||||
<ManualVerifyButton
|
||||
userId={user.id}
|
||||
defaultBankAccountName={user.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -44,14 +44,22 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Cari user, lihat history booking & trip, dan suspend akun yang
|
||||
melakukan abuse (scam, harassment, TOS violation).
|
||||
</p>
|
||||
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Cari user, lihat history booking & trip, dan suspend akun yang
|
||||
melakukan abuse (scam, harassment, TOS violation).
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/users/stats"
|
||||
className="rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
>
|
||||
📊 Stats
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<form method="get" className="mb-4 flex gap-2">
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user