Compare commits
2 Commits
e1966b69f1
...
c52b12daad
| Author | SHA1 | Date | |
|---|---|---|---|
| c52b12daad | |||
| 4bcb93e283 |
@@ -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 `<details>` 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.
|
|
||||||
+30
-14
@@ -11,9 +11,11 @@ Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplika
|
|||||||
| Area | Fungsi | File |
|
| Area | Fungsi | File |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
|
| 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) |
|
| 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) |
|
| 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 | [app/admin/payouts/page.tsx](app/admin/payouts/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).
|
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 |
|
| Roadmap | Prioritas | Status | File |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ⏳ 0% | [ADMIN_TRIP_OPS_ROADMAP.md](ADMIN_TRIP_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 | 🚧 ~15% | [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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`)
|
1. **Audit & Investigation** (HIGH) — filter date range, search global, CSV export. Penting untuk compliance & investigasi dispute.
|
||||||
2. **Payment Ops** — kritikal saat webhook gagal; setengah infra sudah ada (`reconcileFromGateway`)
|
2. **User Management** (MEDIUM) — search + suspend/ban. Butuh schema change (`User.suspended`).
|
||||||
3. **Audit** — compliance + investigasi dispute; data sudah lengkap (`reviewedBy`, `processedBy`, `adminNote`), tinggal UI filter/export
|
3. **System Health** (MEDIUM) — cron monitor + stale state alerts. Butuh model baru (`CronRun`).
|
||||||
4. **User Management** — moderation; butuh schema change (`User.suspended`)
|
4. **Verification** (MEDIUM) — reopen REJECTED + re-upload request. Edge case rare tapi kecil scope.
|
||||||
5. **Verification** — edge case rare; cuma butuh 1 service method + tombol
|
|
||||||
6. **System Health** — operational visibility; butuh model baru (`CronRun`)
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
|
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||||
|
import { formatRupiah } from "@/lib/utils";
|
||||||
|
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||||
|
import { AdminReconcileButton } from "@/features/booking/components/admin-reconcile-button";
|
||||||
|
import { RawCallbackViewer } from "@/features/booking/components/raw-callback-viewer";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminBookingDetailPage({ params }: PageProps) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/login?callbackUrl=/admin/bookings");
|
||||||
|
}
|
||||||
|
if (!isAdminEmail(session.user.email)) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Halaman ini hanya untuk admin SeTrip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const booking = await bookingRepo.findByIdForAdmin(id);
|
||||||
|
if (!booking) notFound();
|
||||||
|
|
||||||
|
// Build chronological timeline lintas Payment + Refund + Payout.
|
||||||
|
type TimelineEvent =
|
||||||
|
| {
|
||||||
|
kind: "payment";
|
||||||
|
at: Date;
|
||||||
|
payment: (typeof booking.payments)[number];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "refund";
|
||||||
|
at: Date;
|
||||||
|
refund: (typeof booking.refunds)[number];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "payout";
|
||||||
|
at: Date;
|
||||||
|
payout: NonNullable<typeof booking.payout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeline: TimelineEvent[] = [];
|
||||||
|
for (const p of booking.payments) {
|
||||||
|
timeline.push({ kind: "payment", at: p.createdAt, payment: p });
|
||||||
|
}
|
||||||
|
for (const r of booking.refunds) {
|
||||||
|
timeline.push({ kind: "refund", at: r.createdAt, refund: r });
|
||||||
|
}
|
||||||
|
if (booking.payout) {
|
||||||
|
timeline.push({
|
||||||
|
kind: "payout",
|
||||||
|
at: booking.payout.createdAt,
|
||||||
|
payout: booking.payout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
timeline.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500">
|
||||||
|
<Link href="/admin" className="hover:text-primary-600">
|
||||||
|
← Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/admin/trips/${booking.tripId}`}
|
||||||
|
className="hover:text-primary-600"
|
||||||
|
>
|
||||||
|
Trip terkait
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Booking
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||||
|
{booking.trip.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
📅 {formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}{" "}
|
||||||
|
· 📍 {booking.trip.destination}, {booking.trip.location}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<FieldRow label="Peserta" value={booking.user.name} sub={booking.user.email} />
|
||||||
|
<FieldRow
|
||||||
|
label="Organizer"
|
||||||
|
value={booking.trip.organizer.name}
|
||||||
|
sub={booking.trip.organizer.email}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
label="Nominal booking"
|
||||||
|
value={formatRupiah(booking.amount)}
|
||||||
|
strong
|
||||||
|
/>
|
||||||
|
<FieldRow label="Status booking" value={booking.status} badge />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-neutral-100 pt-3 text-[11px] text-neutral-500">
|
||||||
|
<p>
|
||||||
|
Booking ID:{" "}
|
||||||
|
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700">
|
||||||
|
{booking.id}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
|
||||||
|
<h2 className="mb-4 text-sm font-bold text-neutral-900 sm:text-base">
|
||||||
|
Timeline Money Flow ({timeline.length} event)
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{timeline.length === 0 ? (
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Belum ada event payment / refund / payout untuk booking ini.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{timeline.map((ev, idx) => (
|
||||||
|
<li key={idx} className="border-l-2 border-neutral-200 pl-4">
|
||||||
|
{ev.kind === "payment" && (
|
||||||
|
<PaymentEventCard payment={ev.payment} />
|
||||||
|
)}
|
||||||
|
{ev.kind === "refund" && <RefundEventCard refund={ev.refund} />}
|
||||||
|
{ev.kind === "payout" && <PayoutEventCard payout={ev.payout} />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
strong,
|
||||||
|
badge,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
strong?: boolean;
|
||||||
|
badge?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{badge ? (
|
||||||
|
<span className="mt-0.5 inline-block rounded-full bg-neutral-200 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-neutral-700">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className={`mt-0.5 text-neutral-800 ${
|
||||||
|
strong ? "text-base font-bold text-primary-700" : "text-sm font-semibold"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sub && (
|
||||||
|
<p className="text-[11px] text-neutral-500">{sub}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventHeader({
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
at,
|
||||||
|
}: {
|
||||||
|
kind: "payment" | "refund" | "payout";
|
||||||
|
title: string;
|
||||||
|
at: Date;
|
||||||
|
}) {
|
||||||
|
const dotCls =
|
||||||
|
kind === "payment"
|
||||||
|
? "bg-secondary-500"
|
||||||
|
: kind === "refund"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-emerald-500";
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${dotCls}`} />
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-neutral-700">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-neutral-400">
|
||||||
|
{at.toLocaleString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentEventCard({
|
||||||
|
payment,
|
||||||
|
}: {
|
||||||
|
payment: {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
method: string | null;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
externalOrderId: string;
|
||||||
|
externalTxId: string | null;
|
||||||
|
snapToken: string | null;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
paidAt: Date | null;
|
||||||
|
failedAt: Date | null;
|
||||||
|
rejectionReason: string | null;
|
||||||
|
rawCallback: unknown;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const canReconcile = payment.provider === "MIDTRANS";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<EventHeader
|
||||||
|
kind="payment"
|
||||||
|
title={`Payment ${payment.provider}`}
|
||||||
|
at={payment.createdAt}
|
||||||
|
/>
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 sm:p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1 text-xs text-neutral-700">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Order ID:</span>{" "}
|
||||||
|
<code className="rounded bg-white px-1.5 py-0.5 font-mono text-[11px]">
|
||||||
|
{payment.externalOrderId}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
{payment.externalTxId && (
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Transaction ID:</span>{" "}
|
||||||
|
<code className="rounded bg-white px-1.5 py-0.5 font-mono text-[11px]">
|
||||||
|
{payment.externalTxId}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Nominal:</span>{" "}
|
||||||
|
{formatRupiah(payment.amount)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Status:</span>{" "}
|
||||||
|
<StatusBadge value={payment.status} />
|
||||||
|
{payment.method && (
|
||||||
|
<span className="ml-2 text-neutral-500">
|
||||||
|
via <span className="font-medium">{payment.method}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{payment.expiresAt && (
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
Expires:{" "}
|
||||||
|
{payment.expiresAt.toLocaleString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payment.paidAt && (
|
||||||
|
<p className="text-emerald-700">
|
||||||
|
Paid at:{" "}
|
||||||
|
{payment.paidAt.toLocaleString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payment.rejectionReason && (
|
||||||
|
<p className="text-red-700">
|
||||||
|
⚠️ {payment.rejectionReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canReconcile && (
|
||||||
|
<AdminReconcileButton orderId={payment.externalOrderId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 border-t border-neutral-200 pt-3">
|
||||||
|
<RawCallbackViewer payload={payment.rawCallback} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefundEventCard({
|
||||||
|
refund,
|
||||||
|
}: {
|
||||||
|
refund: {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
reason: string;
|
||||||
|
status: string;
|
||||||
|
adminNote: string | null;
|
||||||
|
reportNote: string;
|
||||||
|
createdAt: Date;
|
||||||
|
reviewedAt: Date | null;
|
||||||
|
succeededAt: Date | null;
|
||||||
|
failedAt: Date | null;
|
||||||
|
reviewedBy: { id: string; name: string; email: string } | null;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<EventHeader
|
||||||
|
kind="refund"
|
||||||
|
title={`Refund (${refund.reason})`}
|
||||||
|
at={refund.createdAt}
|
||||||
|
/>
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50/60 p-3 sm:p-4">
|
||||||
|
<div className="space-y-1 text-xs text-neutral-700">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Nominal:</span>{" "}
|
||||||
|
{formatRupiah(refund.amount)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Status:</span>{" "}
|
||||||
|
<StatusBadge value={refund.status} />
|
||||||
|
</p>
|
||||||
|
{refund.reportNote && (
|
||||||
|
<p className="text-neutral-600">
|
||||||
|
<span className="font-semibold">Report:</span> {refund.reportNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{refund.adminNote && (
|
||||||
|
<p className="text-neutral-600">
|
||||||
|
<span className="font-semibold">Admin note:</span>{" "}
|
||||||
|
{refund.adminNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{refund.reviewedBy && (
|
||||||
|
<p className="text-[11px] text-neutral-500">
|
||||||
|
Reviewed by {refund.reviewedBy.email}
|
||||||
|
{refund.reviewedAt && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
·{" "}
|
||||||
|
{refund.reviewedAt.toLocaleString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PayoutEventCard({
|
||||||
|
payout,
|
||||||
|
}: {
|
||||||
|
payout: {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
heldUntil: Date;
|
||||||
|
releasedAt: Date | null;
|
||||||
|
paidAt: Date | null;
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
adminNote: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
processedBy: { id: string; name: string; email: string } | null;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<EventHeader kind="payout" title="Payout ke organizer" at={payout.createdAt} />
|
||||||
|
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-3 sm:p-4">
|
||||||
|
<div className="space-y-1 text-xs text-neutral-700">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Nominal:</span>{" "}
|
||||||
|
{formatRupiah(payout.amount)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Status:</span>{" "}
|
||||||
|
<StatusBadge value={payout.status} />
|
||||||
|
</p>
|
||||||
|
<p className="text-neutral-600">
|
||||||
|
Held until:{" "}
|
||||||
|
{payout.heldUntil.toLocaleDateString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{payout.paidAt && (
|
||||||
|
<p className="text-emerald-700">
|
||||||
|
Paid at:{" "}
|
||||||
|
{payout.paidAt.toLocaleString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payout.adminNote && (
|
||||||
|
<p className="text-neutral-600">
|
||||||
|
<span className="font-semibold">Admin note:</span>{" "}
|
||||||
|
{payout.adminNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payout.processedBy && (
|
||||||
|
<p className="text-[11px] text-neutral-500">
|
||||||
|
Processed by {payout.processedBy.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ value }: { value: string }) {
|
||||||
|
const finalStatuses = ["PAID", "SUCCEEDED", "RELEASED"];
|
||||||
|
const negativeStatuses = ["FAILED", "EXPIRED", "CANCELLED", "REJECTED"];
|
||||||
|
const cls = finalStatuses.includes(value)
|
||||||
|
? "bg-emerald-100 text-emerald-800"
|
||||||
|
: negativeStatuses.includes(value)
|
||||||
|
? "bg-red-100 text-red-800"
|
||||||
|
: "bg-amber-100 text-amber-800";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
|
import { tripService } from "@/server/services/trip.service";
|
||||||
|
import { formatRupiah } from "@/lib/utils";
|
||||||
|
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||||
|
import { categoryMeta } from "@/lib/activity-category";
|
||||||
|
import { groupItineraryByDay } from "@/lib/itinerary";
|
||||||
|
import { AdminCancelTripButton } from "@/features/trip/components/admin-cancel-trip-button";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminTripDetailPage({ params }: PageProps) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/login?callbackUrl=/admin/trips");
|
||||||
|
}
|
||||||
|
if (!isAdminEmail(session.user.email)) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Halaman ini hanya untuk admin SeTrip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
let trip;
|
||||||
|
try {
|
||||||
|
trip = await tripService.getTripById(id);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cat = categoryMeta(trip.category);
|
||||||
|
|
||||||
|
const activeParticipants = trip.participants.filter(
|
||||||
|
(p) => p.status !== "CANCELLED"
|
||||||
|
);
|
||||||
|
const confirmedCount = activeParticipants.filter(
|
||||||
|
(p) => p.status === "CONFIRMED"
|
||||||
|
).length;
|
||||||
|
const pendingCount = activeParticipants.filter(
|
||||||
|
(p) => p.status === "PENDING"
|
||||||
|
).length;
|
||||||
|
const cancelledCount = trip.participants.length - activeParticipants.length;
|
||||||
|
|
||||||
|
const grouped = trip.itineraryItems.length
|
||||||
|
? groupItineraryByDay(
|
||||||
|
trip.itineraryItems.map((i) => ({
|
||||||
|
day: i.day,
|
||||||
|
startTime: i.startTime,
|
||||||
|
endTime: i.endTime,
|
||||||
|
activity: i.activity,
|
||||||
|
order: i.order,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const canCancel = trip.status === "OPEN" || trip.status === "FULL";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||||
|
<div className="mb-4 text-xs text-neutral-500">
|
||||||
|
<Link href="/admin/trips" className="hover:text-primary-600">
|
||||||
|
← Kembali ke list trips
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-[11px]">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 font-semibold text-neutral-600">
|
||||||
|
<span aria-hidden>{cat.icon}</span> {cat.label}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={trip.status} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||||
|
{trip.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
📅 {formatTripCalendarDateRangeLong(trip.date, trip.endDate)} ·
|
||||||
|
📍 {trip.destination}, {trip.location}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
Organizer:{" "}
|
||||||
|
<Link
|
||||||
|
href={`/u/${trip.organizer.id}`}
|
||||||
|
className="font-semibold text-neutral-700 hover:text-primary-600"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{trip.organizer.name}
|
||||||
|
</Link>{" "}
|
||||||
|
<span className="text-neutral-400">({trip.organizer.email})</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
Trip ID:{" "}
|
||||||
|
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-700">
|
||||||
|
{trip.id}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Harga
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-primary-700 sm:text-2xl">
|
||||||
|
{formatRupiah(trip.price)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-neutral-500">per orang</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mb-6 grid gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard label="Kapasitas" value={String(trip.maxParticipants)} />
|
||||||
|
<StatCard
|
||||||
|
label="Confirmed"
|
||||||
|
value={String(confirmedCount)}
|
||||||
|
accent="emerald"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Pending"
|
||||||
|
value={String(pendingCount)}
|
||||||
|
accent="amber"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Cancelled"
|
||||||
|
value={String(cancelledCount)}
|
||||||
|
accent="neutral"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canCancel && (
|
||||||
|
<section className="mb-6 rounded-2xl border border-red-200 bg-red-50/60 p-4 sm:p-5">
|
||||||
|
<h2 className="text-sm font-bold text-red-900">
|
||||||
|
Intervensi Admin — Cancel Trip
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-red-900/80">
|
||||||
|
Pakai hanya saat organizer unreachable, safety issue, atau dispute
|
||||||
|
tidak terselesaikan. Semua booking PAID akan auto-refund (full
|
||||||
|
amount). Booking PENDING/AWAITING_PAY langsung CANCELLED.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<AdminCancelTripButton tripId={trip.id} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trip.description && (
|
||||||
|
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||||
|
<h2 className="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||||
|
Deskripsi
|
||||||
|
</h2>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-neutral-700">
|
||||||
|
{trip.description}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{grouped && (
|
||||||
|
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||||
|
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||||
|
Itinerary
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...grouped.entries()].map(([day, items]) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="rounded-lg border border-primary-100 bg-primary-50/40 p-3"
|
||||||
|
>
|
||||||
|
<p className="mb-1.5 text-xs font-bold text-primary-800">
|
||||||
|
Hari {day}
|
||||||
|
</p>
|
||||||
|
<ol className="space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.order}
|
||||||
|
className="flex gap-3 text-xs text-neutral-700"
|
||||||
|
>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] font-semibold text-primary-700">
|
||||||
|
{item.startTime}
|
||||||
|
{item.endTime ? `–${item.endTime}` : ""}
|
||||||
|
</span>
|
||||||
|
<span>{item.activity}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||||
|
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||||
|
Peserta ({activeParticipants.length})
|
||||||
|
</h2>
|
||||||
|
{trip.participants.length === 0 ? (
|
||||||
|
<p className="text-xs text-neutral-500">Belum ada peserta.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-neutral-100">
|
||||||
|
{trip.participants.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/u/${p.user.id}`}
|
||||||
|
target="_blank"
|
||||||
|
className="text-sm font-semibold text-neutral-800 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
{p.user.name}
|
||||||
|
</Link>
|
||||||
|
{p.user.profile?.city && (
|
||||||
|
<span className="ml-2 text-[11px] text-neutral-500">
|
||||||
|
📍 {p.user.profile.city}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ParticipantStatusBadge status={p.status} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const cls =
|
||||||
|
status === "OPEN"
|
||||||
|
? "bg-emerald-100 text-emerald-800"
|
||||||
|
: status === "FULL"
|
||||||
|
? "bg-amber-100 text-amber-800"
|
||||||
|
: status === "CLOSED"
|
||||||
|
? "bg-red-100 text-red-800"
|
||||||
|
: "bg-neutral-200 text-neutral-700";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticipantStatusBadge({ status }: { status: string }) {
|
||||||
|
const cls =
|
||||||
|
status === "CONFIRMED"
|
||||||
|
? "bg-emerald-100 text-emerald-800"
|
||||||
|
: status === "PENDING"
|
||||||
|
? "bg-amber-100 text-amber-800"
|
||||||
|
: "bg-neutral-200 text-neutral-600 line-through";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent = "primary",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
accent?: "primary" | "emerald" | "amber" | "neutral";
|
||||||
|
}) {
|
||||||
|
const map = {
|
||||||
|
primary: "text-primary-700",
|
||||||
|
emerald: "text-emerald-700",
|
||||||
|
amber: "text-amber-700",
|
||||||
|
neutral: "text-neutral-700",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white p-3 sm:p-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-0.5 text-xl font-bold sm:text-2xl ${map[accent]}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
|
import { tripRepo } from "@/server/repositories/trip.repo";
|
||||||
|
import { formatRupiah } from "@/lib/utils";
|
||||||
|
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||||
|
import { categoryMeta } from "@/lib/activity-category";
|
||||||
|
|
||||||
|
type Tab = "ALL" | "OPEN" | "FULL" | "CLOSED" | "COMPLETED";
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "ALL", label: "Semua" },
|
||||||
|
{ key: "OPEN", label: "Open" },
|
||||||
|
{ key: "FULL", label: "Penuh" },
|
||||||
|
{ key: "CLOSED", label: "Dibatalkan" },
|
||||||
|
{ key: "COMPLETED", label: "Selesai" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ tab?: string; q?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminTripsPage({ searchParams }: PageProps) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) redirect("/login?callbackUrl=/admin/trips");
|
||||||
|
if (!isAdminEmail(session.user.email)) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Halaman ini hanya untuk admin SeTrip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await searchParams;
|
||||||
|
const tab: Tab = TABS.some((t) => t.key === params.tab)
|
||||||
|
? (params.tab as Tab)
|
||||||
|
: "ALL";
|
||||||
|
const q = (params.q ?? "").trim();
|
||||||
|
|
||||||
|
const trips = await tripRepo.searchForAdmin({
|
||||||
|
status: tab === "ALL" ? undefined : tab,
|
||||||
|
q: q || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
|
Trip Operations
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Cari trip, lihat detail, dan intervensi (cancel + auto-refund) saat
|
||||||
|
organizer unreachable.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form method="get" className="mb-4 flex gap-2">
|
||||||
|
<input type="hidden" name="tab" value={tab} />
|
||||||
|
<input
|
||||||
|
name="q"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Cari judul, destinasi, lokasi, organizer..."
|
||||||
|
className="flex-1 rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Cari
|
||||||
|
</button>
|
||||||
|
{q && (
|
||||||
|
<Link
|
||||||
|
href={`/admin/trips?tab=${tab}`}
|
||||||
|
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mb-6 flex flex-wrap gap-2">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.key}
|
||||||
|
href={`/admin/trips?tab=${t.key}${q ? `&q=${encodeURIComponent(q)}` : ""}`}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
|
||||||
|
tab === t.key
|
||||||
|
? "bg-primary-600 text-white"
|
||||||
|
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trips.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{q
|
||||||
|
? `Tidak ada trip yang cocok dengan "${q}".`
|
||||||
|
: "Tidak ada trip pada status ini."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{trips.map((t) => {
|
||||||
|
const cat = categoryMeta(t.category);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={t.id}
|
||||||
|
className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md sm:p-5"
|
||||||
|
>
|
||||||
|
<Link href={`/admin/trips/${t.id}`} className="block">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1 flex flex-wrap items-center gap-2 text-[11px]">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 font-semibold text-neutral-600">
|
||||||
|
<span aria-hidden>{cat.icon}</span> {cat.label}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
</div>
|
||||||
|
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
|
||||||
|
{t.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 truncate text-xs text-neutral-500 sm:text-sm">
|
||||||
|
📅 {formatTripCalendarDateRangeLong(t.date, t.endDate)}
|
||||||
|
{" · "}📍 {t.location}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
|
||||||
|
Organizer:{" "}
|
||||||
|
<span className="font-semibold text-neutral-700">
|
||||||
|
{t.organizer.name}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-neutral-400">
|
||||||
|
({t.organizer.email})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<p className="text-sm font-bold text-primary-700 sm:text-base">
|
||||||
|
{formatRupiah(t.price)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-neutral-500">
|
||||||
|
{t._count.participants}/{t.maxParticipants} peserta
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-emerald-700">
|
||||||
|
{t._count.bookings} PAID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const cls =
|
||||||
|
status === "OPEN"
|
||||||
|
? "bg-emerald-100 text-emerald-800"
|
||||||
|
: status === "FULL"
|
||||||
|
? "bg-amber-100 text-amber-800"
|
||||||
|
: status === "CLOSED"
|
||||||
|
? "bg-red-100 text-red-800"
|
||||||
|
: "bg-neutral-200 text-neutral-700";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { signOut } from "next-auth/react";
|
|||||||
|
|
||||||
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
|
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
|
||||||
{ href: "/admin", label: "Dashboard", icon: "📊" },
|
{ href: "/admin", label: "Dashboard", icon: "📊" },
|
||||||
|
{ href: "/admin/trips", label: "Trips", icon: "🧭" },
|
||||||
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
|
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
|
||||||
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
|
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
|
||||||
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
|
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
* Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
|
||||||
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
|
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="inline-flex flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={loading || disabled}
|
||||||
|
className="rounded-lg bg-secondary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Reconciling..." : "Reconcile Midtrans"}
|
||||||
|
</button>
|
||||||
|
{status && (
|
||||||
|
<span className="text-[11px] font-medium text-emerald-700">
|
||||||
|
✓ {reconcileOutcomeLabel(status)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span className="text-[11px] font-medium text-red-600">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<p className="text-[11px] italic text-neutral-400">
|
||||||
|
Belum ada callback dari gateway.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
|
||||||
|
>
|
||||||
|
{open ? "▼ Sembunyikan raw callback" : "▶ Lihat raw callback JSON"}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<pre className="mt-2 max-h-96 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-relaxed text-neutral-700">
|
||||||
|
{JSON.stringify(payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { markPayoutPaidAction } from "@/features/payout/actions";
|
import { markPayoutPaidAction } from "@/features/payout/actions";
|
||||||
import { formatRupiah } from "@/lib/utils";
|
import { formatRupiah } from "@/lib/utils";
|
||||||
@@ -92,6 +93,12 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
|
|||||||
{" · "}
|
{" · "}
|
||||||
<span className="font-mono">{payout.id.slice(0, 8)}…</span>
|
<span className="font-mono">{payout.id.slice(0, 8)}…</span>
|
||||||
</p>
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/admin/bookings/${payout.booking.id}`}
|
||||||
|
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
|
||||||
|
>
|
||||||
|
→ Lihat timeline booking
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill status={payout.status} />
|
<StatusPill status={payout.status} />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { decideRefundAction } from "@/features/refund/actions";
|
import { decideRefundAction } from "@/features/refund/actions";
|
||||||
import { formatRupiah } from "@/lib/utils";
|
import { formatRupiah } from "@/lib/utils";
|
||||||
@@ -119,7 +120,20 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
|
|||||||
label="Peserta booking"
|
label="Peserta booking"
|
||||||
value={`${refund.booking.user.name} · ${refund.booking.user.email}`}
|
value={`${refund.booking.user.name} · ${refund.booking.user.email}`}
|
||||||
/>
|
/>
|
||||||
<Field label="Booking ID" value={refund.booking.id} mono />
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Booking ID
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 font-mono text-xs text-neutral-700">
|
||||||
|
{refund.booking.id}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/admin/bookings/${refund.booking.id}`}
|
||||||
|
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
|
||||||
|
>
|
||||||
|
→ Lihat timeline payment & refund
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<Field
|
<Field
|
||||||
label="Tanggal trip"
|
label="Tanggal trip"
|
||||||
value={formatDate(refund.booking.trip.date)}
|
value={formatDate(refund.booking.trip.date)}
|
||||||
|
|||||||
@@ -206,7 +206,10 @@ export async function cancelTripAction(tripId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await tripService.closeTrip(tripId, session.user.id);
|
const result = await tripService.closeTrip(tripId, {
|
||||||
|
type: "ORGANIZER",
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
revalidatePath(`/trips/${tripId}`);
|
revalidatePath(`/trips/${tripId}`);
|
||||||
revalidatePath("/trips");
|
revalidatePath("/trips");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
@@ -222,3 +225,49 @@ export async function cancelTripAction(tripId: string) {
|
|||||||
return { error: (err as Error).message };
|
return { error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin force-cancel trip dari panel admin (intervensi saat organizer
|
||||||
|
* unreachable / safety issue / dispute). Pakai actor ADMIN — bypass cek
|
||||||
|
* organizer match, record `cancelledByAdminId` + `cancelledReason`.
|
||||||
|
*/
|
||||||
|
export async function adminCancelTripAction(tripId: string, reason: 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" };
|
||||||
|
}
|
||||||
|
const trimmedReason = reason.trim();
|
||||||
|
if (trimmedReason.length < 10) {
|
||||||
|
return {
|
||||||
|
error: "Alasan cancel wajib diisi (minimal 10 karakter untuk audit)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (trimmedReason.length > 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
|
||||||
|
<p className="font-bold">✅ Trip berhasil dibatalkan.</p>
|
||||||
|
<ul className="mt-2 space-y-0.5 text-xs">
|
||||||
|
<li>• {result.refundCount} booking PAID → refund auto-dibuat</li>
|
||||||
|
<li>
|
||||||
|
• {result.cancelledCount} booking PENDING/AWAITING_PAY → CANCELLED
|
||||||
|
</li>
|
||||||
|
{result.skippedCount > 0 && (
|
||||||
|
<li>
|
||||||
|
• {result.skippedCount} booking di-skip (sudah ada refund aktif —
|
||||||
|
cek manual)
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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)
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="cancel-reason"
|
||||||
|
className="mb-1 block text-xs font-semibold text-red-900"
|
||||||
|
>
|
||||||
|
Alasan cancel (wajib, min 10 karakter — untuk audit)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="cancel-reason"
|
||||||
|
rows={3}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="contoh: Organizer tidak responsif sejak H-7, peserta lapor via WA. Safety issue tidak terjawab."
|
||||||
|
className="w-full rounded-xl border border-red-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-red-400"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-red-900/70">
|
||||||
|
{reason.trim().length}/500 karakter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={loading || reason.trim().length < 10}
|
||||||
|
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Membatalkan..." : "Konfirmasi Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -39,6 +39,9 @@ model User {
|
|||||||
/// Payout yang ditandai admin sebagai PAID/CANCELLED oleh user ini.
|
/// Payout yang ditandai admin sebagai PAID/CANCELLED oleh user ini.
|
||||||
processedPayouts Payout[] @relation("PayoutProcessor")
|
processedPayouts Payout[] @relation("PayoutProcessor")
|
||||||
|
|
||||||
|
/// Trip yang dibatalkan admin ini lewat panel admin (intervensi).
|
||||||
|
adminCancelledTrips Trip[] @relation("TripCancelledByAdmin")
|
||||||
|
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +160,12 @@ model Trip {
|
|||||||
/// Ritme/energi trip — dipakai untuk matching dengan vibe user.
|
/// Ritme/energi trip — dipakai untuk matching dengan vibe user.
|
||||||
vibe Vibe?
|
vibe Vibe?
|
||||||
status TripStatus @default(OPEN)
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
async create(
|
||||||
data: Pick<
|
data: Pick<
|
||||||
Prisma.BookingUncheckedCreateInput,
|
Prisma.BookingUncheckedCreateInput,
|
||||||
|
|||||||
@@ -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. */
|
/** Semua trip yang dibuat user (semua status), terbaru dulu — untuk profil. */
|
||||||
async findByOrganizerId(organizerId: string) {
|
async findByOrganizerId(organizerId: string) {
|
||||||
return prisma.trip.findMany({
|
return prisma.trip.findMany({
|
||||||
|
|||||||
@@ -381,6 +381,49 @@ export const paymentService = {
|
|||||||
* Auth: caller harus pastikan `userId` adalah owner booking; kita verifikasi
|
* Auth: caller harus pastikan `userId` adalah owner booking; kita verifikasi
|
||||||
* di sini lewat lookup payment → booking.userId.
|
* 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(
|
async reconcileFromGateway(
|
||||||
orderId: string,
|
orderId: string,
|
||||||
userId: string
|
userId: string
|
||||||
|
|||||||
@@ -404,9 +404,10 @@ export const tripService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
|
* Batalkan trip (Trip.status = CLOSED). Bisa dipicu organizer sendiri ATAU
|
||||||
* serializable transaction:
|
* admin (intervensi). Atomic dalam satu serializable transaction:
|
||||||
* - Set Trip.status = CLOSED.
|
* - Set Trip.status = CLOSED (+ `cancelledByAdminId` & `cancelledReason`
|
||||||
|
* kalau actor = ADMIN).
|
||||||
* - Untuk setiap peserta aktif:
|
* - Untuk setiap peserta aktif:
|
||||||
* - Booking PAID → buat Refund ORGANIZER_CANCELLED (auto-approved, full
|
* - Booking PAID → buat Refund ORGANIZER_CANCELLED (auto-approved, full
|
||||||
* amount). Booking tetap PAID sampai admin mark SUCCEEDED — jejak
|
* 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
|
* Idempotent: trip yang sudah CLOSED/COMPLETED akan ditolak supaya tidak
|
||||||
* dobel-buat refund.
|
* 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;
|
let lastErr: unknown;
|
||||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -433,7 +439,10 @@ export const tripService = {
|
|||||||
if (!trip) {
|
if (!trip) {
|
||||||
throw new Error("Trip tidak ditemukan");
|
throw new Error("Trip tidak ditemukan");
|
||||||
}
|
}
|
||||||
if (trip.organizerId !== organizerId) {
|
if (
|
||||||
|
actor.type === "ORGANIZER" &&
|
||||||
|
trip.organizerId !== actor.userId
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Hanya organizer trip ini yang bisa membatalkan trip"
|
"Hanya organizer trip ini yang bisa membatalkan trip"
|
||||||
);
|
);
|
||||||
@@ -544,7 +553,13 @@ export const tripService = {
|
|||||||
|
|
||||||
await tx.trip.update({
|
await tx.trip.update({
|
||||||
where: { id: tripId },
|
where: { id: tripId },
|
||||||
data: { status: "CLOSED" },
|
data: {
|
||||||
|
status: "CLOSED",
|
||||||
|
...(actor.type === "ADMIN" && {
|
||||||
|
cancelledByAdminId: actor.adminId,
|
||||||
|
cancelledReason: actor.reason,
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user