Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1966b69f1 | |||
| c4efe4453b | |||
| b599d01eea | |||
| 958514d575 |
+4
-1
@@ -31,7 +31,10 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
.env.local
|
||||||
|
|
||||||
# private uploads (KYC: KTP / liveness). Never serve directly.
|
# private uploads (KYC: KTP / liveness). Never serve directly.
|
||||||
/uploads/
|
/uploads/
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Setrip — Admin Audit & Investigation Roadmap
|
||||||
|
|
||||||
|
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
|
||||||
|
|
||||||
|
> **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
- ✅ Data audit sudah ada di schema: `Refund.reviewedBy/reviewedAt/adminNote`, `Payout.processedBy/processedAt/adminNote`, `OrganizerVerification.reviewedBy/reviewedAt/rejectionReason`.
|
||||||
|
- ✅ Existing list pages (`/admin/refunds`, `/admin/payouts`, `/admin/verifications`) sudah grouping by status tab.
|
||||||
|
- ❌ Tidak ada filter date range / reviewer / amount / reason.
|
||||||
|
- ❌ Tidak ada kolom "reviewer email" di list — harus klik detail.
|
||||||
|
- ❌ Tidak ada global search (cari berdasarkan email user, order id, trip id).
|
||||||
|
- ❌ Tidak ada CSV export.
|
||||||
|
- ❌ Tidak ada audit log untuk action admin di entity lain (User suspension, Trip force-cancel, Verification reopen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Filter & Search Enhancements ⏳
|
||||||
|
|
||||||
|
Sebelum bikin audit log baru, perbaiki dulu kemampuan cari & filter di list yang sudah ada.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Pakai `searchParams` di Next.js — tidak perlu state client (server-render fast + shareable URL).
|
||||||
|
- Default date range: 30 hari terakhir, supaya page tidak load semua history.
|
||||||
|
- Reviewer dropdown sumber dari `ADMIN_EMAILS` env (sudah ada).
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.1 | Filter date range (`from`, `to`) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||||
|
| 1.2 | Filter `reviewedBy` (admin email dropdown) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||||
|
| 1.3 | Filter `reason` di `/admin/refunds` (lihat juga [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||||
|
| 1.4 | Filter date range + `processedBy` di `/admin/payouts` | ⏳ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
|
||||||
|
| 1.5 | Filter date range + `reviewedBy` di `/admin/verifications` | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
|
||||||
|
| 1.6 | Tampilkan kolom "reviewer email" + "reviewed at" di tabel/list (semua admin pages) | ⏳ | semua `app/admin/*/page.tsx` |
|
||||||
|
| 1.7 | Repo helper: tambah optional filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | ⏳ | `server/repositories/*.ts` |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Global Search ⏳
|
||||||
|
|
||||||
|
Satu search box yang resolve ke entity detail page paling relevan.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Input string user, prefix-based dispatch:
|
||||||
|
- Email format (`@`) → user search → redirect ke `/admin/users/[id]`
|
||||||
|
- Mulai `midtrans-` / `manual-` → payment lookup by `externalOrderId` → `/admin/bookings/[bookingId]`
|
||||||
|
- Mulai `cm` (cuid pattern) + length 25 → coba lookup berurutan: trip → booking → user
|
||||||
|
- Else: full-text search di trip title/destination
|
||||||
|
- Pakai server action atau route handler `/api/admin/search` — return list hasil + jenis entity.
|
||||||
|
- UI: searchbar di admin layout (top-right) yang dropdown hasil.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.1 | `adminSearchService.resolve(query)` — dispatch ke repo lookup yang tepat | ⏳ | `server/services/admin-search.service.ts` |
|
||||||
|
| 2.2 | Route handler `/api/admin/search?q=...` (GET, guard isAdmin) | ⏳ | `app/api/admin/search/route.ts` |
|
||||||
|
| 2.3 | Component `AdminSearchBar` di admin layout — debounced, dropdown hasil | ⏳ | `features/admin/components/admin-search-bar.tsx` |
|
||||||
|
| 2.4 | Page `/admin/search?q=...` untuk full results kalau dropdown limit terlampaui | ⏳ | `app/admin/search/page.tsx` |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — CSV Export ⏳
|
||||||
|
|
||||||
|
Export untuk laporan keuangan & compliance.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Stream CSV via route handler — jangan load semua ke memory.
|
||||||
|
- Pakai filter yang sama dengan list page — admin pakai URL filter lalu klik "Export".
|
||||||
|
- Header CSV: human-readable bahasa Indonesia (mis. "Tanggal Approve", "Email Peserta", "Nominal Refund").
|
||||||
|
- Tidak ada Excel/xlsx — CSV cukup, mudah dibuka di Sheets/Excel.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.1 | Helper `lib/csv.ts` — `streamCsv(headers, rows)` return Response | ⏳ | `lib/csv.ts` |
|
||||||
|
| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ⏳ | `app/api/admin/export/refunds/route.ts` |
|
||||||
|
| 3.3 | Route `/api/admin/export/payouts` | ⏳ | `app/api/admin/export/payouts/route.ts` |
|
||||||
|
| 3.4 | Route `/api/admin/export/verifications` (tanpa NIK / KTP — hanya metadata) | ⏳ | `app/api/admin/export/verifications/route.ts` |
|
||||||
|
| 3.5 | Tombol "Export CSV" di tiap admin list page | ⏳ | semua `app/admin/*/page.tsx` |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Test export di staging — pastikan tidak leak data sensitif (NIK harus tetap encrypted/excluded).
|
||||||
|
2. Update kebijakan privasi: data export hanya untuk internal compliance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Generic Admin Audit Log ⏳
|
||||||
|
|
||||||
|
Tabel `AdminActionLog` untuk action di entity yang belum punya audit field (User suspend, Trip force-cancel, Verification reopen, dst).
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Single tabel polymorphic: `AdminActionLog { adminId, action, entityType, entityId, payload Json?, createdAt }`.
|
||||||
|
- Append-only, never update/delete.
|
||||||
|
- Service helper `auditLog.record(...)` dipanggil eksplisit di setiap action admin (tidak via Prisma middleware — terlalu magic).
|
||||||
|
- View page `/admin/audit-log` dengan filter `adminId`, `entityType`, `action`, date range.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 4.1 | Model `AdminActionLog` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| 4.2 | Helper `auditLog.record({ adminId, action, entityType, entityId, payload? })` | ⏳ | `server/services/audit-log.service.ts` |
|
||||||
|
| 4.3 | Wire `auditLog.record` di semua admin server action existing (refund approve/reject/mark, payout markPaid, verification approve/reject) | ⏳ | `features/*/actions.ts` |
|
||||||
|
| 4.4 | Page `/admin/audit-log` dengan filter + pagination | ⏳ | `app/admin/audit-log/page.tsx` |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Setrip — Admin Roadmap (Index)
|
||||||
|
|
||||||
|
Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplikasi saat ada insiden**, bukan hanya read-only dashboard.
|
||||||
|
|
||||||
|
> **Prinsip:** admin adalah safety net terakhir saat sistem otomatis gagal atau ada bad actor. Setiap action admin harus auditable (siapa, kapan, alasan), idempotent, dan terbatas hanya untuk admin yang terdaftar di `ADMIN_EMAILS`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline (yang BISA admin lakukan sekarang)
|
||||||
|
|
||||||
|
| Area | Fungsi | File |
|
||||||
|
|---|---|---|
|
||||||
|
| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
|
||||||
|
| Verifikasi KYC | Approve / Reject organizer (KTP, liveness, bank) | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
|
||||||
|
| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||||
|
| Payout | View per status, mark PAID setelah transfer manual | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
|
||||||
|
|
||||||
|
Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap per area
|
||||||
|
|
||||||
|
| Roadmap | Prioritas | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ⏳ 0% | [ADMIN_TRIP_OPS_ROADMAP.md](ADMIN_TRIP_OPS_ROADMAP.md) |
|
||||||
|
| Payment Operations (booking detail, reconcile, dispute) | 🔴 HIGH | 🚧 ~15% | [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md) |
|
||||||
|
| Audit & Investigation (search, filter, export) | 🔴 HIGH | ⏳ 0% | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) |
|
||||||
|
| User Management (search, suspend/ban) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_ROADMAP.md) |
|
||||||
|
| Verification (reopen, re-upload request) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_ROADMAP.md) |
|
||||||
|
| System Health (cron monitor, stale state alerts) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
|
||||||
|
|
||||||
|
**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Urutan implementasi yang direkomendasikan
|
||||||
|
|
||||||
|
Berdasarkan ROI (frekuensi kebutuhan × dampak insiden):
|
||||||
|
|
||||||
|
1. **Trip Ops** — paling sering dibutuhkan, infrastruktur service sudah lengkap (`tripService.closeTrip`)
|
||||||
|
2. **Payment Ops** — kritikal saat webhook gagal; setengah infra sudah ada (`reconcileFromGateway`)
|
||||||
|
3. **Audit** — compliance + investigasi dispute; data sudah lengkap (`reviewedBy`, `processedBy`, `adminNote`), tinggal UI filter/export
|
||||||
|
4. **User Management** — moderation; butuh schema change (`User.suspended`)
|
||||||
|
5. **Verification** — edge case rare; cuma butuh 1 service method + tombol
|
||||||
|
6. **System Health** — operational visibility; butuh model baru (`CronRun`)
|
||||||
|
|
||||||
|
Tiga roadmap pertama menutup ~90% skenario "admin powerless when shit hits the fan".
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Setrip — Admin System Health Roadmap
|
||||||
|
|
||||||
|
Admin perlu visibilitas atas job otomatis (cron) dan deteksi state yang nyangkut (Payment stale, Payout overdue, Refund mandek).
|
||||||
|
|
||||||
|
> **Skenario nyata:** cron auto-complete trip crash karena env variable rusak. 50 trip yang sudah lewat tanggalnya tetap `OPEN` selama 3 hari sampai peserta komplain "kenapa belum bisa kasih review". Admin tidak punya cara cek status cron tanpa SSH ke server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
- ✅ Cron infra ada (system crontab + `CRON_SECRET`) — lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md).
|
||||||
|
- ✅ Cron jobs aktif: `app/api/cron/auto-complete-trips/route.ts`, payout release (lewat `payoutService`), kemungkinan refund timeout.
|
||||||
|
- ❌ Tidak ada log/audit per cron run (success/fail/error).
|
||||||
|
- ❌ Tidak ada page `/admin/system` untuk lihat status.
|
||||||
|
- ❌ Tidak ada alert deteksi state stale (Payment AWAITING > 24h, Payout HELD past `heldUntil`, Refund APPROVED > 7d).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Cron Run Log ⏳
|
||||||
|
|
||||||
|
Tabel `CronRun` yang dicatat setiap kali cron jalan. Foundation untuk semua observability.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Append-only model. Retention: keep all (tabel kecil, ~365 rows/year/cron). Cleanup nanti kalau perlu.
|
||||||
|
- Wrap existing cron handler dengan helper `runCron(name, fn)` yang otomatis log start/finish/error.
|
||||||
|
- Tidak pakai job library (BullMQ/Inngest) — overkill. Tetap pakai system cron + Next route handler.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.1 | Model `CronRun { id, jobName, startedAt, finishedAt?, status (RUNNING/SUCCESS/FAILED), errorMessage?, payload? Json }` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| 1.2 | Helper `runCron(jobName, fn)` — wrap handler, otomatis create RUNNING row → SUCCESS/FAILED | ⏳ | `lib/cron-runner.ts` |
|
||||||
|
| 1.3 | Wire `runCron` di `app/api/cron/auto-complete-trips/route.ts` | ⏳ | `app/api/cron/auto-complete-trips/route.ts` |
|
||||||
|
| 1.4 | Wire `runCron` di cron payout release (kalau sudah ada — kalau belum, daftar sebagai gap) | ⏳ | TBD |
|
||||||
|
| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) | ⏳ | TBD |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — System Status Page ⏳
|
||||||
|
|
||||||
|
Page `/admin/system` yang tampilkan kondisi terkini.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Tabel per cron job: last run, last success, total runs (7d), error count (7d).
|
||||||
|
- Refresh manual (tombol "Refresh") — bukan auto-poll. Cukup untuk admin.
|
||||||
|
- Health badge: 🟢 OK (last success < 25 jam untuk daily), 🟡 STALE (> 25 jam), 🔴 FAILED (last run = FAILED).
|
||||||
|
- Tampilkan 20 cron run terbaru di table bawah untuk drill-down.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.1 | `cronRepo.getJobSummary(jobName)` — last run, last success, count 7d | ⏳ | `server/repositories/cron.repo.ts` |
|
||||||
|
| 2.2 | `cronRepo.listRecent(limit)` — 20 run terakhir lintas job | ⏳ | `server/repositories/cron.repo.ts` |
|
||||||
|
| 2.3 | Page `/admin/system` — tabel job summary + tabel recent runs | ⏳ | `app/admin/system/page.tsx` |
|
||||||
|
| 2.4 | Health badge logic (helper) | ⏳ | `lib/cron-health.ts` |
|
||||||
|
| 2.5 | Link "System" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Set ekspektasi SLA per cron (mis. `auto-complete-trips` harus jalan setiap hari sebelum jam 06:00 WIB).
|
||||||
|
2. Brief admin: cek `/admin/system` minimal sekali per hari pagi sebelum mulai kerja.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Stale State Alerts ⏳
|
||||||
|
|
||||||
|
Deteksi entity yang nyangkut di state non-final terlalu lama. Tampilkan sebagai banner di `/admin/system`.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Stale thresholds (review dengan stakeholder, ini draft):
|
||||||
|
- Payment status `PENDING` > 1 jam → suspect: gagal create Snap token, perlu manual cleanup
|
||||||
|
- Payment status `AWAITING` > 25 jam (lebih dari expiresAt) → suspect: webhook gagal, expire belum di-set, perlu reconcile
|
||||||
|
- Booking status `AWAITING_PAY` + trip date < today → suspect: peserta lupa bayar, butuh cleanup
|
||||||
|
- Payout status `HELD` + `heldUntil < now` > 1 hari → suspect: cron release tidak jalan, perlu trigger manual
|
||||||
|
- Refund status `APPROVED` > 7 hari → suspect: admin lupa proses, atau Midtrans refund gagal
|
||||||
|
- Compute via query parameter pada page load — tidak perlu materialized view.
|
||||||
|
- Setiap kategori tampilkan jumlah + link ke filtered list page yang relevan.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.1 | `systemHealthService.detectStale()` return `{ stalePayments, expiredAwaiting, awaitingPayPastDeparture, overduePayouts, stuckRefunds }` | ⏳ | `server/services/system-health.service.ts` |
|
||||||
|
| 3.2 | Banner alerts di `/admin/system` kalau ada count > 0 | ⏳ | `app/admin/system/page.tsx` |
|
||||||
|
| 3.3 | Link tiap alert ke filtered list (pakai filter di [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 1) | ⏳ | `app/admin/system/page.tsx` |
|
||||||
|
| 3.4 | Stat card di dashboard utama `/admin` kalau ada alert | ⏳ | [app/admin/page.tsx](app/admin/page.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Tuning threshold setelah jalan 1-2 minggu (false positive vs miss).
|
||||||
|
2. SOP per alert: action apa yang admin harus ambil saat banner muncul.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — External Alerting (opsional) ⏳
|
||||||
|
|
||||||
|
Push notif ke channel eksternal (Discord/Telegram/email) saat ada cron FAILED atau stale state critical. Skip kecuali admin sering miss banner.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 4.1 | Helper `notifyAdmins(message)` — POST ke Discord webhook URL dari env | ⏳ | `lib/admin-notify.ts` |
|
||||||
|
| 4.2 | Trigger notify di `runCron` saat FAILED | ⏳ | `lib/cron-runner.ts` |
|
||||||
|
| 4.3 | Trigger notify dari `systemHealthService.detectStale` (rate-limited, max 1x/hari per kategori) | ⏳ | `server/services/system-health.service.ts` |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Buat channel Discord internal + webhook URL → set env `ADMIN_ALERT_WEBHOOK_URL`.
|
||||||
|
2. Test alert dengan trigger fake fail.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 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,81 @@
|
|||||||
|
# Setrip — Admin User Management Roadmap
|
||||||
|
|
||||||
|
Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review).
|
||||||
|
|
||||||
|
> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip. Saat ini admin cuma bisa refund korban — pelaku tetap bisa lanjut bikin trip baru / join trip lain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
- ✅ `userRepo.findByEmail()` dan `userRepo.findById()` ada di [server/repositories/user.repo.ts](server/repositories/user.repo.ts).
|
||||||
|
- ✅ `User` model lengkap dengan relasi ke `trips`, `participations`, `bookings`, `tripReviews`, `organizerVerification`, `payouts`.
|
||||||
|
- ❌ Tidak ada page `/admin/users`.
|
||||||
|
- ❌ Tidak ada field `suspended` di `User`.
|
||||||
|
- ❌ Tidak ada guard di auth/server actions yang reject suspended user.
|
||||||
|
- ❌ Tidak ada stats user (total signups, organizer aktif, peserta aktif).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — User List & Detail View ⏳
|
||||||
|
|
||||||
|
Baseline visibility. Sama pola dengan trip ops — list + search + detail.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Search by email exact match dulu (paling sering dipakai admin saat ada laporan); kalau perlu, tambah name LIKE search nanti.
|
||||||
|
- Detail page tampilkan: profile, verification status, booking history (sebagai peserta), trip history (sebagai organizer), payout history (sebagai organizer), review yang dibuat & diterima.
|
||||||
|
- Sensitive info (password hash, OAuth tokens) **tidak** ditampilkan.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.1 | `userRepo.searchForAdmin({ q?, role?, suspended? })` — q match email atau name (case insensitive) | ⏳ | [server/repositories/user.repo.ts](server/repositories/user.repo.ts) |
|
||||||
|
| 1.2 | Page `/admin/users` — list + search bar + filter (organizer/participant/suspended) | ⏳ | `app/admin/users/page.tsx` |
|
||||||
|
| 1.3 | Page `/admin/users/[id]` — profil + tabs (Bookings, Trips Dibuat, Reviews, Verification) | ⏳ | `app/admin/users/[id]/page.tsx` |
|
||||||
|
| 1.4 | Stats card di top: total bookings, total spent, total revenue (kalau organizer), verification status | ⏳ | `app/admin/users/[id]/page.tsx` |
|
||||||
|
| 1.5 | Link "Users" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — User Suspension ⏳
|
||||||
|
|
||||||
|
Toggle suspend yang mencegah suspended user login + melakukan aksi mutatif.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Tambah 4 kolom di `User`: `suspended Boolean @default(false)`, `suspendedAt DateTime?`, `suspendedReason String?`, `suspendedBy String?` (FK User admin).
|
||||||
|
- **Block sign-in** di NextAuth callbacks (`signIn` callback return false kalau `user.suspended`). Untuk JWT session sudah aktif, cek `suspended` di `session` callback lalu invalidate.
|
||||||
|
- **Block mutating actions** via helper `requireActiveUser(session)` yang dipanggil di awal setiap server action mutating (joinTrip, createTrip, addReview, dst).
|
||||||
|
- Suspended user **tetap bisa** lihat data sendiri (refund history, dll) — tidak hard-delete supaya audit trail terjaga.
|
||||||
|
- Suspended organizer otomatis sembunyikan trip OPEN/FULL miliknya dari public list — tambah filter di `tripRepo.findOpen` (`organizer: { suspended: false }`).
|
||||||
|
- Unsuspend = toggle false + clear field — tetap simpan history via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.1 | Migration: tambah `suspended`, `suspendedAt`, `suspendedReason`, `suspendedBy` di `User` | ⏳ | `prisma/migrations/` |
|
||||||
|
| 2.2 | `userService.suspendUser(userId, adminId, reason)` + `unsuspendUser(userId, adminId)` | ⏳ | `server/services/user.service.ts` |
|
||||||
|
| 2.3 | Block sign-in di NextAuth `signIn` callback | ⏳ | [lib/auth.ts](lib/auth.ts) |
|
||||||
|
| 2.4 | Helper `requireActiveUser(session)` throw kalau suspended | ⏳ | `lib/auth-guards.ts` |
|
||||||
|
| 2.5 | Wire `requireActiveUser` di semua mutating server action (joinTripAction, createTripAction, createReviewAction, dst) | ⏳ | `features/*/actions.ts` |
|
||||||
|
| 2.6 | Filter trip public list: organizer tidak suspended | ⏳ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) |
|
||||||
|
| 2.7 | UI: tombol "Suspend" / "Unsuspend" di `/admin/users/[id]` + modal reason wajib | ⏳ | `app/admin/users/[id]/page.tsx` |
|
||||||
|
| 2.8 | Badge "SUSPENDED" di user list + detail header (visual jelas) | ⏳ | `app/admin/users/[id]/page.tsx` |
|
||||||
|
| 2.9 | Server action `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | ⏳ | `features/admin/actions.ts` (baru) atau `features/user/actions.ts` |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Brief admin: kriteria suspend (3 kategori: scam, harassment, repeated TOS violation). Hindari subjective suspend.
|
||||||
|
2. Tulis halaman info "Akun ditangguhkan" yang ditampilkan saat suspended user coba login (jelaskan kenapa & cara appeal via email).
|
||||||
|
3. Pertimbangkan suspended user di Midtrans webhook — saat ada payment masuk untuk suspended user's booking, tetap di-PAID (uang tetap diterima, refund proses normal).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — User Analytics (low priority, skip MVP) ⏳
|
||||||
|
|
||||||
|
Dashboard stats untuk growth tracking. Skip sampai ada kebutuhan konkret.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.1 | Stats endpoint: total user, signup per minggu (4 minggu terakhir), organizer aktif (yang punya OPEN/FULL trip), peserta aktif (booking PAID) | ⏳ | `app/admin/users/stats/page.tsx` |
|
||||||
|
| 3.2 | Chart sederhana (HTML/SVG inline, no chart library) | ⏳ | `app/admin/users/stats/page.tsx` |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada (skip phase ini).
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Setrip — Admin Verification Roadmap
|
||||||
|
|
||||||
|
Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override.
|
||||||
|
|
||||||
|
> **Skenario nyata:** organizer terverifikasi dengan KTP buram → admin REJECT. Organizer foto ulang dan kirim via email. Admin sekarang harus edit DB manual karena `OrganizerVerification.status = REJECTED` tidak bisa kembali ke `PENDING` lewat UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
- ✅ Approve / Reject ada di [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx).
|
||||||
|
- ✅ `OrganizerVerification` model lengkap dengan `reviewedBy`, `reviewedAt`, `rejectionReason`.
|
||||||
|
- ✅ NIK encrypted (decrypt via `organizerService.decryptNik` saat di-render).
|
||||||
|
- ❌ Tidak ada cara reopen REJECTED → kembali ke PENDING.
|
||||||
|
- ❌ Tidak ada flow "request re-upload" (admin minta organizer upload ulang field tertentu tanpa harus reject penuh).
|
||||||
|
- ❌ Tidak ada history per verification — kalau organizer ajukan ulang setelah reject, history sebelumnya hilang (di-overwrite).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Reopen Rejected Verification ⏳
|
||||||
|
|
||||||
|
Tombol di REJECTED detail untuk reset ke PENDING supaya admin/organizer bisa coba lagi tanpa harus drop & recreate.
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Tidak hapus `rejectionReason` saat reopen — simpan untuk history (rename field jadi `lastRejectionReason`). Sebenarnya rejectionReason cuma string, kalau di-reopen lalu di-reject lagi otomatis di-overwrite. Untuk MVP cukup itu.
|
||||||
|
- Service method baru `reopenVerification(verifId, adminId, note)` — set `status = PENDING`, clear `reviewedBy/reviewedAt`, log via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4 `auditLog.record`.
|
||||||
|
- UI: tombol "Buka kembali" di REJECTED card dengan modal note wajib.
|
||||||
|
- Tidak otomatis kirim notif ke organizer di MVP — admin coordinate via email/WA.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` — set PENDING + clear review fields | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
|
||||||
|
| 1.2 | Server action `reopenVerificationAction(verifId, note)` (guard isAdmin) | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) |
|
||||||
|
| 1.3 | UI: tombol "Buka kembali" di tab REJECTED dengan modal note | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) atau [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
|
||||||
|
| 1.4 | Tampilkan `lastRejectionReason` di tab PENDING juga (sebagai konteks "ini submission ke-N") | ⏳ | [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Brief admin: jangan reopen tanpa konfirmasi organizer sudah siap upload ulang. Note wajib menjelaskan alasan reopen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Re-upload Request Flow ⏳
|
||||||
|
|
||||||
|
Daripada reject penuh, admin bisa request specific field di-update (KTP buram, foto liveness terlalu gelap).
|
||||||
|
|
||||||
|
**Keputusan asumsi:**
|
||||||
|
- Tambah `OrganizerVerification.reuploadRequested Boolean @default(false)` + `reuploadFields String[]` + `reuploadNote String?`.
|
||||||
|
- Saat di-request, set `status = PENDING` (atau status baru `NEEDS_REUPLOAD`). Pakai `PENDING` saja supaya tidak nambah enum (organizer-facing copy bedakan via `reuploadRequested` flag).
|
||||||
|
- Organizer page `/verify` baca flag → tampilkan banner kuning + highlight field yang di-request.
|
||||||
|
- Setelah organizer submit ulang, flag auto-clear → status tetap PENDING menunggu admin review.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2.1 | Migration: tambah `reuploadRequested`, `reuploadFields`, `reuploadNote` di `OrganizerVerification` | ⏳ | `prisma/migrations/` |
|
||||||
|
| 2.2 | `organizerService.requestReupload(verifId, adminId, fields[], note)` | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
|
||||||
|
| 2.3 | Server action + UI tombol "Request re-upload" di admin detail (checkbox per field + textarea note) | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) + admin page |
|
||||||
|
| 2.4 | Banner kuning di `/verify` saat `reuploadRequested = true` + highlight field-nya | ⏳ | [app/(public)/verify/page.tsx](app/(public)/verify/page.tsx) + [features/organizer/components/verify-form.tsx](features/organizer/components/verify-form.tsx) |
|
||||||
|
| 2.5 | Auto-clear `reuploadRequested` saat organizer submit ulang | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) (di `submitVerificationAction`) |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Verification History (opsional) ⏳
|
||||||
|
|
||||||
|
Kalau audit butuh trace "berapa kali organizer ini coba verify", tambah tabel history. Skip untuk MVP.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3.1 | Model `OrganizerVerificationHistory` (snapshot per submission) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| 3.2 | Trigger create snapshot saat submit ulang | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
|
||||||
|
| 3.3 | Tab "History" di admin verification detail | ⏳ | admin page |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada (skip phase).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Manual Override (super-low priority) ⏳
|
||||||
|
|
||||||
|
Admin verifikasi organizer tanpa upload (referral dari partner trusted). Skip kecuali ada use case nyata.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 4.1 | `organizerService.adminCreateVerification(userId, adminId, note)` — buat row APPROVED langsung dengan flag `isManualOverride` | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
|
||||||
|
| 4.2 | Migration: tambah `isManualOverride Boolean @default(false)` | ⏳ | `prisma/migrations/` |
|
||||||
|
| 4.3 | UI: tombol "Verify manually" di `/admin/users/[id]` (organizer tab) | ⏳ | `app/admin/users/[id]/page.tsx` |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada (skip phase, evaluate ulang setelah ada partnership program).
|
||||||
@@ -59,19 +59,28 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 6. Pembayaran
|
# 6. Pembayaran & Escrow
|
||||||
|
|
||||||
- Pembayaran dilakukan sesuai metode yang tersedia di platform
|
- Pembayaran dilakukan melalui metode yang tersedia di platform (Midtrans atau transfer manual yang dikonfirmasi organizer)
|
||||||
- Dalam fase awal, pembayaran dapat dilakukan langsung kepada organizer
|
- **Uang peserta ditahan oleh SeTrip (escrow)** sejak pembayaran berhasil hingga trip selesai + 3 hari, baru kemudian diteruskan ke organizer
|
||||||
- SeTrip tidak menjamin keamanan transaksi yang dilakukan di luar platform
|
- Buffer 3 hari memberi waktu peserta dan organizer melaporkan masalah trip sebelum uang cair
|
||||||
|
- Pembayaran di luar platform tidak dijamin keamanannya oleh SeTrip — kami tidak dapat memediasi sengketa untuk transaksi off-platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 7. Pembatalan & Refund
|
# 7. Pembatalan & Refund
|
||||||
|
|
||||||
- Kebijakan pembatalan ditentukan oleh organizer
|
**Saat peserta membatalkan booking sendiri** (kebijakan default platform):
|
||||||
- SeTrip tidak bertanggung jawab atas refund yang tidak diberikan oleh organizer
|
|
||||||
- Pengguna disarankan untuk memahami kebijakan sebelum melakukan pembayaran
|
- **≥ 7 hari** sebelum tanggal berangkat → refund **80%** dari nominal booking
|
||||||
|
- **3–6 hari** sebelum tanggal berangkat → refund **50%** dari nominal booking
|
||||||
|
- **< 3 hari** sebelum tanggal berangkat / setelah berangkat → **tidak ada refund**
|
||||||
|
|
||||||
|
**Saat organizer membatalkan trip:** peserta yang sudah bayar mendapat refund **100%**.
|
||||||
|
|
||||||
|
**Pengembalian dana** diproses manual oleh admin SeTrip — perlu 1–3 hari kerja sejak refund disetujui untuk uang masuk ke rekening kamu. Setiap pengajuan refund tercatat (tidak pernah dihapus) untuk audit trail.
|
||||||
|
|
||||||
|
Kebijakan di atas berlaku platform-wide; organizer tidak dapat menetapkan policy yang lebih ketat tanpa persetujuan tertulis dari SeTrip.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Navbar } from "@/components/shared/navbar";
|
||||||
|
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
|
||||||
|
import { Footer } from "@/components/shared/footer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout user-facing (semua halaman publik + dashboard organizer/peserta).
|
||||||
|
* Berisi navbar global, profile-nudge banner, dan footer.
|
||||||
|
*
|
||||||
|
* Tidak berlaku untuk halaman admin — admin punya layout sendiri di
|
||||||
|
* app/admin/layout.tsx dengan sidebar khusus.
|
||||||
|
*/
|
||||||
|
export default function PublicLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<ProfileNudgeBanner />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn, getSession } from "next-auth/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -38,7 +38,13 @@ function LoginForm() {
|
|||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
} else {
|
} else {
|
||||||
const next = safeInternalPath(searchParams.get("callbackUrl"));
|
const rawCallback = searchParams.get("callbackUrl");
|
||||||
|
let next = safeInternalPath(rawCallback);
|
||||||
|
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin.
|
||||||
|
if (!rawCallback) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (session?.user?.isAdmin) next = "/admin";
|
||||||
|
}
|
||||||
router.push(next);
|
router.push(next);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,11 @@ import Link from "next/link";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { profileService } from "@/server/services/profile.service";
|
import { profileService } from "@/server/services/profile.service";
|
||||||
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||||
import { TripCard } from "@/features/trip/components/trip-card";
|
import { TripCard } from "@/features/trip/components/trip-card";
|
||||||
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
|
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
|
||||||
import { ProfileEditor } from "@/features/profile/components/profile-editor";
|
import { ProfileEditor } from "@/features/profile/components/profile-editor";
|
||||||
|
import { EarningsSection } from "@/features/payout/components/earnings-section";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Profil Saya",
|
title: "Profil Saya",
|
||||||
@@ -20,9 +22,10 @@ export default async function ProfilePage() {
|
|||||||
redirect("/login?callbackUrl=/profile");
|
redirect("/login?callbackUrl=/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data, ownProfile] = await Promise.all([
|
const [data, ownProfile, payouts] = await Promise.all([
|
||||||
profileService.getProfileDashboard(session.user.id),
|
profileService.getProfileDashboard(session.user.id),
|
||||||
profileService.getOwnProfile(session.user.id),
|
profileService.getOwnProfile(session.user.id),
|
||||||
|
payoutRepo.listForOrganizer(session.user.id),
|
||||||
]);
|
]);
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
@@ -84,6 +87,9 @@ export default async function ProfilePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pendapatan dari peserta (escrow payout) */}
|
||||||
|
<EarningsSection payouts={payouts} />
|
||||||
|
|
||||||
{/* Profil sosial publik */}
|
{/* Profil sosial publik */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ProfileEditor
|
<ProfileEditor
|
||||||
@@ -17,7 +17,6 @@ import { CancelBookingButton } from "@/features/booking/components/cancel-bookin
|
|||||||
import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
|
import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
|
||||||
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
|
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
|
||||||
import { TripProgramBlock } from "@/features/trip/components/trip-program-block";
|
import { TripProgramBlock } from "@/features/trip/components/trip-program-block";
|
||||||
import { OrganizerPaymentQueue } from "@/features/booking/components/organizer-payment-queue";
|
|
||||||
import { ImageGallery } from "@/features/trip/components/image-gallery";
|
import { ImageGallery } from "@/features/trip/components/image-gallery";
|
||||||
import { TripReviewSection } from "@/features/review/components/trip-review-section";
|
import { TripReviewSection } from "@/features/review/components/trip-review-section";
|
||||||
import { RefundPolicySection } from "@/features/refund/components/refund-policy-section";
|
import { RefundPolicySection } from "@/features/refund/components/refund-policy-section";
|
||||||
@@ -135,13 +134,6 @@ export default async function TripDetailPage({
|
|||||||
|
|
||||||
const tripIsFree = isFreeTrip(trip);
|
const tripIsFree = isFreeTrip(trip);
|
||||||
|
|
||||||
// Antrian konfirmasi pembayaran: source dari Booking + Payment (B9).
|
|
||||||
// Hanya organizer yang butuh data ini, dan hanya untuk trip berbayar.
|
|
||||||
const paymentPendingBookings =
|
|
||||||
!tripIsFree && isOrganizer
|
|
||||||
? await bookingService.getAwaitingManualForTrip(trip.id)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Booking peserta saat ini — dipakai untuk render CancelBookingButton vs
|
// Booking peserta saat ini — dipakai untuk render CancelBookingButton vs
|
||||||
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
|
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
|
||||||
const myBooking =
|
const myBooking =
|
||||||
@@ -447,6 +439,13 @@ export default async function TripDetailPage({
|
|||||||
<TripProgramBlock
|
<TripProgramBlock
|
||||||
meetingPoint={trip.meetingPoint}
|
meetingPoint={trip.meetingPoint}
|
||||||
itinerary={trip.itinerary}
|
itinerary={trip.itinerary}
|
||||||
|
itineraryItems={trip.itineraryItems.map((i) => ({
|
||||||
|
day: i.day,
|
||||||
|
startTime: i.startTime,
|
||||||
|
endTime: i.endTime,
|
||||||
|
activity: i.activity,
|
||||||
|
order: i.order,
|
||||||
|
}))}
|
||||||
whatsIncluded={trip.whatsIncluded}
|
whatsIncluded={trip.whatsIncluded}
|
||||||
whatsExcluded={trip.whatsExcluded}
|
whatsExcluded={trip.whatsExcluded}
|
||||||
/>
|
/>
|
||||||
@@ -469,18 +468,6 @@ export default async function TripDetailPage({
|
|||||||
pending={pendingParticipants.map((p) => ({
|
pending={pendingParticipants.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
user: p.user,
|
user: p.user,
|
||||||
markedPaidAt: p.markedPaidAt,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOrganizer && paymentPendingBookings.length > 0 && (
|
|
||||||
<OrganizerPaymentQueue
|
|
||||||
tripId={trip.id}
|
|
||||||
items={paymentPendingBookings.map((b) => ({
|
|
||||||
id: b.participantId,
|
|
||||||
user: { name: b.user.name, image: b.user.image },
|
|
||||||
joinStatus: "CONFIRMED" as const,
|
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -498,15 +485,7 @@ export default async function TripDetailPage({
|
|||||||
? currentParticipation.status
|
? currentParticipation.status
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
participantPayment={
|
bookingStatus={myBooking?.status ?? null}
|
||||||
currentParticipation
|
|
||||||
? {
|
|
||||||
markedPaidAt: currentParticipation.markedPaidAt,
|
|
||||||
paymentConfirmedAt:
|
|
||||||
currentParticipation.paymentConfirmedAt,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
isFull={spotsLeft <= 0}
|
isFull={spotsLeft <= 0}
|
||||||
tripStatus={trip.status}
|
tripStatus={trip.status}
|
||||||
isDeparturePast={isDeparturePast}
|
isDeparturePast={isDeparturePast}
|
||||||
@@ -4,15 +4,13 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { tripService } from "@/server/services/trip.service";
|
import { tripService } from "@/server/services/trip.service";
|
||||||
import { organizerService } from "@/server/services/organizer.service";
|
|
||||||
import { bookingService } from "@/server/services/booking.service";
|
import { bookingService } from "@/server/services/booking.service";
|
||||||
|
import { paymentService } from "@/server/services/payment.service";
|
||||||
import { formatRupiah } from "@/lib/utils";
|
import { formatRupiah } from "@/lib/utils";
|
||||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||||
import { categoryMeta } from "@/lib/activity-category";
|
import { categoryMeta } from "@/lib/activity-category";
|
||||||
import { MarkPaidButton } from "@/features/booking/components/mark-paid-button";
|
|
||||||
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
|
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
|
||||||
import { CopyButton } from "@/features/booking/components/copy-button";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Detail Pembayaran",
|
title: "Detail Pembayaran",
|
||||||
@@ -21,9 +19,10 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PaymentPage({ params }: PageProps) {
|
export default async function PaymentPage({ params, searchParams }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
@@ -37,11 +36,26 @@ export default async function PaymentPage({ params }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Organizer trip-nya sendiri tidak butuh halaman pembayaran.
|
|
||||||
if (trip.organizerId === session.user.id) {
|
if (trip.organizerId === session.user.id) {
|
||||||
redirect(`/trips/${id}`);
|
redirect(`/trips/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Saat user kembali dari Snap, Midtrans append `order_id` (+ status_code +
|
||||||
|
// transaction_status) ke finishUrl. Tarik status terkini dari Core API
|
||||||
|
// sebelum render supaya UI sinkron tanpa menunggu webhook — penting di dev
|
||||||
|
// (localhost) dan saat webhook tertunda.
|
||||||
|
const sp = await searchParams;
|
||||||
|
const orderIdParam = sp.order_id;
|
||||||
|
const orderId = Array.isArray(orderIdParam) ? orderIdParam[0] : orderIdParam;
|
||||||
|
if (orderId) {
|
||||||
|
try {
|
||||||
|
await paymentService.reconcileFromGateway(orderId, session.user.id);
|
||||||
|
} catch {
|
||||||
|
// Jangan blokir render kalau gateway tidak responsif — webhook tetap
|
||||||
|
// sumber kebenaran jangka panjang. Status di UI akan apa adanya dari DB.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const booking = await bookingService.getByTripAndUser(
|
const booking = await bookingService.getByTripAndUser(
|
||||||
trip.id,
|
trip.id,
|
||||||
session.user.id
|
session.user.id
|
||||||
@@ -51,15 +65,10 @@ export default async function PaymentPage({ params }: PageProps) {
|
|||||||
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
|
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestManualPayment = booking.payments.find(
|
|
||||||
(p) => p.provider === "MANUAL"
|
|
||||||
);
|
|
||||||
|
|
||||||
const tripIsFree = isFreeTrip(trip);
|
const tripIsFree = isFreeTrip(trip);
|
||||||
const catMeta = categoryMeta(trip.category);
|
const catMeta = categoryMeta(trip.category);
|
||||||
const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
|
const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
|
||||||
|
|
||||||
// Header info — sama untuk free vs paid
|
|
||||||
const tripHeader = (
|
const tripHeader = (
|
||||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -104,29 +113,19 @@ export default async function PaymentPage({ params }: PageProps) {
|
|||||||
<p className="mb-5 text-sm text-neutral-500">
|
<p className="mb-5 text-sm text-neutral-500">
|
||||||
{tripIsFree
|
{tripIsFree
|
||||||
? "Trip ini gratis — kamu tidak perlu transfer apa-apa."
|
? "Trip ini gratis — kamu tidak perlu transfer apa-apa."
|
||||||
: "Transfer manual ke rekening organizer di bawah, lalu tandai sebagai sudah bayar."}
|
: "Bayar lewat Midtrans untuk mengamankan slot kamu. Pembayaran akan ter-konfirmasi otomatis."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{tripHeader}
|
{tripHeader}
|
||||||
|
|
||||||
{tripIsFree ? (
|
{tripIsFree ? (
|
||||||
<FreeTripSection
|
<FreeTripSection tripId={trip.id} bookingStatus={booking.status} />
|
||||||
tripId={trip.id}
|
|
||||||
bookingStatus={booking.status}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<PaidTripSection
|
<PaidTripSection
|
||||||
tripId={trip.id}
|
tripId={trip.id}
|
||||||
organizerId={trip.organizerId}
|
|
||||||
organizerName={trip.organizer.name}
|
organizerName={trip.organizer.name}
|
||||||
price={trip.price}
|
price={trip.price}
|
||||||
bookingStatus={booking.status}
|
bookingStatus={booking.status}
|
||||||
paymentMarkedAt={
|
|
||||||
latestManualPayment?.status === "AWAITING"
|
|
||||||
? latestManualPayment.updatedAt
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
paymentPaidAt={latestManualPayment?.paidAt ?? null}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,12 +152,7 @@ function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FreeTripSection({
|
type BookingStatus =
|
||||||
tripId,
|
|
||||||
bookingStatus,
|
|
||||||
}: {
|
|
||||||
tripId: string;
|
|
||||||
bookingStatus:
|
|
||||||
| "PENDING"
|
| "PENDING"
|
||||||
| "AWAITING_PAY"
|
| "AWAITING_PAY"
|
||||||
| "PAID"
|
| "PAID"
|
||||||
@@ -166,6 +160,13 @@ function FreeTripSection({
|
|||||||
| "REFUNDED"
|
| "REFUNDED"
|
||||||
| "PARTIALLY_REFUNDED"
|
| "PARTIALLY_REFUNDED"
|
||||||
| "EXPIRED";
|
| "EXPIRED";
|
||||||
|
|
||||||
|
function FreeTripSection({
|
||||||
|
tripId,
|
||||||
|
bookingStatus,
|
||||||
|
}: {
|
||||||
|
tripId: string;
|
||||||
|
bookingStatus: BookingStatus;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
|
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
|
||||||
@@ -202,146 +203,59 @@ function FreeTripSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function PaidTripSection({
|
function PaidTripSection({
|
||||||
tripId,
|
tripId,
|
||||||
organizerId,
|
|
||||||
organizerName,
|
organizerName,
|
||||||
price,
|
price,
|
||||||
bookingStatus,
|
bookingStatus,
|
||||||
paymentMarkedAt,
|
|
||||||
paymentPaidAt,
|
|
||||||
}: {
|
}: {
|
||||||
tripId: string;
|
tripId: string;
|
||||||
organizerId: string;
|
|
||||||
organizerName: string;
|
organizerName: string;
|
||||||
price: number;
|
price: number;
|
||||||
bookingStatus:
|
bookingStatus: BookingStatus;
|
||||||
| "PENDING"
|
|
||||||
| "AWAITING_PAY"
|
|
||||||
| "PAID"
|
|
||||||
| "CANCELLED"
|
|
||||||
| "REFUNDED"
|
|
||||||
| "PARTIALLY_REFUNDED"
|
|
||||||
| "EXPIRED";
|
|
||||||
paymentMarkedAt: Date | null;
|
|
||||||
paymentPaidAt: Date | null;
|
|
||||||
}) {
|
}) {
|
||||||
const verification = await organizerService.getStatusForUser(organizerId);
|
const isApproved =
|
||||||
const bankAvailable = verification?.status === "APPROVED";
|
bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
|
||||||
|
|
||||||
const isApproved = bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
|
|
||||||
const isPendingApproval = bookingStatus === "PENDING";
|
const isPendingApproval = bookingStatus === "PENDING";
|
||||||
const hasMarkedPaid = !!paymentMarkedAt || !!paymentPaidAt;
|
|
||||||
const isFullyPaid = bookingStatus === "PAID";
|
const isFullyPaid = bookingStatus === "PAID";
|
||||||
const canMarkPaid = bookingStatus === "AWAITING_PAY" && !paymentMarkedAt;
|
const canPay = bookingStatus === "AWAITING_PAY";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PaymentTimeline
|
<PaymentTimeline approved={isApproved} confirmedPaid={isFullyPaid} />
|
||||||
approved={isApproved}
|
|
||||||
markedPaid={hasMarkedPaid}
|
|
||||||
confirmedPaid={isFullyPaid}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!bankAvailable && (
|
|
||||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
|
||||||
<p className="font-semibold">Rekening organizer belum tersedia</p>
|
|
||||||
<p className="mt-1 text-amber-800/90">
|
|
||||||
Organizer trip ini belum melengkapi data verifikasi (bank). Hubungi
|
|
||||||
organizer langsung lewat profilnya untuk koordinasi pembayaran.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{bankAvailable && (
|
|
||||||
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
<h3 className="mb-1 text-sm font-bold text-neutral-900 sm:text-base">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
Transfer ke rekening organizer
|
<h3 className="text-sm font-bold text-neutral-900 sm:text-base">
|
||||||
|
Total Pembayaran
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-4 text-xs text-neutral-500 sm:text-sm">
|
<p className="text-lg font-bold text-primary-700 sm:text-xl">
|
||||||
Pastikan nominal persis seperti tercantum supaya organizer mudah
|
{formatRupiah(price)}
|
||||||
mencocokkan.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3 rounded-xl bg-neutral-50 p-4 sm:p-5">
|
|
||||||
<BankRow
|
|
||||||
label="Bank"
|
|
||||||
value={verification.bankName}
|
|
||||||
copyable
|
|
||||||
/>
|
|
||||||
<BankRow
|
|
||||||
label="Nomor rekening"
|
|
||||||
value={verification.bankAccountNumber}
|
|
||||||
copyable
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
<BankRow
|
|
||||||
label="Atas nama"
|
|
||||||
value={verification.bankAccountName}
|
|
||||||
/>
|
|
||||||
<div className="mt-2 border-t border-neutral-200 pt-3">
|
|
||||||
<BankRow
|
|
||||||
label="Nominal transfer"
|
|
||||||
value={formatRupiah(price)}
|
|
||||||
strong
|
|
||||||
copyable
|
|
||||||
copyValue={String(price)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
Pembayaran diproses oleh Midtrans (BCA VA, GoPay, QRIS, kartu, dll).
|
||||||
<ul className="mt-4 space-y-1.5 text-[11px] text-neutral-500 sm:text-xs">
|
Dana ditahan SeTrip sampai trip selesai — bukan transfer langsung
|
||||||
<li>• Transfer dengan nominal pas, jangan dibulatkan.</li>
|
ke organizer.
|
||||||
<li>• Simpan bukti transfer untuk jaga-jaga jika ada konfirmasi.</li>
|
</p>
|
||||||
<li>
|
|
||||||
• Setelah transfer, tekan tombol <em>Saya sudah bayar</em> di bawah
|
|
||||||
supaya organizer tahu dan bisa konfirmasi.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{isPendingApproval && (
|
{isPendingApproval && (
|
||||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
Kamu belum disetujui organizer untuk ikut trip ini. Tunggu persetujuan
|
Kamu belum disetujui organizer untuk ikut trip ini. Tunggu persetujuan
|
||||||
dulu sebelum transfer — supaya tidak perlu refund kalau ditolak.
|
dulu sebelum bayar — supaya tidak perlu refund kalau ditolak.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canMarkPaid && (
|
{canPay && <MidtransPayButton tripId={tripId} />}
|
||||||
<div className="space-y-3">
|
|
||||||
{bankAvailable && (
|
|
||||||
<>
|
|
||||||
<MarkPaidButton tripId={tripId} />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="h-px flex-1 bg-neutral-200" />
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
|
|
||||||
atau
|
|
||||||
</span>
|
|
||||||
<span className="h-px flex-1 bg-neutral-200" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<MidtransPayButton tripId={tripId} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasMarkedPaid && (
|
{isFullyPaid && (
|
||||||
<div className="rounded-2xl border border-neutral-200 bg-white p-4 text-sm text-neutral-600 shadow-sm sm:p-5">
|
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
|
||||||
{isFullyPaid ? (
|
|
||||||
<p>
|
<p>
|
||||||
✅ Pembayaran kamu sudah dikonfirmasi oleh{" "}
|
✅ Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip
|
||||||
<span className="font-semibold text-neutral-800">
|
bareng{" "}
|
||||||
{organizerName}
|
<span className="font-semibold">{organizerName}</span>!
|
||||||
</span>
|
|
||||||
. Sampai jumpa di trip!
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
⏳ Kamu sudah menandai sudah bayar. Menunggu organizer mengecek
|
|
||||||
dan mengonfirmasi.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -359,17 +273,14 @@ async function PaidTripSection({
|
|||||||
|
|
||||||
function PaymentTimeline({
|
function PaymentTimeline({
|
||||||
approved,
|
approved,
|
||||||
markedPaid,
|
|
||||||
confirmedPaid,
|
confirmedPaid,
|
||||||
}: {
|
}: {
|
||||||
approved: boolean;
|
approved: boolean;
|
||||||
markedPaid: boolean;
|
|
||||||
confirmedPaid: boolean;
|
confirmedPaid: boolean;
|
||||||
}) {
|
}) {
|
||||||
const steps = [
|
const steps = [
|
||||||
{ label: "Disetujui organizer", done: approved },
|
{ label: "Disetujui organizer", done: approved },
|
||||||
{ label: "Kamu menandai sudah bayar", done: markedPaid },
|
{ label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid },
|
||||||
{ label: "Organizer konfirmasi pembayaran", done: confirmedPaid },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -404,37 +315,3 @@ function PaymentTimeline({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BankRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
mono,
|
|
||||||
strong,
|
|
||||||
copyable,
|
|
||||||
copyValue,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
mono?: boolean;
|
|
||||||
strong?: boolean;
|
|
||||||
copyable?: boolean;
|
|
||||||
copyValue?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={`mt-0.5 truncate text-sm text-neutral-800 ${
|
|
||||||
mono ? "font-mono" : ""
|
|
||||||
} ${strong ? "text-base font-bold text-primary-700" : ""}`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{copyable && <CopyButton value={copyValue ?? value} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { AdminSidebar } from "@/components/admin/admin-sidebar";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Admin · SeTrip",
|
||||||
|
alternates: { canonical: "/admin" },
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout admin — terpisah penuh dari layout user (navbar/footer publik tidak
|
||||||
|
* dipakai). Sidebar kiri jadi shell global untuk semua /admin/*.
|
||||||
|
*
|
||||||
|
* Auth gate di layout ini berlaku ke seluruh sub-page admin sehingga
|
||||||
|
* sub-page tidak perlu re-check (boleh disederhanakan di iterasi berikutnya).
|
||||||
|
*/
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/login?callbackUrl=/admin");
|
||||||
|
}
|
||||||
|
if (!session.user.isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4">
|
||||||
|
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm">
|
||||||
|
<p className="text-2xl">🔒</p>
|
||||||
|
<h1 className="mt-2 text-base font-bold text-neutral-900">
|
||||||
|
Halaman khusus admin
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Akun kamu tidak punya akses ke panel admin SeTrip.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-4 inline-block rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Kembali ke beranda
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-neutral-50 lg:flex-row">
|
||||||
|
<AdminSidebar
|
||||||
|
user={{ name: session.user.name, email: session.user.email }}
|
||||||
|
/>
|
||||||
|
<main className="flex-1 min-w-0">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||||
|
import { refundRepo } from "@/server/repositories/refund.repo";
|
||||||
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||||
|
|
||||||
|
const REFUND_REASON_LABEL: Record<string, string> = {
|
||||||
|
USER_CANCELLATION: "Peserta cancel",
|
||||||
|
ORGANIZER_CANCELLED: "Organizer cancel",
|
||||||
|
TRIP_ISSUE: "Masalah trip",
|
||||||
|
ADMIN_ADJUSTMENT: "Penyesuaian admin",
|
||||||
|
DISPUTE_RESOLVED: "Dispute selesai",
|
||||||
|
OTHER: "Lainnya",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatIDR(n: number) {
|
||||||
|
return `Rp${n.toLocaleString("id-ID")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(d: Date) {
|
||||||
|
const diff = Date.now() - new Date(d).getTime();
|
||||||
|
const h = Math.floor(diff / 3600000);
|
||||||
|
if (h < 1) return `${Math.max(1, Math.floor(diff / 60000))} mnt lalu`;
|
||||||
|
if (h < 24) return `${h} jam lalu`;
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
return `${days} hari lalu`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminDashboardPage() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) redirect("/login?callbackUrl=/admin");
|
||||||
|
if (!session.user.isAdmin) {
|
||||||
|
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 [
|
||||||
|
pendingVerif,
|
||||||
|
approvedVerif,
|
||||||
|
rejectedVerif,
|
||||||
|
pendingRefund,
|
||||||
|
approvedRefund,
|
||||||
|
succeededRefund,
|
||||||
|
heldPayout,
|
||||||
|
releasedPayout,
|
||||||
|
paidPayout,
|
||||||
|
recentPendingVerif,
|
||||||
|
recentPendingRefund,
|
||||||
|
recentApprovedRefund,
|
||||||
|
recentReleasedPayout,
|
||||||
|
] = await Promise.all([
|
||||||
|
organizerRepo.countByStatus("PENDING"),
|
||||||
|
organizerRepo.countByStatus("APPROVED"),
|
||||||
|
organizerRepo.countByStatus("REJECTED"),
|
||||||
|
refundRepo.countByStatus("PENDING"),
|
||||||
|
refundRepo.countByStatus("APPROVED"),
|
||||||
|
refundRepo.countByStatus("SUCCEEDED"),
|
||||||
|
payoutRepo.countByStatus("HELD"),
|
||||||
|
payoutRepo.countByStatus("RELEASED"),
|
||||||
|
payoutRepo.countByStatus("PAID"),
|
||||||
|
organizerRepo.listRecent("PENDING", 3),
|
||||||
|
refundRepo.listRecent("PENDING", 3),
|
||||||
|
refundRepo.listRecent("APPROVED", 3),
|
||||||
|
payoutRepo.listRecent("RELEASED", 3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
hint: string;
|
||||||
|
href: string;
|
||||||
|
accent: "amber" | "blue" | "primary";
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
label: "Verifikasi menunggu",
|
||||||
|
value: pendingVerif,
|
||||||
|
hint: "KYC organizer perlu ditinjau",
|
||||||
|
href: "/admin/verifications?tab=PENDING",
|
||||||
|
accent: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Refund baru",
|
||||||
|
value: pendingRefund,
|
||||||
|
hint: "Perlu disetujui / ditolak",
|
||||||
|
href: "/admin/refunds?tab=PENDING",
|
||||||
|
accent: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Refund siap transfer",
|
||||||
|
value: approvedRefund,
|
||||||
|
hint: "Refund APPROVED — transfer ke peserta lalu mark SUCCEEDED",
|
||||||
|
href: "/admin/refunds?tab=APPROVED",
|
||||||
|
accent: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Payout siap transfer",
|
||||||
|
value: releasedPayout,
|
||||||
|
hint: "Escrow lepas — transfer ke organizer",
|
||||||
|
href: "/admin/payouts?tab=RELEASED",
|
||||||
|
accent: "blue",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const accentClasses: Record<typeof stats[number]["accent"], string> = {
|
||||||
|
amber: "bg-amber-50 text-amber-900 ring-amber-200",
|
||||||
|
blue: "bg-blue-50 text-blue-900 ring-blue-200",
|
||||||
|
primary: "bg-primary-50 text-primary-900 ring-primary-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalAttention =
|
||||||
|
pendingVerif + pendingRefund + approvedRefund + releasedPayout;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||||
|
<header className="mb-8">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-primary-600">
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Halo {session.user.name}.{" "}
|
||||||
|
{totalAttention > 0 ? (
|
||||||
|
<>
|
||||||
|
Ada <strong className="text-neutral-800">{totalAttention}</strong>{" "}
|
||||||
|
hal yang menunggu tindakan kamu.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Tidak ada antrian pending — semua sudah beres ✨</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<section className="mb-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={s.label}
|
||||||
|
href={s.href}
|
||||||
|
className={`group rounded-2xl bg-white p-5 ring-1 transition-shadow hover:shadow-md ${
|
||||||
|
s.value > 0 ? "ring-neutral-200" : "ring-neutral-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-[11px] font-semibold ring-1 ${accentClasses[s.accent]}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neutral-400 group-hover:text-primary-600">
|
||||||
|
Buka →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">{s.hint}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pending Verifikasi */}
|
||||||
|
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-neutral-800">
|
||||||
|
Verifikasi Organizer
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{approvedVerif} disetujui · {rejectedVerif} ditolak ·{" "}
|
||||||
|
{pendingVerif} menunggu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/verifications?tab=PENDING"
|
||||||
|
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Tinjau pending ({pendingVerif})
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentPendingVerif.length === 0 ? (
|
||||||
|
<p className="px-5 py-6 text-sm text-neutral-500">
|
||||||
|
Tidak ada pengajuan menunggu review.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-neutral-100">
|
||||||
|
{recentPendingVerif.map((v) => (
|
||||||
|
<li
|
||||||
|
key={v.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-5 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-neutral-800">
|
||||||
|
{v.fullName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-neutral-500">
|
||||||
|
{v.user.name} · {v.user.email} · {timeAgo(v.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/verifications?tab=PENDING"
|
||||||
|
className="shrink-0 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Buka
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Refund — Pending & Siap Transfer */}
|
||||||
|
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-neutral-800">Refund</h2>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{succeededRefund} selesai · {approvedRefund} siap transfer ·{" "}
|
||||||
|
{pendingRefund} baru
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/refunds?tab=PENDING"
|
||||||
|
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Tinjau refund
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid divide-neutral-100 sm:grid-cols-2 sm:divide-x">
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-amber-700">
|
||||||
|
Pending ({pendingRefund})
|
||||||
|
</p>
|
||||||
|
{recentPendingRefund.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Tidak ada refund baru.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{recentPendingRefund.map((r) => (
|
||||||
|
<li key={r.id} className="text-sm">
|
||||||
|
<Link
|
||||||
|
href="/admin/refunds?tab=PENDING"
|
||||||
|
className="block rounded-lg p-2 -m-2 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-neutral-800">
|
||||||
|
{formatIDR(r.amount)} ·{" "}
|
||||||
|
<span className="font-normal text-neutral-500">
|
||||||
|
{REFUND_REASON_LABEL[r.reason] ?? r.reason}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-neutral-500">
|
||||||
|
{r.booking.user.name} · {r.booking.trip.title} ·{" "}
|
||||||
|
{timeAgo(r.createdAt)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-blue-700">
|
||||||
|
Siap transfer ({approvedRefund})
|
||||||
|
</p>
|
||||||
|
{recentApprovedRefund.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Tidak ada refund siap transfer.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{recentApprovedRefund.map((r) => (
|
||||||
|
<li key={r.id} className="text-sm">
|
||||||
|
<Link
|
||||||
|
href="/admin/refunds?tab=APPROVED"
|
||||||
|
className="block rounded-lg p-2 -m-2 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-neutral-800">
|
||||||
|
{formatIDR(r.amount)} ·{" "}
|
||||||
|
<span className="font-normal text-neutral-500">
|
||||||
|
{REFUND_REASON_LABEL[r.reason] ?? r.reason}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-neutral-500">
|
||||||
|
{r.booking.user.name} · {r.booking.trip.title} ·{" "}
|
||||||
|
{timeAgo(r.createdAt)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Payout — escrow ke organizer */}
|
||||||
|
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-neutral-800">
|
||||||
|
Payout Organizer (Escrow)
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{paidPayout} dibayar · {releasedPayout} siap transfer ·{" "}
|
||||||
|
{heldPayout} ditahan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/payouts?tab=RELEASED"
|
||||||
|
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Transfer payout ({releasedPayout})
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentReleasedPayout.length === 0 ? (
|
||||||
|
<p className="px-5 py-6 text-sm text-neutral-500">
|
||||||
|
Tidak ada payout siap transfer.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-neutral-100">
|
||||||
|
{recentReleasedPayout.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-5 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-neutral-800">
|
||||||
|
{formatIDR(p.amount)} · {p.organizer.name}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-neutral-500">
|
||||||
|
{p.trip.title} ·{" "}
|
||||||
|
{p.releasedAt
|
||||||
|
? `release ${timeAgo(p.releasedAt)}`
|
||||||
|
: `hold sampai ${new Date(p.heldUntil).toLocaleDateString("id-ID")}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/payouts?tab=RELEASED"
|
||||||
|
className="shrink-0 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Buka
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 px-5 py-4 text-xs text-neutral-500">
|
||||||
|
<p className="mb-1">
|
||||||
|
<span className="font-semibold">Refund APPROVED:</span> admin transfer
|
||||||
|
manual ke peserta lalu tandai <span className="font-semibold">SUCCEEDED</span>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Payout RELEASED:</span> escrow dilepas
|
||||||
|
karena trip sudah selesai + 3 hari. Admin transfer ke organizer lalu
|
||||||
|
tandai <span className="font-semibold">PAID</span>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Admin · Payout Organizer",
|
||||||
|
description:
|
||||||
|
"Halaman admin untuk meneruskan uang escrow ke rekening organizer setelah trip selesai.",
|
||||||
|
alternates: { canonical: "/admin/payouts" },
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminPayoutsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||||
|
import {
|
||||||
|
PayoutReviewCard,
|
||||||
|
type PayoutCardData,
|
||||||
|
} from "@/features/payout/components/payout-review-card";
|
||||||
|
|
||||||
|
type Tab = "RELEASED" | "HELD" | "PAID" | "CANCELLED";
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "RELEASED", label: "Siap transfer" },
|
||||||
|
{ key: "HELD", label: "Ditahan (escrow)" },
|
||||||
|
{ key: "PAID", label: "Selesai" },
|
||||||
|
{ key: "CANCELLED", label: "Dibatalkan" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ tab?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminPayoutsPage({ searchParams }: PageProps) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) redirect("/login?callbackUrl=/admin/payouts");
|
||||||
|
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)
|
||||||
|
: "RELEASED";
|
||||||
|
|
||||||
|
const rows = await payoutRepo.listByStatus(tab);
|
||||||
|
const items: PayoutCardData[] = rows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
amount: p.amount,
|
||||||
|
currency: p.currency,
|
||||||
|
status: p.status,
|
||||||
|
heldUntil: p.heldUntil,
|
||||||
|
releasedAt: p.releasedAt,
|
||||||
|
paidAt: p.paidAt,
|
||||||
|
cancelledAt: p.cancelledAt,
|
||||||
|
bankName: p.bankName,
|
||||||
|
bankAccountNumber: p.bankAccountNumber,
|
||||||
|
bankAccountName: p.bankAccountName,
|
||||||
|
adminNote: p.adminNote,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
trip: p.trip,
|
||||||
|
organizer: p.organizer,
|
||||||
|
booking: {
|
||||||
|
id: p.booking.id,
|
||||||
|
amount: p.booking.amount,
|
||||||
|
status: p.booking.status,
|
||||||
|
user: p.booking.user,
|
||||||
|
},
|
||||||
|
processedBy: p.processedBy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
|
Payout Organizer
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah
|
||||||
|
status <strong>Siap transfer</strong>, admin transfer manual ke
|
||||||
|
rekening organizer lalu tandai sudah dibayar.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-6 flex flex-wrap gap-2">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<a
|
||||||
|
key={t.key}
|
||||||
|
href={`/admin/payouts?tab=${t.key}`}
|
||||||
|
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}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.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">Tidak ada payout pada status ini.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map((p) => (
|
||||||
|
<PayoutReviewCard key={p.id} payout={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { tripService } from "@/server/services/trip.service";
|
import { tripService } from "@/server/services/trip.service";
|
||||||
|
import { payoutService } from "@/server/services/payout.service";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -31,14 +32,19 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await tripService.autoCompletePastTrips();
|
const result = await tripService.autoCompletePastTrips();
|
||||||
|
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
|
||||||
|
// supaya admin bisa langsung transfer ke organizer. Idempotent.
|
||||||
|
const releaseResult = await payoutService.releaseEligible();
|
||||||
console.log("[cron/auto-complete-trips] selesai", {
|
console.log("[cron/auto-complete-trips] selesai", {
|
||||||
count: result.count,
|
completed: result.count,
|
||||||
ids: result.ids,
|
ids: result.ids,
|
||||||
|
payoutsReleased: releaseResult.releasedIds.length,
|
||||||
});
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
completed: result.count,
|
completed: result.count,
|
||||||
ids: result.ids,
|
ids: result.ids,
|
||||||
|
payoutsReleased: releaseResult.releasedIds,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[cron/auto-complete-trips] gagal", err);
|
console.error("[cron/auto-complete-trips] gagal", err);
|
||||||
|
|||||||
+1
-7
@@ -1,8 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { SessionProvider } from "@/components/providers/session-provider";
|
import { SessionProvider } from "@/components/providers/session-provider";
|
||||||
import { Navbar } from "@/components/shared/navbar";
|
|
||||||
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
|
|
||||||
import { siteConfig, siteUrl } from "@/lib/site";
|
import { siteConfig, siteUrl } from "@/lib/site";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -79,11 +77,7 @@ export default function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="flex min-h-full flex-col bg-neutral-50">
|
<body className="flex min-h-full flex-col bg-neutral-50">
|
||||||
<SessionProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
<Navbar />
|
|
||||||
<ProfileNudgeBanner />
|
|
||||||
<main className="flex-1">{children}</main>
|
|
||||||
</SessionProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
|
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
|
||||||
|
{ href: "/admin", label: "Dashboard", icon: "📊" },
|
||||||
|
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
|
||||||
|
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
|
||||||
|
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AdminSidebarProps {
|
||||||
|
user: { name: string; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile top bar */}
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4 lg:hidden">
|
||||||
|
<Link href="/admin" className="flex items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src="/images/SeTrip.png"
|
||||||
|
alt=""
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="h-7 w-7 object-contain"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-bold text-neutral-800">
|
||||||
|
SeTrip <span className="text-primary-600">Admin</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-neutral-600 hover:bg-neutral-100"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M5 5l10 10M15 5L5 15" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M3 5h14M3 10h14M3 15h14" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile drawer backdrop */}
|
||||||
|
{open && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Tutup menu"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="fixed inset-0 z-30 bg-neutral-900/30 lg:hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-neutral-200 bg-white transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||||
|
open ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-5">
|
||||||
|
<Image
|
||||||
|
src="/images/SeTrip.png"
|
||||||
|
alt=""
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
/>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<p className="text-base font-bold text-neutral-800">
|
||||||
|
SeTrip
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-primary-600">
|
||||||
|
Admin Panel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto p-3">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href ||
|
||||||
|
(item.href !== "/admin" && pathname?.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-primary-600 text-white"
|
||||||
|
: "text-neutral-700 hover:bg-neutral-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="text-base">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="my-4 border-t border-neutral-100" />
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
<span aria-hidden>↩</span>
|
||||||
|
<span>Lihat situs publik</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-neutral-100 p-3">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg px-2 py-2">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-semibold text-neutral-800">
|
||||||
|
{user.name}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[10px] text-neutral-500">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
|
className="rounded-lg px-2 py-1 text-[11px] font-medium text-neutral-500 hover:bg-red-50 hover:text-red-600"
|
||||||
|
title="Keluar"
|
||||||
|
>
|
||||||
|
Keluar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { siteConfig } from "@/lib/site";
|
||||||
|
|
||||||
|
const LEGAL_LINKS = [
|
||||||
|
{ href: "/terms", label: "Syarat & Ketentuan" },
|
||||||
|
{ href: "/privacy", label: "Kebijakan Privasi" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const EXPLORE_LINKS = [
|
||||||
|
{ href: "/trips", label: "Open Trip" },
|
||||||
|
{ href: "/people", label: "Cari Teman" },
|
||||||
|
{ href: "/create-trip", label: "Buat Trip" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return (
|
||||||
|
<footer className="mt-12 border-t border-neutral-200 bg-white">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
|
||||||
|
<div className="grid gap-8 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Link href="/" className="inline-flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold text-neutral-800">
|
||||||
|
Se<span className="text-primary-600">Trip</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<p className="mt-2 max-w-xs text-xs text-neutral-500">
|
||||||
|
{siteConfig.slogan} Gabung trip & aktivitas, kenal stranger jadi
|
||||||
|
travel buddies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Jelajah
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{EXPLORE_LINKS.map((l) => (
|
||||||
|
<li key={l.href}>
|
||||||
|
<Link
|
||||||
|
href={l.href}
|
||||||
|
className="text-neutral-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Kebijakan
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{LEGAL_LINKS.map((l) => (
|
||||||
|
<li key={l.href}>
|
||||||
|
<Link
|
||||||
|
href={l.href}
|
||||||
|
className="text-neutral-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col items-start justify-between gap-2 border-t border-neutral-100 pt-4 text-xs text-neutral-500 sm:flex-row sm:items-center">
|
||||||
|
<p>
|
||||||
|
© {year} {siteConfig.name}. Pergi bareng, bukan sendiri.
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-neutral-400">
|
||||||
|
Pembayaran ditahan (escrow) sampai trip selesai · refund manual oleh
|
||||||
|
admin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# Release Workflow
|
||||||
|
|
||||||
|
Panduan rilis: commit perubahan, naikan versi, push. Konsisten dengan pola history repo (single feature commit + version commit terpisah).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aturan versi (semver untuk 0.x)
|
||||||
|
|
||||||
|
Project masih `0.x.y` — API belum stabil, semver yang dipakai:
|
||||||
|
|
||||||
|
| Tipe perubahan | Bump | Contoh |
|
||||||
|
|---|---|---|
|
||||||
|
| **MAJOR** `0.x.y → 1.0.0` | hanya saat siap rilis publik / API stabil | nanti |
|
||||||
|
| **MINOR** `0.11.0 → 0.12.0` | fitur baru, breaking change, schema/migration baru, removal API | midtrans-only flow, structured itinerary |
|
||||||
|
| **PATCH** `0.10.2 → 0.10.3` | bugfix, dependency upgrade, copy/UI tweaks tanpa schema | upgrade lib vulnerability, fix hydration |
|
||||||
|
|
||||||
|
**Aturan praktis:** kalau perlu jalankan `prisma migrate deploy` setelah pull → minor. Kalau cuma `git pull && pm2 restart` → patch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-flight check (wajib sebelum commit)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Type check (filter cache stale Next.js)
|
||||||
|
npx tsc --noEmit 2>&1 | grep -v "\.next"
|
||||||
|
|
||||||
|
# 2. Lint
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# 3. (Opsional) test seed kalau ubah schema/seed
|
||||||
|
npm run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Kalau ada error di TS atau ESLint, **jangan commit**. Fix dulu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard flow (rekomendasi)
|
||||||
|
|
||||||
|
Pola dari history repo: **1 commit fitur** + **1 commit versi** terpisah.
|
||||||
|
|
||||||
|
### 1. Verify status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git diff --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
Pastikan tidak ada file sensitif (`.env`, `*.key`, upload KYC, `app/generated/prisma/`) ter-track.
|
||||||
|
|
||||||
|
### 2. Stage perubahan
|
||||||
|
|
||||||
|
Default — semua perubahan logis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
```
|
||||||
|
|
||||||
|
Kalau ada file yang sengaja dipisah commit-nya, pakai selective:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add path/to/file1 path/to/file2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Commit fitur
|
||||||
|
|
||||||
|
Pakai pesan singkat, lowercase, deskriptif. Pola history:
|
||||||
|
|
||||||
|
- ✅ `midtrans-only payment + reconcile, structured itinerary items, admin roadmap`
|
||||||
|
- ✅ `add payment and integration with midtrans`
|
||||||
|
- ✅ `create public layout and admin and fix escrow and refund`
|
||||||
|
- ✅ `chore: remove generated prisma client from repository`
|
||||||
|
|
||||||
|
Prefix `chore:`, `fix:` boleh dipakai tapi tidak wajib. Yang penting: deskriptif & ringkas.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "deskripsi singkat perubahan utama"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Bump versi
|
||||||
|
|
||||||
|
Edit manual `package.json` di field `"version"`, atau pakai npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bump tanpa auto-commit & tag (kita commit manual)
|
||||||
|
npm version 0.12.0 --no-git-tag-version
|
||||||
|
```
|
||||||
|
|
||||||
|
`--no-git-tag-version` penting — repo ini **tidak pakai git tag**, cuma commit dengan pesan = nomor versi.
|
||||||
|
|
||||||
|
### 5. Commit versi (terpisah)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json
|
||||||
|
git commit -m "0.12.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pesan = nomor versi saja, tanpa prefix/kata lain. Konsisten dengan history (`0.11.0`, `0.10.3`, `0.10.2`, ...).
|
||||||
|
|
||||||
|
### 6. Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-deploy actions
|
||||||
|
|
||||||
|
Setelah merge ke main + auto-deploy / `git pull` di server:
|
||||||
|
|
||||||
|
### Wajib kalau ada migration baru
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cek dulu migration belum applied
|
||||||
|
npx prisma migrate status
|
||||||
|
|
||||||
|
# Apply
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Restart PM2 supaya Prisma client re-load
|
||||||
|
pm2 restart setrip --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wajib kalau ubah field di env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit .env di server, lalu
|
||||||
|
pm2 restart setrip --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opsional — seed (hanya untuk dev/staging, JANGAN production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Production**: seed wipe seluruh data. Jangan dijalankan di production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skenario umum
|
||||||
|
|
||||||
|
### A. Bug fix kecil (patch)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit 2>&1 | grep -v "\.next" && npm run lint
|
||||||
|
|
||||||
|
git add path/to/fix
|
||||||
|
git commit -m "fix: deskripsi bug"
|
||||||
|
|
||||||
|
npm version patch --no-git-tag-version # 0.11.0 → 0.11.1
|
||||||
|
git add package.json
|
||||||
|
git commit -m "0.11.1"
|
||||||
|
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Fitur baru tanpa schema change (minor)
|
||||||
|
|
||||||
|
Sama dengan A, ganti `patch` jadi `minor`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm version minor --no-git-tag-version # 0.11.0 → 0.12.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. Fitur baru DENGAN schema/migration (minor)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Buat migration
|
||||||
|
npx prisma migrate dev --name nama_migration
|
||||||
|
|
||||||
|
# 2. Smoke test
|
||||||
|
npm run seed
|
||||||
|
npx tsc --noEmit 2>&1 | grep -v "\.next"
|
||||||
|
|
||||||
|
# 3. Commit fitur + migration sekaligus
|
||||||
|
git add -A
|
||||||
|
git commit -m "deskripsi fitur"
|
||||||
|
|
||||||
|
# 4. Bump versi minor
|
||||||
|
npm version minor --no-git-tag-version
|
||||||
|
git add package.json
|
||||||
|
git commit -m "$(node -p "require('./package.json').version")"
|
||||||
|
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# 5. Di production, setelah git pull:
|
||||||
|
npx prisma migrate deploy
|
||||||
|
pm2 restart setrip --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
### D. Multiple perubahan logis di branch yang sama (split commits)
|
||||||
|
|
||||||
|
Pisahkan jadi commit kecil per topik supaya history bersih:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commit 1: foundation (mis. schema + service)
|
||||||
|
git add prisma/ server/
|
||||||
|
git commit -m "add X service"
|
||||||
|
|
||||||
|
# Commit 2: UI yang konsumsi
|
||||||
|
git add features/ app/
|
||||||
|
git commit -m "wire X to UI"
|
||||||
|
|
||||||
|
# Commit 3: docs
|
||||||
|
git add docs/ *.md
|
||||||
|
git commit -m "docs: X usage guide"
|
||||||
|
|
||||||
|
# Commit 4: version bump
|
||||||
|
npm version minor --no-git-tag-version
|
||||||
|
git add package.json
|
||||||
|
git commit -m "$(node -p "require('./package.json').version")"
|
||||||
|
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kesalahan umum & cara recovery
|
||||||
|
|
||||||
|
| Kesalahan | Recovery |
|
||||||
|
|---|---|
|
||||||
|
| Commit pesan typo, **belum push** | `git commit --amend -m "pesan baru"` |
|
||||||
|
| Commit pesan typo, **sudah push** | jangan amend (force-push hilangin history kolaborator). Bikin commit baru `git commit --allow-empty -m "fix: pesan sebelumnya typo"` atau biarkan |
|
||||||
|
| Lupa bump versi sebelum push | bikin commit versi baru di atasnya — bukan amend |
|
||||||
|
| Bump versi salah angka (mis. 0.12.0 padahal harusnya patch 0.11.1) | revisi `package.json`, bikin commit baru `chore: revert version to 0.11.1` |
|
||||||
|
| Commit termasuk file sensitif (`.env`, upload) | jangan push. `git reset --soft HEAD~1`, un-stage file sensitif, tambah ke `.gitignore`, commit ulang |
|
||||||
|
| Sudah push dengan file sensitif | rotate secret yang ke-leak, lalu pakai `git filter-repo` atau hubungi maintainer git history |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cheatsheet (one-liner)
|
||||||
|
|
||||||
|
Untuk update biasa (fitur kecil tanpa schema):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit 2>&1 | grep -v "\.next" && npm run lint && \
|
||||||
|
git add -A && git commit -m "deskripsi" && \
|
||||||
|
npm version minor --no-git-tag-version && \
|
||||||
|
git add package.json && git commit -m "$(node -p "require('./package.json').version")" && \
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Ganti `minor` → `patch` untuk bug fix. Jangan jalankan kalau ada step yang minta keputusan manual (mis. conflict, migration baru).
|
||||||
+27
-32
@@ -2,30 +2,12 @@
|
|||||||
|
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { tripService } from "@/server/services/trip.service";
|
|
||||||
import { paymentService } from "@/server/services/payment.service";
|
import { paymentService } from "@/server/services/payment.service";
|
||||||
import { bookingService } from "@/server/services/booking.service";
|
import { bookingService } from "@/server/services/booking.service";
|
||||||
import { refundService } from "@/server/services/refund.service";
|
import { refundService } from "@/server/services/refund.service";
|
||||||
|
import { absoluteUrl } from "@/lib/site";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
export async function markParticipantPaidAction(tripId: string) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) {
|
|
||||||
return { error: "Kamu harus login terlebih dahulu" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await tripService.markParticipantPayment(tripId, session.user.id);
|
|
||||||
revalidatePath(`/trips/${tripId}`);
|
|
||||||
revalidatePath("/trips");
|
|
||||||
revalidatePath("/");
|
|
||||||
revalidatePath("/profile");
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { error: (err as Error).message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StartMidtransResponse =
|
export type StartMidtransResponse =
|
||||||
| { error: string }
|
| { error: string }
|
||||||
| {
|
| {
|
||||||
@@ -33,6 +15,7 @@ export type StartMidtransResponse =
|
|||||||
snapToken: string;
|
snapToken: string;
|
||||||
snapJsUrl: string;
|
snapJsUrl: string;
|
||||||
clientKey: string;
|
clientKey: string;
|
||||||
|
orderId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,39 +41,51 @@ export async function startMidtransPaymentAction(
|
|||||||
|
|
||||||
const result = await paymentService.startMidtransPayment(
|
const result = await paymentService.startMidtransPayment(
|
||||||
booking.id,
|
booking.id,
|
||||||
session.user.id
|
session.user.id,
|
||||||
|
{ finishUrl: absoluteUrl(`/trips/${tripId}/payment`) }
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
snapToken: result.snapToken,
|
snapToken: result.snapToken,
|
||||||
snapJsUrl: result.snapJsUrl,
|
snapJsUrl: result.snapJsUrl,
|
||||||
clientKey: result.clientKey,
|
clientKey: result.clientKey,
|
||||||
|
orderId: result.orderId,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { error: (err as Error).message };
|
return { error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmParticipantPaymentAction(
|
/**
|
||||||
tripId: string,
|
* Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
|
||||||
participantId: string
|
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
|
||||||
) {
|
* `?order_id=...`), dan oleh `MidtransPayButton` di callback `onSuccess`/
|
||||||
|
* `onPending`/`onClose` agar UI ter-update tanpa menunggu webhook.
|
||||||
|
*/
|
||||||
|
export async function reconcileMidtransPaymentAction(orderId: string) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return { error: "Kamu harus login terlebih dahulu" };
|
return { error: "Kamu harus login terlebih dahulu" };
|
||||||
}
|
}
|
||||||
|
if (!orderId || typeof orderId !== "string") {
|
||||||
|
return { error: "order_id tidak valid" };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tripService.confirmParticipantPayment(
|
const result = await paymentService.reconcileFromGateway(
|
||||||
tripId,
|
orderId,
|
||||||
participantId,
|
|
||||||
session.user.id
|
session.user.id
|
||||||
);
|
);
|
||||||
revalidatePath(`/trips/${tripId}`);
|
if (!result.ok) {
|
||||||
revalidatePath("/trips");
|
if (result.reason === "forbidden") {
|
||||||
revalidatePath("/");
|
return { error: "Order ini bukan milikmu" };
|
||||||
revalidatePath("/profile");
|
}
|
||||||
return { success: true };
|
if (result.reason === "not_found") {
|
||||||
|
return { error: "Order tidak ditemukan" };
|
||||||
|
}
|
||||||
|
return { error: "Status pembayaran tidak cocok dengan tagihan" };
|
||||||
|
}
|
||||||
|
return { success: true as const, status: result.status };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { error: (err as Error).message };
|
return { error: (err as Error).message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { markParticipantPaidAction } from "@/features/booking/actions";
|
|
||||||
|
|
||||||
interface MarkPaidButtonProps {
|
|
||||||
tripId: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MarkPaidButton({ tripId, disabled }: MarkPaidButtonProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
async function handleClick() {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
const result = await markParticipantPaidAction(tripId);
|
|
||||||
setLoading(false);
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{error && (
|
|
||||||
<div className="mb-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClick}
|
|
||||||
disabled={loading || disabled}
|
|
||||||
className="w-full rounded-xl bg-primary-600 py-3 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? "Memproses..." : "Saya sudah bayar"}
|
|
||||||
</button>
|
|
||||||
<p className="mt-2 text-center text-[11px] text-neutral-500">
|
|
||||||
Tekan setelah kamu transfer ke rekening di atas. Organizer akan cek &
|
|
||||||
konfirmasi pembayaran kamu.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startMidtransPaymentAction } from "@/features/booking/actions";
|
import {
|
||||||
|
reconcileMidtransPaymentAction,
|
||||||
|
startMidtransPaymentAction,
|
||||||
|
} from "@/features/booking/actions";
|
||||||
|
|
||||||
interface SnapCallbacks {
|
interface SnapCallbacks {
|
||||||
onSuccess?: (result: unknown) => void;
|
onSuccess?: (result: unknown) => void;
|
||||||
@@ -86,23 +89,25 @@ export function MidtransPayButton({ tripId }: MidtransPayButtonProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.snap.pay(result.snapToken, {
|
const orderId = result.orderId;
|
||||||
onSuccess: () => {
|
// Tarik status terkini dari Midtrans server-side, lalu refresh halaman.
|
||||||
// Webhook server akan tetap jadi sumber kebenaran. Refresh page untuk pull state baru.
|
// Tidak menunggu webhook supaya UI ter-update saat webhook belum sampai
|
||||||
|
// (mis. di localhost) atau redirect flow di mana popup tidak dipakai.
|
||||||
|
async function reconcileAndRefresh() {
|
||||||
|
await reconcileMidtransPaymentAction(orderId);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
}
|
||||||
onPending: () => router.refresh(),
|
|
||||||
|
window.snap.pay(result.snapToken, {
|
||||||
|
onSuccess: reconcileAndRefresh,
|
||||||
|
onPending: reconcileAndRefresh,
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setError(
|
setError(
|
||||||
"Pembayaran gagal diproses. Coba lagi atau pakai metode lain."
|
"Pembayaran gagal diproses. Coba lagi atau pakai metode lain."
|
||||||
);
|
);
|
||||||
router.refresh();
|
void reconcileAndRefresh();
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
// User menutup popup tanpa menyelesaikan. Refresh saja, kalau status berubah
|
|
||||||
// (mis. user sudah bayar VA) callback dari Midtrans akan datang ke webhook.
|
|
||||||
router.refresh();
|
|
||||||
},
|
},
|
||||||
|
onClose: reconcileAndRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { confirmParticipantPaymentAction } from "@/features/booking/actions";
|
|
||||||
|
|
||||||
export interface PaymentPendingParticipant {
|
|
||||||
id: string;
|
|
||||||
user: { name: string; image: string | null };
|
|
||||||
/** PENDING atau CONFIRMED (join) — keduanya bisa sudah tandai bayar */
|
|
||||||
joinStatus: "PENDING" | "CONFIRMED";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrganizerPaymentQueueProps {
|
|
||||||
tripId: string;
|
|
||||||
items: PaymentPendingParticipant[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrganizerPaymentQueue({
|
|
||||||
tripId,
|
|
||||||
items,
|
|
||||||
}: OrganizerPaymentQueueProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
async function confirm(participantId: string) {
|
|
||||||
setLoadingId(participantId);
|
|
||||||
setError("");
|
|
||||||
const result = await confirmParticipantPaymentAction(tripId, participantId);
|
|
||||||
setLoadingId(null);
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-primary-200 bg-primary-50/60 p-4 sm:p-5">
|
|
||||||
<h2 className="text-sm font-bold text-primary-950 sm:text-base">
|
|
||||||
Konfirmasi pembayaran ({items.length})
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-xs text-primary-900/85 sm:text-sm">
|
|
||||||
Peserta sudah menandai pembayaran. Cek rekening atau bukti transfer,
|
|
||||||
lalu konfirmasi.
|
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<p className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700 sm:text-sm">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ul className="mt-4 space-y-3">
|
|
||||||
{items.map((p) => (
|
|
||||||
<li
|
|
||||||
key={p.id}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border border-primary-100 bg-white/95 p-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
{p.user.image ? (
|
|
||||||
<Image
|
|
||||||
src={p.user.image}
|
|
||||||
alt=""
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
|
|
||||||
{p.user.name.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-semibold text-neutral-800">
|
|
||||||
{p.user.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-primary-800/90">
|
|
||||||
Menunggu konfirmasi pembayaran
|
|
||||||
{p.joinStatus === "PENDING" && (
|
|
||||||
<span className="text-neutral-500">
|
|
||||||
{" "}
|
|
||||||
· belum disetujui ikut trip
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={loadingId !== null}
|
|
||||||
onClick={() => confirm(p.id)}
|
|
||||||
className="shrink-0 rounded-lg bg-primary-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700 disabled:opacity-50 sm:text-sm"
|
|
||||||
>
|
|
||||||
{loadingId === p.id ? "Memproses…" : "Konfirmasi pembayaran"}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
|
import { payoutService } from "@/server/services/payout.service";
|
||||||
|
import { payoutMarkPaidSchema } from "./schemas";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markPayoutPaidAction(formData: FormData) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return { error: "Tidak memiliki akses admin" };
|
||||||
|
|
||||||
|
const parsed = payoutMarkPaidSchema.safeParse({
|
||||||
|
payoutId: formData.get("payoutId") as string,
|
||||||
|
adminNote: (formData.get("adminNote") as string) ?? "",
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: parsed.error.issues[0].message };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await payoutService.markPaid({
|
||||||
|
payoutId: parsed.data.payoutId,
|
||||||
|
adminId: admin.id,
|
||||||
|
adminNote: parsed.data.adminNote,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/payouts");
|
||||||
|
revalidatePath("/admin");
|
||||||
|
revalidatePath("/profile");
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { formatRupiah } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PayoutStatus = "HELD" | "RELEASED" | "PAID" | "CANCELLED";
|
||||||
|
|
||||||
|
interface EarningRow {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
status: PayoutStatus;
|
||||||
|
heldUntil: Date;
|
||||||
|
releasedAt: Date | null;
|
||||||
|
paidAt: Date | null;
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
trip: { id: string; title: string; date: Date; endDate: Date | null };
|
||||||
|
booking: {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
user: { id: string; name: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EarningsSectionProps {
|
||||||
|
payouts: EarningRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDay(d: Date | null | string): string {
|
||||||
|
if (!d) return "—";
|
||||||
|
return new Date(d).toLocaleDateString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_META: Record<
|
||||||
|
PayoutStatus,
|
||||||
|
{ label: string; cls: string; hint: (row: EarningRow) => string }
|
||||||
|
> = {
|
||||||
|
HELD: {
|
||||||
|
label: "Ditahan",
|
||||||
|
cls: "bg-amber-50 text-amber-700 ring-amber-200",
|
||||||
|
hint: (r) => `Cair setelah ${formatDay(r.heldUntil)}`,
|
||||||
|
},
|
||||||
|
RELEASED: {
|
||||||
|
label: "Siap transfer",
|
||||||
|
cls: "bg-blue-50 text-blue-700 ring-blue-200",
|
||||||
|
hint: (r) =>
|
||||||
|
r.releasedAt
|
||||||
|
? `Antri admin transfer sejak ${formatDay(r.releasedAt)}`
|
||||||
|
: "Antri admin transfer",
|
||||||
|
},
|
||||||
|
PAID: {
|
||||||
|
label: "Diterima",
|
||||||
|
cls: "bg-primary-50 text-primary-700 ring-primary-200",
|
||||||
|
hint: (r) =>
|
||||||
|
r.paidAt ? `Ditransfer ${formatDay(r.paidAt)}` : "Sudah ditransfer admin",
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
label: "Dibatalkan",
|
||||||
|
cls: "bg-neutral-100 text-neutral-600 ring-neutral-200",
|
||||||
|
hint: () => "Trip dibatalkan / di-refund — tidak ada payout",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EarningsSection({ payouts }: EarningsSectionProps) {
|
||||||
|
if (payouts.length === 0) return null;
|
||||||
|
|
||||||
|
const totals = {
|
||||||
|
held: 0,
|
||||||
|
released: 0,
|
||||||
|
paid: 0,
|
||||||
|
};
|
||||||
|
for (const p of payouts) {
|
||||||
|
if (p.status === "HELD") totals.held += p.amount;
|
||||||
|
else if (p.status === "RELEASED") totals.released += p.amount;
|
||||||
|
else if (p.status === "PAID") totals.paid += p.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<header className="mb-4">
|
||||||
|
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
||||||
|
Pendapatan dari peserta
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-500">
|
||||||
|
Uang peserta ditahan oleh SeTrip (escrow) sampai trip selesai + 3 hari,
|
||||||
|
lalu admin transfer ke rekening bank yang kamu daftarkan.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-5 grid grid-cols-3 gap-2 text-xs sm:text-sm">
|
||||||
|
<Stat label="Ditahan" value={totals.held} accent="amber" />
|
||||||
|
<Stat label="Siap transfer" value={totals.released} accent="blue" />
|
||||||
|
<Stat label="Sudah diterima" value={totals.paid} accent="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="divide-y divide-neutral-100">
|
||||||
|
{payouts.map((p) => {
|
||||||
|
const meta = STATUS_META[p.status];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
className="flex flex-wrap items-start justify-between gap-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/trips/${p.trip.id}`}
|
||||||
|
className="block truncate text-sm font-semibold text-neutral-800 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
{p.trip.title}
|
||||||
|
</Link>
|
||||||
|
<p className="truncate text-xs text-neutral-500">
|
||||||
|
{p.booking.user.name} · trip {formatDay(p.trip.date)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-neutral-500">
|
||||||
|
{meta.hint(p)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<span className="text-sm font-bold text-neutral-900">
|
||||||
|
{formatRupiah(p.amount)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ${meta.cls}`}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accent: "amber" | "blue" | "primary";
|
||||||
|
}) {
|
||||||
|
const cls = {
|
||||||
|
amber: "bg-amber-50 text-amber-900",
|
||||||
|
blue: "bg-blue-50 text-blue-900",
|
||||||
|
primary: "bg-primary-50 text-primary-900",
|
||||||
|
}[accent];
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl px-3 py-2 ${cls}`}>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide opacity-70">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm font-bold sm:text-base">
|
||||||
|
{formatRupiah(value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { markPayoutPaidAction } from "@/features/payout/actions";
|
||||||
|
import { formatRupiah } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PayoutStatus = "HELD" | "RELEASED" | "PAID" | "CANCELLED";
|
||||||
|
|
||||||
|
export type PayoutCardData = {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: PayoutStatus;
|
||||||
|
heldUntil: Date;
|
||||||
|
releasedAt: Date | null;
|
||||||
|
paidAt: Date | null;
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
bankName: string | null;
|
||||||
|
bankAccountNumber: string | null;
|
||||||
|
bankAccountName: string | null;
|
||||||
|
adminNote: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
trip: { id: string; title: string; date: Date; endDate: Date | null; status: string };
|
||||||
|
organizer: { id: string; name: string; email: string };
|
||||||
|
booking: {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
user: { id: string; name: string; email: string };
|
||||||
|
};
|
||||||
|
processedBy: { id: string; name: string; email: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(d: Date | null | string): string {
|
||||||
|
if (!d) return "—";
|
||||||
|
return new Date(d).toLocaleString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDay(d: Date | null | string): string {
|
||||||
|
if (!d) return "—";
|
||||||
|
return new Date(d).toLocaleDateString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("payoutId", payout.id);
|
||||||
|
fd.set("adminNote", note);
|
||||||
|
const result = await markPayoutPaidAction(fd);
|
||||||
|
setLoading(false);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
setNote("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bankAvailable =
|
||||||
|
payout.bankName && payout.bankAccountNumber && payout.bankAccountName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate text-base font-bold text-neutral-900">
|
||||||
|
{payout.trip.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-500">
|
||||||
|
Booking {payout.booking.user.name} · trip{" "}
|
||||||
|
{formatDay(payout.trip.date)}
|
||||||
|
{" · "}
|
||||||
|
<span className="font-mono">{payout.id.slice(0, 8)}…</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={payout.status} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
label="Organizer"
|
||||||
|
value={`${payout.organizer.name} · ${payout.organizer.email}`}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Nominal payout"
|
||||||
|
value={`${formatRupiah(payout.amount)} ${payout.currency !== "IDR" ? `(${payout.currency})` : ""}`}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
<Field label="Booking" value={`${formatRupiah(payout.booking.amount)} · ${payout.booking.status}`} />
|
||||||
|
<Field
|
||||||
|
label="Hold sampai"
|
||||||
|
value={formatDay(payout.heldUntil)}
|
||||||
|
/>
|
||||||
|
{payout.releasedAt && (
|
||||||
|
<Field label="Direlease" value={formatDate(payout.releasedAt)} />
|
||||||
|
)}
|
||||||
|
{payout.paidAt && (
|
||||||
|
<Field label="Dibayar" value={formatDate(payout.paidAt)} />
|
||||||
|
)}
|
||||||
|
{payout.cancelledAt && (
|
||||||
|
<Field label="Dibatalkan" value={formatDate(payout.cancelledAt)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-xl bg-neutral-50 p-3 text-sm">
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Rekening tujuan transfer
|
||||||
|
</p>
|
||||||
|
{bankAvailable ? (
|
||||||
|
<div className="space-y-0.5 text-neutral-800">
|
||||||
|
<p className="font-semibold">{payout.bankName}</p>
|
||||||
|
<p className="font-mono text-sm">{payout.bankAccountNumber}</p>
|
||||||
|
<p className="text-xs text-neutral-600">
|
||||||
|
a/n {payout.bankAccountName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-amber-700">
|
||||||
|
⚠️ Organizer belum menyelesaikan verifikasi (KYC) — tidak ada rekening
|
||||||
|
snapshot. Hubungi organizer untuk konfirmasi rekening sebelum transfer.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payout.adminNote && (
|
||||||
|
<div className="mt-3 rounded-xl bg-blue-50 p-3 text-sm text-blue-800">
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-blue-600">
|
||||||
|
Catatan admin
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap">{payout.adminNote}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payout.processedBy && (
|
||||||
|
<p className="mt-3 text-xs text-neutral-500">
|
||||||
|
Diproses oleh {payout.processedBy.name}
|
||||||
|
{payout.paidAt && ` · transfer ${formatDate(payout.paidAt)}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payout.status === "RELEASED" && (
|
||||||
|
<div className="mt-5 border-t border-neutral-100 pt-4">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{open ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Tandai Sudah Transfer
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Referensi transfer / nomor mutasi bank (wajib)"
|
||||||
|
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={loading || note.trim().length < 3}
|
||||||
|
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Memproses…" : "Tandai PAID"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setNote("");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
💸 Tandai sudah ditransfer ke organizer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`mt-0.5 text-sm ${
|
||||||
|
highlight ? "font-bold text-primary-700" : "text-neutral-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: PayoutStatus }) {
|
||||||
|
const cfg: Record<PayoutStatus, { label: string; cls: string }> = {
|
||||||
|
HELD: {
|
||||||
|
label: "Ditahan (escrow)",
|
||||||
|
cls: "bg-amber-50 text-amber-700 ring-amber-200",
|
||||||
|
},
|
||||||
|
RELEASED: {
|
||||||
|
label: "Siap transfer",
|
||||||
|
cls: "bg-blue-50 text-blue-700 ring-blue-200",
|
||||||
|
},
|
||||||
|
PAID: {
|
||||||
|
label: "Selesai",
|
||||||
|
cls: "bg-primary-50 text-primary-700 ring-primary-200",
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
label: "Dibatalkan",
|
||||||
|
cls: "bg-neutral-100 text-neutral-600 ring-neutral-200",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const c = cfg[status];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ${c.cls}`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
import { LIMITS } from "@/lib/limits";
|
||||||
|
|
||||||
|
export const payoutMarkPaidSchema = z.object({
|
||||||
|
payoutId: z.string().trim().min(1, "Payout ID wajib"),
|
||||||
|
adminNote: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, "Catatan/referensi transfer minimal 3 karakter")
|
||||||
|
.max(
|
||||||
|
LIMITS.MAX_REFUND_NOTE_LENGTH,
|
||||||
|
`Catatan maksimal ${LIMITS.MAX_REFUND_NOTE_LENGTH} karakter`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PayoutMarkPaidInput = z.infer<typeof payoutMarkPaidSchema>;
|
||||||
@@ -20,20 +20,17 @@ export function ProfileTripRow({
|
|||||||
rightSlot,
|
rightSlot,
|
||||||
}: ProfileTripRowProps) {
|
}: ProfileTripRowProps) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-200 hover:bg-primary-50/40 sm:px-4 sm:py-3">
|
||||||
href={href}
|
<Link href={href} className="min-w-0 flex-1">
|
||||||
className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-200 hover:bg-primary-50/40 sm:px-4 sm:py-3"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-semibold text-neutral-800">{title}</p>
|
<p className="truncate text-sm font-semibold text-neutral-800">{title}</p>
|
||||||
<p className="truncate text-xs text-neutral-500">{destination}</p>
|
<p className="truncate text-xs text-neutral-500">{destination}</p>
|
||||||
<p className="mt-0.5 text-[11px] text-neutral-400 sm:text-xs">
|
<p className="mt-0.5 text-[11px] text-neutral-400 sm:text-xs">
|
||||||
{formatTripCalendarDateRangeLong(date, endDate)}
|
{formatTripCalendarDateRangeLong(date, endDate)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Link>
|
||||||
{rightSlot && (
|
{rightSlot && (
|
||||||
<div className="shrink-0 text-right text-xs font-medium">{rightSlot}</div>
|
<div className="shrink-0 text-right text-xs font-medium">{rightSlot}</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { createTripSchema, tripImageUrlsSchema } from "./schemas";
|
import {
|
||||||
|
createTripSchema,
|
||||||
|
itineraryItemsSchema,
|
||||||
|
tripImageUrlsSchema,
|
||||||
|
} from "./schemas";
|
||||||
import { tripService } from "@/server/services/trip.service";
|
import { tripService } from "@/server/services/trip.service";
|
||||||
import { organizerService } from "@/server/services/organizer.service";
|
import { organizerService } from "@/server/services/organizer.service";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
@@ -21,7 +25,6 @@ export async function createTripAction(formData: FormData) {
|
|||||||
destination: formData.get("destination") as string,
|
destination: formData.get("destination") as string,
|
||||||
location: formData.get("location") as string,
|
location: formData.get("location") as string,
|
||||||
meetingPoint: formData.get("meetingPoint") as string,
|
meetingPoint: formData.get("meetingPoint") as string,
|
||||||
itinerary: formData.get("itinerary") as string,
|
|
||||||
whatsIncluded: formData.get("whatsIncluded") as string,
|
whatsIncluded: formData.get("whatsIncluded") as string,
|
||||||
whatsExcluded: formData.get("whatsExcluded") as string,
|
whatsExcluded: formData.get("whatsExcluded") as string,
|
||||||
date: formData.get("date") as string,
|
date: formData.get("date") as string,
|
||||||
@@ -36,6 +39,22 @@ export async function createTripAction(formData: FormData) {
|
|||||||
return { error: result.error.issues[0].message };
|
return { error: result.error.issues[0].message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itineraryJson = formData.get("itineraryItems");
|
||||||
|
let itineraryItems: ReturnType<typeof itineraryItemsSchema.parse> = [];
|
||||||
|
if (typeof itineraryJson === "string" && itineraryJson.trim().length > 0) {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(itineraryJson);
|
||||||
|
} catch {
|
||||||
|
return { error: "Format itinerary tidak valid" };
|
||||||
|
}
|
||||||
|
const itineraryParsed = itineraryItemsSchema.safeParse(parsed);
|
||||||
|
if (!itineraryParsed.success) {
|
||||||
|
return { error: itineraryParsed.error.issues[0].message };
|
||||||
|
}
|
||||||
|
itineraryItems = itineraryParsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.data.price > 0) {
|
if (result.data.price > 0) {
|
||||||
const approved = await organizerService.isApproved(session.user.id);
|
const approved = await organizerService.isApproved(session.user.id);
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
@@ -69,7 +88,6 @@ export async function createTripAction(formData: FormData) {
|
|||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
meetingPoint,
|
meetingPoint,
|
||||||
itinerary,
|
|
||||||
whatsIncluded,
|
whatsIncluded,
|
||||||
whatsExcluded,
|
whatsExcluded,
|
||||||
...tripCore
|
...tripCore
|
||||||
@@ -78,13 +96,13 @@ export async function createTripAction(formData: FormData) {
|
|||||||
const trip = await tripService.createTrip({
|
const trip = await tripService.createTrip({
|
||||||
...tripCore,
|
...tripCore,
|
||||||
meetingPoint,
|
meetingPoint,
|
||||||
itinerary,
|
|
||||||
whatsIncluded,
|
whatsIncluded,
|
||||||
whatsExcluded,
|
whatsExcluded,
|
||||||
date,
|
date,
|
||||||
endDate,
|
endDate,
|
||||||
organizerId: session.user.id,
|
organizerId: session.user.id,
|
||||||
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
||||||
|
itineraryItems: itineraryItems.length > 0 ? itineraryItems : undefined,
|
||||||
});
|
});
|
||||||
revalidatePath("/trips");
|
revalidatePath("/trips");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { LIMITS } from "@/lib/limits";
|
||||||
|
|
||||||
export function ImageUrlInput() {
|
interface ImageUrlInputProps {
|
||||||
const [urls, setUrls] = useState<string[]>([""]);
|
value: string[];
|
||||||
|
onChange: (urls: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) {
|
||||||
|
const urls = value.length > 0 ? value : [""];
|
||||||
|
const max = LIMITS.MAX_IMAGE_URLS;
|
||||||
|
|
||||||
function addField() {
|
function addField() {
|
||||||
if (urls.length < 5) {
|
if (urls.length < max) {
|
||||||
setUrls([...urls, ""]);
|
onChange([...urls, ""]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeField(index: number) {
|
function removeField(index: number) {
|
||||||
setUrls(urls.filter((_, i) => i !== index));
|
const next = urls.filter((_, i) => i !== index);
|
||||||
|
onChange(next.length > 0 ? next : [""]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateField(index: number, value: string) {
|
function updateField(index: number, next: string) {
|
||||||
const updated = [...urls];
|
const updated = [...urls];
|
||||||
updated[index] = value;
|
updated[index] = next;
|
||||||
setUrls(updated);
|
onChange(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,13 +34,14 @@ export function ImageUrlInput() {
|
|||||||
<span className="text-sm font-semibold text-neutral-700">
|
<span className="text-sm font-semibold text-neutral-700">
|
||||||
Foto Trip (URL)
|
Foto Trip (URL)
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-400">{urls.length}/5</span>
|
<span className="text-xs text-neutral-400">
|
||||||
|
{urls.length}/{max}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{urls.map((url, i) => (
|
{urls.map((url, i) => (
|
||||||
<div key={i} className="flex gap-2">
|
<div key={i} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
name="imageUrls"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => updateField(i, e.target.value)}
|
onChange={(e) => updateField(i, e.target.value)}
|
||||||
@@ -48,6 +56,7 @@ export function ImageUrlInput() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeField(i)}
|
onClick={() => removeField(i)}
|
||||||
|
aria-label={`Hapus foto ${i + 1}`}
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-neutral-200 text-neutral-400 hover:bg-red-50 hover:text-red-500"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-neutral-200 text-neutral-400 hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -56,7 +65,7 @@ export function ImageUrlInput() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{urls.length < 5 && (
|
{urls.length < max && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addField}
|
onClick={addField}
|
||||||
@@ -66,7 +75,7 @@ export function ImageUrlInput() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1.5 text-xs text-neutral-400">
|
<p className="mt-1.5 text-xs text-neutral-400">
|
||||||
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini
|
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { useRouter } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
|
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
|
||||||
|
|
||||||
|
type BookingStatus =
|
||||||
|
| "PENDING"
|
||||||
|
| "AWAITING_PAY"
|
||||||
|
| "PAID"
|
||||||
|
| "CANCELLED"
|
||||||
|
| "REFUNDED"
|
||||||
|
| "PARTIALLY_REFUNDED"
|
||||||
|
| "EXPIRED";
|
||||||
|
|
||||||
interface JoinTripButtonProps {
|
interface JoinTripButtonProps {
|
||||||
tripId: string;
|
tripId: string;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
@@ -14,11 +23,8 @@ interface JoinTripButtonProps {
|
|||||||
isFree: boolean;
|
isFree: boolean;
|
||||||
/** Status partisipasi user saat isJoined (bukan organizer) */
|
/** Status partisipasi user saat isJoined (bukan organizer) */
|
||||||
participationStatus?: "PENDING" | "CONFIRMED" | null;
|
participationStatus?: "PENDING" | "CONFIRMED" | null;
|
||||||
/** Status pembayaran manual (peserta). Hanya relevan untuk trip berbayar. */
|
/** Status booking peserta (hanya relevan untuk trip berbayar). */
|
||||||
participantPayment?: {
|
bookingStatus?: BookingStatus | null;
|
||||||
markedPaidAt: string | Date | null;
|
|
||||||
paymentConfirmedAt: string | Date | null;
|
|
||||||
} | null;
|
|
||||||
isFull: boolean;
|
isFull: boolean;
|
||||||
tripStatus: string;
|
tripStatus: string;
|
||||||
/** Tanggal berangkat trip sudah lewat */
|
/** Tanggal berangkat trip sudah lewat */
|
||||||
@@ -35,7 +41,7 @@ export function JoinTripButton({
|
|||||||
isJoined,
|
isJoined,
|
||||||
isFree,
|
isFree,
|
||||||
participationStatus,
|
participationStatus,
|
||||||
participantPayment,
|
bookingStatus,
|
||||||
isFull,
|
isFull,
|
||||||
tripStatus,
|
tripStatus,
|
||||||
isDeparturePast,
|
isDeparturePast,
|
||||||
@@ -114,11 +120,9 @@ export function JoinTripButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pay = participantPayment;
|
const needsPayment = !isFree && isJoined && bookingStatus === "AWAITING_PAY";
|
||||||
const showPaymentLink = !isFree && isJoined && !isDeparturePast;
|
const paymentDone = !isFree && isJoined && bookingStatus === "PAID";
|
||||||
const waitingPaymentConfirm =
|
const showPaymentLink = (needsPayment || paymentDone) && !isDeparturePast;
|
||||||
!isFree && isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
|
|
||||||
const paymentDone = !isFree && isJoined && pay && pay.paymentConfirmedAt;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -142,16 +146,17 @@ export function JoinTripButton({
|
|||||||
{isFree && <span> — trip gratis, tidak ada pembayaran 🎉</span>}.
|
{isFree && <span> — trip gratis, tidak ada pembayaran 🎉</span>}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{waitingPaymentConfirm && (
|
{needsPayment && (
|
||||||
<div className="mb-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-3 text-sm font-medium leading-relaxed text-primary-950">
|
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium leading-relaxed text-amber-900">
|
||||||
Kamu sudah menandai <span className="font-semibold">sudah bayar</span>.
|
Selesaikan pembayaran lewat{" "}
|
||||||
Tunggu organizer mengonfirmasi pembayaran.
|
<span className="font-semibold">Midtrans</span> untuk mengamankan slot
|
||||||
|
kamu.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{paymentDone && (
|
{paymentDone && (
|
||||||
<div className="mb-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-900">
|
<div className="mb-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-900">
|
||||||
Pembayaran kamu sudah{" "}
|
Pembayaran kamu sudah{" "}
|
||||||
<span className="font-semibold">dikonfirmasi organizer</span>.
|
<span className="font-semibold">terkonfirmasi</span>.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showPaymentLink && (
|
{showPaymentLink && (
|
||||||
@@ -159,11 +164,7 @@ export function JoinTripButton({
|
|||||||
href={`/trips/${tripId}/payment`}
|
href={`/trips/${tripId}/payment`}
|
||||||
className="mb-3 block w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-center text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50"
|
className="mb-3 block w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-center text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50"
|
||||||
>
|
>
|
||||||
{paymentDone
|
{paymentDone ? "Lihat detail pembayaran" : "Bayar sekarang"}
|
||||||
? "Lihat detail pembayaran"
|
|
||||||
: pay?.markedPaidAt
|
|
||||||
? "Lihat status pembayaran"
|
|
||||||
: "Buka detail pembayaran"}
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isJoined ? (
|
{isJoined ? (
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
export interface PendingJoinRequest {
|
export interface PendingJoinRequest {
|
||||||
id: string;
|
id: string;
|
||||||
user: { name: string; image: string | null };
|
user: { name: string; image: string | null };
|
||||||
/** Peserta sudah menekan "Saya sudah bayar" */
|
|
||||||
markedPaidAt?: string | Date | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrganizerJoinRequestsProps {
|
interface OrganizerJoinRequestsProps {
|
||||||
@@ -83,14 +81,7 @@ export function OrganizerJoinRequests({
|
|||||||
<p className="truncate text-sm font-semibold text-neutral-800">
|
<p className="truncate text-sm font-semibold text-neutral-800">
|
||||||
{p.user.name}
|
{p.user.name}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p>
|
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p>
|
||||||
{p.markedPaidAt ? (
|
|
||||||
<span className="rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold text-primary-800">
|
|
||||||
Sudah tandai bayar
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
|
import { groupItineraryByDay } from "@/lib/itinerary";
|
||||||
|
|
||||||
|
interface ItineraryItem {
|
||||||
|
day: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
activity: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface TripProgramBlockProps {
|
interface TripProgramBlockProps {
|
||||||
meetingPoint: string | null;
|
meetingPoint: string | null;
|
||||||
itinerary: string | null;
|
itinerary: string | null;
|
||||||
|
itineraryItems: ItineraryItem[];
|
||||||
whatsIncluded: string | null;
|
whatsIncluded: string | null;
|
||||||
whatsExcluded: string | null;
|
whatsExcluded: string | null;
|
||||||
}
|
}
|
||||||
@@ -8,13 +19,24 @@ interface TripProgramBlockProps {
|
|||||||
export function TripProgramBlock({
|
export function TripProgramBlock({
|
||||||
meetingPoint,
|
meetingPoint,
|
||||||
itinerary,
|
itinerary,
|
||||||
|
itineraryItems,
|
||||||
whatsIncluded,
|
whatsIncluded,
|
||||||
whatsExcluded,
|
whatsExcluded,
|
||||||
}: TripProgramBlockProps) {
|
}: TripProgramBlockProps) {
|
||||||
|
const hasStructuredItinerary = itineraryItems.length > 0;
|
||||||
|
const hasLegacyItinerary = !hasStructuredItinerary && !!itinerary;
|
||||||
const hasAny =
|
const hasAny =
|
||||||
meetingPoint || itinerary || whatsIncluded || whatsExcluded;
|
meetingPoint ||
|
||||||
|
hasStructuredItinerary ||
|
||||||
|
hasLegacyItinerary ||
|
||||||
|
whatsIncluded ||
|
||||||
|
whatsExcluded;
|
||||||
if (!hasAny) return null;
|
if (!hasAny) return null;
|
||||||
|
|
||||||
|
const grouped = hasStructuredItinerary
|
||||||
|
? groupItineraryByDay(itineraryItems)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-xl border border-neutral-200 bg-neutral-50/50 p-4 sm:p-5">
|
<div className="space-y-4 rounded-xl border border-neutral-200 bg-neutral-50/50 p-4 sm:p-5">
|
||||||
<h2 className="text-xs font-bold text-neutral-800 sm:text-sm">
|
<h2 className="text-xs font-bold text-neutral-800 sm:text-sm">
|
||||||
@@ -32,7 +54,41 @@ export function TripProgramBlock({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{itinerary && (
|
{grouped && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
|
||||||
|
Itinerary
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...grouped.entries()].map(([day, items]) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="rounded-lg border border-primary-100 bg-white p-3 sm:p-4"
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-xs font-bold text-primary-800 sm:text-sm">
|
||||||
|
Hari {day}
|
||||||
|
</p>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.order}
|
||||||
|
className="flex gap-3 text-xs leading-relaxed text-neutral-700 sm:text-sm"
|
||||||
|
>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] font-semibold text-primary-700 sm:text-xs">
|
||||||
|
{item.startTime}
|
||||||
|
{item.endTime ? `–${item.endTime}` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">{item.activity}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasLegacyItinerary && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
|
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
|
||||||
Itinerary
|
Itinerary
|
||||||
|
|||||||
+70
-14
@@ -7,6 +7,7 @@ import {
|
|||||||
isTripDepartureDayPast,
|
isTripDepartureDayPast,
|
||||||
tripStoredInstantFromYmd,
|
tripStoredInstantFromYmd,
|
||||||
} from "@/lib/trip-dates";
|
} from "@/lib/trip-dates";
|
||||||
|
import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary";
|
||||||
|
|
||||||
export const tripImageUrlsSchema = z
|
export const tripImageUrlsSchema = z
|
||||||
.array(
|
.array(
|
||||||
@@ -18,6 +19,75 @@ export const tripImageUrlsSchema = z
|
|||||||
)
|
)
|
||||||
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
|
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
|
||||||
|
|
||||||
|
export const itineraryItemSchema = z
|
||||||
|
.object({
|
||||||
|
day: z.coerce
|
||||||
|
.number()
|
||||||
|
.int("Nomor hari tidak valid")
|
||||||
|
.min(1, "Nomor hari minimal 1")
|
||||||
|
.max(
|
||||||
|
LIMITS.MAX_ITINERARY_DAYS,
|
||||||
|
`Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari`
|
||||||
|
),
|
||||||
|
startTime: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine(isValidTimeFormat, "Format jam mulai harus HH:mm"),
|
||||||
|
endTime: z.preprocess(
|
||||||
|
(val) => {
|
||||||
|
if (val == null) return undefined;
|
||||||
|
const s = String(val).trim();
|
||||||
|
return s === "" ? undefined : s;
|
||||||
|
},
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.refine(isValidTimeFormat, "Format jam selesai harus HH:mm")
|
||||||
|
.optional()
|
||||||
|
),
|
||||||
|
activity: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Deskripsi aktivitas harus diisi")
|
||||||
|
.max(
|
||||||
|
LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH,
|
||||||
|
`Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (
|
||||||
|
data.endTime &&
|
||||||
|
timeToMinutes(data.endTime) < timeToMinutes(data.startTime)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Jam selesai tidak boleh sebelum jam mulai",
|
||||||
|
path: ["endTime"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itineraryItemsSchema = z
|
||||||
|
.array(itineraryItemSchema)
|
||||||
|
.max(
|
||||||
|
LIMITS.MAX_ITINERARY_ITEMS,
|
||||||
|
`Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`
|
||||||
|
)
|
||||||
|
.superRefine((items, ctx) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const days = [...new Set(items.map((i) => i.day))].sort((a, b) => a - b);
|
||||||
|
for (let i = 0; i < days.length; i++) {
|
||||||
|
if (days[i] !== i + 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)",
|
||||||
|
path: [0, "day"],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const createTripSchema = z
|
export const createTripSchema = z
|
||||||
.object({
|
.object({
|
||||||
category: z.enum(
|
category: z.enum(
|
||||||
@@ -105,20 +175,6 @@ export const createTripSchema = z
|
|||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
),
|
),
|
||||||
itinerary: z.preprocess(
|
|
||||||
(val) => {
|
|
||||||
if (val == null) return undefined;
|
|
||||||
const s = String(val).trim();
|
|
||||||
return s === "" ? undefined : s;
|
|
||||||
},
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.max(
|
|
||||||
LIMITS.MAX_TRIP_ITINERARY_LENGTH,
|
|
||||||
`Itinerary maksimal ${LIMITS.MAX_TRIP_ITINERARY_LENGTH} karakter`
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
),
|
|
||||||
whatsIncluded: z.preprocess(
|
whatsIncluded: z.preprocess(
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val == null) return undefined;
|
if (val == null) return undefined;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
|
|||||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { isAdminEmail } from "@/lib/admin";
|
||||||
|
|
||||||
// Adapter dipakai untuk persist User + Account saat OAuth (Google).
|
// Adapter dipakai untuk persist User + Account saat OAuth (Google).
|
||||||
// Session tetap pakai JWT supaya kompatibel dengan CredentialsProvider.
|
// Session tetap pakai JWT supaya kompatibel dengan CredentialsProvider.
|
||||||
@@ -89,6 +90,7 @@ export const authOptions: AuthOptions = {
|
|||||||
if (session.user) {
|
if (session.user) {
|
||||||
session.user.id = token.id as string;
|
session.user.id = token.id as string;
|
||||||
session.user.acceptedTermsAndPrivacy = token.acceptedTermsAndPrivacy ?? false;
|
session.user.acceptedTermsAndPrivacy = token.acceptedTermsAndPrivacy ?? false;
|
||||||
|
session.user.isAdmin = isAdminEmail(session.user.email);
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { LIMITS } from "@/lib/limits";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item itinerary terstruktur. Format jam: "HH:mm" 24-jam.
|
||||||
|
* Bentuk ini dipakai di form, action, dan render — DB-nya pakai
|
||||||
|
* `TripItineraryItem` (lihat schema.prisma).
|
||||||
|
*/
|
||||||
|
export interface ItineraryItemInput {
|
||||||
|
day: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime?: string | null;
|
||||||
|
activity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
export function isValidTimeFormat(value: string): boolean {
|
||||||
|
return TIME_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konversi "HH:mm" ke total menit sejak 00:00. Pakai untuk perbandingan
|
||||||
|
* jam mulai vs jam selesai. Mengembalikan NaN kalau format invalid.
|
||||||
|
*/
|
||||||
|
export function timeToMinutes(value: string): number {
|
||||||
|
if (!isValidTimeFormat(value)) return Number.NaN;
|
||||||
|
const [h, m] = value.split(":").map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format display jam (sudah HH:mm di DB, sekedar pass-through dengan
|
||||||
|
* trimming defensif).
|
||||||
|
*/
|
||||||
|
export function formatItineraryTime(value: string): string {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kelompokkan items per hari, urut ascending. Item dalam satu hari diurut
|
||||||
|
* berdasarkan `order` lalu `startTime`.
|
||||||
|
*/
|
||||||
|
export function groupItineraryByDay<
|
||||||
|
T extends { day: number; order: number; startTime: string },
|
||||||
|
>(items: T[]): Map<number, T[]> {
|
||||||
|
const grouped = new Map<number, T[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const list = grouped.get(item.day) ?? [];
|
||||||
|
list.push(item);
|
||||||
|
grouped.set(item.day, list);
|
||||||
|
}
|
||||||
|
for (const list of grouped.values()) {
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (a.order !== b.order) return a.order - b.order;
|
||||||
|
return timeToMinutes(a.startTime) - timeToMinutes(b.startTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
[...grouped.entries()].sort(([a], [b]) => a - b)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validasi semantik (selain Zod): jam selesai harus >= jam mulai (kalau diisi),
|
||||||
|
* dan jumlah day harus rapat (1..N tanpa lompat) untuk menjaga UX builder
|
||||||
|
* tetap deterministik. Return error pertama yang ditemui, atau null kalau OK.
|
||||||
|
*/
|
||||||
|
export function validateItineraryItems(
|
||||||
|
items: ItineraryItemInput[]
|
||||||
|
): string | null {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
if (items.length > LIMITS.MAX_ITINERARY_ITEMS) {
|
||||||
|
return `Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = new Set<number>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (!Number.isInteger(item.day) || item.day < 1) {
|
||||||
|
return "Nomor hari tidak valid";
|
||||||
|
}
|
||||||
|
if (item.day > LIMITS.MAX_ITINERARY_DAYS) {
|
||||||
|
return `Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari`;
|
||||||
|
}
|
||||||
|
if (!isValidTimeFormat(item.startTime)) {
|
||||||
|
return "Format jam mulai harus HH:mm (00:00 – 23:59)";
|
||||||
|
}
|
||||||
|
if (item.endTime && !isValidTimeFormat(item.endTime)) {
|
||||||
|
return "Format jam selesai harus HH:mm (00:00 – 23:59)";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
item.endTime &&
|
||||||
|
timeToMinutes(item.endTime) < timeToMinutes(item.startTime)
|
||||||
|
) {
|
||||||
|
return "Jam selesai tidak boleh sebelum jam mulai";
|
||||||
|
}
|
||||||
|
const trimmedActivity = item.activity.trim();
|
||||||
|
if (trimmedActivity.length === 0) {
|
||||||
|
return "Deskripsi aktivitas harus diisi";
|
||||||
|
}
|
||||||
|
if (trimmedActivity.length > LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH) {
|
||||||
|
return `Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`;
|
||||||
|
}
|
||||||
|
days.add(item.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedDays = [...days].sort((a, b) => a - b);
|
||||||
|
for (let i = 0; i < sortedDays.length; i++) {
|
||||||
|
if (sortedDays[i] !== i + 1) {
|
||||||
|
return "Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ export const LIMITS = {
|
|||||||
/** Meeting point & tiap blok include/exclude */
|
/** Meeting point & tiap blok include/exclude */
|
||||||
MAX_MEETING_POINT_LENGTH: 500,
|
MAX_MEETING_POINT_LENGTH: 500,
|
||||||
MAX_TRIP_ITINERARY_LENGTH: 8000,
|
MAX_TRIP_ITINERARY_LENGTH: 8000,
|
||||||
|
/** Itinerary baru terstruktur: maksimal hari dalam satu trip. */
|
||||||
|
MAX_ITINERARY_DAYS: 14,
|
||||||
|
/** Maksimal item itinerary per trip (lintas hari). */
|
||||||
|
MAX_ITINERARY_ITEMS: 60,
|
||||||
|
/** Maksimal panjang deskripsi satu aktivitas. */
|
||||||
|
MAX_ITINERARY_ACTIVITY_LENGTH: 200,
|
||||||
MAX_TRIP_BULLET_SECTION_LENGTH: 4000,
|
MAX_TRIP_BULLET_SECTION_LENGTH: 4000,
|
||||||
MAX_REVIEW_COMMENT: 500,
|
MAX_REVIEW_COMMENT: 500,
|
||||||
MAX_IMAGE_URLS: 5,
|
MAX_IMAGE_URLS: 5,
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export const MIDTRANS = {
|
|||||||
isProduction()
|
isProduction()
|
||||||
? "https://app.midtrans.com/snap/snap.js"
|
? "https://app.midtrans.com/snap/snap.js"
|
||||||
: "https://app.sandbox.midtrans.com/snap/snap.js",
|
: "https://app.sandbox.midtrans.com/snap/snap.js",
|
||||||
|
/** Core API base — dipakai untuk GET /v2/{order_id}/status (rekonsiliasi). */
|
||||||
|
coreApiBase: () =>
|
||||||
|
isProduction()
|
||||||
|
? "https://api.midtrans.com/v2"
|
||||||
|
: "https://api.sandbox.midtrans.com/v2",
|
||||||
};
|
};
|
||||||
|
|
||||||
function requireServerKey(): string {
|
function requireServerKey(): string {
|
||||||
@@ -70,6 +75,9 @@ interface SnapTransactionPayload {
|
|||||||
itemName: string;
|
itemName: string;
|
||||||
/// Berapa detik sampai expire. Default Midtrans 24 jam, kita pakai itu kalau undefined.
|
/// Berapa detik sampai expire. Default Midtrans 24 jam, kita pakai itu kalau undefined.
|
||||||
expirySeconds?: number;
|
expirySeconds?: number;
|
||||||
|
/// URL absolut untuk redirect user setelah selesai bayar (success / pending / error).
|
||||||
|
/// Tanpa ini, Midtrans pakai default `example.com`.
|
||||||
|
finishUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnapTransactionResult {
|
export interface SnapTransactionResult {
|
||||||
@@ -110,6 +118,10 @@ export async function createSnapTransaction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.finishUrl) {
|
||||||
|
body.callbacks = { finish: payload.finishUrl };
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${MIDTRANS.snapApiBase()}/transactions`, {
|
const res = await fetch(`${MIDTRANS.snapApiBase()}/transactions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -137,6 +149,67 @@ export async function createSnapTransaction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bentuk minimal response dari Midtrans Core API GET /v2/{order_id}/status.
|
||||||
|
* Sub-set field yang kita pakai untuk rekonsiliasi (sama dengan field webhook).
|
||||||
|
* https://docs.midtrans.com/reference/get-transaction-status
|
||||||
|
*/
|
||||||
|
export interface MidtransTransactionStatus {
|
||||||
|
order_id: string;
|
||||||
|
status_code: string;
|
||||||
|
transaction_status: string;
|
||||||
|
gross_amount: string;
|
||||||
|
transaction_id?: string;
|
||||||
|
payment_type?: string;
|
||||||
|
fraud_status?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch status transaksi langsung dari Midtrans untuk rekonsiliasi server-side.
|
||||||
|
* Dipakai saat kita tidak bisa mengandalkan webhook (mis. dev di localhost,
|
||||||
|
* atau webhook tertunda). Auth pakai server key — response sudah terpercaya
|
||||||
|
* karena datang dari Midtrans atas request kita, jadi tidak perlu verifikasi
|
||||||
|
* signature.
|
||||||
|
*
|
||||||
|
* Return null kalau Midtrans tidak menemukan order (404).
|
||||||
|
*/
|
||||||
|
export async function fetchMidtransTransactionStatus(
|
||||||
|
orderId: string
|
||||||
|
): Promise<MidtransTransactionStatus | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${MIDTRANS.coreApiBase()}/${encodeURIComponent(orderId)}/status`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: basicAuthHeader(),
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
|
||||||
|
const json = (await res.json().catch(() => null)) as
|
||||||
|
| (Partial<MidtransTransactionStatus> & { status_message?: string })
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (!res.ok || !json?.order_id || !json.transaction_status) {
|
||||||
|
const reason = json?.status_message ?? `HTTP ${res.status}`;
|
||||||
|
throw new Error(`Midtrans status fetch gagal: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
order_id: json.order_id,
|
||||||
|
status_code: json.status_code ?? String(res.status),
|
||||||
|
transaction_status: json.transaction_status,
|
||||||
|
gross_amount: json.gross_amount ?? "0",
|
||||||
|
transaction_id: json.transaction_id,
|
||||||
|
payment_type: json.payment_type,
|
||||||
|
fraud_status: json.fraud_status ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifikasi signature webhook Midtrans.
|
* Verifikasi signature webhook Midtrans.
|
||||||
* Formula: SHA512(order_id + status_code + gross_amount + serverKey).
|
* Formula: SHA512(order_id + status_code + gross_amount + serverKey).
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.10.3",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.10.3",
|
"version": "0.12.0",
|
||||||
"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.10.3",
|
"version": "0.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PayoutStatus" AS ENUM ('HELD', 'RELEASED', 'PAID', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Payout" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bookingId" TEXT NOT NULL,
|
||||||
|
"tripId" TEXT NOT NULL,
|
||||||
|
"organizerId" TEXT NOT NULL,
|
||||||
|
"amount" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'IDR',
|
||||||
|
"status" "PayoutStatus" NOT NULL DEFAULT 'HELD',
|
||||||
|
"heldUntil" TIMESTAMP(3) NOT NULL,
|
||||||
|
"releasedAt" TIMESTAMP(3),
|
||||||
|
"paidAt" TIMESTAMP(3),
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"bankName" TEXT,
|
||||||
|
"bankAccountNumber" TEXT,
|
||||||
|
"bankAccountName" TEXT,
|
||||||
|
"adminNote" TEXT,
|
||||||
|
"processedById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Payout_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Payout_bookingId_key" ON "Payout"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Payout_organizerId_status_idx" ON "Payout"("organizerId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Payout_status_heldUntil_idx" ON "Payout"("status", "heldUntil");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_processedById_fkey" FOREIGN KEY ("processedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TripItineraryItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tripId" TEXT NOT NULL,
|
||||||
|
"day" INTEGER NOT NULL,
|
||||||
|
"startTime" TEXT NOT NULL,
|
||||||
|
"endTime" TEXT,
|
||||||
|
"activity" TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TripItineraryItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TripItineraryItem_tripId_day_order_idx" ON "TripItineraryItem"("tripId", "day", "order");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TripItineraryItem" ADD CONSTRAINT "TripItineraryItem_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+102
-1
@@ -34,6 +34,11 @@ model User {
|
|||||||
|
|
||||||
reviewedRefunds Refund[] @relation("RefundReviewer")
|
reviewedRefunds Refund[] @relation("RefundReviewer")
|
||||||
|
|
||||||
|
/// Payout yang diterima user ini sebagai organizer (escrow trip selesai).
|
||||||
|
payouts Payout[] @relation("PayoutOrganizer")
|
||||||
|
/// Payout yang ditandai admin sebagai PAID/CANCELLED oleh user ini.
|
||||||
|
processedPayouts Payout[] @relation("PayoutProcessor")
|
||||||
|
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +143,8 @@ model Trip {
|
|||||||
location String
|
location String
|
||||||
/// Titik kumpul / meeting point (teks bebas)
|
/// Titik kumpul / meeting point (teks bebas)
|
||||||
meetingPoint String?
|
meetingPoint String?
|
||||||
/// Itinerary hari per hari (teks bebas, bullet OK)
|
/// @deprecated — itinerary lama bentuk teks bebas, backward-compat untuk data
|
||||||
|
/// lama. Trip baru pakai `itineraryItems` (struktural per hari + jam).
|
||||||
itinerary String?
|
itinerary String?
|
||||||
/// Yang termasuk harga (teks bebas)
|
/// Yang termasuk harga (teks bebas)
|
||||||
whatsIncluded String?
|
whatsIncluded String?
|
||||||
@@ -161,11 +167,37 @@ model Trip {
|
|||||||
images TripImage[]
|
images TripImage[]
|
||||||
reviews TripReview[]
|
reviews TripReview[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
|
payouts Payout[]
|
||||||
|
itineraryItems TripItineraryItem[]
|
||||||
|
|
||||||
@@index([category, status, date])
|
@@index([category, status, date])
|
||||||
@@index([vibe, status, date])
|
@@index([vibe, status, date])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Itinerary item terstruktur per hari + jam. Satu Trip punya banyak item;
|
||||||
|
/// dikelompokkan per `day` lalu diurutkan `order`. Format jam: "HH:mm" 24-jam.
|
||||||
|
model TripItineraryItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tripId String
|
||||||
|
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Hari ke-berapa, mulai dari 1.
|
||||||
|
day Int
|
||||||
|
/// Waktu mulai aktivitas, format "HH:mm".
|
||||||
|
startTime String
|
||||||
|
/// Waktu selesai (opsional), format "HH:mm".
|
||||||
|
endTime String?
|
||||||
|
/// Deskripsi aktivitas singkat.
|
||||||
|
activity String
|
||||||
|
/// Urutan dalam hari, untuk preserve order saat render.
|
||||||
|
order Int @default(0)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tripId, day, order])
|
||||||
|
}
|
||||||
|
|
||||||
model TripReview {
|
model TripReview {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
rating Int
|
rating Int
|
||||||
@@ -261,6 +293,7 @@ model Booking {
|
|||||||
|
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
refunds Refund[]
|
refunds Refund[]
|
||||||
|
payout Payout?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -436,3 +469,71 @@ enum RefundReporter {
|
|||||||
PARTICIPANT
|
PARTICIPANT
|
||||||
ORGANIZER
|
ORGANIZER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escrow payout ke organizer. Uang peserta ditahan sejak Booking → PAID sampai
|
||||||
|
/// trip selesai + buffer beberapa hari, baru di-release untuk ditransfer admin.
|
||||||
|
///
|
||||||
|
/// State machine:
|
||||||
|
/// HELD → diciptakan saat booking PAID, heldUntil = endDate/date + 3 hari
|
||||||
|
/// RELEASED → cron flip setelah heldUntil lewat + trip COMPLETED
|
||||||
|
/// PAID → admin sudah transfer manual ke rekening organizer
|
||||||
|
/// CANCELLED → booking di-refund / trip dibatalkan; payout tidak jadi
|
||||||
|
///
|
||||||
|
/// Audit: 1-1 dengan Booking (unique). Refund SUCCEEDED mengurangi amount
|
||||||
|
/// (partial) atau membatalkan payout (full).
|
||||||
|
model Payout {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
bookingId String @unique
|
||||||
|
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
tripId String
|
||||||
|
trip Trip @relation(fields: [tripId], references: [id])
|
||||||
|
|
||||||
|
organizerId String
|
||||||
|
organizer User @relation("PayoutOrganizer", fields: [organizerId], references: [id])
|
||||||
|
|
||||||
|
/// Nominal yg organizer terima (IDR integer). Default = booking.amount saat
|
||||||
|
/// payout dibuat. Refund SUCCEEDED memotong nilai ini supaya total payout +
|
||||||
|
/// total refund = uang yang dibayar peserta.
|
||||||
|
amount Int
|
||||||
|
currency String @default("IDR")
|
||||||
|
|
||||||
|
status PayoutStatus @default(HELD)
|
||||||
|
|
||||||
|
/// Tanggal payout boleh di-release ke organizer
|
||||||
|
/// (= trip.endDate ?? trip.date + buffer days).
|
||||||
|
heldUntil DateTime
|
||||||
|
releasedAt DateTime?
|
||||||
|
paidAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
|
||||||
|
/// Snapshot bank info organizer dari OrganizerVerification saat payout dibuat.
|
||||||
|
/// Disimpan inline supaya audit-friendly walau organizer ganti bank nanti.
|
||||||
|
bankName String?
|
||||||
|
bankAccountNumber String?
|
||||||
|
bankAccountName String?
|
||||||
|
|
||||||
|
/// Catatan admin: referensi transfer manual, alasan cancel, dst.
|
||||||
|
adminNote String?
|
||||||
|
|
||||||
|
/// Admin yang menandai PAID/CANCELLED.
|
||||||
|
processedById String?
|
||||||
|
processedBy User? @relation("PayoutProcessor", fields: [processedById], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([organizerId, status])
|
||||||
|
@@index([status, heldUntil])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PayoutStatus {
|
||||||
|
/// Menunggu trip selesai + buffer beberapa hari sebelum boleh ditransfer.
|
||||||
|
HELD
|
||||||
|
/// Buffer lewat & trip COMPLETED, siap di-transfer admin ke rekening organizer.
|
||||||
|
RELEASED
|
||||||
|
/// Admin sudah transfer ke rekening organizer.
|
||||||
|
PAID
|
||||||
|
/// Booking di-refund penuh / trip dibatalkan — uang tidak jadi ke organizer.
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|||||||
+238
-228
@@ -27,231 +27,226 @@ const img = (id: string) =>
|
|||||||
const SEED_PASSWORD = "password123";
|
const SEED_PASSWORD = "password123";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Itineraries — disimpan sebagai const supaya data trip tetap padat.
|
// Itineraries — array terstruktur per hari/jam. Setiap item: {day, startTime,
|
||||||
// Format: header `Hari N — <hari>` lalu bullet `• HH:MM–HH:MM aktivitas`.
|
// endTime, activity}. Disimpan ke TripItineraryItem (lihat schema.prisma).
|
||||||
// Lokasi, pos, dan spot diambil dari kasus nyata (Papandayan via Cisurupan,
|
|
||||||
// Ciremai via Apuy, USS Liberty Tulamben, Karimun Jawa hopping, dst).
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const ITIN_PAPANDAYAN = `Hari 1 — Sabtu
|
interface SeedItineraryItem {
|
||||||
• 05:00–05:30 Meeting & briefing di Alun-alun Garut
|
day: number;
|
||||||
• 05:30–07:00 Perjalanan menuju basecamp Cisurupan
|
startTime: string;
|
||||||
• 07:00–08:00 Sarapan + repacking + pemanasan
|
endTime?: string;
|
||||||
• 08:00–11:00 Trekking Camp David — Hutan Mati — Pondok Salada
|
activity: string;
|
||||||
• 11:00–12:30 Setup tenda di Pondok Salada
|
}
|
||||||
• 12:30–14:00 ISHOMA + games kenalan grup
|
|
||||||
• 14:00–17:00 Eksplor Tegal Alun & Hutan Mati (golden hour foto)
|
|
||||||
• 17:00–19:00 Masak bareng + makan malam
|
|
||||||
• 19:00–21:00 Api unggun, kopi, sharing rencana summit
|
|
||||||
• 21:00 Istirahat
|
|
||||||
|
|
||||||
Hari 2 — Minggu
|
const ITIN_PAPANDAYAN: SeedItineraryItem[] = [
|
||||||
• 03:30–04:00 Bangun + sarapan ringan + air panas
|
{ day: 1, startTime: "05:00", endTime: "05:30", activity: "Meeting & briefing di Alun-alun Garut" },
|
||||||
• 04:00–05:30 Summit attack ke puncak Papandayan
|
{ day: 1, startTime: "05:30", endTime: "07:00", activity: "Perjalanan menuju basecamp Cisurupan" },
|
||||||
• 05:30–07:00 Sunrise + foto bareng di puncak
|
{ day: 1, startTime: "07:00", endTime: "08:00", activity: "Sarapan + repacking + pemanasan" },
|
||||||
• 07:00–09:00 Turun ke camp + sarapan utama
|
{ day: 1, startTime: "08:00", endTime: "11:00", activity: "Trekking Camp David — Hutan Mati — Pondok Salada" },
|
||||||
• 09:00–11:00 Beres-beres tenda + repacking
|
{ day: 1, startTime: "11:00", endTime: "12:30", activity: "Setup tenda di Pondok Salada" },
|
||||||
• 11:00–13:30 Turun ke basecamp Cisurupan
|
{ day: 1, startTime: "12:30", endTime: "14:00", activity: "ISHOMA + games kenalan grup" },
|
||||||
• 13:30–14:30 Bersih-bersih + makan siang
|
{ day: 1, startTime: "14:00", endTime: "17:00", activity: "Eksplor Tegal Alun & Hutan Mati (golden hour foto)" },
|
||||||
• 14:30–16:30 Perjalanan kembali ke Garut
|
{ day: 1, startTime: "17:00", endTime: "19:00", activity: "Masak bareng + makan malam" },
|
||||||
• 16:30 Sampai Garut, bubar grup`;
|
{ day: 1, startTime: "19:00", endTime: "21:00", activity: "Api unggun, kopi, sharing rencana summit" },
|
||||||
|
{ day: 1, startTime: "21:00", activity: "Istirahat" },
|
||||||
|
{ day: 2, startTime: "03:30", endTime: "04:00", activity: "Bangun + sarapan ringan + air panas" },
|
||||||
|
{ day: 2, startTime: "04:00", endTime: "05:30", activity: "Summit attack ke puncak Papandayan" },
|
||||||
|
{ day: 2, startTime: "05:30", endTime: "07:00", activity: "Sunrise + foto bareng di puncak" },
|
||||||
|
{ day: 2, startTime: "07:00", endTime: "09:00", activity: "Turun ke camp + sarapan utama" },
|
||||||
|
{ day: 2, startTime: "09:00", endTime: "11:00", activity: "Beres-beres tenda + repacking" },
|
||||||
|
{ day: 2, startTime: "11:00", endTime: "13:30", activity: "Turun ke basecamp Cisurupan" },
|
||||||
|
{ day: 2, startTime: "13:30", endTime: "14:30", activity: "Bersih-bersih + makan siang" },
|
||||||
|
{ day: 2, startTime: "14:30", endTime: "16:30", activity: "Perjalanan kembali ke Garut" },
|
||||||
|
{ day: 2, startTime: "16:30", activity: "Sampai Garut, bubar grup" },
|
||||||
|
];
|
||||||
|
|
||||||
const ITIN_CIREMAI = `Hari 1 — Sabtu
|
const ITIN_CIREMAI: SeedItineraryItem[] = [
|
||||||
• 04:00–04:30 Meeting & briefing di Stasiun Kuningan
|
{ day: 1, startTime: "04:00", endTime: "04:30", activity: "Meeting & briefing di Stasiun Kuningan" },
|
||||||
• 04:30–06:30 Perjalanan ke basecamp Apuy via Maja & Argapura
|
{ day: 1, startTime: "04:30", endTime: "06:30", activity: "Perjalanan ke basecamp Apuy via Maja & Argapura" },
|
||||||
• 06:30–07:30 Sarapan + registrasi simaksi + repacking
|
{ day: 1, startTime: "06:30", endTime: "07:30", activity: "Sarapan + registrasi simaksi + repacking" },
|
||||||
• 07:30–10:30 Trek Pos 1 (Berod) → Pos 2 (Arban) → Pos 3 (Tegal Masawa)
|
{ day: 1, startTime: "07:30", endTime: "10:30", activity: "Trek Pos 1 (Berod) → Pos 2 (Arban) → Pos 3 (Tegal Masawa)" },
|
||||||
• 10:30–11:30 ISHOMA di Pos 4 (Tegal Jamuju)
|
{ day: 1, startTime: "10:30", endTime: "11:30", activity: "ISHOMA di Pos 4 (Tegal Jamuju)" },
|
||||||
• 11:30–14:30 Lanjut Pos 5 (Sanghyang Rangkah) → Pos 6 (Goa Walet)
|
{ day: 1, startTime: "11:30", endTime: "14:30", activity: "Lanjut Pos 5 (Sanghyang Rangkah) → Pos 6 (Goa Walet)" },
|
||||||
• 14:30–16:00 Setup tenda di Pos 6
|
{ day: 1, startTime: "14:30", endTime: "16:00", activity: "Setup tenda di Pos 6" },
|
||||||
• 16:00–18:00 Acara bebas + makan sore + persiapan summit
|
{ day: 1, startTime: "16:00", endTime: "18:00", activity: "Acara bebas + makan sore + persiapan summit" },
|
||||||
• 18:00–20:00 Briefing summit + early dinner
|
{ day: 1, startTime: "18:00", endTime: "20:00", activity: "Briefing summit + early dinner" },
|
||||||
• 20:00 Istirahat (bangun dini hari)
|
{ day: 1, startTime: "20:00", activity: "Istirahat (bangun dini hari)" },
|
||||||
|
{ day: 2, startTime: "02:00", endTime: "02:30", activity: "Bangun + cemilan + minuman hangat" },
|
||||||
|
{ day: 2, startTime: "02:30", endTime: "05:00", activity: "Summit attack ke puncak Sunan Cirebon (3.078 mdpl)" },
|
||||||
|
{ day: 2, startTime: "05:00", endTime: "06:30", activity: "Sunrise di puncak Ciremai" },
|
||||||
|
{ day: 2, startTime: "06:30", endTime: "08:30", activity: "Turun ke Pos 6 + sarapan" },
|
||||||
|
{ day: 2, startTime: "08:30", endTime: "10:30", activity: "Beres tenda + repacking" },
|
||||||
|
{ day: 2, startTime: "10:30", endTime: "14:00", activity: "Turun ke basecamp Apuy (track curam — hati-hati lutut)" },
|
||||||
|
{ day: 2, startTime: "14:00", endTime: "15:00", activity: "Bersih-bersih + makan siang di basecamp" },
|
||||||
|
{ day: 2, startTime: "15:00", endTime: "17:00", activity: "Kembali ke Stasiun Kuningan" },
|
||||||
|
{ day: 2, startTime: "17:00", activity: "Bubar grup" },
|
||||||
|
];
|
||||||
|
|
||||||
Hari 2 — Minggu
|
const ITIN_CAMPING: SeedItineraryItem[] = [
|
||||||
• 02:00–02:30 Bangun + cemilan + minuman hangat
|
{ day: 1, startTime: "13:00", endTime: "13:30", activity: "Meeting di Pertigaan Pasar Lembang" },
|
||||||
• 02:30–05:00 Summit attack ke puncak Sunan Cirebon (3.078 mdpl)
|
{ day: 1, startTime: "13:30", endTime: "15:00", activity: "Perjalanan ke Ranca Upas via Ciwidey" },
|
||||||
• 05:00–06:30 Sunrise di puncak Ciremai
|
{ day: 1, startTime: "15:00", endTime: "16:00", activity: "Check-in + setup tenda dome (sudah disiapkan tim)" },
|
||||||
• 06:30–08:30 Turun ke Pos 6 + sarapan
|
{ day: 1, startTime: "16:00", endTime: "17:30", activity: "Tour camp area + ketemu rusa-rusa" },
|
||||||
• 08:30–10:30 Beres tenda + repacking
|
{ day: 1, startTime: "17:30", endTime: "19:00", activity: "Persiapan BBQ + nyalakan api unggun" },
|
||||||
• 10:30–14:00 Turun ke basecamp Apuy (track curam — hati-hati lutut)
|
{ day: 1, startTime: "19:00", endTime: "21:00", activity: "Makan malam BBQ" },
|
||||||
• 14:00–15:00 Bersih-bersih + makan siang di basecamp
|
{ day: 1, startTime: "21:00", endTime: "23:00", activity: "Live music akustik + games" },
|
||||||
• 15:00–17:00 Kembali ke Stasiun Kuningan
|
{ day: 1, startTime: "23:00", activity: "Istirahat" },
|
||||||
• 17:00 Bubar grup`;
|
{ day: 2, startTime: "06:00", endTime: "07:00", activity: "Sunrise + foto di hutan pinus" },
|
||||||
|
{ day: 2, startTime: "07:00", endTime: "08:30", activity: "Sarapan (nasi goreng + roti bakar + kopi)" },
|
||||||
|
{ day: 2, startTime: "08:30", endTime: "10:00", activity: "Memberi makan rusa + sesi foto" },
|
||||||
|
{ day: 2, startTime: "10:00", endTime: "11:00", activity: "Beres tenda + bersih-bersih" },
|
||||||
|
{ day: 2, startTime: "11:00", endTime: "11:30", activity: "Pulang menuju Lembang" },
|
||||||
|
{ day: 2, startTime: "12:00", activity: "Sampai Lembang, bubar grup" },
|
||||||
|
];
|
||||||
|
|
||||||
const ITIN_CAMPING = `Hari 1 — Sabtu
|
const ITIN_PAHAWANG: SeedItineraryItem[] = [
|
||||||
• 13:00–13:30 Meeting di Pertigaan Pasar Lembang
|
{ day: 1, startTime: "07:00", endTime: "07:30", activity: "Meeting di Dermaga Ketapang, Lampung Selatan" },
|
||||||
• 13:30–15:00 Perjalanan ke Ranca Upas via Ciwidey
|
{ day: 1, startTime: "07:30", endTime: "08:30", activity: "Briefing safety + fitting alat snorkel" },
|
||||||
• 15:00–16:00 Check-in + setup tenda dome (sudah disiapkan tim)
|
{ day: 1, startTime: "08:30", endTime: "09:30", activity: "Sailing menuju Pulau Pahawang Kecil" },
|
||||||
• 16:00–17:30 Tour camp area + ketemu rusa-rusa
|
{ day: 1, startTime: "09:30", endTime: "11:30", activity: "Snorkeling spot Cukuh Bedil — terumbu warna-warni" },
|
||||||
• 17:30–19:00 Persiapan BBQ + nyalakan api unggun
|
{ day: 1, startTime: "11:30", endTime: "12:30", activity: "Pindah spot ke Pulau Kelagian Kecil" },
|
||||||
• 19:00–21:00 Makan malam BBQ
|
{ day: 1, startTime: "12:30", endTime: "14:00", activity: "Makan siang + istirahat di pasir putih" },
|
||||||
• 21:00–23:00 Live music akustik + games
|
{ day: 1, startTime: "14:00", endTime: "15:30", activity: "Snorkeling Tanjung Putus + sesi foto underwater" },
|
||||||
• 23:00 Istirahat
|
{ day: 1, startTime: "15:30", endTime: "16:30", activity: "Sailing kembali ke dermaga" },
|
||||||
|
{ day: 1, startTime: "16:30", endTime: "17:00", activity: "Bersih-bersih + bubar grup" },
|
||||||
|
];
|
||||||
|
|
||||||
Hari 2 — Minggu
|
const ITIN_DIVING: SeedItineraryItem[] = [
|
||||||
• 06:00–07:00 Sunrise + foto di hutan pinus
|
{ day: 1, startTime: "06:30", endTime: "07:00", activity: "Meeting di dive shop Tulamben + welcome coffee" },
|
||||||
• 07:00–08:30 Sarapan (nasi goreng + roti bakar + kopi)
|
{ day: 1, startTime: "07:00", endTime: "08:00", activity: "Briefing dive plan + cek sertifikasi + fitting gear" },
|
||||||
• 08:30–10:00 Memberi makan rusa + sesi foto
|
{ day: 1, startTime: "08:00", endTime: "09:00", activity: "Surface interval + pengecekan tank/regulator" },
|
||||||
• 10:00–11:00 Beres tenda + bersih-bersih
|
{ day: 1, startTime: "09:00", endTime: "10:30", activity: "Dive #1 — USS Liberty Wreck (5–28m, ~50 menit bottom time)" },
|
||||||
• 11:00–11:30 Pulang menuju Lembang
|
{ day: 1, startTime: "10:30", endTime: "12:00", activity: "Surface interval + brunch + log dive" },
|
||||||
• 12:00 Sampai Lembang, bubar grup`;
|
{ day: 1, startTime: "12:00", endTime: "13:30", activity: "Dive #2 — Coral Garden / Drop Off (~50 menit)" },
|
||||||
|
{ day: 1, startTime: "13:30", endTime: "15:00", activity: "Debrief + makan siang" },
|
||||||
|
{ day: 1, startTime: "15:00", endTime: "17:00", activity: "Acara bebas (rest atau eksplor desa Tulamben)" },
|
||||||
|
{ day: 1, startTime: "17:00", endTime: "19:00", activity: "Sunset di pantai Tulamben + dinner" },
|
||||||
|
{ day: 1, startTime: "19:00", activity: "Istirahat di homestay (mandiri)" },
|
||||||
|
{ day: 2, startTime: "06:00", endTime: "06:30", activity: "Bangun + kopi" },
|
||||||
|
{ day: 2, startTime: "06:30", endTime: "07:30", activity: "Briefing dive #3 (early morning visibility tinggi)" },
|
||||||
|
{ day: 2, startTime: "07:30", endTime: "09:00", activity: "Dive #3 — Liberty Wreck pagi" },
|
||||||
|
{ day: 2, startTime: "09:00", endTime: "10:30", activity: "Surface interval + sarapan + log" },
|
||||||
|
{ day: 2, startTime: "10:30", endTime: "12:00", activity: "Dive #4 (opsional, fun dive shallow reef)" },
|
||||||
|
{ day: 2, startTime: "12:00", endTime: "13:00", activity: "Bersih gear + debrief akhir" },
|
||||||
|
{ day: 2, startTime: "13:00", endTime: "14:00", activity: "Makan siang penutupan" },
|
||||||
|
{ day: 2, startTime: "14:00", activity: "Bubar grup" },
|
||||||
|
];
|
||||||
|
|
||||||
const ITIN_PAHAWANG = `Hari 1 — Sabtu (one-day trip)
|
const ITIN_ISLANDHOP: SeedItineraryItem[] = [
|
||||||
• 07:00–07:30 Meeting di Dermaga Ketapang, Lampung Selatan
|
{ day: 1, startTime: "07:00", endTime: "07:30", activity: "Meeting di Pelabuhan Kartini Jepara" },
|
||||||
• 07:30–08:30 Briefing safety + fitting alat snorkel
|
{ day: 1, startTime: "07:30", endTime: "13:00", activity: "Penyeberangan kapal feri Jepara → Karimun Jawa" },
|
||||||
• 08:30–09:30 Sailing menuju Pulau Pahawang Kecil
|
{ day: 1, startTime: "13:00", endTime: "14:00", activity: "Tiba di Pelabuhan Karimun + transfer homestay" },
|
||||||
• 09:30–11:30 Snorkeling spot Cukuh Bedil — terumbu warna-warni
|
{ day: 1, startTime: "14:00", endTime: "15:00", activity: "Check-in homestay + ISHOMA" },
|
||||||
• 11:30–12:30 Pindah spot ke Pulau Kelagian Kecil
|
{ day: 1, startTime: "15:00", endTime: "17:30", activity: "Sunset di Bukit Love + foto-foto" },
|
||||||
• 12:30–14:00 Makan siang + istirahat di pasir putih
|
{ day: 1, startTime: "17:30", endTime: "19:00", activity: "Bersih-bersih + makan malam" },
|
||||||
• 14:00–15:30 Snorkeling Tanjung Putus + sesi foto underwater
|
{ day: 1, startTime: "19:00", endTime: "21:00", activity: "Alun-alun Karimun + jajan kuliner" },
|
||||||
• 15:30–16:30 Sailing kembali ke dermaga
|
{ day: 1, startTime: "21:00", activity: "Istirahat" },
|
||||||
• 16:30–17:00 Bersih-bersih + bubar grup`;
|
{ day: 2, startTime: "06:30", endTime: "07:30", activity: "Sarapan + briefing hopping" },
|
||||||
|
{ day: 2, startTime: "07:30", endTime: "09:30", activity: "Boat ke Pulau Menjangan Kecil — snorkeling spot terumbu" },
|
||||||
|
{ day: 2, startTime: "09:30", endTime: "11:30", activity: "Pulau Menjangan Besar — interaksi hiu (penangkaran)" },
|
||||||
|
{ day: 2, startTime: "11:30", endTime: "13:00", activity: "Makan siang BBQ ikan di Pulau Cemara Besar" },
|
||||||
|
{ day: 2, startTime: "13:00", endTime: "15:00", activity: "Pulau Cemara Kecil + foto pasir putih" },
|
||||||
|
{ day: 2, startTime: "15:00", endTime: "17:00", activity: "Pulau Cilik — sunset + snorkel terakhir" },
|
||||||
|
{ day: 2, startTime: "17:00", endTime: "18:30", activity: "Kembali ke homestay + bersih-bersih" },
|
||||||
|
{ day: 2, startTime: "18:30", endTime: "20:00", activity: "Makan malam seafood" },
|
||||||
|
{ day: 2, startTime: "20:00", activity: "Acara bebas" },
|
||||||
|
{ day: 3, startTime: "06:00", endTime: "07:00", activity: "Sunrise di Tanjung Gelam" },
|
||||||
|
{ day: 3, startTime: "07:00", endTime: "09:00", activity: "Sarapan + pack-up" },
|
||||||
|
{ day: 3, startTime: "09:00", endTime: "10:00", activity: "Belanja oleh-oleh di pelabuhan" },
|
||||||
|
{ day: 3, startTime: "10:00", endTime: "16:00", activity: "Penyeberangan kapal feri Karimun → Jepara" },
|
||||||
|
{ day: 3, startTime: "16:00", endTime: "17:00", activity: "Tiba di Pelabuhan Kartini, bubar grup" },
|
||||||
|
];
|
||||||
|
|
||||||
const ITIN_DIVING = `Hari 1 — Sabtu
|
const ITIN_CITYTRIP: SeedItineraryItem[] = [
|
||||||
• 06:30–07:00 Meeting di dive shop Tulamben + welcome coffee
|
{ day: 1, startTime: "08:00", endTime: "08:30", activity: "Meeting di Stasiun Tugu Yogyakarta" },
|
||||||
• 07:00–08:00 Briefing dive plan + cek sertifikasi + fitting gear
|
{ day: 1, startTime: "08:30", endTime: "10:00", activity: "Sarapan Gudeg Yu Djum + briefing rute" },
|
||||||
• 08:00–09:00 Surface interval + pengecekan tank/regulator
|
{ day: 1, startTime: "10:00", endTime: "12:00", activity: "Kotagede — kerajinan perak + Masjid Mataram" },
|
||||||
• 09:00–10:30 Dive #1 — USS Liberty Wreck (5–28m, ~50 menit bottom time)
|
{ day: 1, startTime: "12:00", endTime: "13:30", activity: "Makan siang Sate Klathak Pak Pong" },
|
||||||
• 10:30–12:00 Surface interval + brunch + log dive
|
{ day: 1, startTime: "13:30", endTime: "16:00", activity: "Tamansari — pemandian Sultan + sumur Gumuling" },
|
||||||
• 12:00–13:30 Dive #2 — Coral Garden / Drop Off (~50 menit)
|
{ day: 1, startTime: "16:00", endTime: "17:30", activity: "Coffee break di kedai lokal Prawirotaman" },
|
||||||
• 13:30–15:00 Debrief + makan siang
|
{ day: 1, startTime: "17:30", endTime: "19:30", activity: "Sunset di Bukit Bintang (Jl. Imogiri)" },
|
||||||
• 15:00–17:00 Acara bebas (rest atau eksplor desa Tulamben)
|
{ day: 1, startTime: "19:30", endTime: "21:30", activity: "Angkringan Lik Man — kopi joss + nasi kucing" },
|
||||||
• 17:00–19:00 Sunset di pantai Tulamben + dinner
|
{ day: 1, startTime: "21:30", activity: "Drop ke penginapan masing-masing" },
|
||||||
• 19:00 Istirahat di homestay (mandiri)
|
{ day: 2, startTime: "06:00", endTime: "07:00", activity: "Pickup dari penginapan" },
|
||||||
|
{ day: 2, startTime: "07:00", endTime: "09:30", activity: "Perjalanan ke Kalibiru, Kulon Progo" },
|
||||||
|
{ day: 2, startTime: "09:30", endTime: "11:30", activity: "Kalibiru — spot foto rumah pohon di tebing" },
|
||||||
|
{ day: 2, startTime: "11:30", endTime: "13:00", activity: "Makan siang pecel di warung lokal" },
|
||||||
|
{ day: 2, startTime: "13:00", endTime: "15:00", activity: "Pinus Pengger — instalasi seni alam" },
|
||||||
|
{ day: 2, startTime: "15:00", endTime: "16:30", activity: "Heha Sky View (opsional, cek cuaca)" },
|
||||||
|
{ day: 2, startTime: "16:30", endTime: "18:00", activity: "Kembali ke kota — drop di Stasiun Tugu / Bandara" },
|
||||||
|
{ day: 2, startTime: "18:00", activity: "Bubar grup" },
|
||||||
|
];
|
||||||
|
|
||||||
Hari 2 — Minggu
|
const ITIN_CULINARY: SeedItineraryItem[] = [
|
||||||
• 06:00–06:30 Bangun + kopi
|
{ day: 1, startTime: "09:00", endTime: "09:30", activity: "Meeting di Stasiun Bandung pintu utara + briefing rute" },
|
||||||
• 06:30–07:30 Briefing dive #3 (early morning visibility tinggi)
|
{ day: 1, startTime: "09:30", endTime: "10:15", activity: "Stop 1: Surabi Enhaii (sarapan tradisional)" },
|
||||||
• 07:30–09:00 Dive #3 — Liberty Wreck pagi
|
{ day: 1, startTime: "10:15", endTime: "11:00", activity: "Stop 2: Lotek Kalipah Apo" },
|
||||||
• 09:00–10:30 Surface interval + sarapan + log
|
{ day: 1, startTime: "11:00", endTime: "11:45", activity: "Stop 3: Mie Kocok Mang Dadeng (Kebon Jukut)" },
|
||||||
• 10:30–12:00 Dive #4 (opsional, fun dive shallow reef)
|
{ day: 1, startTime: "11:45", endTime: "12:30", activity: "Stop 4: Bakso Akung (cabang Burangrang)" },
|
||||||
• 12:00–13:00 Bersih gear + debrief akhir
|
{ day: 1, startTime: "12:30", endTime: "13:30", activity: "Istirahat + jalan santai di Cihampelas" },
|
||||||
• 13:00–14:00 Makan siang penutupan
|
{ day: 1, startTime: "13:30", endTime: "14:15", activity: "Stop 5: Batagor Kingsley" },
|
||||||
• 14:00 Bubar grup`;
|
{ day: 1, startTime: "14:15", endTime: "15:00", activity: "Stop 6: Cuanki Serayu" },
|
||||||
|
{ day: 1, startTime: "15:00", endTime: "15:45", activity: "Stop 7: Es Cendol Elizabeth" },
|
||||||
|
{ day: 1, startTime: "15:45", endTime: "16:30", activity: "Stop 8: Roti Gempol & Kopi Anjis (penutup)" },
|
||||||
|
{ day: 1, startTime: "16:30", endTime: "17:00", activity: "Closing + foto bareng di Braga" },
|
||||||
|
];
|
||||||
|
|
||||||
const ITIN_ISLANDHOP = `Hari 1 — Jumat
|
const ITIN_CONCERT: SeedItineraryItem[] = [
|
||||||
• 07:00–07:30 Meeting di Pelabuhan Kartini Jepara
|
{ day: 1, startTime: "17:00", endTime: "17:30", activity: "Meeting di Plaza GBK, depan loket Cat 1" },
|
||||||
• 07:30–13:00 Penyeberangan kapal feri Jepara → Karimun Jawa
|
{ day: 1, startTime: "17:30", endTime: "18:30", activity: "Foto bareng pre-show + obrolan singkat" },
|
||||||
• 13:00–14:00 Tiba di Pelabuhan Karimun + transfer homestay
|
{ day: 1, startTime: "18:30", endTime: "19:00", activity: "Masuk venue bareng (kategori tetap masing-masing)" },
|
||||||
• 14:00–15:00 Check-in homestay + ISHOMA
|
{ day: 1, startTime: "19:00", endTime: "22:30", activity: "Konser Coldplay — Music of the Spheres" },
|
||||||
• 15:00–17:30 Sunset di Bukit Love + foto-foto
|
{ day: 1, startTime: "22:30", endTime: "23:00", activity: "Berkumpul lagi di luar gerbang utama GBK" },
|
||||||
• 17:30–19:00 Bersih-bersih + makan malam
|
{ day: 1, startTime: "23:00", activity: "After-party dinner di Senayan (resto TBA via grup WA)" },
|
||||||
• 19:00–21:00 Alun-alun Karimun + jajan kuliner
|
];
|
||||||
• 21:00 Istirahat
|
|
||||||
|
|
||||||
Hari 2 — Sabtu
|
const ITIN_WORKSHOP: SeedItineraryItem[] = [
|
||||||
• 06:30–07:30 Sarapan + briefing hopping
|
{ day: 1, startTime: "04:00", endTime: "04:30", activity: "Meeting di Alun-alun Pangalengan" },
|
||||||
• 07:30–09:30 Boat ke Pulau Menjangan Kecil — snorkeling spot terumbu
|
{ day: 1, startTime: "04:30", endTime: "05:30", activity: "Briefing teknis + setup peralatan" },
|
||||||
• 09:30–11:30 Pulau Menjangan Besar — interaksi hiu (penangkaran)
|
{ day: 1, startTime: "05:30", endTime: "07:30", activity: "Sunrise shoot di Perkebunan Teh Malabar" },
|
||||||
• 11:30–13:00 Makan siang BBQ ikan di Pulau Cemara Besar
|
{ day: 1, startTime: "07:30", endTime: "09:00", activity: "Sarapan + sesi review foto bareng mentor" },
|
||||||
• 13:00–15:00 Pulau Cemara Kecil + foto pasir putih
|
{ day: 1, startTime: "09:00", endTime: "11:00", activity: "Materi indoor: long exposure & filter ND" },
|
||||||
• 15:00–17:00 Pulau Cilik — sunset + snorkel terakhir
|
{ day: 1, startTime: "11:00", endTime: "13:00", activity: "ISHOMA + transfer ke Situ Cileunca" },
|
||||||
• 17:00–18:30 Kembali ke homestay + bersih-bersih
|
{ day: 1, startTime: "13:00", endTime: "16:00", activity: "Workshop on-field di Situ Cileunca (panorama, refleksi)" },
|
||||||
• 18:30–20:00 Makan malam seafood
|
{ day: 1, startTime: "16:00", endTime: "18:00", activity: "Golden hour shoot di Bukit Nini" },
|
||||||
• 20:00 Acara bebas
|
{ day: 1, startTime: "18:00", endTime: "19:30", activity: "Makan malam di villa" },
|
||||||
|
{ day: 1, startTime: "19:30", endTime: "22:00", activity: "Sesi malam — milky way / star trail (cuaca permitting)" },
|
||||||
|
{ day: 1, startTime: "22:00", activity: "Istirahat di villa" },
|
||||||
|
{ day: 2, startTime: "05:00", endTime: "07:00", activity: "Sunrise shoot di Pangalengan + foto siluet" },
|
||||||
|
{ day: 2, startTime: "07:00", endTime: "08:30", activity: "Sarapan + diskusi hasil" },
|
||||||
|
{ day: 2, startTime: "08:30", endTime: "11:00", activity: "Sesi editing Lightroom (laptop pribadi)" },
|
||||||
|
{ day: 2, startTime: "11:00", endTime: "12:00", activity: "Review akhir + sertifikat" },
|
||||||
|
{ day: 2, startTime: "12:00", endTime: "13:00", activity: "Makan siang penutupan" },
|
||||||
|
{ day: 2, startTime: "13:00", endTime: "15:00", activity: "Kembali ke Alun-alun Pangalengan" },
|
||||||
|
{ day: 2, startTime: "15:00", activity: "Bubar grup" },
|
||||||
|
];
|
||||||
|
|
||||||
Hari 3 — Minggu
|
const ITIN_RETREAT: SeedItineraryItem[] = [
|
||||||
• 06:00–07:00 Sunrise di Tanjung Gelam
|
{ day: 1, startTime: "14:00", endTime: "15:00", activity: "Check-in Villa Sawah Ubud + welcome drink (jamu)" },
|
||||||
• 07:00–09:00 Sarapan + pack-up
|
{ day: 1, startTime: "15:00", endTime: "16:00", activity: "Tour fasilitas + pembagian welcome kit" },
|
||||||
• 09:00–10:00 Belanja oleh-oleh di pelabuhan
|
{ day: 1, startTime: "16:00", endTime: "17:30", activity: "Yin Yoga pembuka — release perjalanan" },
|
||||||
• 10:00–16:00 Penyeberangan kapal feri Karimun → Jepara
|
{ day: 1, startTime: "17:30", endTime: "18:30", activity: "Journaling: niat & ekspektasi retreat" },
|
||||||
• 16:00–17:00 Tiba di Pelabuhan Kartini, bubar grup`;
|
{ day: 1, startTime: "18:30", endTime: "20:00", activity: "Dinner vegan (set menu)" },
|
||||||
|
{ day: 1, startTime: "20:00", endTime: "21:00", activity: "Circle pengenalan + meditasi singkat" },
|
||||||
const ITIN_CITYTRIP = `Hari 1 — Sabtu
|
{ day: 1, startTime: "21:00", activity: "Lights-out" },
|
||||||
• 08:00–08:30 Meeting di Stasiun Tugu Yogyakarta
|
{ day: 2, startTime: "06:00", endTime: "07:30", activity: "Hatha Yoga matahari terbit" },
|
||||||
• 08:30–10:00 Sarapan Gudeg Yu Djum + briefing rute
|
{ day: 2, startTime: "07:30", endTime: "09:00", activity: "Sarapan vegan + tea ceremony" },
|
||||||
• 10:00–12:00 Kotagede — kerajinan perak + Masjid Mataram
|
{ day: 2, startTime: "09:00", endTime: "10:30", activity: "Meditasi guided: body scan" },
|
||||||
• 12:00–13:30 Makan siang Sate Klathak Pak Pong
|
{ day: 2, startTime: "10:30", endTime: "12:00", activity: "Pranayama (latihan napas)" },
|
||||||
• 13:30–16:00 Tamansari — pemandian Sultan + sumur Gumuling
|
{ day: 2, startTime: "12:00", endTime: "13:30", activity: "Lunch + acara bebas (sawah walk)" },
|
||||||
• 16:00–17:30 Coffee break di kedai lokal Prawirotaman
|
{ day: 2, startTime: "13:30", endTime: "15:00", activity: "Sound healing dengan singing bowl" },
|
||||||
• 17:30–19:30 Sunset di Bukit Bintang (Jl. Imogiri)
|
{ day: 2, startTime: "15:00", endTime: "16:30", activity: "Workshop: mindful eating + jamu making" },
|
||||||
• 19:30–21:30 Angkringan Lik Man — kopi joss + nasi kucing
|
{ day: 2, startTime: "16:30", endTime: "18:00", activity: "Yin Yoga sore + savasana panjang" },
|
||||||
• 21:30 Drop ke penginapan masing-masing
|
{ day: 2, startTime: "18:00", endTime: "19:30", activity: "Dinner vegan" },
|
||||||
|
{ day: 2, startTime: "19:30", endTime: "21:00", activity: "Sharing circle + meditasi malam" },
|
||||||
Hari 2 — Minggu
|
{ day: 2, startTime: "21:00", activity: "Lights-out" },
|
||||||
• 06:00–07:00 Pickup dari penginapan
|
{ day: 3, startTime: "06:00", endTime: "07:30", activity: "Vinyasa Flow penutupan" },
|
||||||
• 07:00–09:30 Perjalanan ke Kalibiru, Kulon Progo
|
{ day: 3, startTime: "07:30", endTime: "09:00", activity: "Sarapan + closing journaling" },
|
||||||
• 09:30–11:30 Kalibiru — spot foto rumah pohon di tebing
|
{ day: 3, startTime: "09:00", endTime: "10:30", activity: "Closing circle + tukar pesan" },
|
||||||
• 11:30–13:00 Makan siang pecel di warung lokal
|
{ day: 3, startTime: "10:30", endTime: "11:30", activity: "Check-out + pelukan perpisahan" },
|
||||||
• 13:00–15:00 Pinus Pengger — instalasi seni alam
|
{ day: 3, startTime: "11:30", endTime: "14:00", activity: "Acara bebas (rekomendasi spa/cafe sekitar)" },
|
||||||
• 15:00–16:30 Heha Sky View (opsional, cek cuaca)
|
{ day: 3, startTime: "14:00", activity: "Trip resmi ditutup" },
|
||||||
• 16:30–18:00 Kembali ke kota — drop di Stasiun Tugu / Bandara
|
];
|
||||||
• 18:00 Bubar grup`;
|
|
||||||
|
|
||||||
const ITIN_CULINARY = `Hari 1 — Sabtu (one-day food tour)
|
|
||||||
• 09:00–09:30 Meeting di Stasiun Bandung pintu utara + briefing rute
|
|
||||||
• 09:30–10:15 Stop 1: Surabi Enhaii (sarapan tradisional)
|
|
||||||
• 10:15–11:00 Stop 2: Lotek Kalipah Apo
|
|
||||||
• 11:00–11:45 Stop 3: Mie Kocok Mang Dadeng (Kebon Jukut)
|
|
||||||
• 11:45–12:30 Stop 4: Bakso Akung (cabang Burangrang)
|
|
||||||
• 12:30–13:30 Istirahat + jalan santai di Cihampelas
|
|
||||||
• 13:30–14:15 Stop 5: Batagor Kingsley
|
|
||||||
• 14:15–15:00 Stop 6: Cuanki Serayu
|
|
||||||
• 15:00–15:45 Stop 7: Es Cendol Elizabeth
|
|
||||||
• 15:45–16:30 Stop 8: Roti Gempol & Kopi Anjis (penutup)
|
|
||||||
• 16:30–17:00 Closing + foto bareng di Braga`;
|
|
||||||
|
|
||||||
const ITIN_CONCERT = `Hari 1 — Sabtu (showtime)
|
|
||||||
• 17:00–17:30 Meeting di Plaza GBK, depan loket Cat 1
|
|
||||||
• 17:30–18:30 Foto bareng pre-show + obrolan singkat
|
|
||||||
• 18:30–19:00 Masuk venue bareng (kategori tetap masing-masing)
|
|
||||||
• 19:00–22:30 Konser Coldplay — Music of the Spheres
|
|
||||||
• 22:30–23:00 Berkumpul lagi di luar gerbang utama GBK
|
|
||||||
• 23:00–00:30 After-party dinner di Senayan (resto TBA via grup WA)
|
|
||||||
• 00:30 Bubar`;
|
|
||||||
|
|
||||||
const ITIN_WORKSHOP = `Hari 1 — Sabtu
|
|
||||||
• 04:00–04:30 Meeting di Alun-alun Pangalengan
|
|
||||||
• 04:30–05:30 Briefing teknis + setup peralatan
|
|
||||||
• 05:30–07:30 Sunrise shoot di Perkebunan Teh Malabar
|
|
||||||
• 07:30–09:00 Sarapan + sesi review foto bareng mentor
|
|
||||||
• 09:00–11:00 Materi indoor: long exposure & filter ND
|
|
||||||
• 11:00–13:00 ISHOMA + transfer ke Situ Cileunca
|
|
||||||
• 13:00–16:00 Workshop on-field di Situ Cileunca (panorama, refleksi)
|
|
||||||
• 16:00–18:00 Golden hour shoot di Bukit Nini
|
|
||||||
• 18:00–19:30 Makan malam di villa
|
|
||||||
• 19:30–22:00 Sesi malam — milky way / star trail (cuaca permitting)
|
|
||||||
• 22:00 Istirahat di villa
|
|
||||||
|
|
||||||
Hari 2 — Minggu
|
|
||||||
• 05:00–07:00 Sunrise shoot di Pangalengan + foto siluet
|
|
||||||
• 07:00–08:30 Sarapan + diskusi hasil
|
|
||||||
• 08:30–11:00 Sesi editing Lightroom (laptop pribadi)
|
|
||||||
• 11:00–12:00 Review akhir + sertifikat
|
|
||||||
• 12:00–13:00 Makan siang penutupan
|
|
||||||
• 13:00–15:00 Kembali ke Alun-alun Pangalengan
|
|
||||||
• 15:00 Bubar grup`;
|
|
||||||
|
|
||||||
const ITIN_RETREAT = `Hari 1 — Jumat
|
|
||||||
• 14:00–15:00 Check-in Villa Sawah Ubud + welcome drink (jamu)
|
|
||||||
• 15:00–16:00 Tour fasilitas + pembagian welcome kit
|
|
||||||
• 16:00–17:30 Yin Yoga pembuka — release perjalanan
|
|
||||||
• 17:30–18:30 Journaling: niat & ekspektasi retreat
|
|
||||||
• 18:30–20:00 Dinner vegan (set menu)
|
|
||||||
• 20:00–21:00 Circle pengenalan + meditasi singkat
|
|
||||||
• 21:00 Lights-out
|
|
||||||
|
|
||||||
Hari 2 — Sabtu
|
|
||||||
• 06:00–07:30 Hatha Yoga matahari terbit
|
|
||||||
• 07:30–09:00 Sarapan vegan + tea ceremony
|
|
||||||
• 09:00–10:30 Meditasi guided: body scan
|
|
||||||
• 10:30–12:00 Pranayama (latihan napas)
|
|
||||||
• 12:00–13:30 Lunch + acara bebas (sawah walk)
|
|
||||||
• 13:30–15:00 Sound healing dengan singing bowl
|
|
||||||
• 15:00–16:30 Workshop: mindful eating + jamu making
|
|
||||||
• 16:30–18:00 Yin Yoga sore + savasana panjang
|
|
||||||
• 18:00–19:30 Dinner vegan
|
|
||||||
• 19:30–21:00 Sharing circle + meditasi malam
|
|
||||||
• 21:00 Lights-out
|
|
||||||
|
|
||||||
Hari 3 — Minggu
|
|
||||||
• 06:00–07:30 Vinyasa Flow penutupan
|
|
||||||
• 07:30–09:00 Sarapan + closing journaling
|
|
||||||
• 09:00–10:30 Closing circle + tukar pesan
|
|
||||||
• 10:30–11:30 Check-out + pelukan perpisahan
|
|
||||||
• 11:30–14:00 Acara bebas (rekomendasi spa/cafe sekitar)
|
|
||||||
• 14:00 Trip resmi ditutup`;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 1. Cleanup — urutan FK aware (Payment → Booking → Review → Image →
|
// 1. Cleanup — urutan FK aware (Payment → Booking → Review → Image →
|
||||||
@@ -260,10 +255,15 @@ Hari 3 — Minggu
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
|
// Refund & Payout pakai onDelete: Restrict ke Booking/Payment, jadi mereka
|
||||||
|
// wajib dihapus duluan sebelum Booking/Payment bisa dibersihkan.
|
||||||
|
await prisma.refund.deleteMany();
|
||||||
|
await prisma.payout.deleteMany();
|
||||||
await prisma.payment.deleteMany();
|
await prisma.payment.deleteMany();
|
||||||
await prisma.booking.deleteMany();
|
await prisma.booking.deleteMany();
|
||||||
await prisma.tripReview.deleteMany();
|
await prisma.tripReview.deleteMany();
|
||||||
await prisma.tripImage.deleteMany();
|
await prisma.tripImage.deleteMany();
|
||||||
|
await prisma.tripItineraryItem.deleteMany();
|
||||||
await prisma.tripParticipant.deleteMany();
|
await prisma.tripParticipant.deleteMany();
|
||||||
await prisma.trip.deleteMany();
|
await prisma.trip.deleteMany();
|
||||||
await prisma.organizerVerification.deleteMany();
|
await prisma.organizerVerification.deleteMany();
|
||||||
@@ -556,7 +556,7 @@ interface SeedTrip {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
meetingPoint?: string;
|
meetingPoint?: string;
|
||||||
itinerary?: string;
|
itineraryItems?: SeedItineraryItem[];
|
||||||
whatsIncluded?: string;
|
whatsIncluded?: string;
|
||||||
whatsExcluded?: string;
|
whatsExcluded?: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
@@ -580,7 +580,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
title: "Open Trip Papandayan — Maret 2026",
|
title: "Open Trip Papandayan — Maret 2026",
|
||||||
description: `Pendakian santai ke Gunung Papandayan, batch Maret. Cocok untuk pemula.`,
|
description: `Pendakian santai ke Gunung Papandayan, batch Maret. Cocok untuk pemula.`,
|
||||||
meetingPoint: "Alun-alun Garut, 05:00 WIB",
|
meetingPoint: "Alun-alun Garut, 05:00 WIB",
|
||||||
itinerary: ITIN_PAPANDAYAN,
|
itineraryItems: ITIN_PAPANDAYAN,
|
||||||
destination: "Gunung Papandayan",
|
destination: "Gunung Papandayan",
|
||||||
location: "Garut, Jawa Barat",
|
location: "Garut, Jawa Barat",
|
||||||
date: utc(2026, 2, 14, 22, 0),
|
date: utc(2026, 2, 14, 22, 0),
|
||||||
@@ -601,7 +601,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
title: "Snorkeling Pulau Pahawang — Februari 2026",
|
title: "Snorkeling Pulau Pahawang — Februari 2026",
|
||||||
description: `Trip snorkeling batch Februari. Cuaca bersahabat, visibility 10m+.`,
|
description: `Trip snorkeling batch Februari. Cuaca bersahabat, visibility 10m+.`,
|
||||||
meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB",
|
meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB",
|
||||||
itinerary: ITIN_PAHAWANG,
|
itineraryItems: ITIN_PAHAWANG,
|
||||||
destination: "Pulau Pahawang Kecil",
|
destination: "Pulau Pahawang Kecil",
|
||||||
location: "Lampung Selatan, Lampung",
|
location: "Lampung Selatan, Lampung",
|
||||||
date: utc(2026, 1, 21, 0, 0),
|
date: utc(2026, 1, 21, 0, 0),
|
||||||
@@ -621,7 +621,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
title: "Kulineran Street Food Bandung — April 2026",
|
title: "Kulineran Street Food Bandung — April 2026",
|
||||||
description: `Food tour batch April. 8 spot legend dilibas dalam satu hari.`,
|
description: `Food tour batch April. 8 spot legend dilibas dalam satu hari.`,
|
||||||
meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB",
|
meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB",
|
||||||
itinerary: ITIN_CULINARY,
|
itineraryItems: ITIN_CULINARY,
|
||||||
destination: "Street Food Tour Bandung",
|
destination: "Street Food Tour Bandung",
|
||||||
location: "Bandung, Jawa Barat",
|
location: "Bandung, Jawa Barat",
|
||||||
date: utc(2026, 3, 12, 0, 0),
|
date: utc(2026, 3, 12, 0, 0),
|
||||||
@@ -662,7 +662,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
|
|
||||||
⚠️ Bawa: Sleeping bag, jaket, headlamp, air 2L`,
|
⚠️ Bawa: Sleeping bag, jaket, headlamp, air 2L`,
|
||||||
meetingPoint: "Alun-alun Garut, Sabtu 05:00 WIB — detail grup WA.",
|
meetingPoint: "Alun-alun Garut, Sabtu 05:00 WIB — detail grup WA.",
|
||||||
itinerary: ITIN_PAPANDAYAN,
|
itineraryItems: ITIN_PAPANDAYAN,
|
||||||
whatsIncluded: `• Transport PP Garut–basecamp
|
whatsIncluded: `• Transport PP Garut–basecamp
|
||||||
• Guide lokal
|
• Guide lokal
|
||||||
• Tenda tim
|
• Tenda tim
|
||||||
@@ -693,7 +693,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
📍 Meeting Point: Stasiun Kuningan, 04:00 WIB
|
📍 Meeting Point: Stasiun Kuningan, 04:00 WIB
|
||||||
⚠️ Level: Menengah — perlu stamina baik`,
|
⚠️ Level: Menengah — perlu stamina baik`,
|
||||||
meetingPoint: "Stasiun Kuningan, Sabtu 04:00 WIB",
|
meetingPoint: "Stasiun Kuningan, Sabtu 04:00 WIB",
|
||||||
itinerary: ITIN_CIREMAI,
|
itineraryItems: ITIN_CIREMAI,
|
||||||
destination: "Gunung Ciremai",
|
destination: "Gunung Ciremai",
|
||||||
location: "Kuningan, Jawa Barat",
|
location: "Kuningan, Jawa Barat",
|
||||||
date: utc(2026, 5, 23, 4, 0),
|
date: utc(2026, 5, 23, 4, 0),
|
||||||
@@ -718,7 +718,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
🎒 Fasilitas: Tenda dome, sleeping bag, BBQ, api unggun
|
🎒 Fasilitas: Tenda dome, sleeping bag, BBQ, api unggun
|
||||||
🔥 Bonus: Live music akustik malam hari`,
|
🔥 Bonus: Live music akustik malam hari`,
|
||||||
meetingPoint: "Pertigaan Pasar Lembang, Sabtu 13:00 WIB",
|
meetingPoint: "Pertigaan Pasar Lembang, Sabtu 13:00 WIB",
|
||||||
itinerary: ITIN_CAMPING,
|
itineraryItems: ITIN_CAMPING,
|
||||||
whatsIncluded: `• Tenda + sleeping bag + matras
|
whatsIncluded: `• Tenda + sleeping bag + matras
|
||||||
• Logistik camp
|
• Logistik camp
|
||||||
• Makan malam BBQ + sarapan
|
• Makan malam BBQ + sarapan
|
||||||
@@ -748,7 +748,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
🤿 Pemula friendly — guide profesional
|
🤿 Pemula friendly — guide profesional
|
||||||
📷 Underwater photo session included`,
|
📷 Underwater photo session included`,
|
||||||
meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB",
|
meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB",
|
||||||
itinerary: ITIN_PAHAWANG,
|
itineraryItems: ITIN_PAHAWANG,
|
||||||
whatsIncluded: `• Boat PP
|
whatsIncluded: `• Boat PP
|
||||||
• Alat snorkel (masker, fin, life vest)
|
• Alat snorkel (masker, fin, life vest)
|
||||||
• Guide & pemandu underwater
|
• Guide & pemandu underwater
|
||||||
@@ -777,7 +777,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
|
|
||||||
⚠️ Sertifikasi minimal: Open Water (PADI/SSI)`,
|
⚠️ Sertifikasi minimal: Open Water (PADI/SSI)`,
|
||||||
meetingPoint: "Dive shop Tulamben, 06:30 WITA",
|
meetingPoint: "Dive shop Tulamben, 06:30 WITA",
|
||||||
itinerary: ITIN_DIVING,
|
itineraryItems: ITIN_DIVING,
|
||||||
whatsIncluded: `• 2x dive guided
|
whatsIncluded: `• 2x dive guided
|
||||||
• Full gear rental
|
• Full gear rental
|
||||||
• Tank & weight
|
• Tank & weight
|
||||||
@@ -807,7 +807,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
|
|
||||||
🏝️ Cocok untuk solo traveler & couple`,
|
🏝️ Cocok untuk solo traveler & couple`,
|
||||||
meetingPoint: "Pelabuhan Kartini Jepara, Jumat 07:00 WIB",
|
meetingPoint: "Pelabuhan Kartini Jepara, Jumat 07:00 WIB",
|
||||||
itinerary: ITIN_ISLANDHOP,
|
itineraryItems: ITIN_ISLANDHOP,
|
||||||
whatsIncluded: `• Tiket kapal feri PP Jepara–Karimun
|
whatsIncluded: `• Tiket kapal feri PP Jepara–Karimun
|
||||||
• Homestay 2 malam (twin sharing)
|
• Homestay 2 malam (twin sharing)
|
||||||
• Boat hopping 2 hari
|
• Boat hopping 2 hari
|
||||||
@@ -837,7 +837,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
|
|
||||||
🚐 Mobil grup, bukan tour bus`,
|
🚐 Mobil grup, bukan tour bus`,
|
||||||
meetingPoint: "Stasiun Tugu Yogyakarta, Sabtu 08:00 WIB",
|
meetingPoint: "Stasiun Tugu Yogyakarta, Sabtu 08:00 WIB",
|
||||||
itinerary: ITIN_CITYTRIP,
|
itineraryItems: ITIN_CITYTRIP,
|
||||||
whatsIncluded: `• Transport mobil grup 2 hari
|
whatsIncluded: `• Transport mobil grup 2 hari
|
||||||
• Tour leader lokal
|
• Tour leader lokal
|
||||||
• Makan 3x (kuliner lokal)
|
• Makan 3x (kuliner lokal)
|
||||||
@@ -866,7 +866,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
|
|
||||||
🍜 Cocok buat foodie & first-timer`,
|
🍜 Cocok buat foodie & first-timer`,
|
||||||
meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB",
|
meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB",
|
||||||
itinerary: ITIN_CULINARY,
|
itineraryItems: ITIN_CULINARY,
|
||||||
whatsIncluded: `• Transport angkot/grup
|
whatsIncluded: `• Transport angkot/grup
|
||||||
• Tour leader food explorer
|
• Tour leader food explorer
|
||||||
• Sample setiap spot (8 tempat)`,
|
• Sample setiap spot (8 tempat)`,
|
||||||
@@ -893,7 +893,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
🎤 Tiket BUKAN termasuk — peserta bawa tiket masing-masing
|
🎤 Tiket BUKAN termasuk — peserta bawa tiket masing-masing
|
||||||
🤝 Grup hanya untuk koordinasi meet-up & after-party`,
|
🤝 Grup hanya untuk koordinasi meet-up & after-party`,
|
||||||
meetingPoint: "Plaza GBK, depan loket Cat 1, 17:00 WIB",
|
meetingPoint: "Plaza GBK, depan loket Cat 1, 17:00 WIB",
|
||||||
itinerary: ITIN_CONCERT,
|
itineraryItems: ITIN_CONCERT,
|
||||||
whatsIncluded: `• Koordinasi grup
|
whatsIncluded: `• Koordinasi grup
|
||||||
• Foto bareng pre-show
|
• Foto bareng pre-show
|
||||||
• After-party dinner di Senayan`,
|
• After-party dinner di Senayan`,
|
||||||
@@ -921,7 +921,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
📷 Bawa kamera DSLR/mirrorless + tripod
|
📷 Bawa kamera DSLR/mirrorless + tripod
|
||||||
👨🏫 Mentor: fotografer pro (10+ tahun pengalaman)`,
|
👨🏫 Mentor: fotografer pro (10+ tahun pengalaman)`,
|
||||||
meetingPoint: "Alun-alun Pangalengan, Sabtu 04:00 WIB",
|
meetingPoint: "Alun-alun Pangalengan, Sabtu 04:00 WIB",
|
||||||
itinerary: ITIN_WORKSHOP,
|
itineraryItems: ITIN_WORKSHOP,
|
||||||
whatsIncluded: `• Materi workshop (briefing + on-field)
|
whatsIncluded: `• Materi workshop (briefing + on-field)
|
||||||
• Tour leader & mentor
|
• Tour leader & mentor
|
||||||
• Penginapan villa 1 malam
|
• Penginapan villa 1 malam
|
||||||
@@ -951,7 +951,7 @@ const SEED_TRIPS: SeedTrip[] = [
|
|||||||
🧘 Untuk yang lagi burnout & butuh reset
|
🧘 Untuk yang lagi burnout & butuh reset
|
||||||
👥 Grup kecil (max 8) — pengalaman akrab`,
|
👥 Grup kecil (max 8) — pengalaman akrab`,
|
||||||
meetingPoint: "Villa Sawah Ubud (alamat dikirim H-3 via WA)",
|
meetingPoint: "Villa Sawah Ubud (alamat dikirim H-3 via WA)",
|
||||||
itinerary: ITIN_RETREAT,
|
itineraryItems: ITIN_RETREAT,
|
||||||
whatsIncluded: `• Penginapan villa 2 malam
|
whatsIncluded: `• Penginapan villa 2 malam
|
||||||
• Yoga 4 sesi + meditasi 6 sesi
|
• Yoga 4 sesi + meditasi 6 sesi
|
||||||
• Sound healing (1 sesi)
|
• Sound healing (1 sesi)
|
||||||
@@ -990,7 +990,6 @@ async function seedTrips(users: UserMap): Promise<TripMap> {
|
|||||||
title: t.title,
|
title: t.title,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
meetingPoint: t.meetingPoint,
|
meetingPoint: t.meetingPoint,
|
||||||
itinerary: t.itinerary,
|
|
||||||
whatsIncluded: t.whatsIncluded,
|
whatsIncluded: t.whatsIncluded,
|
||||||
whatsExcluded: t.whatsExcluded,
|
whatsExcluded: t.whatsExcluded,
|
||||||
destination: t.destination,
|
destination: t.destination,
|
||||||
@@ -1011,6 +1010,17 @@ async function seedTrips(users: UserMap): Promise<TripMap> {
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
itineraryItems: t.itineraryItems?.length
|
||||||
|
? {
|
||||||
|
create: t.itineraryItems.map((item, order) => ({
|
||||||
|
day: item.day,
|
||||||
|
startTime: item.startTime,
|
||||||
|
endTime: item.endTime ?? null,
|
||||||
|
activity: item.activity,
|
||||||
|
order,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
map[t.key] = { id: created.id, price: created.price, status: t.status };
|
map[t.key] = { id: created.id, price: created.price, status: t.status };
|
||||||
|
|||||||
@@ -20,32 +20,6 @@ export const bookingRepo = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Daftar booking di trip ini yang masih menunggu konfirmasi pembayaran
|
|
||||||
* dari organizer (Payment MANUAL status AWAITING).
|
|
||||||
*/
|
|
||||||
async findAwaitingManualConfirmation(tripId: string) {
|
|
||||||
return prisma.booking.findMany({
|
|
||||||
where: {
|
|
||||||
tripId,
|
|
||||||
status: "AWAITING_PAY",
|
|
||||||
payments: {
|
|
||||||
some: { provider: "MANUAL", status: "AWAITING" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
participant: true,
|
|
||||||
user: { select: { id: true, name: true, image: true } },
|
|
||||||
payments: {
|
|
||||||
where: { provider: "MANUAL", status: "AWAITING" },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: "asc" },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
data: Pick<
|
data: Pick<
|
||||||
Prisma.BookingUncheckedCreateInput,
|
Prisma.BookingUncheckedCreateInput,
|
||||||
|
|||||||
@@ -36,6 +36,25 @@ export const organizerRepo = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async countByStatus(status: "PENDING" | "APPROVED" | "REJECTED") {
|
||||||
|
return prisma.organizerVerification.count({ where: { status } });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Verifikasi terbaru (default PENDING) untuk preview di dashboard admin. */
|
||||||
|
async listRecent(status: "PENDING" | "APPROVED" | "REJECTED", limit = 3) {
|
||||||
|
return prisma.organizerVerification.findMany({
|
||||||
|
where: { status },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fullName: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async updateReview(
|
async updateReview(
|
||||||
id: string,
|
id: string,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@/app/generated/prisma/client";
|
||||||
|
import type { PayoutStatus } from "@/app/generated/prisma/enums";
|
||||||
|
|
||||||
|
const payoutListInclude = {
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trip: {
|
||||||
|
select: { id: true, title: true, date: true, endDate: true, status: true },
|
||||||
|
},
|
||||||
|
organizer: { select: { id: true, name: true, email: true } },
|
||||||
|
processedBy: { select: { id: true, name: true, email: true } },
|
||||||
|
} satisfies Prisma.PayoutInclude;
|
||||||
|
|
||||||
|
export const payoutRepo = {
|
||||||
|
async findById(id: string) {
|
||||||
|
return prisma.payout.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: payoutListInclude,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async findByBookingId(bookingId: string, tx?: Prisma.TransactionClient) {
|
||||||
|
const client = tx ?? prisma;
|
||||||
|
return client.payout.findUnique({ where: { bookingId } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async listByStatus(status: PayoutStatus) {
|
||||||
|
return prisma.payout.findMany({
|
||||||
|
where: { status },
|
||||||
|
orderBy: { heldUntil: "asc" },
|
||||||
|
include: payoutListInclude,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async listForOrganizer(organizerId: string) {
|
||||||
|
return prisma.payout.findMany({
|
||||||
|
where: { organizerId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
trip: {
|
||||||
|
select: { id: true, title: true, date: true, endDate: true, status: true },
|
||||||
|
},
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async countByStatus(status: PayoutStatus) {
|
||||||
|
return prisma.payout.count({ where: { status } });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Payout terbaru untuk satu status — dipakai dashboard admin. */
|
||||||
|
async listRecent(status: PayoutStatus, limit = 3) {
|
||||||
|
return prisma.payout.findMany({
|
||||||
|
where: { status },
|
||||||
|
orderBy: status === "HELD" ? { heldUntil: "asc" } : { updatedAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
heldUntil: true,
|
||||||
|
releasedAt: true,
|
||||||
|
organizer: { select: { id: true, name: true } },
|
||||||
|
trip: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(
|
||||||
|
data: Pick<
|
||||||
|
Prisma.PayoutUncheckedCreateInput,
|
||||||
|
| "bookingId"
|
||||||
|
| "tripId"
|
||||||
|
| "organizerId"
|
||||||
|
| "amount"
|
||||||
|
| "heldUntil"
|
||||||
|
| "bankName"
|
||||||
|
| "bankAccountNumber"
|
||||||
|
| "bankAccountName"
|
||||||
|
>,
|
||||||
|
tx?: Prisma.TransactionClient
|
||||||
|
) {
|
||||||
|
const client = tx ?? prisma;
|
||||||
|
return client.payout.create({ data });
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Prisma.PayoutUncheckedUpdateInput,
|
||||||
|
tx?: Prisma.TransactionClient
|
||||||
|
) {
|
||||||
|
const client = tx ?? prisma;
|
||||||
|
return client.payout.update({ where: { id }, data });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Cari semua HELD payout yang sudah lewat heldUntil & trip-nya COMPLETED. */
|
||||||
|
async findEligibleForRelease(now: Date) {
|
||||||
|
return prisma.payout.findMany({
|
||||||
|
where: {
|
||||||
|
status: "HELD",
|
||||||
|
heldUntil: { lte: now },
|
||||||
|
trip: { status: "COMPLETED" },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PayoutWithRelations = Awaited<ReturnType<typeof payoutRepo.findById>>;
|
||||||
@@ -49,6 +49,36 @@ export const refundRepo = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async countByStatus(
|
||||||
|
status: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
|
||||||
|
) {
|
||||||
|
return prisma.refund.count({ where: { status } });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Refund terbaru untuk satu status — dipakai dashboard admin. */
|
||||||
|
async listRecent(
|
||||||
|
status: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED",
|
||||||
|
limit = 3
|
||||||
|
) {
|
||||||
|
return prisma.refund.findMany({
|
||||||
|
where: { status },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
reason: true,
|
||||||
|
createdAt: true,
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
trip: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async listByBooking(bookingId: string) {
|
async listByBooking(bookingId: string) {
|
||||||
return prisma.refund.findMany({
|
return prisma.refund.findMany({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ export const tripRepo = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
images: { orderBy: { order: "asc" } },
|
images: { orderBy: { order: "asc" } },
|
||||||
|
itineraryItems: {
|
||||||
|
orderBy: [{ day: "asc" }, { order: "asc" }],
|
||||||
|
},
|
||||||
participants: {
|
participants: {
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -1,23 +1,4 @@
|
|||||||
import { Prisma } from "@/app/generated/prisma/client";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||||
import { paymentRepo } from "@/server/repositories/payment.repo";
|
|
||||||
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
|
||||||
|
|
||||||
const SERIAL_TX_ATTEMPTS = 6;
|
|
||||||
|
|
||||||
function isSerializationConflict(err: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof err === "object" &&
|
|
||||||
err !== null &&
|
|
||||||
"code" in err &&
|
|
||||||
(err as { code: string }).code === "P2034"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function manualOrderId(bookingId: string): string {
|
|
||||||
return `manual-${bookingId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bookingService = {
|
export const bookingService = {
|
||||||
async getByParticipantId(participantId: string) {
|
async getByParticipantId(participantId: string) {
|
||||||
@@ -27,196 +8,4 @@ export const bookingService = {
|
|||||||
async getByTripAndUser(tripId: string, userId: string) {
|
async getByTripAndUser(tripId: string, userId: string) {
|
||||||
return bookingRepo.findByTripAndUser(tripId, userId);
|
return bookingRepo.findByTripAndUser(tripId, userId);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Peserta tandai sudah transfer manual. Idempotent: kalau sudah ada Payment
|
|
||||||
* MANUAL aktif, biarkan; kalau Booking sudah PAID, tolak.
|
|
||||||
*/
|
|
||||||
async markPaidManual(bookingId: string, userId: string) {
|
|
||||||
let lastErr: unknown;
|
|
||||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
|
||||||
try {
|
|
||||||
return await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
const booking = await tx.booking.findUnique({
|
|
||||||
where: { id: bookingId },
|
|
||||||
include: { trip: { select: { price: true, date: true } } },
|
|
||||||
});
|
|
||||||
if (!booking) {
|
|
||||||
throw new Error("Booking tidak ditemukan");
|
|
||||||
}
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new Error("Booking ini bukan milikmu");
|
|
||||||
}
|
|
||||||
if (booking.amount <= 0) {
|
|
||||||
throw new Error(
|
|
||||||
"Booking ini tidak butuh pembayaran (gratis)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (booking.status === "PAID") {
|
|
||||||
throw new Error("Pembayaran sudah dikonfirmasi");
|
|
||||||
}
|
|
||||||
if (booking.status !== "AWAITING_PAY") {
|
|
||||||
throw new Error(
|
|
||||||
"Booking belum siap menerima pembayaran (tunggu approve organizer)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isTripDepartureDayPast(booking.trip.date)) {
|
|
||||||
throw new Error(
|
|
||||||
"Trip sudah lewat tanggal berangkat — pembayaran ditutup"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await tx.payment.findFirst({
|
|
||||||
where: {
|
|
||||||
bookingId,
|
|
||||||
provider: "MANUAL",
|
|
||||||
status: { in: ["PENDING", "AWAITING"] },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing && existing.status === "AWAITING") {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payment = existing
|
|
||||||
? await tx.payment.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: { status: "AWAITING" },
|
|
||||||
})
|
|
||||||
: await tx.payment.create({
|
|
||||||
data: {
|
|
||||||
bookingId,
|
|
||||||
provider: "MANUAL",
|
|
||||||
externalOrderId: manualOrderId(bookingId),
|
|
||||||
amount: booking.amount,
|
|
||||||
status: "AWAITING",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backward-compat: tetap update timestamp di TripParticipant
|
|
||||||
// selama UI lama masih membaca kolom ini.
|
|
||||||
await tx.tripParticipant.update({
|
|
||||||
where: { id: booking.participantId },
|
|
||||||
data: { markedPaidAt: new Date() },
|
|
||||||
});
|
|
||||||
|
|
||||||
return payment;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
|
||||||
maxWait: 5000,
|
|
||||||
timeout: 15000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
lastErr = e;
|
|
||||||
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastErr instanceof Error
|
|
||||||
? lastErr
|
|
||||||
: new Error("Gagal menandai pembayaran. Coba lagi sebentar.");
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Organizer konfirmasi pembayaran manual masuk.
|
|
||||||
* Idempotent: kalau sudah PAID, tolak (UI lama bisa muncul tombol dua kali).
|
|
||||||
*/
|
|
||||||
async confirmPaidManual(bookingId: string, organizerId: string) {
|
|
||||||
let lastErr: unknown;
|
|
||||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
|
||||||
try {
|
|
||||||
return await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
const booking = await tx.booking.findUnique({
|
|
||||||
where: { id: bookingId },
|
|
||||||
include: { trip: { select: { organizerId: true, price: true } } },
|
|
||||||
});
|
|
||||||
if (!booking) {
|
|
||||||
throw new Error("Booking tidak ditemukan");
|
|
||||||
}
|
|
||||||
if (booking.trip.organizerId !== organizerId) {
|
|
||||||
throw new Error(
|
|
||||||
"Hanya organizer trip ini yang bisa mengonfirmasi pembayaran"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (booking.amount <= 0) {
|
|
||||||
throw new Error(
|
|
||||||
"Booking ini gratis — tidak ada pembayaran yang perlu dikonfirmasi"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (booking.status === "PAID") {
|
|
||||||
throw new Error("Pembayaran sudah dikonfirmasi sebelumnya");
|
|
||||||
}
|
|
||||||
|
|
||||||
const awaitingPayment = await tx.payment.findFirst({
|
|
||||||
where: {
|
|
||||||
bookingId,
|
|
||||||
provider: "MANUAL",
|
|
||||||
status: "AWAITING",
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!awaitingPayment) {
|
|
||||||
throw new Error(
|
|
||||||
"Peserta belum menandai sudah membayar"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
await tx.payment.update({
|
|
||||||
where: { id: awaitingPayment.id },
|
|
||||||
data: {
|
|
||||||
status: "PAID",
|
|
||||||
paidAt: now,
|
|
||||||
method: "manual_transfer",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.booking.update({
|
|
||||||
where: { id: bookingId },
|
|
||||||
data: { status: "PAID" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backward-compat: tetap update timestamp di TripParticipant.
|
|
||||||
await tx.tripParticipant.update({
|
|
||||||
where: { id: booking.participantId },
|
|
||||||
data: { paymentConfirmedAt: now },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ok: true as const };
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
|
||||||
maxWait: 5000,
|
|
||||||
timeout: 15000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
lastErr = e;
|
|
||||||
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastErr instanceof Error
|
|
||||||
? lastErr
|
|
||||||
: new Error("Gagal mengonfirmasi pembayaran. Coba lagi sebentar.");
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Daftar booking yang masih menunggu konfirmasi organizer di trip tertentu.
|
|
||||||
* Dipakai OrganizerPaymentQueue.
|
|
||||||
*/
|
|
||||||
async getAwaitingManualForTrip(tripId: string) {
|
|
||||||
return bookingRepo.findAwaitingManualConfirmation(tripId);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
+229
-133
@@ -3,11 +3,14 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import {
|
import {
|
||||||
MIDTRANS,
|
MIDTRANS,
|
||||||
createSnapTransaction,
|
createSnapTransaction,
|
||||||
|
fetchMidtransTransactionStatus,
|
||||||
mapMidtransStatus,
|
mapMidtransStatus,
|
||||||
verifyMidtransSignature,
|
verifyMidtransSignature,
|
||||||
|
type MidtransTransactionStatus,
|
||||||
type MidtransWebhookBody,
|
type MidtransWebhookBody,
|
||||||
} from "@/lib/midtrans";
|
} from "@/lib/midtrans";
|
||||||
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||||
|
import { payoutService } from "@/server/services/payout.service";
|
||||||
|
|
||||||
const SERIAL_TX_ATTEMPTS = 6;
|
const SERIAL_TX_ATTEMPTS = 6;
|
||||||
|
|
||||||
@@ -30,11 +33,166 @@ export interface StartMidtransResult {
|
|||||||
snapJsUrl: string;
|
snapJsUrl: string;
|
||||||
clientKey: string;
|
clientKey: string;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
|
orderId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebhookOutcome =
|
export type ApplyOutcome =
|
||||||
| { ok: true; status: "updated" | "skipped" | "ignored" | "booking_conflict" }
|
| { ok: true; status: "updated" | "skipped" | "ignored" | "booking_conflict" }
|
||||||
| { ok: false; reason: "signature_mismatch" | "amount_mismatch" };
|
| { ok: false; reason: "amount_mismatch" };
|
||||||
|
|
||||||
|
export type WebhookOutcome =
|
||||||
|
| ApplyOutcome
|
||||||
|
| { ok: false; reason: "signature_mismatch" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bentuk minimum yang dibutuhkan oleh `applyGatewayStatus` — bisa berasal dari
|
||||||
|
* webhook callback (Midtrans → kita) atau dari hasil GET /v2/{order_id}/status
|
||||||
|
* (kita → Midtrans saat rekonsiliasi). Bedanya cuma asal dan apakah signature
|
||||||
|
* perlu dicek.
|
||||||
|
*/
|
||||||
|
interface GatewayUpdatePayload {
|
||||||
|
order_id: string;
|
||||||
|
gross_amount: string;
|
||||||
|
transaction_status: string;
|
||||||
|
fraud_status?: string | null;
|
||||||
|
transaction_id?: string;
|
||||||
|
payment_type?: string;
|
||||||
|
/** Snapshot mentah untuk audit trail di `Payment.rawCallback`. */
|
||||||
|
rawSource: Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State machine: terjemahkan status dari gateway ke perubahan Payment + Booking
|
||||||
|
* di DB. Idempotent: kalau payment sudah final, hanya update rawCallback.
|
||||||
|
* Tidak melakukan auth — caller wajib pastikan source-nya terpercaya
|
||||||
|
* (signature webhook valid, atau response Midtrans Core API).
|
||||||
|
*/
|
||||||
|
async function applyGatewayStatus(
|
||||||
|
update: GatewayUpdatePayload
|
||||||
|
): Promise<ApplyOutcome> {
|
||||||
|
const payment = await prisma.payment.findUnique({
|
||||||
|
where: { externalOrderId: update.order_id },
|
||||||
|
include: { booking: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return { ok: true, status: "ignored" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountFromGateway = Math.round(Number(update.gross_amount));
|
||||||
|
if (
|
||||||
|
Number.isNaN(amountFromGateway) ||
|
||||||
|
amountFromGateway !== payment.amount
|
||||||
|
) {
|
||||||
|
await prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: {
|
||||||
|
rawCallback: update.rawSource,
|
||||||
|
rejectionReason: `Amount mismatch: gateway=${update.gross_amount}, expected=${payment.amount}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: false, reason: "amount_mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalStatuses = new Set([
|
||||||
|
"PAID",
|
||||||
|
"FAILED",
|
||||||
|
"EXPIRED",
|
||||||
|
"CANCELLED",
|
||||||
|
"REFUNDED",
|
||||||
|
]);
|
||||||
|
if (finalStatuses.has(payment.status)) {
|
||||||
|
await prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: { rawCallback: update.rawSource },
|
||||||
|
});
|
||||||
|
return { ok: true, status: "skipped" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = mapMidtransStatus(
|
||||||
|
update.transaction_status,
|
||||||
|
update.fraud_status ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const currentBooking = await tx.booking.findUnique({
|
||||||
|
where: { id: payment.bookingId },
|
||||||
|
select: { status: true, participantId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingInConflictState =
|
||||||
|
currentBooking?.status === "CANCELLED" ||
|
||||||
|
currentBooking?.status === "REFUNDED" ||
|
||||||
|
currentBooking?.status === "EXPIRED";
|
||||||
|
|
||||||
|
const conflictNote =
|
||||||
|
bookingInConflictState && newStatus === "PAID"
|
||||||
|
? `Gateway PAID but Booking is ${currentBooking.status}. Manual review required (potential refund).`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await tx.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: {
|
||||||
|
status: newStatus,
|
||||||
|
externalTxId: update.transaction_id ?? null,
|
||||||
|
method: update.payment_type ?? null,
|
||||||
|
rawCallback: update.rawSource,
|
||||||
|
paidAt: newStatus === "PAID" ? now : null,
|
||||||
|
failedAt:
|
||||||
|
newStatus === "FAILED" || newStatus === "EXPIRED" ? now : null,
|
||||||
|
rejectionReason: conflictNote,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newStatus === "PAID" && !bookingInConflictState) {
|
||||||
|
await tx.booking.update({
|
||||||
|
where: { id: payment.bookingId },
|
||||||
|
data: { status: "PAID" },
|
||||||
|
});
|
||||||
|
await tx.tripParticipant.update({
|
||||||
|
where: { id: payment.booking.participantId },
|
||||||
|
data: { paymentConfirmedAt: now, markedPaidAt: now },
|
||||||
|
});
|
||||||
|
await payoutService.createForPaidBooking(tx, {
|
||||||
|
bookingId: payment.bookingId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||||
|
maxWait: 5000,
|
||||||
|
timeout: 15000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalBooking = await prisma.booking.findUnique({
|
||||||
|
where: { id: payment.bookingId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
const isConflict =
|
||||||
|
newStatus === "PAID" && finalBooking?.status !== "PAID";
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: isConflict ? "booking_conflict" : "updated",
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr instanceof Error
|
||||||
|
? lastErr
|
||||||
|
: new Error("Gagal apply status gateway karena konflik transaksi");
|
||||||
|
}
|
||||||
|
|
||||||
export const paymentService = {
|
export const paymentService = {
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +200,11 @@ export const paymentService = {
|
|||||||
* MIDTRANS aktif (status PENDING/AWAITING), reuse token yang sudah ada
|
* MIDTRANS aktif (status PENDING/AWAITING), reuse token yang sudah ada
|
||||||
* selama belum expired.
|
* selama belum expired.
|
||||||
*/
|
*/
|
||||||
async startMidtransPayment(bookingId: string, userId: string): Promise<StartMidtransResult> {
|
async startMidtransPayment(
|
||||||
|
bookingId: string,
|
||||||
|
userId: string,
|
||||||
|
options?: { finishUrl?: string }
|
||||||
|
): Promise<StartMidtransResult> {
|
||||||
const clientKey = process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY;
|
const clientKey = process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY;
|
||||||
if (!clientKey) {
|
if (!clientKey) {
|
||||||
throw new Error("NEXT_PUBLIC_MIDTRANS_CLIENT_KEY belum di-set");
|
throw new Error("NEXT_PUBLIC_MIDTRANS_CLIENT_KEY belum di-set");
|
||||||
@@ -101,6 +263,7 @@ export const paymentService = {
|
|||||||
snapJsUrl,
|
snapJsUrl,
|
||||||
clientKey,
|
clientKey,
|
||||||
expiresAt: reusable.expiresAt ?? new Date(now.getTime() + 24 * 3600 * 1000),
|
expiresAt: reusable.expiresAt ?? new Date(now.getTime() + 24 * 3600 * 1000),
|
||||||
|
orderId: reusable.externalOrderId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +311,7 @@ export const paymentService = {
|
|||||||
},
|
},
|
||||||
itemName: booking.trip.title,
|
itemName: booking.trip.title,
|
||||||
expirySeconds,
|
expirySeconds,
|
||||||
|
finishUrl: options?.finishUrl,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Roll back Payment ke FAILED supaya orderId tidak nyangkut PENDING selamanya.
|
// Roll back Payment ke FAILED supaya orderId tidak nyangkut PENDING selamanya.
|
||||||
@@ -176,160 +340,92 @@ export const paymentService = {
|
|||||||
snapJsUrl,
|
snapJsUrl,
|
||||||
clientKey,
|
clientKey,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
orderId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle webhook callback dari Midtrans. Idempotent — boleh dipanggil berulang.
|
* Handle webhook callback dari Midtrans. Idempotent — boleh dipanggil berulang.
|
||||||
* Selalu return result yang webhook handler bisa terjemahkan ke HTTP status.
|
* Verifikasi signature dulu, lalu delegate ke `applyGatewayStatus`.
|
||||||
*/
|
*/
|
||||||
async handleMidtransWebhook(
|
async handleMidtransWebhook(
|
||||||
payload: MidtransWebhookBody
|
payload: MidtransWebhookBody
|
||||||
): Promise<WebhookOutcome> {
|
): Promise<WebhookOutcome> {
|
||||||
const { order_id: orderId, status_code: statusCode, gross_amount: grossAmount, signature_key: signatureKey } = payload;
|
|
||||||
|
|
||||||
const signatureValid = verifyMidtransSignature(
|
const signatureValid = verifyMidtransSignature(
|
||||||
orderId,
|
payload.order_id,
|
||||||
statusCode,
|
payload.status_code,
|
||||||
grossAmount,
|
payload.gross_amount,
|
||||||
signatureKey
|
payload.signature_key
|
||||||
);
|
);
|
||||||
if (!signatureValid) {
|
if (!signatureValid) {
|
||||||
return { ok: false, reason: "signature_mismatch" };
|
return { ok: false, reason: "signature_mismatch" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return applyGatewayStatus({
|
||||||
|
order_id: payload.order_id,
|
||||||
|
gross_amount: payload.gross_amount,
|
||||||
|
transaction_status: payload.transaction_status,
|
||||||
|
fraud_status: payload.fraud_status ?? null,
|
||||||
|
transaction_id: payload.transaction_id,
|
||||||
|
payment_type: payload.payment_type,
|
||||||
|
rawSource: payload as unknown as Prisma.InputJsonValue,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rekonsiliasi server-side: tarik status terkini dari Midtrans Core API,
|
||||||
|
* lalu apply ke DB. Dipakai saat user kembali dari Snap (redirect flow) atau
|
||||||
|
* saat webhook belum sampai (mis. dev di localhost). Aman dipanggil
|
||||||
|
* berulang — idempotent via `applyGatewayStatus`.
|
||||||
|
*
|
||||||
|
* Auth: caller harus pastikan `userId` adalah owner booking; kita verifikasi
|
||||||
|
* di sini lewat lookup payment → booking.userId.
|
||||||
|
*/
|
||||||
|
async reconcileFromGateway(
|
||||||
|
orderId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
status:
|
||||||
|
| "updated"
|
||||||
|
| "skipped"
|
||||||
|
| "ignored"
|
||||||
|
| "booking_conflict"
|
||||||
|
| "not_found";
|
||||||
|
}
|
||||||
|
| { ok: false; reason: "amount_mismatch" | "forbidden" | "not_found" }
|
||||||
|
> {
|
||||||
const payment = await prisma.payment.findUnique({
|
const payment = await prisma.payment.findUnique({
|
||||||
where: { externalOrderId: orderId },
|
where: { externalOrderId: orderId },
|
||||||
include: { booking: true },
|
include: { booking: { select: { userId: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payment) {
|
if (!payment) {
|
||||||
// Order tidak dikenal — return ok supaya Midtrans tidak retry forever.
|
return { ok: false, reason: "not_found" };
|
||||||
return { ok: true, status: "ignored" };
|
}
|
||||||
|
if (payment.booking.userId !== userId) {
|
||||||
|
return { ok: false, reason: "forbidden" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cek amount cocok. gross_amount dari Midtrans format "100000.00".
|
const status = await fetchMidtransTransactionStatus(orderId);
|
||||||
const amountFromGateway = Math.round(Number(grossAmount));
|
if (!status) {
|
||||||
if (
|
return { ok: true, status: "not_found" };
|
||||||
Number.isNaN(amountFromGateway) ||
|
|
||||||
amountFromGateway !== payment.amount
|
|
||||||
) {
|
|
||||||
// Tetap simpan callback mentah untuk audit, tapi jangan ubah status.
|
|
||||||
await prisma.payment.update({
|
|
||||||
where: { id: payment.id },
|
|
||||||
data: {
|
|
||||||
rawCallback: payload as unknown as Prisma.InputJsonValue,
|
|
||||||
rejectionReason: `Amount mismatch: gateway=${grossAmount}, expected=${payment.amount}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { ok: false, reason: "amount_mismatch" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalStatuses = new Set([
|
const result = await applyGatewayStatus({
|
||||||
"PAID",
|
order_id: status.order_id,
|
||||||
"FAILED",
|
gross_amount: status.gross_amount,
|
||||||
"EXPIRED",
|
transaction_status: status.transaction_status,
|
||||||
"CANCELLED",
|
fraud_status: status.fraud_status ?? null,
|
||||||
"REFUNDED",
|
transaction_id: status.transaction_id,
|
||||||
]);
|
payment_type: status.payment_type,
|
||||||
if (finalStatuses.has(payment.status)) {
|
rawSource: status as unknown as Prisma.InputJsonValue,
|
||||||
// Idempotent: skip update tapi tetap log callback baru.
|
|
||||||
await prisma.payment.update({
|
|
||||||
where: { id: payment.id },
|
|
||||||
data: {
|
|
||||||
rawCallback: payload as unknown as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { ok: true, status: "skipped" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const newStatus = mapMidtransStatus(
|
|
||||||
payload.transaction_status,
|
|
||||||
payload.fraud_status ?? null
|
|
||||||
);
|
|
||||||
|
|
||||||
let lastErr: unknown;
|
|
||||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Re-baca booking di dalam transaksi — bisa berubah (mis. user cancel) sejak
|
|
||||||
// outer fetch. Lock-free read tetap aman karena isolasi serializable.
|
|
||||||
const currentBooking = await tx.booking.findUnique({
|
|
||||||
where: { id: payment.bookingId },
|
|
||||||
select: { status: true, participantId: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingInConflictState =
|
return result;
|
||||||
currentBooking?.status === "CANCELLED" ||
|
|
||||||
currentBooking?.status === "REFUNDED" ||
|
|
||||||
currentBooking?.status === "EXPIRED";
|
|
||||||
|
|
||||||
// Selalu simpan callback Payment (truth dari gateway). Kalau booking
|
|
||||||
// di state konflik dan webhook mau set PAID, payment tetap PAID
|
|
||||||
// (uang masuk) tapi tidak propagate ke booking — flag untuk manual review/refund.
|
|
||||||
const conflictNote =
|
|
||||||
bookingInConflictState && newStatus === "PAID"
|
|
||||||
? `Webhook PAID arrived but Booking is ${currentBooking.status}. Manual review required (potential refund).`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await tx.payment.update({
|
|
||||||
where: { id: payment.id },
|
|
||||||
data: {
|
|
||||||
status: newStatus,
|
|
||||||
externalTxId: payload.transaction_id ?? null,
|
|
||||||
method: payload.payment_type ?? null,
|
|
||||||
rawCallback: payload as unknown as Prisma.InputJsonValue,
|
|
||||||
paidAt: newStatus === "PAID" ? now : null,
|
|
||||||
failedAt:
|
|
||||||
newStatus === "FAILED" || newStatus === "EXPIRED" ? now : null,
|
|
||||||
rejectionReason: conflictNote,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newStatus === "PAID" && !bookingInConflictState) {
|
|
||||||
await tx.booking.update({
|
|
||||||
where: { id: payment.bookingId },
|
|
||||||
data: { status: "PAID" },
|
|
||||||
});
|
|
||||||
// Backward-compat: sync timestamp di TripParticipant.
|
|
||||||
await tx.tripParticipant.update({
|
|
||||||
where: { id: payment.booking.participantId },
|
|
||||||
data: { paymentConfirmedAt: now, markedPaidAt: now },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
|
||||||
maxWait: 5000,
|
|
||||||
timeout: 15000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Recheck post-tx untuk reporting outcome.
|
|
||||||
const finalBooking = await prisma.booking.findUnique({
|
|
||||||
where: { id: payment.bookingId },
|
|
||||||
select: { status: true },
|
|
||||||
});
|
|
||||||
const isConflict =
|
|
||||||
newStatus === "PAID" &&
|
|
||||||
finalBooking?.status !== "PAID";
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: isConflict ? "booking_conflict" : "updated",
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
lastErr = e;
|
|
||||||
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastErr instanceof Error
|
|
||||||
? lastErr
|
|
||||||
: new Error("Webhook gagal diproses karena konflik transaksi");
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
|
||||||
|
export const _internal = { applyGatewayStatus };
|
||||||
|
export type { MidtransTransactionStatus };
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { Prisma } from "@/app/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||||
|
|
||||||
|
const SERIAL_TX_ATTEMPTS = 6;
|
||||||
|
|
||||||
|
function isSerializationConflict(err: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof err === "object" &&
|
||||||
|
err !== null &&
|
||||||
|
"code" in err &&
|
||||||
|
(err as { code: string }).code === "P2034"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSerializable<T>(
|
||||||
|
fn: (tx: Prisma.TransactionClient) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(fn, {
|
||||||
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||||
|
maxWait: 5000,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr instanceof Error
|
||||||
|
? lastErr
|
||||||
|
: new Error("Gagal memproses payout. Coba lagi sebentar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buffer hari setelah trip selesai sebelum payout boleh ditransfer. */
|
||||||
|
export const PAYOUT_HOLD_BUFFER_DAYS = 3;
|
||||||
|
|
||||||
|
/** Hitung heldUntil dari trip date. Pakai endDate kalau ada, kalau tidak pakai date. */
|
||||||
|
function computeHeldUntil(tripDate: Date, tripEndDate: Date | null): Date {
|
||||||
|
const baseDate = tripEndDate ?? tripDate;
|
||||||
|
const result = new Date(baseDate);
|
||||||
|
result.setUTCDate(result.getUTCDate() + PAYOUT_HOLD_BUFFER_DAYS);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const payoutService = {
|
||||||
|
/**
|
||||||
|
* Dipanggil saat Booking → PAID (webhook Midtrans atau organizer confirm manual).
|
||||||
|
* Idempotent: kalau Payout untuk booking ini sudah ada, no-op (return existing).
|
||||||
|
*
|
||||||
|
* Snapshot bank info dari OrganizerVerification (kalau ada) supaya audit-friendly
|
||||||
|
* walau organizer ganti bank nanti.
|
||||||
|
*/
|
||||||
|
async createForPaidBooking(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: { bookingId: string }
|
||||||
|
) {
|
||||||
|
const booking = await tx.booking.findUnique({
|
||||||
|
where: { id: input.bookingId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
userId: true,
|
||||||
|
trip: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
organizerId: true,
|
||||||
|
date: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error("Booking tidak ditemukan saat membuat payout");
|
||||||
|
}
|
||||||
|
if (booking.amount <= 0) {
|
||||||
|
// Trip gratis — tidak ada uang yang perlu di-payout.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const existing = await payoutRepo.findByBookingId(booking.id, tx);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bankInfo = await tx.organizerVerification.findUnique({
|
||||||
|
where: { userId: booking.trip.organizerId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
bankName: true,
|
||||||
|
bankAccountNumber: true,
|
||||||
|
bankAccountName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const heldUntil = computeHeldUntil(booking.trip.date, booking.trip.endDate);
|
||||||
|
|
||||||
|
return payoutRepo.create(
|
||||||
|
{
|
||||||
|
bookingId: booking.id,
|
||||||
|
tripId: booking.trip.id,
|
||||||
|
organizerId: booking.trip.organizerId,
|
||||||
|
amount: booking.amount,
|
||||||
|
heldUntil,
|
||||||
|
bankName:
|
||||||
|
bankInfo?.status === "APPROVED" ? bankInfo.bankName : null,
|
||||||
|
bankAccountNumber:
|
||||||
|
bankInfo?.status === "APPROVED" ? bankInfo.bankAccountNumber : null,
|
||||||
|
bankAccountName:
|
||||||
|
bankInfo?.status === "APPROVED" ? bankInfo.bankAccountName : null,
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron-callable: cari semua HELD payout yang sudah lewat heldUntil & trip-nya
|
||||||
|
* COMPLETED, lalu flip ke RELEASED. Idempotent.
|
||||||
|
*/
|
||||||
|
async releaseEligible() {
|
||||||
|
const now = new Date();
|
||||||
|
const eligible = await payoutRepo.findEligibleForRelease(now);
|
||||||
|
if (eligible.length === 0) {
|
||||||
|
return { releasedIds: [] as string[] };
|
||||||
|
}
|
||||||
|
const ids = eligible.map((p) => p.id);
|
||||||
|
await prisma.payout.updateMany({
|
||||||
|
where: { id: { in: ids }, status: "HELD" },
|
||||||
|
data: { status: "RELEASED", releasedAt: now },
|
||||||
|
});
|
||||||
|
return { releasedIds: ids };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** RELEASED → PAID. Catatan/referensi transfer wajib (audit trail). */
|
||||||
|
async markPaid(input: { payoutId: string; adminId: string; adminNote: string }) {
|
||||||
|
if (!input.adminNote.trim()) {
|
||||||
|
throw new Error("Catatan/referensi transfer wajib diisi");
|
||||||
|
}
|
||||||
|
return runSerializable(async (tx) => {
|
||||||
|
const payout = await tx.payout.findUnique({
|
||||||
|
where: { id: input.payoutId },
|
||||||
|
});
|
||||||
|
if (!payout) {
|
||||||
|
throw new Error("Payout tidak ditemukan");
|
||||||
|
}
|
||||||
|
if (payout.status !== "RELEASED") {
|
||||||
|
throw new Error(
|
||||||
|
"Hanya payout RELEASED yang bisa ditandai PAID. Tunggu trip selesai + buffer."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payoutRepo.update(
|
||||||
|
input.payoutId,
|
||||||
|
{
|
||||||
|
status: "PAID",
|
||||||
|
paidAt: new Date(),
|
||||||
|
processedById: input.adminId,
|
||||||
|
adminNote: input.adminNote.trim(),
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel payout — biasanya dipanggil internal saat refund SUCCEEDED penuh
|
||||||
|
* atau trip dibatalkan. Tidak boleh untuk payout yang sudah PAID (uang sudah
|
||||||
|
* keluar ke organizer; admin perlu clawback manual).
|
||||||
|
*/
|
||||||
|
async cancel(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: { payoutId: string; reason: string; adminId?: string | null }
|
||||||
|
) {
|
||||||
|
const payout = await tx.payout.findUnique({
|
||||||
|
where: { id: input.payoutId },
|
||||||
|
});
|
||||||
|
if (!payout) return null;
|
||||||
|
if (payout.status === "CANCELLED") return payout;
|
||||||
|
if (payout.status === "PAID") {
|
||||||
|
// Uang sudah ditransfer — tidak bisa undo otomatis. Catat note saja.
|
||||||
|
return payoutRepo.update(
|
||||||
|
input.payoutId,
|
||||||
|
{
|
||||||
|
adminNote:
|
||||||
|
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
|
||||||
|
`[!] ${input.reason} setelah PAID — perlu clawback manual.`,
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payoutRepo.update(
|
||||||
|
input.payoutId,
|
||||||
|
{
|
||||||
|
status: "CANCELLED",
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
processedById: input.adminId ?? null,
|
||||||
|
adminNote: input.reason,
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refund SUCCEEDED — kurangi nominal payout sesuai nominal refund. Kalau
|
||||||
|
* jatuh ke 0 atau lebih, cancel payout. Dipanggil dari refund.service.
|
||||||
|
*/
|
||||||
|
async applyRefundDelta(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: { bookingId: string; refundAmount: number }
|
||||||
|
) {
|
||||||
|
const payout = await payoutRepo.findByBookingId(input.bookingId, tx);
|
||||||
|
if (!payout) return null;
|
||||||
|
if (payout.status === "CANCELLED") return payout;
|
||||||
|
if (payout.status === "PAID") {
|
||||||
|
// Uang sudah ditransfer ke organizer — flag untuk clawback manual.
|
||||||
|
return payoutRepo.update(
|
||||||
|
payout.id,
|
||||||
|
{
|
||||||
|
adminNote:
|
||||||
|
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
|
||||||
|
`[!] Refund Rp${input.refundAmount.toLocaleString("id-ID")} terjadi setelah payout PAID. Perlu clawback manual dari organizer.`,
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAmount = payout.amount - input.refundAmount;
|
||||||
|
if (nextAmount <= 0) {
|
||||||
|
return payoutRepo.update(
|
||||||
|
payout.id,
|
||||||
|
{
|
||||||
|
status: "CANCELLED",
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
adminNote:
|
||||||
|
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
|
||||||
|
"Dibatalkan otomatis karena refund penuh.",
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payoutRepo.update(payout.id, { amount: nextAmount }, tx);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { refundRepo } from "@/server/repositories/refund.repo";
|
import { refundRepo } from "@/server/repositories/refund.repo";
|
||||||
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
|
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
|
||||||
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||||
|
import { payoutService } from "@/server/services/payout.service";
|
||||||
|
|
||||||
const SERIAL_TX_ATTEMPTS = 6;
|
const SERIAL_TX_ATTEMPTS = 6;
|
||||||
|
|
||||||
@@ -236,6 +237,12 @@ export const refundService = {
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Escrow: kurangi (atau cancel) payout organizer sesuai nominal refund.
|
||||||
|
await payoutService.applyRefundDelta(tx, {
|
||||||
|
bookingId: refund.bookingId,
|
||||||
|
refundAmount: refund.amount,
|
||||||
|
});
|
||||||
|
|
||||||
const totalRefunded = await refundRepo.sumSucceededAmount(
|
const totalRefunded = await refundRepo.sumSucceededAmount(
|
||||||
refund.bookingId,
|
refund.bookingId,
|
||||||
tx
|
tx
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
|
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
|
||||||
import { participantRepo } from "@/server/repositories/participant.repo";
|
import { participantRepo } from "@/server/repositories/participant.repo";
|
||||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||||
import { bookingService } from "@/server/services/booking.service";
|
|
||||||
import { refundService } from "@/server/services/refund.service";
|
import { refundService } from "@/server/services/refund.service";
|
||||||
|
import { payoutService } from "@/server/services/payout.service";
|
||||||
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||||
import { LIMITS } from "@/lib/limits";
|
import { LIMITS } from "@/lib/limits";
|
||||||
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
|
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||||
|
import type { ItineraryItemInput } from "@/lib/itinerary";
|
||||||
|
|
||||||
const SERIAL_TX_ATTEMPTS = 6;
|
const SERIAL_TX_ATTEMPTS = 6;
|
||||||
|
|
||||||
@@ -28,7 +30,6 @@ interface CreateTripInput {
|
|||||||
destination: string;
|
destination: string;
|
||||||
location: string;
|
location: string;
|
||||||
meetingPoint?: string;
|
meetingPoint?: string;
|
||||||
itinerary?: string;
|
|
||||||
whatsIncluded?: string;
|
whatsIncluded?: string;
|
||||||
whatsExcluded?: string;
|
whatsExcluded?: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -38,6 +39,7 @@ interface CreateTripInput {
|
|||||||
vibe?: Vibe;
|
vibe?: Vibe;
|
||||||
organizerId: string;
|
organizerId: string;
|
||||||
imageUrls?: string[];
|
imageUrls?: string[];
|
||||||
|
itineraryItems?: ItineraryItemInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tripService = {
|
export const tripService = {
|
||||||
@@ -73,6 +75,18 @@ export const tripService = {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const itineraryItems = input.itineraryItems?.length
|
||||||
|
? {
|
||||||
|
create: input.itineraryItems.map((item, i) => ({
|
||||||
|
day: item.day,
|
||||||
|
startTime: item.startTime,
|
||||||
|
endTime: item.endTime ?? null,
|
||||||
|
activity: item.activity.trim(),
|
||||||
|
order: i,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const tripData = {
|
const tripData = {
|
||||||
category: input.category,
|
category: input.category,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
@@ -80,7 +94,6 @@ export const tripService = {
|
|||||||
destination: input.destination,
|
destination: input.destination,
|
||||||
location: input.location,
|
location: input.location,
|
||||||
meetingPoint: input.meetingPoint,
|
meetingPoint: input.meetingPoint,
|
||||||
itinerary: input.itinerary,
|
|
||||||
whatsIncluded: input.whatsIncluded,
|
whatsIncluded: input.whatsIncluded,
|
||||||
whatsExcluded: input.whatsExcluded,
|
whatsExcluded: input.whatsExcluded,
|
||||||
date: input.date,
|
date: input.date,
|
||||||
@@ -90,6 +103,7 @@ export const tripService = {
|
|||||||
vibe: input.vibe,
|
vibe: input.vibe,
|
||||||
organizer: { connect: { id: input.organizerId } },
|
organizer: { connect: { id: input.organizerId } },
|
||||||
images,
|
images,
|
||||||
|
itineraryItems,
|
||||||
} satisfies Prisma.TripCreateInput;
|
} satisfies Prisma.TripCreateInput;
|
||||||
|
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
@@ -375,30 +389,6 @@ export const tripService = {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
},
|
},
|
||||||
|
|
||||||
async markParticipantPayment(tripId: string, userId: string) {
|
|
||||||
const trip = await tripRepo.findById(tripId);
|
|
||||||
if (!trip) {
|
|
||||||
throw new Error("Trip tidak ditemukan");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFreeTrip(trip)) {
|
|
||||||
throw new Error(
|
|
||||||
"Trip ini gratis — tidak ada pembayaran yang perlu ditandai"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTripDepartureDayPast(trip.date)) {
|
|
||||||
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
|
|
||||||
}
|
|
||||||
|
|
||||||
const booking = await bookingRepo.findByTripAndUser(tripId, userId);
|
|
||||||
if (!booking || booking.status === "CANCELLED") {
|
|
||||||
throw new Error("Kamu tidak terdaftar di trip ini");
|
|
||||||
}
|
|
||||||
|
|
||||||
return bookingService.markPaidManual(booking.id, userId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-complete trip yang sudah lewat. Dipakai cron harian.
|
* Auto-complete trip yang sudah lewat. Dipakai cron harian.
|
||||||
*
|
*
|
||||||
@@ -413,30 +403,6 @@ export const tripService = {
|
|||||||
return tripRepo.bulkCompletePastTrips(cutoff);
|
return tripRepo.bulkCompletePastTrips(cutoff);
|
||||||
},
|
},
|
||||||
|
|
||||||
async confirmParticipantPayment(
|
|
||||||
tripId: string,
|
|
||||||
participantId: string,
|
|
||||||
organizerId: string
|
|
||||||
) {
|
|
||||||
const trip = await tripRepo.findById(tripId);
|
|
||||||
if (!trip) {
|
|
||||||
throw new Error("Trip tidak ditemukan");
|
|
||||||
}
|
|
||||||
if (trip.organizerId !== organizerId) {
|
|
||||||
throw new Error("Hanya organizer yang bisa mengonfirmasi pembayaran");
|
|
||||||
}
|
|
||||||
if (isFreeTrip(trip)) {
|
|
||||||
throw new Error("Trip ini gratis — tidak ada pembayaran yang perlu dikonfirmasi");
|
|
||||||
}
|
|
||||||
|
|
||||||
const booking = await bookingRepo.findByParticipantId(participantId);
|
|
||||||
if (!booking || booking.tripId !== tripId) {
|
|
||||||
throw new Error("Booking tidak ditemukan");
|
|
||||||
}
|
|
||||||
|
|
||||||
return bookingService.confirmPaidManual(booking.id, organizerId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
|
* Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
|
||||||
* serializable transaction:
|
* serializable transaction:
|
||||||
@@ -546,6 +512,16 @@ export const tripService = {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
refundsCreated.push(refund.id);
|
refundsCreated.push(refund.id);
|
||||||
|
|
||||||
|
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
|
||||||
|
// booking ini. Payout PAID di-flag clawback otomatis.
|
||||||
|
const payout = await payoutRepo.findByBookingId(b.id, tx);
|
||||||
|
if (payout) {
|
||||||
|
await payoutService.cancel(tx, {
|
||||||
|
payoutId: payout.id,
|
||||||
|
reason: "Trip dibatalkan organizer.",
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED.
|
// PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED.
|
||||||
await tx.booking.update({
|
await tx.booking.update({
|
||||||
|
|||||||
Vendored
+1
@@ -8,6 +8,7 @@ declare module "next-auth" {
|
|||||||
email: string;
|
email: string;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
acceptedTermsAndPrivacy: boolean;
|
acceptedTermsAndPrivacy: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user