admin roadmap trips ops and payment ops
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
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 { bookingRepo } from "@/server/repositories/booking.repo";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { AdminReconcileButton } from "@/features/booking/components/admin-reconcile-button";
|
||||
import { RawCallbackViewer } from "@/features/booking/components/raw-callback-viewer";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminBookingDetailPage({ params }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
redirect("/login?callbackUrl=/admin/bookings");
|
||||
}
|
||||
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;
|
||||
const booking = await bookingRepo.findByIdForAdmin(id);
|
||||
if (!booking) notFound();
|
||||
|
||||
// Build chronological timeline lintas Payment + Refund + Payout.
|
||||
type TimelineEvent =
|
||||
| {
|
||||
kind: "payment";
|
||||
at: Date;
|
||||
payment: (typeof booking.payments)[number];
|
||||
}
|
||||
| {
|
||||
kind: "refund";
|
||||
at: Date;
|
||||
refund: (typeof booking.refunds)[number];
|
||||
}
|
||||
| {
|
||||
kind: "payout";
|
||||
at: Date;
|
||||
payout: NonNullable<typeof booking.payout>;
|
||||
};
|
||||
|
||||
const timeline: TimelineEvent[] = [];
|
||||
for (const p of booking.payments) {
|
||||
timeline.push({ kind: "payment", at: p.createdAt, payment: p });
|
||||
}
|
||||
for (const r of booking.refunds) {
|
||||
timeline.push({ kind: "refund", at: r.createdAt, refund: r });
|
||||
}
|
||||
if (booking.payout) {
|
||||
timeline.push({
|
||||
kind: "payout",
|
||||
at: booking.payout.createdAt,
|
||||
payout: booking.payout,
|
||||
});
|
||||
}
|
||||
timeline.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500">
|
||||
<Link href="/admin" className="hover:text-primary-600">
|
||||
← Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/trips/${booking.tripId}`}
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
Trip terkait
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<header className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Booking
|
||||
</p>
|
||||
<h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||
{booking.trip.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
📅 {formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}{" "}
|
||||
· 📍 {booking.trip.destination}, {booking.trip.location}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<FieldRow label="Peserta" value={booking.user.name} sub={booking.user.email} />
|
||||
<FieldRow
|
||||
label="Organizer"
|
||||
value={booking.trip.organizer.name}
|
||||
sub={booking.trip.organizer.email}
|
||||
/>
|
||||
<FieldRow
|
||||
label="Nominal booking"
|
||||
value={formatRupiah(booking.amount)}
|
||||
strong
|
||||
/>
|
||||
<FieldRow label="Status booking" value={booking.status} badge />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-neutral-100 pt-3 text-[11px] text-neutral-500">
|
||||
<p>
|
||||
Booking ID:{" "}
|
||||
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700">
|
||||
{booking.id}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<h2 className="mb-4 text-sm font-bold text-neutral-900 sm:text-base">
|
||||
Timeline Money Flow ({timeline.length} event)
|
||||
</h2>
|
||||
|
||||
{timeline.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">
|
||||
Belum ada event payment / refund / payout untuk booking ini.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-4">
|
||||
{timeline.map((ev, idx) => (
|
||||
<li key={idx} className="border-l-2 border-neutral-200 pl-4">
|
||||
{ev.kind === "payment" && (
|
||||
<PaymentEventCard payment={ev.payment} />
|
||||
)}
|
||||
{ev.kind === "refund" && <RefundEventCard refund={ev.refund} />}
|
||||
{ev.kind === "payout" && <PayoutEventCard payout={ev.payout} />}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
strong,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
strong?: boolean;
|
||||
badge?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
{badge ? (
|
||||
<span className="mt-0.5 inline-block rounded-full bg-neutral-200 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-neutral-700">
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<p
|
||||
className={`mt-0.5 text-neutral-800 ${
|
||||
strong ? "text-base font-bold text-primary-700" : "text-sm font-semibold"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
{sub && (
|
||||
<p className="text-[11px] text-neutral-500">{sub}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventHeader({
|
||||
kind,
|
||||
title,
|
||||
at,
|
||||
}: {
|
||||
kind: "payment" | "refund" | "payout";
|
||||
title: string;
|
||||
at: Date;
|
||||
}) {
|
||||
const dotCls =
|
||||
kind === "payment"
|
||||
? "bg-secondary-500"
|
||||
: kind === "refund"
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${dotCls}`} />
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-neutral-700">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{at.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentEventCard({
|
||||
payment,
|
||||
}: {
|
||||
payment: {
|
||||
id: string;
|
||||
provider: string;
|
||||
method: string | null;
|
||||
amount: number;
|
||||
status: string;
|
||||
externalOrderId: string;
|
||||
externalTxId: string | null;
|
||||
snapToken: string | null;
|
||||
expiresAt: Date | null;
|
||||
paidAt: Date | null;
|
||||
failedAt: Date | null;
|
||||
rejectionReason: string | null;
|
||||
rawCallback: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
}) {
|
||||
const canReconcile = payment.provider === "MIDTRANS";
|
||||
return (
|
||||
<div>
|
||||
<EventHeader
|
||||
kind="payment"
|
||||
title={`Payment ${payment.provider}`}
|
||||
at={payment.createdAt}
|
||||
/>
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-xs text-neutral-700">
|
||||
<p>
|
||||
<span className="font-semibold">Order ID:</span>{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{payment.externalOrderId}
|
||||
</code>
|
||||
</p>
|
||||
{payment.externalTxId && (
|
||||
<p>
|
||||
<span className="font-semibold">Transaction ID:</span>{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{payment.externalTxId}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-semibold">Nominal:</span>{" "}
|
||||
{formatRupiah(payment.amount)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Status:</span>{" "}
|
||||
<StatusBadge value={payment.status} />
|
||||
{payment.method && (
|
||||
<span className="ml-2 text-neutral-500">
|
||||
via <span className="font-medium">{payment.method}</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{payment.expiresAt && (
|
||||
<p className="text-neutral-500">
|
||||
Expires:{" "}
|
||||
{payment.expiresAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{payment.paidAt && (
|
||||
<p className="text-emerald-700">
|
||||
Paid at:{" "}
|
||||
{payment.paidAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{payment.rejectionReason && (
|
||||
<p className="text-red-700">
|
||||
⚠️ {payment.rejectionReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canReconcile && (
|
||||
<AdminReconcileButton orderId={payment.externalOrderId} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 border-t border-neutral-200 pt-3">
|
||||
<RawCallbackViewer payload={payment.rawCallback} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefundEventCard({
|
||||
refund,
|
||||
}: {
|
||||
refund: {
|
||||
id: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
status: string;
|
||||
adminNote: string | null;
|
||||
reportNote: string;
|
||||
createdAt: Date;
|
||||
reviewedAt: Date | null;
|
||||
succeededAt: Date | null;
|
||||
failedAt: Date | null;
|
||||
reviewedBy: { id: string; name: string; email: string } | null;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<EventHeader
|
||||
kind="refund"
|
||||
title={`Refund (${refund.reason})`}
|
||||
at={refund.createdAt}
|
||||
/>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50/60 p-3 sm:p-4">
|
||||
<div className="space-y-1 text-xs text-neutral-700">
|
||||
<p>
|
||||
<span className="font-semibold">Nominal:</span>{" "}
|
||||
{formatRupiah(refund.amount)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Status:</span>{" "}
|
||||
<StatusBadge value={refund.status} />
|
||||
</p>
|
||||
{refund.reportNote && (
|
||||
<p className="text-neutral-600">
|
||||
<span className="font-semibold">Report:</span> {refund.reportNote}
|
||||
</p>
|
||||
)}
|
||||
{refund.adminNote && (
|
||||
<p className="text-neutral-600">
|
||||
<span className="font-semibold">Admin note:</span>{" "}
|
||||
{refund.adminNote}
|
||||
</p>
|
||||
)}
|
||||
{refund.reviewedBy && (
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Reviewed by {refund.reviewedBy.email}
|
||||
{refund.reviewedAt && (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
{refund.reviewedAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PayoutEventCard({
|
||||
payout,
|
||||
}: {
|
||||
payout: {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
heldUntil: Date;
|
||||
releasedAt: Date | null;
|
||||
paidAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
adminNote: string | null;
|
||||
createdAt: Date;
|
||||
processedBy: { id: string; name: string; email: string } | null;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<EventHeader kind="payout" title="Payout ke organizer" at={payout.createdAt} />
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-3 sm:p-4">
|
||||
<div className="space-y-1 text-xs text-neutral-700">
|
||||
<p>
|
||||
<span className="font-semibold">Nominal:</span>{" "}
|
||||
{formatRupiah(payout.amount)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Status:</span>{" "}
|
||||
<StatusBadge value={payout.status} />
|
||||
</p>
|
||||
<p className="text-neutral-600">
|
||||
Held until:{" "}
|
||||
{payout.heldUntil.toLocaleDateString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
{payout.paidAt && (
|
||||
<p className="text-emerald-700">
|
||||
Paid at:{" "}
|
||||
{payout.paidAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{payout.adminNote && (
|
||||
<p className="text-neutral-600">
|
||||
<span className="font-semibold">Admin note:</span>{" "}
|
||||
{payout.adminNote}
|
||||
</p>
|
||||
)}
|
||||
{payout.processedBy && (
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Processed by {payout.processedBy.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ value }: { value: string }) {
|
||||
const finalStatuses = ["PAID", "SUCCEEDED", "RELEASED"];
|
||||
const negativeStatuses = ["FAILED", "EXPIRED", "CANCELLED", "REJECTED"];
|
||||
const cls = finalStatuses.includes(value)
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: negativeStatuses.includes(value)
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-amber-100 text-amber-800";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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