Files
setrip/app/admin/trips/[id]/page.tsx
T

298 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}