admin roadmap trips ops and payment ops
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { categoryMeta } from "@/lib/activity-category";
|
||||
import { groupItineraryByDay } from "@/lib/itinerary";
|
||||
import { AdminCancelTripButton } from "@/features/trip/components/admin-cancel-trip-button";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminTripDetailPage({ params }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
redirect("/login?callbackUrl=/admin/trips");
|
||||
}
|
||||
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 { id } = await params;
|
||||
let trip;
|
||||
try {
|
||||
trip = await tripService.getTripById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const cat = categoryMeta(trip.category);
|
||||
|
||||
const activeParticipants = trip.participants.filter(
|
||||
(p) => p.status !== "CANCELLED"
|
||||
);
|
||||
const confirmedCount = activeParticipants.filter(
|
||||
(p) => p.status === "CONFIRMED"
|
||||
).length;
|
||||
const pendingCount = activeParticipants.filter(
|
||||
(p) => p.status === "PENDING"
|
||||
).length;
|
||||
const cancelledCount = trip.participants.length - activeParticipants.length;
|
||||
|
||||
const grouped = trip.itineraryItems.length
|
||||
? groupItineraryByDay(
|
||||
trip.itineraryItems.map((i) => ({
|
||||
day: i.day,
|
||||
startTime: i.startTime,
|
||||
endTime: i.endTime,
|
||||
activity: i.activity,
|
||||
order: i.order,
|
||||
}))
|
||||
)
|
||||
: null;
|
||||
|
||||
const canCancel = trip.status === "OPEN" || trip.status === "FULL";
|
||||
|
||||
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/trips" className="hover:text-primary-600">
|
||||
← Kembali ke list trips
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<header className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 font-semibold text-neutral-600">
|
||||
<span aria-hidden>{cat.icon}</span> {cat.label}
|
||||
</span>
|
||||
<StatusBadge status={trip.status} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||
{trip.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
📅 {formatTripCalendarDateRangeLong(trip.date, trip.endDate)} ·
|
||||
📍 {trip.destination}, {trip.location}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Organizer:{" "}
|
||||
<Link
|
||||
href={`/u/${trip.organizer.id}`}
|
||||
className="font-semibold text-neutral-700 hover:text-primary-600"
|
||||
target="_blank"
|
||||
>
|
||||
{trip.organizer.name}
|
||||
</Link>{" "}
|
||||
<span className="text-neutral-400">({trip.organizer.email})</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Trip ID:{" "}
|
||||
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-700">
|
||||
{trip.id}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Harga
|
||||
</p>
|
||||
<p className="text-xl font-bold text-primary-700 sm:text-2xl">
|
||||
{formatRupiah(trip.price)}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-500">per orang</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mb-6 grid gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Kapasitas" value={String(trip.maxParticipants)} />
|
||||
<StatCard
|
||||
label="Confirmed"
|
||||
value={String(confirmedCount)}
|
||||
accent="emerald"
|
||||
/>
|
||||
<StatCard
|
||||
label="Pending"
|
||||
value={String(pendingCount)}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Cancelled"
|
||||
value={String(cancelledCount)}
|
||||
accent="neutral"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{canCancel && (
|
||||
<section className="mb-6 rounded-2xl border border-red-200 bg-red-50/60 p-4 sm:p-5">
|
||||
<h2 className="text-sm font-bold text-red-900">
|
||||
Intervensi Admin — Cancel Trip
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-red-900/80">
|
||||
Pakai hanya saat organizer unreachable, safety issue, atau dispute
|
||||
tidak terselesaikan. Semua booking PAID akan auto-refund (full
|
||||
amount). Booking PENDING/AWAITING_PAY langsung CANCELLED.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<AdminCancelTripButton tripId={trip.id} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{trip.description && (
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Deskripsi
|
||||
</h2>
|
||||
<p className="whitespace-pre-wrap text-sm text-neutral-700">
|
||||
{trip.description}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{grouped && (
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Itinerary
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{[...grouped.entries()].map(([day, items]) => (
|
||||
<div
|
||||
key={day}
|
||||
className="rounded-lg border border-primary-100 bg-primary-50/40 p-3"
|
||||
>
|
||||
<p className="mb-1.5 text-xs font-bold text-primary-800">
|
||||
Hari {day}
|
||||
</p>
|
||||
<ol className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.order}
|
||||
className="flex gap-3 text-xs text-neutral-700"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] font-semibold text-primary-700">
|
||||
{item.startTime}
|
||||
{item.endTime ? `–${item.endTime}` : ""}
|
||||
</span>
|
||||
<span>{item.activity}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Peserta ({activeParticipants.length})
|
||||
</h2>
|
||||
{trip.participants.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">Belum ada peserta.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-neutral-100">
|
||||
{trip.participants.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/u/${p.user.id}`}
|
||||
target="_blank"
|
||||
className="text-sm font-semibold text-neutral-800 hover:text-primary-600"
|
||||
>
|
||||
{p.user.name}
|
||||
</Link>
|
||||
{p.user.profile?.city && (
|
||||
<span className="ml-2 text-[11px] text-neutral-500">
|
||||
📍 {p.user.profile.city}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ParticipantStatusBadge status={p.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === "OPEN"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: status === "FULL"
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: status === "CLOSED"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-neutral-200 text-neutral-700";
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantStatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === "CONFIRMED"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: status === "PENDING"
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: "bg-neutral-200 text-neutral-600 line-through";
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
accent = "primary",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
accent?: "primary" | "emerald" | "amber" | "neutral";
|
||||
}) {
|
||||
const map = {
|
||||
primary: "text-primary-700",
|
||||
emerald: "text-emerald-700",
|
||||
amber: "text-amber-700",
|
||||
neutral: "text-neutral-700",
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-3 sm:p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xl font-bold sm:text-2xl ${map[accent]}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 { tripRepo } from "@/server/repositories/trip.repo";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { categoryMeta } from "@/lib/activity-category";
|
||||
|
||||
type Tab = "ALL" | "OPEN" | "FULL" | "CLOSED" | "COMPLETED";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "ALL", label: "Semua" },
|
||||
{ key: "OPEN", label: "Open" },
|
||||
{ key: "FULL", label: "Penuh" },
|
||||
{ key: "CLOSED", label: "Dibatalkan" },
|
||||
{ key: "COMPLETED", label: "Selesai" },
|
||||
];
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string; q?: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminTripsPage({ searchParams }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/trips");
|
||||
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 params = await searchParams;
|
||||
const tab: Tab = TABS.some((t) => t.key === params.tab)
|
||||
? (params.tab as Tab)
|
||||
: "ALL";
|
||||
const q = (params.q ?? "").trim();
|
||||
|
||||
const trips = await tripRepo.searchForAdmin({
|
||||
status: tab === "ALL" ? undefined : tab,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
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">
|
||||
Trip Operations
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Cari trip, lihat detail, dan intervensi (cancel + auto-refund) saat
|
||||
organizer unreachable.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form method="get" className="mb-4 flex gap-2">
|
||||
<input type="hidden" name="tab" value={tab} />
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Cari judul, destinasi, lokasi, organizer..."
|
||||
className="flex-1 rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Cari
|
||||
</button>
|
||||
{q && (
|
||||
<Link
|
||||
href={`/admin/trips?tab=${tab}`}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Reset
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{TABS.map((t) => (
|
||||
<Link
|
||||
key={t.key}
|
||||
href={`/admin/trips?tab=${t.key}${q ? `&q=${encodeURIComponent(q)}` : ""}`}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
|
||||
tab === t.key
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{trips.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||
<p className="text-sm text-neutral-500">
|
||||
{q
|
||||
? `Tidak ada trip yang cocok dengan "${q}".`
|
||||
: "Tidak ada trip pada status ini."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{trips.map((t) => {
|
||||
const cat = categoryMeta(t.category);
|
||||
return (
|
||||
<li
|
||||
key={t.id}
|
||||
className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md sm:p-5"
|
||||
>
|
||||
<Link href={`/admin/trips/${t.id}`} className="block">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 font-semibold text-neutral-600">
|
||||
<span aria-hidden>{cat.icon}</span> {cat.label}
|
||||
</span>
|
||||
<StatusBadge status={t.status} />
|
||||
</div>
|
||||
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="mt-1 truncate text-xs text-neutral-500 sm:text-sm">
|
||||
📅 {formatTripCalendarDateRangeLong(t.date, t.endDate)}
|
||||
{" · "}📍 {t.location}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
|
||||
Organizer:{" "}
|
||||
<span className="font-semibold text-neutral-700">
|
||||
{t.organizer.name}
|
||||
</span>{" "}
|
||||
<span className="text-neutral-400">
|
||||
({t.organizer.email})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-sm font-bold text-primary-700 sm:text-base">
|
||||
{formatRupiah(t.price)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-neutral-500">
|
||||
{t._count.participants}/{t.maxParticipants} peserta
|
||||
</p>
|
||||
<p className="text-[11px] text-emerald-700">
|
||||
{t._count.bookings} PAID
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === "OPEN"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: status === "FULL"
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: status === "CLOSED"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-neutral-200 text-neutral-700";
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user