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

471 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 { 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>
);
}