diff --git a/ADMIN_PAYMENT_OPS_ROADMAP.md b/ADMIN_PAYMENT_OPS_ROADMAP.md
deleted file mode 100644
index e9b9316..0000000
--- a/ADMIN_PAYMENT_OPS_ROADMAP.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# Setrip — Admin Payment Operations Roadmap
-
-Admin perlu visibilitas + kontrol penuh atas alur uang: payment Midtrans, refund, payout. Saat webhook gagal atau status mismatch, admin harus bisa reconcile tanpa edit DB.
-
-> **Skenario nyata:** webhook Midtrans drop di production. `Booking.status = AWAITING_PAY` padahal user sudah bayar (confirm email dari Midtrans). User komplain via WhatsApp. Saat ini admin harus query DB manual lalu update via Prisma Studio.
-
----
-
-## Baseline
-
-- ✅ `paymentService.reconcileFromGateway(orderId, userId)` di [server/services/payment.service.ts](server/services/payment.service.ts) sudah call Midtrans Core API + apply state-machine. Tapi `userId` check membatasi penggunaan ke user pemilik booking — admin perlu variant.
-- ✅ `paymentService.handleMidtransWebhook` ada + idempotent via `applyGatewayStatus` helper.
-- ✅ `Payment.rawCallback` simpan snapshot mentah untuk audit.
-- ✅ `Refund` + `Payout` model lengkap dengan `reviewedBy`/`processedBy`/`adminNote`.
-- ❌ Tidak ada page `/admin/bookings/[id]` untuk drill-down per-booking timeline.
-- ❌ Tidak ada admin variant `reconcileFromGateway` (yang tidak butuh userId check).
-- ❌ Tidak ada UI yang tampilkan `Payment.rawCallback` JSON.
-- ❌ Tidak ada filter refund per `reason` (mis. cari semua DISPUTE_RESOLVED).
-- ❌ Tidak ada bulk reconcile untuk stale PENDING/AWAITING payments.
-
----
-
-## Phase 1 — Booking + Payment Detail View ⏳
-
-Admin perlu satu halaman yang tampilkan **seluruh** event uang untuk satu booking: payment attempts (Midtrans + manual legacy), refund history, payout status, raw callback.
-
-**Keputusan asumsi:**
-- Drill-down dari `/admin/trips/[id]`, `/admin/refunds`, `/admin/payouts`, dan global search nanti.
-- Tampilkan **timeline chronological** semua event Payment + Refund + Payout untuk booking — bukan tabel terpisah.
-- `Payment.rawCallback` ditampilkan sebagai collapsible JSON viewer (tidak default expanded — verbose).
-- Show juga `Booking.status` history kalau ada (tidak ada saat ini — `updatedAt` jadi proxy).
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 1.1 | `bookingRepo.findByIdForAdmin(id)` — include payments (with raw), refunds, payout, trip, user, participant | ⏳ | [server/repositories/booking.repo.ts](server/repositories/booking.repo.ts) |
-| 1.2 | Page `/admin/bookings/[id]` — header (trip, user, amount, status), timeline events | ⏳ | `app/admin/bookings/[id]/page.tsx` |
-| 1.3 | Component `PaymentTimelineAdmin` — render Payment + Refund + Payout sorted by `createdAt` | ⏳ | `features/booking/components/payment-timeline-admin.tsx` |
-| 1.4 | Component `RawCallbackViewer` — collapsible `` block dengan JSON pretty-printed | ⏳ | `features/booking/components/raw-callback-viewer.tsx` |
-| 1.5 | Link "Lihat detail" dari `/admin/refunds` ke `/admin/bookings/[id]` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| 1.6 | Link "Lihat detail" dari `/admin/payouts` ke `/admin/bookings/[id]` | ⏳ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
-
-**Tindakan manual:** tidak ada.
-
----
-
-## Phase 2 — Admin Midtrans Reconciliation UI ⏳
-
-Tombol di booking detail page yang panggil Midtrans Core API + apply update. Admin variant tidak butuh `userId` check.
-
-**Keputusan asumsi:**
-- **Reuse internal helper `applyGatewayStatus`** di [server/services/payment.service.ts](server/services/payment.service.ts) — sudah ekstrak.
-- Buat `paymentService.adminReconcile(orderId, adminId)` — sama dengan `reconcileFromGateway` tapi:
- - Skip ownership check (admin bypass).
- - Log `adminId` di `Payment.rawCallback` snapshot (tambah field `_reconciledByAdminId`).
-- Server action `adminReconcileMidtransAction(orderId)` guard `isAdmin`.
-- UI: tombol per Payment row di timeline. Disable kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) tapi tetap show last-reconciled-at.
-- Tampilkan toast hasil: "Updated to PAID" / "Already PAID, no change" / "Amount mismatch (audit)".
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 2.1 | `paymentService.adminReconcile(orderId, adminId)` — variant tanpa ownership check | ⏳ | [server/services/payment.service.ts](server/services/payment.service.ts) |
-| 2.2 | Server action `adminReconcileMidtransAction(orderId)` | ⏳ | `features/booking/actions.ts` (atau `features/admin/actions.ts` baru) |
-| 2.3 | Tombol "Reconcile dari Midtrans" di tiap Payment Midtrans di timeline | ⏳ | `features/booking/components/payment-timeline-admin.tsx` |
-| 2.4 | Tampilkan `Payment.rejectionReason` (untuk amount mismatch log) di card payment | ⏳ | `features/booking/components/payment-timeline-admin.tsx` |
-| 2.5 | (Optional) Bulk reconcile: `/admin/payments/stale` — list Payment status PENDING/AWAITING > 6 jam | ⏳ | `app/admin/payments/stale/page.tsx` |
-
-**Tindakan manual:**
-1. Brief admin: kapan pakai reconcile (peserta lapor "sudah bayar tapi status belum update"). Jangan dipakai untuk PAID booking (idempotent tapi noise).
-
----
-
-## Phase 3 — Dispute & Chargeback Tracking ⏳
-
-`RefundReason.DISPUTE_RESOLVED` sudah ada di enum tapi tidak ada flow khusus.
-
-**Keputusan asumsi:**
-- Tidak buat tabel baru. Filter di refund list page cukup.
-- Tambah "Chargeback note" field di Refund kalau perlu (skip untuk MVP — pakai `adminNote` saja).
-- Highlight visual: badge merah untuk DISPUTE_RESOLVED supaya admin treat khusus.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 3.1 | Tab/filter `reason` di `/admin/refunds` — dropdown semua nilai `RefundReason` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| 3.2 | Badge khusus untuk `DISPUTE_RESOLVED` di refund card | ⏳ | [features/refund/components/refund-review-card.tsx](features/refund/components/refund-review-card.tsx) |
-| 3.3 | Dokumentasi SOP: kapan pakai `DISPUTE_RESOLVED` vs reason lain | ⏳ | `docs/admin/refund-reasons.md` (baru) |
-
-**Tindakan manual:**
-1. Tulis SOP dispute handling (alur bank → admin → refund creation).
-2. Brief admin: `DISPUTE_RESOLVED` hanya untuk chargeback yang sudah resolve via bank.
diff --git a/ADMIN_ROADMAP.md b/ADMIN_ROADMAP.md
index fe7a087..ec260ba 100644
--- a/ADMIN_ROADMAP.md
+++ b/ADMIN_ROADMAP.md
@@ -11,9 +11,11 @@ Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplika
| Area | Fungsi | File |
|---|---|---|
| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
+| **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) |
+| **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) |
| Verifikasi KYC | Approve / Reject organizer (KTP, liveness, bank) | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
-| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
-| Payout | View per status, mark PAID setelah transfer manual | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
+| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED + link ke booking timeline | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
+| Payout | View per status, mark PAID setelah transfer manual + link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
@@ -23,26 +25,40 @@ Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassin
| Roadmap | Prioritas | Status | File |
|---|---|---|---|
-| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ⏳ 0% | [ADMIN_TRIP_OPS_ROADMAP.md](ADMIN_TRIP_OPS_ROADMAP.md) |
-| Payment Operations (booking detail, reconcile, dispute) | 🔴 HIGH | 🚧 ~15% | [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md) |
+| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
+| Payment Operations (booking detail, reconcile, dispute) | 🔴 HIGH | ✅ **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
| Audit & Investigation (search, filter, export) | 🔴 HIGH | ⏳ 0% | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) |
| User Management (search, suspend/ban) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_ROADMAP.md) |
| Verification (reopen, re-upload request) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_ROADMAP.md) |
| System Health (cron monitor, stale state alerts) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
-**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai
+**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai (lihat archive untuk detail delivery)
---
-## Urutan implementasi yang direkomendasikan
+## Iterasi berikutnya (sisa HIGH + MEDIUM)
-Berdasarkan ROI (frekuensi kebutuhan × dampak insiden):
+Setelah Trip Ops + Payment Ops, urutan berikutnya:
-1. **Trip Ops** — paling sering dibutuhkan, infrastruktur service sudah lengkap (`tripService.closeTrip`)
-2. **Payment Ops** — kritikal saat webhook gagal; setengah infra sudah ada (`reconcileFromGateway`)
-3. **Audit** — compliance + investigasi dispute; data sudah lengkap (`reviewedBy`, `processedBy`, `adminNote`), tinggal UI filter/export
-4. **User Management** — moderation; butuh schema change (`User.suspended`)
-5. **Verification** — edge case rare; cuma butuh 1 service method + tombol
-6. **System Health** — operational visibility; butuh model baru (`CronRun`)
+1. **Audit & Investigation** (HIGH) — filter date range, search global, CSV export. Penting untuk compliance & investigasi dispute.
+2. **User Management** (MEDIUM) — search + suspend/ban. Butuh schema change (`User.suspended`).
+3. **System Health** (MEDIUM) — cron monitor + stale state alerts. Butuh model baru (`CronRun`).
+4. **Verification** (MEDIUM) — reopen REJECTED + re-upload request. Edge case rare tapi kecil scope.
-Tiga roadmap pertama menutup ~90% skenario "admin powerless when shit hits the fan".
+---
+
+## Tindakan manual setelah deploy
+
+Untuk versi yang berisi delivery Trip Ops + Payment Ops:
+
+```bash
+# Apply migration baru (add_trip_admin_cancel)
+npx prisma migrate deploy
+
+# Restart Next.js / PM2 supaya Prisma client baru ter-load
+pm2 restart setrip --update-env
+```
+
+Brief admin tentang dua kapabilitas baru:
+- **Force-cancel trip** di `/admin/trips/[id]` — pakai saat organizer unreachable / dispute, reason wajib min 10 karakter.
+- **Reconcile Midtrans** di `/admin/bookings/[id]` — pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent, aman diulang.
diff --git a/ADMIN_TRIP_OPS_ROADMAP.md b/ADMIN_TRIP_OPS_ROADMAP.md
deleted file mode 100644
index a952471..0000000
--- a/ADMIN_TRIP_OPS_ROADMAP.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Setrip — Admin Trip Operations Roadmap
-
-Admin perlu visibilitas penuh atas trip dan bisa intervensi (cancel + auto-refund) saat organizer unreachable atau ada masalah safety.
-
-> **Skenario nyata:** peserta lapor trip berjalan tidak sesuai itinerary. Organizer tidak responsif. Hari berikutnya peserta minta refund. Saat ini admin harus refund satu-satu manual via `/admin/refunds` tanpa konteks trip atau cara cancel trip-nya.
-
----
-
-## Baseline
-
-- ✅ `tripService.closeTrip(tripId, organizerId)` di [server/services/trip.service.ts](server/services/trip.service.ts) sudah handle cancel + auto-refund semua booking PAID atomically. Hanya menerima `organizerId` — perlu varian admin.
-- ✅ `tripRepo.findAll()` dan `tripRepo.findById()` ada — siap dipakai untuk admin list/detail.
-- ❌ Tidak ada page `/admin/trips`.
-- ❌ Tidak ada UI search/filter trip untuk admin.
-- ❌ Tidak ada UI view detail trip dari sisi admin (kondisi participant, booking, payment).
-
----
-
-## Phase 1 — Trip List + Detail View (admin read-only) ⏳
-
-Foundation. Tanpa cara cari & lihat trip, admin tidak tahu apa yang mau di-intervene.
-
-**Keputusan asumsi:**
-- Reuse `tripRepo.findAll()` tapi tambah filter param: `status`, `organizerId`, `q` (search title/destination).
-- Detail page reuse `tripService.getTripById()` yang sudah include `participants`, `images`, `reviews`, `itineraryItems`.
-- Tampilkan **semua participant** (PENDING/CONFIRMED/CANCELLED) — admin perlu konteks lengkap.
-- Drill-down ke booking detail (lihat [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) untuk lihat payment timeline.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 1.1 | `tripRepo.searchForAdmin({ q?, status?, organizerId?, dateFrom?, dateTo? })` | ⏳ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) |
-| 1.2 | Page `/admin/trips` — list + tab status (OPEN/FULL/CLOSED/COMPLETED) + search bar | ⏳ | `app/admin/trips/page.tsx` |
-| 1.3 | Filter: tanggal berangkat range, organizer (dropdown), kategori | ⏳ | `app/admin/trips/page.tsx` |
-| 1.4 | Page `/admin/trips/[id]` — full detail (trip core + itinerary items + participants + bookings ringkasan) | ⏳ | `app/admin/trips/[id]/page.tsx` |
-| 1.5 | Badge metrics di detail: peserta PAID/AWAITING/PENDING, total revenue (sum amount PAID), refund total | ⏳ | `app/admin/trips/[id]/page.tsx` |
-| 1.6 | Tambah link "Trips" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) |
-
-**Tindakan manual:** tidak ada.
-
----
-
-## Phase 2 — Admin Force-Cancel Trip dengan Auto-Refund ⏳
-
-Tombol "Cancel trip" di admin detail page yang setara dengan organizer cancel, tapi dilakukan oleh admin untuk emergency intervention.
-
-**Keputusan asumsi:**
-- **Tidak buat method baru di service**. Refactor `tripService.closeTrip` agar terima `actor: { type: "ORGANIZER", id } | { type: "ADMIN", id, reason }`. Atomic dalam satu serializable transaction (sama seperti existing).
-- Refund yang dibuat pakai `RefundReason.ORGANIZER_CANCELLED` (tetap, karena dari perspektif peserta sama saja). Tambah `adminNote` di refund record kalau actor ADMIN supaya audit trail jelas.
-- Tambah kolom `Trip.cancelledByAdminId` (nullable) + `Trip.cancelledReason` di schema — bukan kolom umum, hanya saat admin yang cancel.
-- Modal konfirmasi wajib tampilkan: jumlah booking PAID yang akan auto-refund, total nominal. Kalau organizer yang biasa cancel sudah ada confirm modal di [cancel-trip-button.tsx](features/trip/components/cancel-trip-button.tsx) — reuse pola.
-- Idempotent: kalau trip sudah CLOSED, tolak dengan pesan jelas.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 2.1 | Migration: tambah `cancelledByAdminId` (FK User) + `cancelledReason` di `Trip` | ⏳ | `prisma/migrations/` |
-| 2.2 | Refactor `tripService.closeTrip` terima `actor` discriminated union | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) |
-| 2.3 | Server action `adminCancelTripAction(tripId, reason)` — guard `isAdmin`, panggil closeTrip dengan actor ADMIN | ⏳ | `features/trip/actions.ts` |
-| 2.4 | UI: tombol "Cancel trip (admin)" di `/admin/trips/[id]` dengan modal konfirmasi + textarea reason wajib | ⏳ | `app/admin/trips/[id]/page.tsx` (atau component terpisah) |
-| 2.5 | Tampilkan badge "Dibatalkan admin" + reason di trip detail saat `cancelledByAdminId` not null | ⏳ | [app/(public)/trips/[id]/page.tsx](app/(public)/trips/[id]/page.tsx) — opsional, transparansi |
-
-**Tindakan manual:**
-1. Setelah deploy, brief admin tentang kapan boleh pakai (kriteria: organizer unreachable >7 hari, dispute peserta tidak terselesaikan, safety issue).
-2. Tulis SOP internal: kategori reason yang valid + template komunikasi ke peserta.
-
----
-
-## Phase 3 — Trip Edit Override (opsional, low priority) ⏳
-
-Admin bisa edit field non-critical (description, meetingPoint, itinerary) atas request organizer saat organizer tidak bisa login. Skip untuk MVP.
-
-| # | Item | Status | File |
-|---|---|---|---|
-| 3.1 | `tripService.adminUpdateTrip(tripId, partial, adminId, reason)` — whitelist field | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) |
-| 3.2 | UI form edit di `/admin/trips/[id]/edit` | ⏳ | `app/admin/trips/[id]/edit/page.tsx` |
-| 3.3 | Audit log entry untuk setiap edit (siapa, field apa, before/after) | ⏳ | TBD (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)) |
-
-**Tindakan manual:** tidak ada (skip phase ini sampai ada keluhan konkret).
diff --git a/app/admin/bookings/[id]/page.tsx b/app/admin/bookings/[id]/page.tsx
new file mode 100644
index 0000000..3cd7e34
--- /dev/null
+++ b/app/admin/bookings/[id]/page.tsx
@@ -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 (
+
+
+ Halaman ini hanya untuk admin SeTrip.
+
+
+ );
+ }
+
+ 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;
+ };
+
+ 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 (
+
+
+
+ ← Dashboard
+
+
+ Trip terkait
+
+
+
+
+
+ Booking
+
+
+ {booking.trip.title}
+
+
+ 📅 {formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}{" "}
+ · 📍 {booking.trip.destination}, {booking.trip.location}
+
+
+
+
+
+
+
+
+
+
+
+ Booking ID:{" "}
+
+ {booking.id}
+
+
+
+
+
+
+
+ Timeline Money Flow ({timeline.length} event)
+
+
+ {timeline.length === 0 ? (
+
+ Belum ada event payment / refund / payout untuk booking ini.
+
+ ) : (
+
+ {timeline.map((ev, idx) => (
+
+ {ev.kind === "payment" && (
+
+ )}
+ {ev.kind === "refund" && }
+ {ev.kind === "payout" && }
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function FieldRow({
+ label,
+ value,
+ sub,
+ strong,
+ badge,
+}: {
+ label: string;
+ value: string;
+ sub?: string;
+ strong?: boolean;
+ badge?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+ {badge ? (
+
+ {value}
+
+ ) : (
+
+ {value}
+
+ )}
+ {sub && (
+
{sub}
+ )}
+
+ );
+}
+
+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 (
+
+
+
+ {title}
+
+
+ {at.toLocaleString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ Order ID: {" "}
+
+ {payment.externalOrderId}
+
+
+ {payment.externalTxId && (
+
+ Transaction ID: {" "}
+
+ {payment.externalTxId}
+
+
+ )}
+
+ Nominal: {" "}
+ {formatRupiah(payment.amount)}
+
+
+ Status: {" "}
+
+ {payment.method && (
+
+ via {payment.method}
+
+ )}
+
+ {payment.expiresAt && (
+
+ Expires:{" "}
+ {payment.expiresAt.toLocaleString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ )}
+ {payment.paidAt && (
+
+ Paid at:{" "}
+ {payment.paidAt.toLocaleString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ )}
+ {payment.rejectionReason && (
+
+ ⚠️ {payment.rejectionReason}
+
+ )}
+
+ {canReconcile && (
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ Nominal: {" "}
+ {formatRupiah(refund.amount)}
+
+
+ Status: {" "}
+
+
+ {refund.reportNote && (
+
+ Report: {refund.reportNote}
+
+ )}
+ {refund.adminNote && (
+
+ Admin note: {" "}
+ {refund.adminNote}
+
+ )}
+ {refund.reviewedBy && (
+
+ 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",
+ })}
+ >
+ )}
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ Nominal: {" "}
+ {formatRupiah(payout.amount)}
+
+
+ Status: {" "}
+
+
+
+ Held until:{" "}
+ {payout.heldUntil.toLocaleDateString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ })}
+
+ {payout.paidAt && (
+
+ Paid at:{" "}
+ {payout.paidAt.toLocaleString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ )}
+ {payout.adminNote && (
+
+ Admin note: {" "}
+ {payout.adminNote}
+
+ )}
+ {payout.processedBy && (
+
+ Processed by {payout.processedBy.email}
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+ {value}
+
+ );
+}
diff --git a/app/admin/trips/[id]/page.tsx b/app/admin/trips/[id]/page.tsx
new file mode 100644
index 0000000..1b0e15f
--- /dev/null
+++ b/app/admin/trips/[id]/page.tsx
@@ -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 (
+
+
+ Halaman ini hanya untuk admin SeTrip.
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ ← Kembali ke list trips
+
+
+
+
+
+
+
+
+ {cat.icon} {cat.label}
+
+
+
+
+ {trip.title}
+
+
+ 📅 {formatTripCalendarDateRangeLong(trip.date, trip.endDate)} ·
+ 📍 {trip.destination}, {trip.location}
+
+
+ Organizer:{" "}
+
+ {trip.organizer.name}
+ {" "}
+ ({trip.organizer.email})
+
+
+ Trip ID:{" "}
+
+ {trip.id}
+
+
+
+
+
+ Harga
+
+
+ {formatRupiah(trip.price)}
+
+
per orang
+
+
+
+
+
+
+ {canCancel && (
+
+
+ Intervensi Admin — Cancel Trip
+
+
+ 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.
+
+
+
+ )}
+
+ {trip.description && (
+
+
+ Deskripsi
+
+
+ {trip.description}
+
+
+ )}
+
+ {grouped && (
+
+
+ Itinerary
+
+
+ {[...grouped.entries()].map(([day, items]) => (
+
+
+ Hari {day}
+
+
+ {items.map((item) => (
+
+
+ {item.startTime}
+ {item.endTime ? `–${item.endTime}` : ""}
+
+ {item.activity}
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
+
+
+ Peserta ({activeParticipants.length})
+
+ {trip.participants.length === 0 ? (
+ Belum ada peserta.
+ ) : (
+
+ )}
+
+
+ );
+}
+
+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 (
+
+ {status}
+
+ );
+}
+
+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 (
+
+ {status}
+
+ );
+}
+
+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 (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/app/admin/trips/page.tsx b/app/admin/trips/page.tsx
new file mode 100644
index 0000000..af61631
--- /dev/null
+++ b/app/admin/trips/page.tsx
@@ -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 (
+
+
+ Halaman ini hanya untuk admin SeTrip.
+
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+
+ {TABS.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+ {trips.length === 0 ? (
+
+
+ {q
+ ? `Tidak ada trip yang cocok dengan "${q}".`
+ : "Tidak ada trip pada status ini."}
+
+
+ ) : (
+
+ {trips.map((t) => {
+ const cat = categoryMeta(t.category);
+ return (
+
+
+
+
+
+
+ {cat.icon} {cat.label}
+
+
+
+
+ {t.title}
+
+
+ 📅 {formatTripCalendarDateRangeLong(t.date, t.endDate)}
+ {" · "}📍 {t.location}
+
+
+ Organizer:{" "}
+
+ {t.organizer.name}
+ {" "}
+
+ ({t.organizer.email})
+
+
+
+
+
+ {formatRupiah(t.price)}
+
+
+ {t._count.participants}/{t.maxParticipants} peserta
+
+
+ {t._count.bookings} PAID
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+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 (
+
+ {status}
+
+ );
+}
diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx
index 89db5ef..e5da104 100644
--- a/components/admin/admin-sidebar.tsx
+++ b/components/admin/admin-sidebar.tsx
@@ -8,6 +8,7 @@ import { signOut } from "next-auth/react";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" },
+ { href: "/admin/trips", label: "Trips", icon: "🧭" },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
diff --git a/docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md b/docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md
new file mode 100644
index 0000000..53072d6
--- /dev/null
+++ b/docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md
@@ -0,0 +1,51 @@
+# Setrip — Admin Payment Operations Roadmap (ARCHIVED — DELIVERED 2026-05-18)
+
+Admin perlu visibilitas + kontrol penuh atas alur uang: payment Midtrans, refund, payout. Saat webhook gagal atau status mismatch, admin harus bisa reconcile tanpa edit DB.
+
+> **Skenario nyata:** webhook Midtrans drop di production. `Booking.status = AWAITING_PAY` padahal user sudah bayar (confirm email dari Midtrans). User komplain via WhatsApp. Saat ini admin harus query DB manual lalu update via Prisma Studio.
+
+---
+
+## Status delivery
+
+| Phase | Status | Catatan |
+|---|---|---|
+| Phase 1 — Booking + Payment Detail View | ✅ Delivered | Timeline lintas Payment + Refund + Payout dengan raw callback viewer. |
+| Phase 2 — Admin Midtrans Reconcile UI | ✅ Delivered | Tombol reconcile per Payment Midtrans, panggil Core API + apply state-machine. Bulk reconcile deferred. |
+| Phase 3 — Dispute & Chargeback Tracking | ⏳ Deferred | Enum `DISPUTE_RESOLVED` sudah ada — admin bisa pakai existing flow refund. UI filter khusus bisa ditambah nanti. |
+
+---
+
+## Phase 1 — Booking + Payment Detail View ✅
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 1.1 | `bookingRepo.findByIdForAdmin(id)` — include payments (with raw), refunds, payout, trip, user | ✅ | [server/repositories/booking.repo.ts](../../server/repositories/booking.repo.ts) |
+| 1.2 | Page `/admin/bookings/[id]` — header booking + timeline events | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
+| 1.3 | Inline timeline (Payment + Refund + Payout) sorted by createdAt — implemented inline di page | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
+| 1.4 | Component `RawCallbackViewer` — collapsible JSON pretty-printed | ✅ | [features/booking/components/raw-callback-viewer.tsx](../../features/booking/components/raw-callback-viewer.tsx) |
+| 1.5 | Link "Lihat timeline" dari `/admin/refunds` ke `/admin/bookings/[id]` | ✅ | [features/refund/components/refund-review-card.tsx](../../features/refund/components/refund-review-card.tsx) |
+| 1.6 | Link "Lihat timeline" dari `/admin/payouts` ke `/admin/bookings/[id]` | ✅ | [features/payout/components/payout-review-card.tsx](../../features/payout/components/payout-review-card.tsx) |
+
+---
+
+## Phase 2 — Admin Midtrans Reconciliation UI ✅
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 2.1 | `paymentService.adminReconcile(orderId)` — variant tanpa ownership check, reuse `applyGatewayStatus` helper | ✅ | [server/services/payment.service.ts](../../server/services/payment.service.ts) |
+| 2.2 | Server action `adminReconcileMidtransAction(orderId)` (guard isAdmin) | ✅ | [features/booking/actions.ts](../../features/booking/actions.ts) |
+| 2.3 | Component `AdminReconcileButton` per Payment Midtrans di timeline | ✅ | [features/booking/components/admin-reconcile-button.tsx](../../features/booking/components/admin-reconcile-button.tsx) |
+| 2.4 | Tampilkan `Payment.rejectionReason` (amount mismatch log) di card payment | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
+| 2.5 | Bulk reconcile: `/admin/payments/stale` — list Payment AWAITING > 6 jam | ⏳ | Deferred — admin bisa filter manual via list saat itu butuh, tidak ada incident concrete yang minta bulk. |
+
+**Tindakan manual yang masih perlu dilakukan ops:**
+1. Brief admin: kapan pakai tombol "Reconcile Midtrans" — saat peserta lapor "sudah bayar tapi status belum update".
+2. Tombol idempotent — aman ditekan berkali-kali. Tidak menggandakan payment.
+3. Pakai `RawCallbackViewer` untuk inspeksi error gateway / metadata transaksi saat investigasi dispute.
+
+---
+
+## Phase 3 — Dispute & Chargeback Tracking ⏳ (deferred)
+
+Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). Akan diangkat kembali kalau volume chargeback membesar.
diff --git a/docs/archive/ADMIN_TRIP_OPS_ROADMAP.md b/docs/archive/ADMIN_TRIP_OPS_ROADMAP.md
new file mode 100644
index 0000000..6c41616
--- /dev/null
+++ b/docs/archive/ADMIN_TRIP_OPS_ROADMAP.md
@@ -0,0 +1,52 @@
+# Setrip — Admin Trip Operations Roadmap (ARCHIVED — DELIVERED 2026-05-18)
+
+Admin perlu visibilitas penuh atas trip dan bisa intervensi (cancel + auto-refund) saat organizer unreachable atau ada masalah safety.
+
+> **Skenario nyata:** peserta lapor trip berjalan tidak sesuai itinerary. Organizer tidak responsif. Hari berikutnya peserta minta refund. Saat ini admin harus refund satu-satu manual via `/admin/refunds` tanpa konteks trip atau cara cancel trip-nya.
+
+---
+
+## Status delivery
+
+| Phase | Status | Catatan |
+|---|---|---|
+| Phase 1 — List + Detail View | ✅ Delivered | Filter dasar `status` + search `q`. Filter advanced (date range, organizer dropdown, kategori) belum dipakai — bisa ditambah di iterasi berikut kalau ada kebutuhan konkret. |
+| Phase 2 — Admin Force-Cancel | ✅ Delivered | `tripService.closeTrip` di-refactor terima actor union (ORGANIZER \| ADMIN). Migration menambah `Trip.cancelledByAdminId` + `cancelledReason`. |
+| Phase 3 — Trip Edit Override | ⏳ Deferred | Opsional, skip MVP. Evaluasi ulang kalau ada keluhan konkret. |
+
+---
+
+## Phase 1 — Trip List + Detail View ✅
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 1.1 | `tripRepo.searchForAdmin({ q?, status? })` | ✅ | [server/repositories/trip.repo.ts](../../server/repositories/trip.repo.ts) |
+| 1.2 | Page `/admin/trips` — list + tab status (ALL/OPEN/FULL/CLOSED/COMPLETED) + search bar | ✅ | [app/admin/trips/page.tsx](../../app/admin/trips/page.tsx) |
+| 1.3 | Filter: tanggal range, organizer dropdown, kategori | ⏳ | Deferred — search `q` sudah cover sebagian (search organizer name/email). |
+| 1.4 | Page `/admin/trips/[id]` — full detail (trip core + itinerary items + participants) | ✅ | [app/admin/trips/[id]/page.tsx](../../app/admin/trips/[id]/page.tsx) |
+| 1.5 | Stat cards: kapasitas, confirmed, pending, cancelled | ✅ | [app/admin/trips/[id]/page.tsx](../../app/admin/trips/[id]/page.tsx) |
+| 1.6 | Link "Trips" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
+
+---
+
+## Phase 2 — Admin Force-Cancel Trip ✅
+
+| # | Item | Status | File |
+|---|---|---|---|
+| 2.1 | Migration: `cancelledByAdminId` (FK User, ON DELETE SET NULL) + `cancelledReason` | ✅ | `prisma/migrations/20260518150000_add_trip_admin_cancel/` |
+| 2.2 | Refactor `tripService.closeTrip` terima `actor: { type: "ORGANIZER" \| "ADMIN", ... }` | ✅ | [server/services/trip.service.ts](../../server/services/trip.service.ts) |
+| 2.3 | Server action `adminCancelTripAction(tripId, reason)` — guard isAdmin, reason min 10 char | ✅ | [features/trip/actions.ts](../../features/trip/actions.ts) |
+| 2.4 | UI: tombol "Cancel Trip (Admin)" di `/admin/trips/[id]` dengan modal reason wajib + summary impact | ✅ | [features/trip/components/admin-cancel-trip-button.tsx](../../features/trip/components/admin-cancel-trip-button.tsx) |
+| 2.5 | Badge "Dibatalkan admin" + reason di public trip detail | ⏳ | Deferred — kolom DB sudah ada, tinggal tambah UI saat ada kebutuhan transparansi. |
+
+**Tindakan manual yang sudah dilakukan:** none — admin tinggal pakai.
+
+**Tindakan manual yang masih perlu dilakukan ops:**
+1. Jalankan migration di production: `npx prisma migrate deploy`.
+2. Brief admin: kriteria reason yang valid (organizer unreachable >7 hari, dispute peserta tidak terselesaikan, safety issue). Reason wajib min 10 karakter untuk audit.
+
+---
+
+## Phase 3 — Trip Edit Override ⏳ (deferred)
+
+Skip MVP. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md) kalau perlu re-open.
diff --git a/features/booking/actions.ts b/features/booking/actions.ts
index 9273bd5..c30d227 100644
--- a/features/booking/actions.ts
+++ b/features/booking/actions.ts
@@ -56,6 +56,37 @@ export async function startMidtransPaymentAction(
}
}
+/**
+ * Admin variant reconcile — skip ownership check, dipakai dari panel admin
+ * `/admin/bookings/[id]` saat investigasi.
+ */
+export async function adminReconcileMidtransAction(orderId: string) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { error: "Kamu harus login terlebih dahulu" };
+ }
+ const { isAdminEmail } = await import("@/lib/admin");
+ if (!isAdminEmail(session.user.email)) {
+ return { error: "Hanya admin yang bisa melakukan aksi ini" };
+ }
+ if (!orderId || typeof orderId !== "string") {
+ return { error: "order_id tidak valid" };
+ }
+
+ try {
+ const result = await paymentService.adminReconcile(orderId);
+ if (!result.ok) {
+ if (result.reason === "not_found") {
+ return { error: "Order tidak ditemukan di sistem" };
+ }
+ return { error: "Status pembayaran tidak cocok dengan tagihan" };
+ }
+ return { success: true as const, status: result.status };
+ } catch (err) {
+ return { error: (err as Error).message };
+ }
+}
+
/**
* Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
diff --git a/features/booking/components/admin-reconcile-button.tsx b/features/booking/components/admin-reconcile-button.tsx
new file mode 100644
index 0000000..4e1e824
--- /dev/null
+++ b/features/booking/components/admin-reconcile-button.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { adminReconcileMidtransAction } from "@/features/booking/actions";
+
+interface AdminReconcileButtonProps {
+ orderId: string;
+ disabled?: boolean;
+}
+
+export function AdminReconcileButton({
+ orderId,
+ disabled,
+}: AdminReconcileButtonProps) {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const [status, setStatus] = useState(null);
+ const [error, setError] = useState("");
+
+ async function handleClick() {
+ setLoading(true);
+ setError("");
+ setStatus(null);
+ const res = await adminReconcileMidtransAction(orderId);
+ setLoading(false);
+ if ("error" in res && res.error) {
+ setError(res.error);
+ return;
+ }
+ if ("status" in res && res.status) {
+ setStatus(res.status);
+ }
+ router.refresh();
+ }
+
+ return (
+
+
+ {loading ? "Reconciling..." : "Reconcile Midtrans"}
+
+ {status && (
+
+ ✓ {reconcileOutcomeLabel(status)}
+
+ )}
+ {error && (
+ {error}
+ )}
+
+ );
+}
+
+function reconcileOutcomeLabel(status: string): string {
+ switch (status) {
+ case "updated":
+ return "Status di-update";
+ case "skipped":
+ return "Sudah final (tidak ada perubahan)";
+ case "ignored":
+ return "Tidak dikenali (mungkin sudah dihapus)";
+ case "booking_conflict":
+ return "Gateway PAID tapi booking di state konflik — perlu review manual";
+ case "not_found":
+ return "Order tidak ditemukan di Midtrans";
+ default:
+ return status;
+ }
+}
diff --git a/features/booking/components/raw-callback-viewer.tsx b/features/booking/components/raw-callback-viewer.tsx
new file mode 100644
index 0000000..f87991c
--- /dev/null
+++ b/features/booking/components/raw-callback-viewer.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useState } from "react";
+
+interface RawCallbackViewerProps {
+ payload: unknown;
+}
+
+export function RawCallbackViewer({ payload }: RawCallbackViewerProps) {
+ const [open, setOpen] = useState(false);
+
+ if (payload == null) {
+ return (
+
+ Belum ada callback dari gateway.
+
+ );
+ }
+
+ return (
+
+
setOpen((v) => !v)}
+ className="text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
+ >
+ {open ? "▼ Sembunyikan raw callback" : "▶ Lihat raw callback JSON"}
+
+ {open && (
+
+ {JSON.stringify(payload, null, 2)}
+
+ )}
+
+ );
+}
diff --git a/features/payout/components/payout-review-card.tsx b/features/payout/components/payout-review-card.tsx
index 5bff0a3..049136a 100644
--- a/features/payout/components/payout-review-card.tsx
+++ b/features/payout/components/payout-review-card.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
import { markPayoutPaidAction } from "@/features/payout/actions";
import { formatRupiah } from "@/lib/utils";
@@ -92,6 +93,12 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
{" · "}
{payout.id.slice(0, 8)}…
+
+ → Lihat timeline booking
+
diff --git a/features/refund/components/refund-review-card.tsx b/features/refund/components/refund-review-card.tsx
index 471a9bf..df0516b 100644
--- a/features/refund/components/refund-review-card.tsx
+++ b/features/refund/components/refund-review-card.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
import { decideRefundAction } from "@/features/refund/actions";
import { formatRupiah } from "@/lib/utils";
@@ -119,7 +120,20 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
label="Peserta booking"
value={`${refund.booking.user.name} · ${refund.booking.user.email}`}
/>
-
+
+
+ Booking ID
+
+
+ {refund.booking.id}
+
+
+ → Lihat timeline payment & refund
+
+
500) {
+ return { error: "Alasan cancel maksimal 500 karakter" };
+ }
+
+ try {
+ const result = await tripService.closeTrip(tripId, {
+ type: "ADMIN",
+ adminId: session.user.id,
+ reason: trimmedReason,
+ });
+ revalidatePath(`/trips/${tripId}`);
+ revalidatePath(`/admin/trips/${tripId}`);
+ revalidatePath("/admin/trips");
+ revalidatePath("/admin/refunds");
+ revalidatePath("/trips");
+ return {
+ success: true as const,
+ refundCount: result.refundsCreated.length,
+ cancelledCount: result.cancelledBookings.length,
+ skippedCount: result.skippedBookings.length,
+ };
+ } catch (err) {
+ return { error: (err as Error).message };
+ }
+}
diff --git a/features/trip/components/admin-cancel-trip-button.tsx b/features/trip/components/admin-cancel-trip-button.tsx
new file mode 100644
index 0000000..b8d8e44
--- /dev/null
+++ b/features/trip/components/admin-cancel-trip-button.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { adminCancelTripAction } from "@/features/trip/actions";
+
+interface AdminCancelTripButtonProps {
+ tripId: string;
+}
+
+export function AdminCancelTripButton({ tripId }: AdminCancelTripButtonProps) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [reason, setReason] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [result, setResult] = useState<{
+ refundCount: number;
+ cancelledCount: number;
+ skippedCount: number;
+ } | null>(null);
+
+ async function handleConfirm() {
+ setLoading(true);
+ setError("");
+ const res = await adminCancelTripAction(tripId, reason);
+ setLoading(false);
+ if ("error" in res && res.error) {
+ setError(res.error);
+ return;
+ }
+ if ("success" in res && res.success) {
+ setResult({
+ refundCount: res.refundCount,
+ cancelledCount: res.cancelledCount,
+ skippedCount: res.skippedCount,
+ });
+ router.refresh();
+ }
+ }
+
+ if (result) {
+ return (
+
+
✅ Trip berhasil dibatalkan.
+
+ • {result.refundCount} booking PAID → refund auto-dibuat
+
+ • {result.cancelledCount} booking PENDING/AWAITING_PAY → CANCELLED
+
+ {result.skippedCount > 0 && (
+
+ • {result.skippedCount} booking di-skip (sudah ada refund aktif —
+ cek manual)
+
+ )}
+
+
+ );
+ }
+
+ if (!open) {
+ return (
+ setOpen(true)}
+ className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700"
+ >
+ Cancel Trip (Admin)
+
+ );
+ }
+
+ return (
+
+
+
+ Alasan cancel (wajib, min 10 karakter — untuk audit)
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {loading ? "Membatalkan..." : "Konfirmasi Cancel"}
+
+ {
+ setOpen(false);
+ setReason("");
+ setError("");
+ }}
+ disabled={loading}
+ className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
+ >
+ Batal
+
+
+
+ );
+}
diff --git a/prisma/migrations/20260518150000_add_trip_admin_cancel/migration.sql b/prisma/migrations/20260518150000_add_trip_admin_cancel/migration.sql
new file mode 100644
index 0000000..3308310
--- /dev/null
+++ b/prisma/migrations/20260518150000_add_trip_admin_cancel/migration.sql
@@ -0,0 +1,11 @@
+-- AlterTable: tambah jejak admin yang membatalkan trip (Phase 2 admin trip ops).
+-- `cancelledByAdminId` nullable — diisi hanya saat actor cancel = ADMIN
+-- (organizer cancel tidak mengisi field ini).
+ALTER TABLE "Trip" ADD COLUMN "cancelledByAdminId" TEXT;
+ALTER TABLE "Trip" ADD COLUMN "cancelledReason" TEXT;
+
+-- AddForeignKey: organizer/admin cancel terjaga referensinya kalau admin
+-- nantinya dihapus, set NULL supaya history Trip tidak rusak.
+ALTER TABLE "Trip" ADD CONSTRAINT "Trip_cancelledByAdminId_fkey"
+ FOREIGN KEY ("cancelledByAdminId") REFERENCES "User"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e20d755..b6e7d4e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -39,6 +39,9 @@ model User {
/// Payout yang ditandai admin sebagai PAID/CANCELLED oleh user ini.
processedPayouts Payout[] @relation("PayoutProcessor")
+ /// Trip yang dibatalkan admin ini lewat panel admin (intervensi).
+ adminCancelledTrips Trip[] @relation("TripCancelledByAdmin")
+
profile UserProfile?
}
@@ -157,6 +160,12 @@ model Trip {
/// Ritme/energi trip — dipakai untuk matching dengan vibe user.
vibe Vibe?
status TripStatus @default(OPEN)
+ /// Admin yang membatalkan trip via panel admin (intervensi). NULL kalau
+ /// organizer yang cancel sendiri atau trip tidak dibatalkan.
+ cancelledByAdminId String?
+ cancelledByAdmin User? @relation("TripCancelledByAdmin", fields: [cancelledByAdminId], references: [id], onDelete: SetNull)
+ /// Alasan admin membatalkan trip — wajib diisi saat admin cancel.
+ cancelledReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
diff --git a/server/repositories/booking.repo.ts b/server/repositories/booking.repo.ts
index 27fe2b7..453e0b9 100644
--- a/server/repositories/booking.repo.ts
+++ b/server/repositories/booking.repo.ts
@@ -20,6 +20,46 @@ export const bookingRepo = {
});
},
+ /**
+ * Detail booking lengkap untuk admin investigation: payments (with raw
+ * callback), refunds, payout, trip + organizer, user. Dipakai oleh
+ * `/admin/bookings/[id]` untuk timeline lengkap money flow.
+ */
+ async findByIdForAdmin(id: string) {
+ return prisma.booking.findUnique({
+ where: { id },
+ include: {
+ trip: {
+ select: {
+ id: true,
+ title: true,
+ destination: true,
+ location: true,
+ date: true,
+ endDate: true,
+ price: true,
+ status: true,
+ organizer: { select: { id: true, name: true, email: true } },
+ },
+ },
+ user: { select: { id: true, name: true, email: true, image: true } },
+ participant: true,
+ payments: { orderBy: { createdAt: "asc" } },
+ refunds: {
+ orderBy: { createdAt: "asc" },
+ include: {
+ reviewedBy: { select: { id: true, name: true, email: true } },
+ },
+ },
+ payout: {
+ include: {
+ processedBy: { select: { id: true, name: true, email: true } },
+ },
+ },
+ },
+ });
+ },
+
async create(
data: Pick<
Prisma.BookingUncheckedCreateInput,
diff --git a/server/repositories/trip.repo.ts b/server/repositories/trip.repo.ts
index 46f5b68..5bbbd36 100644
--- a/server/repositories/trip.repo.ts
+++ b/server/repositories/trip.repo.ts
@@ -191,6 +191,45 @@ export const tripRepo = {
});
},
+ /**
+ * Admin search lintas trip. Tidak filter berdasarkan status departure (admin
+ * boleh lihat semua: OPEN/FULL/CLOSED/COMPLETED). Include _count peserta
+ * aktif untuk indikator cepat.
+ */
+ async searchForAdmin(filters: {
+ q?: string;
+ status?: "OPEN" | "FULL" | "CLOSED" | "COMPLETED";
+ }) {
+ const where: Prisma.TripWhereInput = {};
+ if (filters.status) {
+ where.status = filters.status;
+ }
+ if (filters.q) {
+ where.OR = [
+ { title: { contains: filters.q, mode: "insensitive" } },
+ { destination: { contains: filters.q, mode: "insensitive" } },
+ { location: { contains: filters.q, mode: "insensitive" } },
+ { organizer: { name: { contains: filters.q, mode: "insensitive" } } },
+ { organizer: { email: { contains: filters.q, mode: "insensitive" } } },
+ ];
+ }
+ return prisma.trip.findMany({
+ where,
+ include: {
+ organizer: { select: { id: true, name: true, email: true } },
+ images: { orderBy: { order: "asc" }, take: 1 },
+ _count: {
+ select: {
+ participants: { where: { status: { not: "CANCELLED" } } },
+ bookings: { where: { status: "PAID" } },
+ },
+ },
+ },
+ orderBy: { date: "desc" },
+ take: 100,
+ });
+ },
+
/** Semua trip yang dibuat user (semua status), terbaru dulu — untuk profil. */
async findByOrganizerId(organizerId: string) {
return prisma.trip.findMany({
diff --git a/server/services/payment.service.ts b/server/services/payment.service.ts
index e28574f..09bd524 100644
--- a/server/services/payment.service.ts
+++ b/server/services/payment.service.ts
@@ -381,6 +381,49 @@ export const paymentService = {
* Auth: caller harus pastikan `userId` adalah owner booking; kita verifikasi
* di sini lewat lookup payment → booking.userId.
*/
+ /**
+ * Admin variant `reconcileFromGateway` — skip ownership check (admin bypass).
+ * Dipakai dari `/admin/bookings/[id]` saat user lapor "sudah bayar tapi
+ * status belum update". Idempotent: aman dipanggil berulang.
+ */
+ async adminReconcile(
+ orderId: string
+ ): Promise<
+ | {
+ ok: true;
+ status:
+ | "updated"
+ | "skipped"
+ | "ignored"
+ | "booking_conflict"
+ | "not_found";
+ }
+ | { ok: false; reason: "amount_mismatch" | "not_found" }
+ > {
+ const payment = await prisma.payment.findUnique({
+ where: { externalOrderId: orderId },
+ select: { id: true },
+ });
+ if (!payment) {
+ return { ok: false, reason: "not_found" };
+ }
+
+ const status = await fetchMidtransTransactionStatus(orderId);
+ if (!status) {
+ return { ok: true, status: "not_found" };
+ }
+
+ return applyGatewayStatus({
+ order_id: status.order_id,
+ gross_amount: status.gross_amount,
+ transaction_status: status.transaction_status,
+ fraud_status: status.fraud_status ?? null,
+ transaction_id: status.transaction_id,
+ payment_type: status.payment_type,
+ rawSource: status as unknown as Prisma.InputJsonValue,
+ });
+ },
+
async reconcileFromGateway(
orderId: string,
userId: string
diff --git a/server/services/trip.service.ts b/server/services/trip.service.ts
index 766180a..7b5e6b1 100644
--- a/server/services/trip.service.ts
+++ b/server/services/trip.service.ts
@@ -404,9 +404,10 @@ export const tripService = {
},
/**
- * Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
- * serializable transaction:
- * - Set Trip.status = CLOSED.
+ * Batalkan trip (Trip.status = CLOSED). Bisa dipicu organizer sendiri ATAU
+ * admin (intervensi). Atomic dalam satu serializable transaction:
+ * - Set Trip.status = CLOSED (+ `cancelledByAdminId` & `cancelledReason`
+ * kalau actor = ADMIN).
* - Untuk setiap peserta aktif:
* - Booking PAID → buat Refund ORGANIZER_CANCELLED (auto-approved, full
* amount). Booking tetap PAID sampai admin mark SUCCEEDED — jejak
@@ -420,7 +421,12 @@ export const tripService = {
* Idempotent: trip yang sudah CLOSED/COMPLETED akan ditolak supaya tidak
* dobel-buat refund.
*/
- async closeTrip(tripId: string, organizerId: string) {
+ async closeTrip(
+ tripId: string,
+ actor:
+ | { type: "ORGANIZER"; userId: string }
+ | { type: "ADMIN"; adminId: string; reason: string }
+ ) {
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
@@ -433,7 +439,10 @@ export const tripService = {
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
- if (trip.organizerId !== organizerId) {
+ if (
+ actor.type === "ORGANIZER" &&
+ trip.organizerId !== actor.userId
+ ) {
throw new Error(
"Hanya organizer trip ini yang bisa membatalkan trip"
);
@@ -544,7 +553,13 @@ export const tripService = {
await tx.trip.update({
where: { id: tripId },
- data: { status: "CLOSED" },
+ data: {
+ status: "CLOSED",
+ ...(actor.type === "ADMIN" && {
+ cancelledByAdminId: actor.adminId,
+ cancelledReason: actor.reason,
+ }),
+ },
});
return {