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) => ( +
  1. + {ev.kind === "payment" && ( + + )} + {ev.kind === "refund" && } + {ev.kind === "payout" && } +
  2. + ))} +
+ )} +
+
+ ); +} + +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) => ( +
  1. + + {item.startTime} + {item.endTime ? `–${item.endTime}` : ""} + + {item.activity} +
  2. + ))} +
+
+ ))} +
+
+ )} + +
+

+ Peserta ({activeParticipants.length}) +

+ {trip.participants.length === 0 ? ( +

Belum ada peserta.

+ ) : ( +
    + {trip.participants.map((p) => ( +
  • +
    + + {p.user.name} + + {p.user.profile?.city && ( + + 📍 {p.user.profile.city} + + )} +
    + +
  • + ))} +
+ )} +
+
+ ); +} + +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 ( +
+
+

+ Trip Operations +

+

+ Cari trip, lihat detail, dan intervensi (cancel + auto-refund) saat + organizer unreachable. +

+
+ +
+ + + + {q && ( + + Reset + + )} +
+ +
+ {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 ( +
+ + {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 ( +
+ + {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 ( + + ); + } + + return ( +
+
+ +