Compare commits

...

2 Commits

Author SHA1 Message Date
arifal c52b12daad 0.12.1 2026-05-18 19:26:28 +07:00
arifal 4bcb93e283 admin roadmap trips ops and payment ops 2026-05-18 19:25:32 +07:00
24 changed files with 1589 additions and 191 deletions
-89
View File
@@ -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
View File
@@ -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.
-77
View File
@@ -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).
+470
View File
@@ -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>
);
}
+297
View File
@@ -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>
);
}
+182
View File
@@ -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>
);
}
+1
View File
@@ -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: "💸" },
+51
View File
@@ -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.
+52
View File
@@ -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.
+31
View File
@@ -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)}
+50 -1
View File
@@ -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>
);
}
+2 -2
View File
@@ -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
View File
@@ -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;
+9
View File
@@ -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
+40
View File
@@ -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,
+39
View File
@@ -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({
+43
View File
@@ -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
+21 -6
View File
@@ -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 {