Compare commits
38 Commits
a07942c4b4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 88353d5d06 | |||
| 0e7bb07772 | |||
| 3268a6284e | |||
| 73406d0b86 | |||
| 4c449a572a | |||
| 9022f983a2 | |||
| 6b8f9dec5d | |||
| e6a032e8e0 | |||
| 81a0c2c6c8 | |||
| 03887fb1cd | |||
| f84d0e3726 | |||
| 22e66ce493 | |||
| d4e5d6be38 | |||
| b4d39d86ae | |||
| ef7aa528d4 | |||
| 5d095151e4 | |||
| db71159613 | |||
| cb03967deb | |||
| 306396ae43 | |||
| b836d08b10 | |||
| 57f7764bf5 | |||
| da217c2946 | |||
| 43ea725107 | |||
| 1200bf85c2 | |||
| d5842b984b | |||
| bf5c97c442 | |||
| f0ce22bbb8 | |||
| bc4973a594 | |||
| b844ebdfac | |||
| ea63f56e97 | |||
| 244a6da9bb | |||
| 6e02f2f0d7 | |||
| c52b12daad | |||
| 4bcb93e283 | |||
| e1966b69f1 | |||
| c4efe4453b | |||
| b599d01eea | |||
| 958514d575 |
@@ -10,7 +10,8 @@
|
||||
"PowerShell(npx prisma generate 2>&1)",
|
||||
"PowerShell(npx tsc --noEmit 2>&1)",
|
||||
"PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)",
|
||||
"PowerShell(npx eslint server lib features app 2>&1)"
|
||||
"PowerShell(npx eslint server lib features app 2>&1)",
|
||||
"Bash(npx eslint *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ KYC_ENCRYPTION_KEY=
|
||||
KYC_NIK_PEPPER=
|
||||
# Absolute path for private KYC uploads (default: <cwd>/uploads/private)
|
||||
KYC_UPLOAD_DIR=
|
||||
# Absolute path for public trip image uploads (default: <cwd>/uploads/trips)
|
||||
# Pakai volume persisten — file di sini harus selamat saat redeploy/restart.
|
||||
TRIP_UPLOAD_DIR=
|
||||
|
||||
GOOGLE_CLIENT_ID="xxxxxxxx"
|
||||
GOOGLE_CLIENT_SECRET="xxxxxxxx"
|
||||
@@ -36,3 +39,24 @@ NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
|
||||
# openssl rand -hex 32
|
||||
# Setup detail: lihat docs/CRON_SETUP.md
|
||||
CRON_SECRET=
|
||||
|
||||
|
||||
# === Admin alerting (opsional) ===
|
||||
# Discord webhook URL untuk push notif saat cron FAILED. Tanpa env, `notifyAdmins`
|
||||
# no-op — admin tetap bisa cek manual di /admin/system. Cara setup:
|
||||
# 1. Discord channel internal → Edit Channel → Integrations → Webhooks → New
|
||||
# 2. Copy "Webhook URL", paste di sini
|
||||
# Format: https://discord.com/api/webhooks/<id>/<token>
|
||||
ADMIN_ALERT_WEBHOOK_URL=
|
||||
|
||||
|
||||
# === Email notifications (Resend) ===
|
||||
# API key Resend untuk kirim email transaksional (KYC, refund, payment, suspend).
|
||||
# Tanpa env, sync send di-skip dan semua email di-queue di DB (status PENDING).
|
||||
# Setelah env di-set, cron `/api/cron/process-email-jobs` akan drain queue.
|
||||
# Sign up: https://resend.com → API Keys
|
||||
RESEND_API_KEY=
|
||||
# Email sender — format RFC 5322 "Display Name <email@domain>".
|
||||
# Domain harus diverifikasi di Resend dashboard (SPF + DKIM).
|
||||
# Default `onboarding@resend.dev` cocok untuk dev/testing.
|
||||
EMAIL_FROM="SeTrip <onboarding@resend.dev>"
|
||||
+6
-2
@@ -31,9 +31,13 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# 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.
|
||||
# runtime uploads — KYC (encrypted, private) & trip images (public, served via
|
||||
# /api/trip-images). User data, not source: keep out of git, back up separately.
|
||||
/uploads/
|
||||
|
||||
# vercel
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Setrip — Admin Roadmap (Index) · ✅ ALL DELIVERED
|
||||
|
||||
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`.
|
||||
|
||||
> **Status:** 6 dari 6 roadmap area selesai (4 fully, 2 dengan minor skip yang dijelaskan di archive masing-masing). Detail lengkap per area di [docs/archive/](docs/archive/).
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
| **Global search** | Search bar di sidebar — by email, order_id, cuid, fuzzy trip/user | [features/admin/components/admin-search-bar.tsx](features/admin/components/admin-search-bar.tsx) |
|
||||
| **Trips** | List + search + detail; force-cancel dengan auto-refund (admin intervention) | [app/admin/trips/](app/admin/trips/) |
|
||||
| **Users** | List + search + filter; detail dengan trip + booking history; suspend/unsuspend; manual verify (override KYC); analytics | [app/admin/users/](app/admin/users/) |
|
||||
| **Bookings detail** | Timeline lintas Payment + Refund + Payout, raw callback viewer, Midtrans reconcile | [app/admin/bookings/[id]/page.tsx](app/admin/bookings/[id]/page.tsx) |
|
||||
| **Verifikasi KYC** | Approve / Reject / Reopen REJECTED / Request re-upload (lebih lembut dari reject) / Manual override; filter date range + reviewer; CSV export | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
|
||||
| **Refund** | Create manual, approve, reject, mark SUCCEEDED/FAILED; filter date/reviewer/reason; link ke booking timeline; CSV export | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
|
||||
| **Payout** | View per status, mark PAID; filter date/processor; link ke booking timeline; CSV export | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
|
||||
| **Audit Log** | View semua action admin lintas entity; filter by admin/entity/action/date | [app/admin/audit-log/page.tsx](app/admin/audit-log/page.tsx) |
|
||||
| **System Health** | Status cron jobs (last run, health badge), 20 recent runs, **stale state alerts** (Payment AWAITING > 25h, Payout HELD overdue, Refund stuck), **Discord webhook** untuk cron FAILED | [app/admin/system/page.tsx](app/admin/system/page.tsx) |
|
||||
|
||||
**Aksi mutating yang diblokir untuk suspended user:** sign-in (NextAuth), `createTripAction`, `joinTripAction`. Trip public list otomatis sembunyikan organizer suspended.
|
||||
|
||||
**Audit trail otomatis:** semua aksi admin (suspend, force-cancel, reconcile, approve/reject/reopen/request-reupload/manual-override verification, create/decide refund, mark payout PAID) tercatat di `AdminActionLog` via `auditLog.record()`.
|
||||
|
||||
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 — status final
|
||||
|
||||
| Roadmap | Prioritas | Status | Archive |
|
||||
|---|---|---|---|
|
||||
| Trip Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) |
|
||||
| Payment Operations | 🔴 HIGH | ✅ Delivered | [ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) |
|
||||
| Audit & Investigation | 🔴 HIGH | ✅ Delivered | [ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) |
|
||||
| User Management | 🟡 MEDIUM | ✅ Delivered | [ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) |
|
||||
| Verification | 🟡 MEDIUM | ✅ Delivered | [ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) |
|
||||
| System Health | 🟡 MEDIUM | ✅ Delivered | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
|
||||
|
||||
**Minor item yang sengaja di-skip** (dengan justifikasi di archive):
|
||||
- Audit Phase 2.5 — page `/admin/search` full-results (dropdown 10 hit cukup).
|
||||
- Verification Phase 3.1 — snapshot full data per submission (counter + array rejection cukup).
|
||||
- System Health Phase 4.3 — push notif harian dari stale alerts (admin sudah lihat banner di `/admin/system`).
|
||||
- Trip Ops Phase 3 — trip edit override (skip MVP, evaluate ulang saat ada keluhan konkret).
|
||||
|
||||
---
|
||||
|
||||
## Tindakan manual setelah deploy versi final
|
||||
|
||||
```bash
|
||||
# Apply 5 migration baru (urutan time-stamp tidak masalah, prisma resolve):
|
||||
# - 20260518150000_add_trip_admin_cancel
|
||||
# - 20260518160000_add_user_suspension
|
||||
# - 20260518170000_add_cron_run
|
||||
# - 20260518180000_add_admin_action_log
|
||||
# - 20260518190000_verification_enhancements
|
||||
npx prisma migrate deploy
|
||||
|
||||
# (Opsional) Set env Discord webhook untuk alert cron failed
|
||||
echo 'ADMIN_ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/...' >> .env
|
||||
|
||||
# Restart Next.js / PM2 supaya Prisma client + env baru ter-load
|
||||
pm2 restart setrip --update-env
|
||||
```
|
||||
|
||||
Brief admin tentang kapabilitas baru (lihat archive masing-masing untuk detail SOP):
|
||||
|
||||
- **Global search** di sidebar — ketik email, order_id, atau cuid; auto-detect ke detail page yang tepat.
|
||||
- **Force-cancel trip** di `/admin/trips/[id]` — saat organizer unreachable / dispute.
|
||||
- **Reconcile Midtrans** di `/admin/bookings/[id]` — saat peserta lapor "sudah bayar tapi status belum update".
|
||||
- **Suspend user** di `/admin/users/[id]` — untuk scam/harassment.
|
||||
- **Manual verify** di `/admin/users/[id]` — partner trusted, bypass KYC, ter-flag jelas.
|
||||
- **Reopen verification** di REJECTED card — organizer kirim ulang foto via email/WA.
|
||||
- **Request re-upload** di PENDING card — lebih lembut dari reject; organizer dapat banner di `/verify`.
|
||||
- **System status** di `/admin/system` — cek setiap pagi, lihat alert stale + cron health.
|
||||
- **Discord alert** otomatis saat cron FAILED (kalau `ADMIN_ALERT_WEBHOOK_URL` di-set).
|
||||
- **Audit log** di `/admin/audit-log` — bukti compliance untuk audit eksternal.
|
||||
- **CSV export** di refunds/payouts/verifications — laporan keuangan/compliance.
|
||||
- **User stats** di `/admin/users/stats` — total user, signup per minggu.
|
||||
|
||||
---
|
||||
|
||||
## File-file penting yang ditambahkan / diubah
|
||||
|
||||
**Service & helper:**
|
||||
- [server/services/audit-log.service.ts](server/services/audit-log.service.ts) — log polymorphic
|
||||
- [server/services/system-health.service.ts](server/services/system-health.service.ts) — stale state detection
|
||||
- [server/services/admin-search.service.ts](server/services/admin-search.service.ts) — search dispatcher
|
||||
- [server/services/user.service.ts](server/services/user.service.ts) — suspend/unsuspend
|
||||
- [lib/cron-runner.ts](lib/cron-runner.ts) — `runCron()` wrapper
|
||||
- [lib/admin-notify.ts](lib/admin-notify.ts) — Discord webhook helper
|
||||
- [lib/auth-guards.ts](lib/auth-guards.ts) — `requireActiveUser()`
|
||||
- [lib/csv.ts](lib/csv.ts) — CSV builder
|
||||
|
||||
**Model baru:** `AdminActionLog`, `CronRun` + 5 migration baru di `prisma/migrations/`.
|
||||
|
||||
**Admin UI baru:** `/admin/trips`, `/admin/users`, `/admin/bookings/[id]`, `/admin/system`, `/admin/audit-log`, `/admin/users/stats`.
|
||||
|
||||
**API routes baru:** `/api/admin/search`, `/api/admin/export/{refunds,payouts,verifications}`.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Setrip — Email Notifications Roadmap
|
||||
|
||||
Status implementasi notifikasi email transaksional ke user & organizer. Pakai pola yang sama dengan refund/admin roadmap: per-phase checklist, idempotent, auditable.
|
||||
|
||||
> **Prinsip:**
|
||||
> - **Transactional only di MVP** — KYC, refund, payment, account moderation. Marketing/reminder belakangan.
|
||||
> - **Idempotent** — webhook retry / cron rerun tidak boleh double-send. Pakai `idempotencyKey` unique constraint di `EmailSent`.
|
||||
> - **Non-blocking** — server action utama tidak boleh gagal kalau email gateway down. Pattern: try send sync; kalau gagal, enqueue `EmailJob` untuk retry cron.
|
||||
> - **Audit-friendly** — semua email tercatat (sent atau queued) supaya admin bisa cek "kenapa user X belum dapat email Y?".
|
||||
> - **Unsubscribe-aware** — transactional email (refund, payment, suspend) tetap dikirim. Marketing (reminder, social signal) opt-in dengan unsubscribe link.
|
||||
|
||||
**Progress (per 2026-05-20):** PR-E1, E2, E3, E5 ✅ — foundation, transactional email, notifikasi event Phase 2, dan admin email log + retry selesai. PR-E4 ⏳ (marketing/reminder) sengaja ditunda — belum dibutuhkan.
|
||||
|
||||
---
|
||||
|
||||
## Baseline (kondisi sekarang)
|
||||
|
||||
- ❌ Tidak ada email service terintegrasi.
|
||||
- ❌ Tidak ada template engine.
|
||||
- ❌ User & organizer hanya tahu state via UI — kalau tidak buka app, miss event penting (refund cair, KYC approve, dst).
|
||||
|
||||
---
|
||||
|
||||
## Provider choice
|
||||
|
||||
**Resend** — alasan:
|
||||
- Free tier 3000 email/bulan + 100/hari (cukup untuk MVP)
|
||||
- React Email native (kalau mau upgrade dari plain HTML)
|
||||
- API simple (POST `/emails`)
|
||||
- DNS setup ringan (SPF + DKIM auto)
|
||||
|
||||
Alternatif: AWS SES (paling murah at scale), SendGrid, Mailgun. **Decision:** Resend untuk MVP, evaluate ulang saat lebih dari 50k email/bulan.
|
||||
|
||||
**Library dependency:** SKIP — pakai `fetch` ke `https://api.resend.com/emails` directly. Lebih ringkas, satu-satunya dependency-free option.
|
||||
|
||||
---
|
||||
|
||||
## PR-E1 — Foundation: schema + service + cron (MVP wajib) ✅
|
||||
|
||||
**Tujuan:** infrastructure kirim email yang idempotent + retry-able, tanpa nge-block server action.
|
||||
|
||||
**Keputusan asumsi:**
|
||||
- 2 model baru:
|
||||
- `EmailSent` — append-only log dengan `idempotencyKey @unique`. Cek di sini sebelum kirim → cegah double-send.
|
||||
- `EmailJob` — retry queue untuk send yang gagal sync. Status `PENDING/PROCESSING/SUCCESS/FAILED`, attempt counter, max 5 retry exponential.
|
||||
- Service `emailService.send({ to, template, data, idempotencyKey })`:
|
||||
1. Cek `EmailSent` by idempotencyKey → kalau exist, return early.
|
||||
2. Render template ke `{ subject, html }`.
|
||||
3. POST ke Resend.
|
||||
4. Sukses → insert `EmailSent` row.
|
||||
5. Gagal → insert `EmailJob` row (status PENDING, attempts=1).
|
||||
- Cron `/api/cron/process-email-jobs` setiap 5 menit — pick PENDING + FAILED (attempts<5), retry, mark SUCCESS atau bump attempts.
|
||||
- Caller pattern: `void emailService.send(...)` (fire-and-forget) supaya tidak nge-block server action. Try/catch internal sudah handle error.
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| E1.1 | Model `EmailSent { idempotencyKey @unique, to, template, sentAt, providerMessageId? }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| E1.2 | Model `EmailJob { idempotencyKey, to, subject, html, status, attempts, lastError?, scheduledAt }` + migration | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| E1.3 | Service `lib/email/send.ts` — Resend client (raw fetch) + idempotency + enqueue on failure | ✅ | `lib/email/send.ts` |
|
||||
| E1.4 | Template registry — function per template return `{ subject, html }` | ✅ | `lib/email/templates.ts` |
|
||||
| E1.5 | Cron route `/api/cron/process-email-jobs` pakai `runCron` helper | ✅ | `app/api/cron/process-email-jobs/route.ts` |
|
||||
| E1.6 | Env: `RESEND_API_KEY`, `EMAIL_FROM` (mis. `"SeTrip <no-reply@setrip.id>"`) | ✅ | `.env.example` |
|
||||
|
||||
**Tindakan manual ops:**
|
||||
1. Buat akun Resend, verify domain `setrip.id` (atau pakai `onboarding@resend.dev` di dev).
|
||||
2. Set DNS SPF + DKIM record di provider domain.
|
||||
3. Generate API key, set env `RESEND_API_KEY` + `EMAIL_FROM` di production.
|
||||
4. Daftarkan cron baru di system crontab: `*/5 * * * * curl ... /api/cron/process-email-jobs`.
|
||||
|
||||
---
|
||||
|
||||
## PR-E2 — Phase 1: Wire transactional emails (MVP wajib) ✅
|
||||
|
||||
**Tujuan:** kirim email untuk event paling kritis (KYC, refund, payment, suspend) — yang kalau miss bikin user kehilangan uang atau bingung permanen.
|
||||
|
||||
| # | Trigger | Penerima | Template | Wire point | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| E2.1 | KYC `VERIFICATION_APPROVE` | User | `kyc_approved` | `reviewVerificationAction` | ✅ |
|
||||
| E2.2 | KYC `VERIFICATION_REJECT` | User | `kyc_rejected` (sertakan reason) | `reviewVerificationAction` | ✅ |
|
||||
| E2.3 | KYC re-upload request | User | `kyc_reupload_request` (fields + note) | `requestReuploadAction` | ✅ |
|
||||
| E2.4 | Refund created (admin atau auto-trigger) | User | `refund_created` (amount + reason) | `createRefundAction` + `tripService.closeTrip` (loop semua peserta PAID) | ✅ |
|
||||
| E2.5 | Refund SUCCEEDED | User | `refund_succeeded` (amount, cek rekening) | `decideRefundAction` (decision=SUCCEEDED) | ✅ |
|
||||
| E2.6 | Refund FAILED | User | `refund_failed` (alasan + langkah next) | `decideRefundAction` (decision=FAILED) | ✅ |
|
||||
| E2.7 | Midtrans webhook PAID | User | `payment_paid` (terima kasih + detail booking) | `paymentService.applyGatewayStatus` (di branch PAID success) | ✅ |
|
||||
| E2.8 | Booking di-approve organizer (status → AWAITING_PAY) | User | `booking_approved` (link bayar + deadline) | `confirmParticipantAction` | ✅ |
|
||||
| E2.9 | Account suspend | User | `account_suspended` (reason + email support) | `suspendUserAction` | ✅ |
|
||||
|
||||
> ℹ️ **E2.4** — jalur admin (`createRefundAction`) kirim `refund_created`. Untuk auto-refund saat trip dibatalkan, peserta dikabari lewat email `trip_cancelled_organizer` / `trip_cancelled_admin` (E3.4/E3.5) yang sudah memuat blok nominal refund — satu email konsolidasi, bukan dua.
|
||||
|
||||
**Format idempotencyKey:**
|
||||
- `kyc_approved-<verificationId>`
|
||||
- `refund_succeeded-<refundId>`
|
||||
- `payment_paid-<paymentId>`
|
||||
- `booking_approved-<bookingId>`
|
||||
- `account_suspended-<userId>-<suspendedAt>` (allow re-suspend kalau diulang)
|
||||
|
||||
**Tindakan manual ops:**
|
||||
1. Test setiap template di staging — render via Resend "Send test" atau preview HTML lokal.
|
||||
2. Pastikan `EMAIL_FROM` domain match SPF/DKIM supaya tidak masuk spam.
|
||||
|
||||
---
|
||||
|
||||
## PR-E3 — Phase 2: UX enhancement (post-MVP) ✅
|
||||
|
||||
Email yang berguna tapi tidak critical kalau miss.
|
||||
|
||||
| # | Trigger | Penerima | Template | Wire point | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| E3.1 | User join trip (PENDING) | Organizer | `join_request` (siapa, link approve) | `joinTripAction` | ✅ |
|
||||
| E3.2 | Organizer reject join | User | `join_rejected` (singkat) | `rejectParticipantAction` | ✅ |
|
||||
| E3.3 | Payment EXPIRED / FAILED | User | `payment_expired` (suruh start ulang) | `paymentService.applyGatewayStatus` | ✅ |
|
||||
| E3.4 | Trip CLOSED (organizer cancel) | Semua peserta aktif | `trip_cancelled_organizer` (batch) | `tripService.closeTrip` (organizer actor) | ✅ |
|
||||
| E3.5 | Trip CLOSED (admin force-cancel) | Semua peserta + organizer | `trip_cancelled_admin` (reason) | `tripService.closeTrip` (admin actor) | ✅ |
|
||||
| E3.6 | Payout RELEASED | Organizer | `payout_released` (ETA transfer dari admin) | `payoutService.releaseEligible` | ✅ |
|
||||
| E3.7 | Payout PAID | Organizer | `payout_paid` (cek rekening + adminNote) | `markPayoutPaidAction` | ✅ |
|
||||
| E3.8 | Account unsuspended | User | `account_unsuspended` | `unsuspendUserAction` | ✅ |
|
||||
| E3.9 | KYC submitted | User | `kyc_submitted` (ETA review) | `submitVerificationAction` | ✅ |
|
||||
| E3.10 | KYC manual override | User | `kyc_manual_override` (admin verified) | `manualOverrideVerificationAction` | ✅ |
|
||||
| E3.11 | KYC reopened (admin) | User | `kyc_reopened` (boleh submit ulang) | `reopenVerificationAction` | ✅ |
|
||||
|
||||
**Wire point email:** semua dikirim `void emailService.send(...)` (fire-and-forget, idempotent). Untuk batch trip-cancelled, `closeTrip` mengembalikan daftar penerima + nominal refund; email dikirim oleh action setelah transaksi commit (bukan di dalam tx).
|
||||
|
||||
---
|
||||
|
||||
## PR-E4 — Phase 3: Marketing / reminder (post-MVP, opt-in) ⏳
|
||||
|
||||
Email engagement — perlu user preference + unsubscribe link.
|
||||
|
||||
| # | Trigger | Penerima | Template | Wire point |
|
||||
|---|---|---|---|---|
|
||||
| E4.1 | Welcome email saat signup | User | `welcome` | NextAuth `events.signIn` first time |
|
||||
| E4.2 | Reminder H-3 keberangkatan | User | `trip_reminder_h3` (meeting point + itinerary) | Cron daily |
|
||||
| E4.3 | Reminder H-1 keberangkatan | User | `trip_reminder_h1` | Cron daily |
|
||||
| E4.4 | Trip selesai → minta review (H+1) | User CONFIRMED | `review_prompt` | Cron daily |
|
||||
| E4.5 | Review baru diterima | Organizer | `new_review` | `createReviewAction` |
|
||||
| E4.6 | Trip jadi FULL | Organizer | `trip_full` | `tripService.joinTrip` (saat FULL transition) |
|
||||
|
||||
**Prerequisite:** tabel `UserEmailPreference` dengan kategori `marketing` / `reminders` + unsubscribe token. Skip sampai Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## PR-E5 — Admin UI: email log + queue retry (post-MVP) ✅
|
||||
|
||||
Visibilitas admin untuk troubleshoot "kenapa user X tidak dapat email?".
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| E5.1 | Page `/admin/emails` — list `EmailSent` + `EmailJob`, filter recipient + template, status lewat tab | ✅ | `app/admin/emails/page.tsx` |
|
||||
| E5.2 | Tombol "Kirim ulang" untuk EmailJob gagal/antri — retry sync langsung | ✅ | `features/email/components/email-row-actions.tsx` |
|
||||
| E5.3 | Tombol "Resend" untuk EmailSent — key turunan `#resend-<ts>`, butuh `EmailSent.html` | ✅ | `features/email/actions.ts` |
|
||||
| E5.4 | Stats card di `/admin/system` + `/admin/emails`: antri, gagal 24 jam, perlu aksi manual | ✅ | `app/admin/system/page.tsx` |
|
||||
|
||||
**Tindakan manual ops:**
|
||||
1. Run migration `20260520000000_add_email_sent_html` (kolom `EmailSent.html`) di staging → production. Tanpa ini, resend (E5.3) tidak tersedia untuk email yang dikirim sebelum migration.
|
||||
2. Tambahkan `/admin/emails` ke admin nav — sudah dilakukan di `components/admin/admin-sidebar.tsx`.
|
||||
|
||||
> ℹ️ Deviasi minor dari rencana awal: filter tanggal tidak diimplementasikan (list dibatasi 100 baris terbaru); filter status diwujudkan sebagai tab (Gagal / Antrian / Terkirim).
|
||||
|
||||
---
|
||||
|
||||
## Skip / never (eksplisit)
|
||||
|
||||
- ❌ SMS / WhatsApp — beda regulatory, beda cost. Stick to email.
|
||||
- ❌ Push notification (browser/mobile) — perlu PWA setup terpisah.
|
||||
- ❌ In-app inbox — komplexitas tinggi, low ROI di MVP. Email cukup.
|
||||
@@ -94,7 +94,7 @@ Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayara
|
||||
### 5. Ringkasan peran data
|
||||
|
||||
| Konsep | Penyimpanan |
|
||||
|--------|-------------|
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer |
|
||||
| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual |
|
||||
| Organizer (kepercayaan) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
|
||||
@@ -128,3 +128,28 @@ Buka [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs)
|
||||
- [Prisma Documentation](https://www.prisma.io/docs)
|
||||
|
||||
# 1. Install SEMUA dep (termasuk dev) — deterministik dari lockfile.
|
||||
|
||||
# --include=dev memaksa dev terpasang walau NODE_ENV=production ter-export.
|
||||
|
||||
npm ci --include=dev
|
||||
|
||||
# 2. Prisma: generate client + apply migrasi
|
||||
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
|
||||
# 3. Build (butuh devDependencies)
|
||||
|
||||
npm run build
|
||||
|
||||
# 4. (Opsional) ramping-kan node_modules — buang dev SETELAH build selesai
|
||||
|
||||
npm prune --omit=dev
|
||||
|
||||
# 5. Jalankan
|
||||
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# atau restart: pm2 restart setrip --update-env
|
||||
|
||||
+22
-20
@@ -4,6 +4,8 @@ Status implementasi sistem refund yang dapat dipercaya dan auditable — dari sc
|
||||
|
||||
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
|
||||
|
||||
**Progress (per 2026-05-20):** PR-R1, R2, R3 ✅ — MVP refund (schema + service, organizer-cancel auto-refund, self-service user cancel) selesai. PR-R4 / R5 / R6 ⏳ post-MVP belum dikerjakan.
|
||||
|
||||
---
|
||||
|
||||
## Audit state sekarang (baseline)
|
||||
@@ -34,7 +36,7 @@ File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/bo
|
||||
|
||||
---
|
||||
|
||||
## PR-R1 — Refund Schema + Service Stub (foundation) ⏳
|
||||
## PR-R1 — Refund Schema + Service Stub (foundation) ✅
|
||||
|
||||
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
|
||||
|
||||
@@ -48,14 +50,14 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| R1.3 | Migration `add_refund_model` | ⏳ | `prisma/migrations/` |
|
||||
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | ⏳ | `server/repositories/refund.repo.ts` |
|
||||
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | ⏳ | `server/services/refund.service.ts` |
|
||||
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | ⏳ | `server/services/refund.service.ts` |
|
||||
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | ⏳ | `server/services/refund.service.ts` |
|
||||
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | ⏳ | `app/admin/refunds/page.tsx` |
|
||||
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||
| R1.3 | Migration `add_refund_model` | ✅ | `prisma/migrations/` |
|
||||
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | ✅ | `server/repositories/refund.repo.ts` |
|
||||
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | ✅ | `server/services/refund.service.ts` |
|
||||
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | ✅ | `server/services/refund.service.ts` |
|
||||
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | ✅ | `server/services/refund.service.ts` |
|
||||
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | ✅ | `app/admin/refunds/page.tsx` |
|
||||
|
||||
**Tindakan manual:**
|
||||
1. Run migration di staging → smoke test → run di production.
|
||||
@@ -63,7 +65,7 @@ Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDIN
|
||||
|
||||
---
|
||||
|
||||
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ⏳
|
||||
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ✅
|
||||
|
||||
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
|
||||
|
||||
@@ -75,16 +77,16 @@ Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | ⏳ | `server/services/refund.service.ts` |
|
||||
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | ⏳ | `features/trip/components/cancel-trip-button.tsx` |
|
||||
| R2.4 | Server action `cancelTripAction` | ⏳ | `features/trip/actions.ts` |
|
||||
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | ✅ | `server/services/refund.service.ts` |
|
||||
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | ✅ | `features/trip/components/cancel-trip-button.tsx` |
|
||||
| R2.4 | Server action `cancelTripAction` | ✅ | `features/trip/actions.ts` |
|
||||
|
||||
**Tindakan manual:** tidak ada.
|
||||
|
||||
---
|
||||
|
||||
## PR-R3 — Self-Service User Cancel dengan Refund Window ⏳
|
||||
## PR-R3 — Self-Service User Cancel dengan Refund Window ✅
|
||||
|
||||
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
|
||||
|
||||
@@ -100,11 +102,11 @@ User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| R3.1 | `lib/refund-policy.ts` — `calculateRefundAmount(bookingAmount, daysUntilDeparture)` | ⏳ | `lib/refund-policy.ts` |
|
||||
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | ⏳ | `server/services/refund.service.ts` |
|
||||
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | ⏳ | `features/booking/components/cancel-booking-button.tsx` |
|
||||
| R3.4 | Server action `cancelBookingAction` | ⏳ | `features/booking/actions.ts` |
|
||||
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||
| R3.1 | `lib/refund-policy.ts` — `calculateRefundAmount(bookingAmount, daysUntilDeparture)` | ✅ | `lib/refund-policy.ts` |
|
||||
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | ✅ | `server/services/refund.service.ts` |
|
||||
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | ✅ | `features/booking/components/cancel-booking-button.tsx` |
|
||||
| R3.4 | Server action `cancelBookingAction` | ✅ | `features/booking/actions.ts` |
|
||||
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||
|
||||
**Tindakan manual:**
|
||||
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
|
||||
|
||||
@@ -59,19 +59,28 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
||||
|
||||
---
|
||||
|
||||
# 6. Pembayaran
|
||||
# 6. Pembayaran & Escrow
|
||||
|
||||
- Pembayaran dilakukan sesuai metode yang tersedia di platform
|
||||
- Dalam fase awal, pembayaran dapat dilakukan langsung kepada organizer
|
||||
- SeTrip tidak menjamin keamanan transaksi yang dilakukan di luar platform
|
||||
- Pembayaran dilakukan melalui metode yang tersedia di platform (Midtrans atau transfer manual yang dikonfirmasi organizer)
|
||||
- **Uang peserta ditahan oleh SeTrip (escrow)** sejak pembayaran berhasil hingga trip selesai + 3 hari, baru kemudian diteruskan ke organizer
|
||||
- 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
|
||||
|
||||
- Kebijakan pembatalan ditentukan oleh organizer
|
||||
- SeTrip tidak bertanggung jawab atas refund yang tidak diberikan oleh organizer
|
||||
- Pengguna disarankan untuk memahami kebijakan sebelum melakukan pembayaran
|
||||
**Saat peserta membatalkan booking sendiri** (kebijakan default platform):
|
||||
|
||||
- **≥ 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,198 @@
|
||||
# 🎨 SeTrip — UI Style Guide
|
||||
|
||||
Panduan visual untuk membuat tampilan SeTrip terasa **natural, manusiawi, dan tidak "AI-generated"** — tanpa mengorbankan SEO.
|
||||
|
||||
> Prinsip utama: **clean, calm, earthy.** SeTrip itu social-companion platform ("pergi bareng, bukan sendiri"), bukan marketplace booking. UI harus terasa hangat & tenang, bukan ramai & promosi.
|
||||
|
||||
---
|
||||
|
||||
## 1. Filosofi Desain
|
||||
|
||||
| Hindari (kesan AI-generated) | Gunakan (kesan natural) |
|
||||
| --- | --- |
|
||||
| ❌ Gradient berlebihan | ✅ Background putih bersih / `neutral-50` |
|
||||
| ❌ Neumorphism | ✅ Soft green / earthy tone |
|
||||
| ❌ Glassmorphism ekstrem | ✅ Border tipis 1px + shadow lembut |
|
||||
| ❌ Icon 3D / emoji sebagai UI icon | ✅ Stroke icon tipis (lucide-react) |
|
||||
| ❌ Card mengambang dengan blur tebal | ✅ Simple rounded card, datar, jelas |
|
||||
| ❌ Warna saturasi tinggi di mana-mana | ✅ 1 warna aksen, sisanya netral |
|
||||
|
||||
**Tiga kata kunci:** *bersih · tenang · jujur.* Kalau sebuah elemen terasa "ingin pamer", kemungkinan besar perlu disederhanakan.
|
||||
|
||||
---
|
||||
|
||||
## 2. Warna
|
||||
|
||||
Token warna sudah tersedia di [app/globals.css](app/globals.css) — **gunakan token, jangan hardcode hex.**
|
||||
|
||||
| Peran | Token | Catatan |
|
||||
| --- | --- | --- |
|
||||
| Aksi utama / brand | `primary-600` (#16A34A) | Hijau gunung — earthy, tidak neon |
|
||||
| Hover aksi utama | `primary-700` | Hindari `primary-500` (terlalu terang) untuk hover tombol |
|
||||
| Aksen sekunder | `secondary-600` (#0EA5E9) | Pakai hemat — info, link, badge vibe |
|
||||
| Teks utama | `neutral-800` | |
|
||||
| Teks sekunder | `neutral-500` | |
|
||||
| Border | `neutral-200` | Selalu 1px |
|
||||
| Background halaman | `neutral-50` | |
|
||||
| Surface / card | `white` | |
|
||||
|
||||
### Aturan warna
|
||||
|
||||
- **Satu aksen per layar.** Hijau adalah bintangnya. Biru hanya bumbu.
|
||||
- **Maksimal 1 area gradient per halaman**, dan harus halus (mis. hero). Sisanya warna solid.
|
||||
- Surface = putih solid. Jangan pakai `bg-white/80 + backdrop-blur` untuk card biasa.
|
||||
- Earthy tone tambahan diperbolehkan sebagai background section (`primary-50`, `amber-50`) tapi jangan dijadikan blok besar warna-warni.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sistem Ikon — lucide-react
|
||||
|
||||
`lucide-react` sudah terpasang. **Stroke icon = wajah baru SeTrip.**
|
||||
|
||||
### Aturan ikon
|
||||
|
||||
- **Stroke icon, bukan filled.** Lucide default sudah stroke — jangan ganti `fill`.
|
||||
- Ukuran konsisten: `16` (inline teks), `20` (tombol/list), `24` (header section).
|
||||
- Ketebalan stroke seragam: `strokeWidth={1.75}` (default lucide `2` sedikit terlalu tebal untuk gaya clean ini).
|
||||
- Warna ikut teks: `text-neutral-500` untuk netral, `text-primary-600` untuk aktif.
|
||||
- **Jangan** beri ikon background bulat berwarna + emoji di dalamnya (pola lama). Cukup ikon polos, atau ikon di atas lingkaran `neutral-100` yang sangat soft bila perlu penekanan.
|
||||
|
||||
```tsx
|
||||
import { Mountain } from "lucide-react";
|
||||
|
||||
// inline
|
||||
<Mountain size={16} strokeWidth={1.75} className="text-neutral-500" />
|
||||
|
||||
// di tombol
|
||||
<Plus size={20} strokeWidth={1.75} />
|
||||
```
|
||||
|
||||
### Pemetaan ikon per fitur
|
||||
|
||||
| Fitur | Ikon lucide |
|
||||
| --- | --- |
|
||||
| Trip | `Mountain` |
|
||||
| Group / peserta | `Users` |
|
||||
| Organizer | `BadgeCheck` |
|
||||
| Verified | `ShieldCheck` |
|
||||
| Payment | `Wallet` |
|
||||
| Meeting Point | `MapPinned` |
|
||||
| Chat | `MessageCircle` |
|
||||
| Review / rating | `Star` |
|
||||
| Profil | `UserRound` |
|
||||
|
||||
Saran tambahan yang konsisten dengan set di atas:
|
||||
|
||||
| Konteks | Ikon lucide |
|
||||
| --- | --- |
|
||||
| Tanggal / jadwal | `CalendarDays` |
|
||||
| Lokasi umum | `MapPin` |
|
||||
| Buat trip (FAB & CTA) | `Plus` |
|
||||
| Cari / filter | `Search`, `SlidersHorizontal` |
|
||||
| Menu mobile | `Menu` / `X` |
|
||||
| Kategori (jelajah) | `Compass` |
|
||||
| Sedang ramai / populer | `Flame` atau `TrendingUp` |
|
||||
| Harga | `Tag` |
|
||||
|
||||
> **Catatan emoji kategori:** `categoryMeta()` di [lib/activity-category.ts](lib/activity-category.ts) masih memakai emoji (🏔️🏕️🤿). Boleh dipertahankan **hanya** di konten data trip (terasa playful & manusiawi di tempat itu), tapi **elemen UI/chrome** (navbar, header section, tombol, badge status) harus pakai stroke icon.
|
||||
|
||||
---
|
||||
|
||||
## 4. Komponen
|
||||
|
||||
### Card
|
||||
|
||||
```
|
||||
✅ rounded-2xl · border border-neutral-200 · bg-white
|
||||
✅ hover: shadow lembut + translate-y-0.5 (sudah dipakai di TripCard — pertahankan)
|
||||
❌ jangan: shadow tebal default, blur, gradient border
|
||||
```
|
||||
|
||||
### Tombol
|
||||
|
||||
| Jenis | Style |
|
||||
| --- | --- |
|
||||
| Primer | `bg-primary-600 hover:bg-primary-700 text-white rounded-xl` |
|
||||
| Sekunder | `border border-neutral-200 text-neutral-700 hover:bg-neutral-50` |
|
||||
| Ghost | `text-neutral-600 hover:bg-neutral-100` |
|
||||
|
||||
- Shadow tombol seperlunya. `shadow-lg shadow-primary-600/25` boleh untuk **satu** CTA utama per layar, jangan semua tombol.
|
||||
- `hover:scale-105` cukup untuk CTA hero saja — jangan di semua tombol (terasa "demo template").
|
||||
- Sertakan ikon lucide bila memperjelas aksi (mis. `Plus` untuk "Buat Trip").
|
||||
|
||||
### Badge / pill
|
||||
|
||||
- `rounded-full`, teks kecil, warna soft (`primary-50`/`primary-700`).
|
||||
- Status pakai warna semantik solid lembut, bukan transparan + blur.
|
||||
|
||||
### Header section
|
||||
|
||||
Pola lama: kotak berwarna + emoji. Pola baru:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Compass size={20} strokeWidth={1.75} className="text-primary-600" />
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">Jelajah per Kategori</h2>
|
||||
<p className="text-xs text-neutral-500">Hiking, diving, konser, sampai retreat</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Yang Perlu Dirombak di Codebase
|
||||
|
||||
Temuan konkret dari kode saat ini:
|
||||
|
||||
| Lokasi | Masalah | Aksi |
|
||||
| --- | --- | --- |
|
||||
| [app/(public)/page.tsx](app/(public)/page.tsx) | Header section pakai kotak warna + emoji (✨🔥🏔️🤝), badge hero pakai emoji 🤝 | Ganti ke stroke icon (`Compass`, `Flame`, `Mountain`, `Users`) |
|
||||
| [app/(public)/page.tsx](app/(public)/page.tsx#L110) | Hero gradient 3 warna (`from-primary-900 via-neutral-900 to-secondary-900`) | Sederhanakan jadi overlay solid `neutral-900/80` atau gradient 2 warna halus |
|
||||
| [app/(public)/page.tsx](app/(public)/page.tsx#L386) | FAB pakai teks `"+"` | Ganti `<Plus size={24} />` |
|
||||
| [app/(public)/page.tsx](app/(public)/page.tsx#L153) | Stat "100% Seru" terasa filler/AI | Ganti metrik nyata (jumlah peserta, organizer terverifikasi) atau hapus |
|
||||
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L112) | Hamburger pakai inline SVG manual | Ganti `Menu` / `X` dari lucide |
|
||||
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L13) | `bg-white/90 backdrop-blur-md` | Boleh dipertahankan (tipis, wajar untuk sticky nav) — jangan ditebalkan |
|
||||
| [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx) | Avatar fallback & meta info bisa diperkuat dengan ikon stroke (`Users`, `CalendarDays`, `MapPin`) | Tambah ikon kecil di baris meta |
|
||||
|
||||
Prioritas: **homepage dulu** (paling sering dilihat & paling kuat kesan AI-nya), lalu navbar, lalu komponen kartu.
|
||||
|
||||
---
|
||||
|
||||
## 6. SEO — Wajib Dijaga
|
||||
|
||||
Perubahan visual **tidak boleh** menurunkan SEO. Aturan:
|
||||
|
||||
- **Ikon lucide = inline SVG**, ringan & tidak memblokir render. Aman untuk Core Web Vitals.
|
||||
- **Ikon dekoratif** (hiasan di samping teks) harus `aria-hidden`. Lucide perlu di-set manual:
|
||||
```tsx
|
||||
<Mountain size={16} aria-hidden className="text-neutral-500" />
|
||||
```
|
||||
- **Ikon yang berdiri sendiri sebagai tombol** (mis. tombol menu) wajib punya label:
|
||||
```tsx
|
||||
<button aria-label="Buka menu"><Menu size={20} aria-hidden /></button>
|
||||
```
|
||||
- **Jangan ubah teks jadi gambar.** Heading, slogan, deskripsi harus tetap teks HTML.
|
||||
- **Pertahankan hirarki heading:** satu `<h1>` per halaman, `<h2>` untuk section. Jangan turunkan jadi `<div>` saat merapikan visual.
|
||||
- **Pertahankan metadata & JSON-LD** di [app/layout.tsx](app/layout.tsx) dan [app/(public)/page.tsx](app/(public)/page.tsx) — structured data, OpenGraph, canonical jangan disentuh saat refactor UI.
|
||||
- **Komponen tetap Server Component** kalau memungkinkan. Jangan tambah `"use client"` cuma untuk render ikon — lucide jalan di server.
|
||||
- **Gambar:** terus pakai `next/image` dengan `alt` deskriptif dan `priority` untuk LCP (cover hero & kartu pertama).
|
||||
- **Kontras warna** minimal AA: stroke icon `neutral-500` di atas putih sudah memenuhi; jangan pakai `neutral-300` untuk ikon/teks penting.
|
||||
|
||||
---
|
||||
|
||||
## 7. Checklist Implementasi
|
||||
|
||||
- [ ] Ganti semua emoji di chrome UI (navbar, header section, tombol, FAB) → stroke icon lucide
|
||||
- [ ] Standarkan `size` (16/20/24) & `strokeWidth={1.75}` di seluruh ikon
|
||||
- [ ] Sederhanakan gradient hero homepage jadi maksimal 2 warna / overlay solid
|
||||
- [ ] Ganti hamburger SVG manual di navbar → `Menu`/`X`
|
||||
- [ ] Tinjau metrik "100% Seru" — ganti angka nyata atau hapus
|
||||
- [ ] Pastikan ikon dekoratif `aria-hidden`, ikon-tombol punya `aria-label`
|
||||
- [ ] Pastikan struktur heading `h1`/`h2` tetap utuh setelah refactor
|
||||
- [ ] Jalankan Lighthouse — skor SEO & Accessibility tidak turun
|
||||
- [ ] Verifikasi tidak ada `"use client"` baru yang ditambahkan hanya demi ikon
|
||||
|
||||
---
|
||||
|
||||
*Acuan token: [app/globals.css](app/globals.css) · Acuan brand: [lib/site.ts](lib/site.ts)*
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Lock, Clock, CircleAlert } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
@@ -11,8 +12,13 @@ export default async function CreateTripPage() {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 text-3xl">
|
||||
🔒
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50">
|
||||
<Lock
|
||||
size={28}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-4 text-neutral-500">
|
||||
Kamu harus login untuk membuat trip.
|
||||
@@ -57,8 +63,9 @@ function VerificationBanner({
|
||||
if (status === "PENDING") {
|
||||
return (
|
||||
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
|
||||
<p className="text-sm font-bold text-amber-800">
|
||||
⏳ Verifikasi sedang diproses
|
||||
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
|
||||
<Clock size={15} strokeWidth={2} aria-hidden />
|
||||
Verifikasi sedang diproses
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-700">
|
||||
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
|
||||
@@ -73,8 +80,9 @@ function VerificationBanner({
|
||||
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-amber-800">
|
||||
⚠️ {isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
|
||||
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
|
||||
<CircleAlert size={15} strokeWidth={2} aria-hidden />
|
||||
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-700">
|
||||
{isRejected
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Skeleton generik untuk route group `(public)` — fallback streaming bagi
|
||||
* halaman yang tidak punya `loading.tsx` sendiri (beranda, profil, dll).
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-10">
|
||||
<div className="h-8 w-1/2 animate-pulse rounded-xl bg-neutral-200" />
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-neutral-100" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded bg-neutral-100" />
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-neutral-100" />
|
||||
</div>
|
||||
<div className="mt-8 h-64 animate-pulse rounded-2xl bg-neutral-100" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Masuk",
|
||||
description:
|
||||
"Masuk ke akun SeTrip untuk gabung open trip & aktivitas bareng dan kelola perjalananmu.",
|
||||
alternates: { canonical: "/login" },
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default async function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// User yang sudah login tidak boleh mengakses halaman login lagi.
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user) {
|
||||
redirect(session.user.isAdmin ? "/admin" : "/");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 Link from "next/link";
|
||||
import Image from "next/image";
|
||||
@@ -38,7 +38,16 @@ function LoginForm() {
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
const next = safeInternalPath(searchParams.get("callbackUrl"));
|
||||
const callbackPath = safeInternalPath(searchParams.get("callbackUrl"));
|
||||
const session = await getSession();
|
||||
// Admin selalu diarahkan ke dashboard /admin setelah login — kecuali
|
||||
// callbackUrl memang menuju sub-halaman admin (deep link dari /admin/...).
|
||||
// callbackUrl non-admin (mis. "/" sisa dari percobaan login Google) tidak
|
||||
// boleh membuat admin "nyangkut" di halaman publik.
|
||||
const next =
|
||||
session?.user?.isAdmin && !callbackPath.startsWith("/admin")
|
||||
? "/admin"
|
||||
: callbackPath;
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
}
|
||||
@@ -78,7 +87,7 @@ function LoginForm() {
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
||||
{error}
|
||||
@@ -8,6 +8,15 @@ import { profileRepo } from "@/server/repositories/profile.repo";
|
||||
import { TripCard } from "@/features/trip/components/trip-card";
|
||||
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
|
||||
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
|
||||
import {
|
||||
Compass,
|
||||
Flame,
|
||||
Mountain,
|
||||
Handshake,
|
||||
Tent,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
|
||||
|
||||
@@ -44,6 +53,9 @@ export default async function HomePage() {
|
||||
const now = new Date();
|
||||
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Social proof: total orang yang sudah gabung di seluruh open trip.
|
||||
const joinerCount = trips.reduce((sum, t) => sum + t._count.participants, 0);
|
||||
|
||||
const upcomingTrips = trips
|
||||
.filter((t) => new Date(t.date) <= nextWeek)
|
||||
.slice(0, 3);
|
||||
@@ -107,12 +119,17 @@ export default async function HomePage() {
|
||||
className="object-cover opacity-10 brightness-150"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-br from-primary-900/85 via-neutral-900/75 to-secondary-900/80" />
|
||||
<div className="absolute inset-0 bg-linear-to-br from-neutral-900/90 to-primary-900/80" />
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
|
||||
{/* Brand badge */}
|
||||
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
|
||||
<span className="text-xs sm:text-sm">🤝</span>
|
||||
<Handshake
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-300"
|
||||
/>
|
||||
<span className="text-xs font-medium text-primary-300 sm:text-sm">
|
||||
Cari teman trip & aktivitas
|
||||
</span>
|
||||
@@ -150,8 +167,12 @@ export default async function HomePage() {
|
||||
</div>
|
||||
<div className="h-8 w-px bg-neutral-700 sm:h-10" />
|
||||
<div>
|
||||
<p className="text-xl font-bold text-white sm:text-2xl">100%</p>
|
||||
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p>
|
||||
<p className="text-xl font-bold text-white sm:text-2xl">
|
||||
{joinerCount}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-400 sm:text-xs">
|
||||
Sudah Gabung
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,19 +182,11 @@ export default async function HomePage() {
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
|
||||
{/* Jelajah per kategori */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-3 sm:mb-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
|
||||
✨
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
||||
Jelajah per Kategori
|
||||
</h2>
|
||||
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
||||
Hiking, diving, konser, sampai retreat
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeading
|
||||
icon={Compass}
|
||||
title="Jelajah per Kategori"
|
||||
subtitle="Hiking, diving, konser, sampai retreat"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ACTIVITY_CATEGORIES.map((c) => {
|
||||
const m = categoryMeta(c);
|
||||
@@ -194,19 +207,11 @@ export default async function HomePage() {
|
||||
{/* Trip Terdekat */}
|
||||
{upcomingTrips.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-3 sm:mb-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
|
||||
🔥
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
||||
Trip Terdekat
|
||||
</h2>
|
||||
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
||||
Berangkat dalam 7 hari ke depan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeading
|
||||
icon={Flame}
|
||||
title="Trip Terdekat"
|
||||
subtitle="Berangkat dalam 7 hari ke depan"
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{upcomingTrips.slice(0, 3).map((trip, i) => (
|
||||
<TripCard
|
||||
@@ -239,32 +244,29 @@ export default async function HomePage() {
|
||||
|
||||
{/* Open Trip */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between sm:mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
|
||||
🏔️
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
||||
Open Trip
|
||||
</h2>
|
||||
<p className="hidden text-xs text-neutral-500 sm:block">
|
||||
Pilih trip, ketemu teman baru
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeading
|
||||
icon={Mountain}
|
||||
title="Open Trip"
|
||||
subtitle="Pilih trip, ketemu teman baru"
|
||||
action={
|
||||
<Link
|
||||
href="/trips"
|
||||
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
|
||||
className="shrink-0 rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
|
||||
>
|
||||
Lihat semua
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{latestTrips.length === 0 ? (
|
||||
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
|
||||
🏕️
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
|
||||
<Tent
|
||||
size={26}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
|
||||
Belum ada trip tersedia
|
||||
@@ -312,19 +314,11 @@ export default async function HomePage() {
|
||||
{/* Lagi Ramai — social proof, bukan price proof */}
|
||||
{buzzingTrips.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-3 sm:mb-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
|
||||
🤝
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
||||
Lagi Ramai
|
||||
</h2>
|
||||
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
||||
Banyak yang sudah gabung — kamu nggak bakal jalan sendirian
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeading
|
||||
icon={Handshake}
|
||||
title="Lagi Ramai"
|
||||
subtitle="Banyak yang sudah gabung — kamu nggak bakal jalan sendirian"
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{buzzingTrips.map((trip) => (
|
||||
<TripCard
|
||||
@@ -383,11 +377,48 @@ export default async function HomePage() {
|
||||
{/* ========== FAB ========== */}
|
||||
<Link
|
||||
href="/create-trip"
|
||||
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14 sm:text-2xl"
|
||||
title="Buat Trip"
|
||||
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14"
|
||||
aria-label="Buat Trip"
|
||||
>
|
||||
+
|
||||
<Plus size={24} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Heading section homepage — ikon stroke + judul, opsional aksi di kanan. */
|
||||
function SectionHeading({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3 sm:mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon
|
||||
size={22}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0 text-primary-600"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { UserCard } from "@/features/profile/components/user-card";
|
||||
import { PeopleFilter } from "@/features/profile/components/people-filter";
|
||||
import { isVibe, vibeLabel } from "@/lib/vibe";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
interface PeoplePageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -68,8 +69,13 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
|
||||
|
||||
{people.length === 0 ? (
|
||||
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
|
||||
🔍
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
|
||||
<Users
|
||||
size={26}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
|
||||
{hasFilters
|
||||
@@ -1,12 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldCheck, CircleCheck } from "lucide-react";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
|
||||
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
|
||||
<header className="mb-8 border-b border-neutral-200 pb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
🔒 Kebijakan Privasi SeTrip
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
<ShieldCheck
|
||||
size={28}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0 text-primary-600"
|
||||
/>
|
||||
Kebijakan Privasi SeTrip
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Terakhir diperbarui: 2026-04-27
|
||||
@@ -205,7 +212,15 @@ export default function PrivacyPage() {
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl bg-neutral-50 p-5">
|
||||
<h2 className="mb-2 text-lg font-bold text-neutral-900">✅ Persetujuan</h2>
|
||||
<h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
|
||||
<CircleCheck
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
Persetujuan
|
||||
</h2>
|
||||
<p className="mb-2">
|
||||
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
||||
</p>
|
||||
@@ -5,9 +5,12 @@ import Link from "next/link";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { profileService } from "@/server/services/profile.service";
|
||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
import { TripCard } from "@/features/trip/components/trip-card";
|
||||
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
|
||||
import { ProfileEditor } from "@/features/profile/components/profile-editor";
|
||||
import { EarningsSection } from "@/features/payout/components/earnings-section";
|
||||
import { Plus, ChevronRight } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Profil Saya",
|
||||
@@ -20,9 +23,10 @@ export default async function ProfilePage() {
|
||||
redirect("/login?callbackUrl=/profile");
|
||||
}
|
||||
|
||||
const [data, ownProfile] = await Promise.all([
|
||||
const [data, ownProfile, payouts] = await Promise.all([
|
||||
profileService.getProfileDashboard(session.user.id),
|
||||
profileService.getOwnProfile(session.user.id),
|
||||
payoutRepo.listForOrganizer(session.user.id),
|
||||
]);
|
||||
const {
|
||||
user,
|
||||
@@ -78,12 +82,16 @@ export default async function ProfilePage() {
|
||||
</div>
|
||||
<Link
|
||||
href="/create-trip"
|
||||
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700"
|
||||
className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md hover:bg-primary-700"
|
||||
>
|
||||
+ Buat trip
|
||||
<Plus size={16} strokeWidth={2} aria-hidden />
|
||||
Buat trip
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Pendapatan dari peserta (escrow payout) */}
|
||||
<EarningsSection payouts={payouts} />
|
||||
|
||||
{/* Profil sosial publik */}
|
||||
<div className="mb-6">
|
||||
<ProfileEditor
|
||||
@@ -127,13 +135,14 @@ export default async function ProfilePage() {
|
||||
endDate={t.endDate}
|
||||
rightSlot={
|
||||
<span
|
||||
className={
|
||||
className={`inline-flex items-center gap-0.5 ${
|
||||
hasReview
|
||||
? "text-secondary-700"
|
||||
: "font-bold text-amber-800"
|
||||
}
|
||||
}`}
|
||||
>
|
||||
{hasReview ? "Ubah ulasan →" : "Beri ulasan →"}
|
||||
{hasReview ? "Ubah ulasan" : "Beri ulasan"}
|
||||
<ChevronRight size={14} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Daftar Akun",
|
||||
description:
|
||||
"Buat akun SeTrip gratis. Cari open trip & aktivitas bareng, gabung bareng, dan mulai petualanganmu.",
|
||||
alternates: { canonical: "/register" },
|
||||
};
|
||||
|
||||
export default async function RegisterLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// User yang sudah login tidak boleh mengakses halaman daftar lagi.
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user) {
|
||||
redirect(session.user.isAdmin ? "/admin" : "/");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
||||
{error}
|
||||
@@ -1,12 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { FileText, CircleCheck } from "lucide-react";
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
|
||||
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
|
||||
<header className="mb-8 border-b border-neutral-200 pb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
📜 Syarat & Ketentuan SeTrip
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
<FileText
|
||||
size={28}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0 text-primary-600"
|
||||
/>
|
||||
Syarat & Ketentuan SeTrip
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Terakhir diperbarui: 2026-04-27
|
||||
@@ -262,7 +269,15 @@ export default function TermsPage() {
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl bg-neutral-50 p-5">
|
||||
<h2 className="mb-2 text-lg font-bold text-neutral-900">✅ Persetujuan</h2>
|
||||
<h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
|
||||
<CircleCheck
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
Persetujuan
|
||||
</h2>
|
||||
<p className="mb-2">
|
||||
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
|
||||
</p>
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Skeleton halaman detail trip — tampil instan saat data masih di-fetch. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
|
||||
<div className="mb-3 h-4 w-40 animate-pulse rounded bg-neutral-200 sm:mb-4" />
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||
<div className="h-44 animate-pulse bg-neutral-200 sm:h-56 lg:h-72" />
|
||||
|
||||
<div className="border-b border-neutral-100 px-4 py-4 sm:px-6">
|
||||
<div className="h-6 w-2/3 animate-pulse rounded bg-neutral-200" />
|
||||
<div className="mt-2 h-4 w-1/3 animate-pulse rounded bg-neutral-100" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-4 sm:space-y-6 sm:p-6">
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 animate-pulse rounded-xl bg-neutral-100 sm:h-[72px]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-24 animate-pulse rounded-xl bg-neutral-100" />
|
||||
<div className="h-32 animate-pulse rounded-xl bg-neutral-100" />
|
||||
<div className="h-12 animate-pulse rounded-xl bg-neutral-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
import { siteConfig, siteUrl } from "@/lib/site";
|
||||
|
||||
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
|
||||
export const size = { width: 1200, height: 630 };
|
||||
@@ -43,7 +43,15 @@ export default async function TripOgImage({
|
||||
);
|
||||
}
|
||||
|
||||
const cover = trip.images[0]?.url;
|
||||
// Satori (ImageResponse) mem-fetch gambar server-side dan butuh URL absolut.
|
||||
// Foto trip baru disimpan sebagai path relatif `/api/trip-images/...` —
|
||||
// prefix dengan origin. Foto lama (URL eksternal absolut) dipakai apa adanya.
|
||||
const coverRaw = trip.images[0]?.url;
|
||||
const cover = coverRaw
|
||||
? coverRaw.startsWith("http")
|
||||
? coverRaw
|
||||
: `${siteUrl}${coverRaw}`
|
||||
: undefined;
|
||||
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
|
||||
const price = formatRupiah(trip.price);
|
||||
|
||||
@@ -17,7 +17,6 @@ import { CancelBookingButton } from "@/features/booking/components/cancel-bookin
|
||||
import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
|
||||
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
|
||||
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 { TripReviewSection } from "@/features/review/components/trip-review-section";
|
||||
import { RefundPolicySection } from "@/features/refund/components/refund-policy-section";
|
||||
@@ -29,6 +28,14 @@ import {
|
||||
isTripDepartureDayPast,
|
||||
} from "@/lib/trip-dates";
|
||||
import { previewRefund } from "@/lib/refund-policy";
|
||||
import {
|
||||
MapPin,
|
||||
CalendarDays,
|
||||
Wallet,
|
||||
UserRound,
|
||||
Zap,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -135,13 +142,6 @@ export default async function TripDetailPage({
|
||||
|
||||
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
|
||||
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
|
||||
const myBooking =
|
||||
@@ -317,8 +317,13 @@ export default async function TripDetailPage({
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
||||
📍
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
|
||||
<MapPin
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-secondary-700"
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
|
||||
@@ -327,8 +332,13 @@ export default async function TripDetailPage({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
||||
📅
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
|
||||
<CalendarDays
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-secondary-700"
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
|
||||
@@ -339,8 +349,13 @@ export default async function TripDetailPage({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
||||
💰
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 sm:h-10 sm:w-10">
|
||||
<Wallet
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-700"
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
|
||||
@@ -351,8 +366,13 @@ export default async function TripDetailPage({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-sm sm:h-10 sm:w-10 sm:text-lg">
|
||||
👤
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 sm:h-10 sm:w-10">
|
||||
<UserRound
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-neutral-600"
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
|
||||
@@ -380,8 +400,9 @@ export default async function TripDetailPage({
|
||||
Peserta
|
||||
</span>
|
||||
{spotsLeft > 0 && spotsLeft <= 3 && (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
|
||||
⚡ Tinggal {spotsLeft} spot!
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
|
||||
<Zap size={11} strokeWidth={2} aria-hidden />
|
||||
Tinggal {spotsLeft} spot!
|
||||
</span>
|
||||
)}
|
||||
{spotsLeft <= 0 && (
|
||||
@@ -426,8 +447,14 @@ export default async function TripDetailPage({
|
||||
)}
|
||||
</p>
|
||||
{confirmedCount > 0 && (
|
||||
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs">
|
||||
<span aria-hidden>👥</span> Sudah join:{" "}
|
||||
<p className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-neutral-600 sm:text-xs">
|
||||
<Users
|
||||
size={13}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-neutral-400"
|
||||
/>
|
||||
Sudah join:{" "}
|
||||
<span className="font-medium text-neutral-800">
|
||||
{confirmedParticipants
|
||||
.slice(0, 3)
|
||||
@@ -447,6 +474,13 @@ export default async function TripDetailPage({
|
||||
<TripProgramBlock
|
||||
meetingPoint={trip.meetingPoint}
|
||||
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}
|
||||
whatsExcluded={trip.whatsExcluded}
|
||||
/>
|
||||
@@ -469,18 +503,6 @@ export default async function TripDetailPage({
|
||||
pending={pendingParticipants.map((p) => ({
|
||||
id: p.id,
|
||||
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 +520,7 @@ export default async function TripDetailPage({
|
||||
? currentParticipation.status
|
||||
: null
|
||||
}
|
||||
participantPayment={
|
||||
currentParticipation
|
||||
? {
|
||||
markedPaidAt: currentParticipation.markedPaidAt,
|
||||
paymentConfirmedAt:
|
||||
currentParticipation.paymentConfirmedAt,
|
||||
}
|
||||
: null
|
||||
}
|
||||
bookingStatus={myBooking?.status ?? null}
|
||||
isFull={spotsLeft <= 0}
|
||||
tripStatus={trip.status}
|
||||
isDeparturePast={isDeparturePast}
|
||||
@@ -568,7 +582,7 @@ export default async function TripDetailPage({
|
||||
Belum ada peserta yang dikonfirmasi.{" "}
|
||||
{pendingParticipants.length > 0
|
||||
? "Cek permintaan join di atas untuk menyetujui peserta."
|
||||
: "Jadilah yang pertama mendaftar! 🎒"}
|
||||
: "Jadilah yang pertama mendaftar!"}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
@@ -599,8 +613,14 @@ export default async function TripDetailPage({
|
||||
{p.user.name}
|
||||
</p>
|
||||
{city && (
|
||||
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
|
||||
📍 {city}
|
||||
<p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
|
||||
<MapPin
|
||||
size={11}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
/>
|
||||
{city}
|
||||
</p>
|
||||
)}
|
||||
{interests.length > 0 && (
|
||||
@@ -4,15 +4,22 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import { bookingService } from "@/server/services/booking.service";
|
||||
import { paymentService } from "@/server/services/payment.service";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||
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 { CopyButton } from "@/features/booking/components/copy-button";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
MapPin,
|
||||
PartyPopper,
|
||||
CircleCheck,
|
||||
Clock,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Detail Pembayaran",
|
||||
@@ -21,9 +28,10 @@ export const metadata: Metadata = {
|
||||
|
||||
interface PageProps {
|
||||
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 session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
@@ -37,11 +45,26 @@ export default async function PaymentPage({ params }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Organizer trip-nya sendiri tidak butuh halaman pembayaran.
|
||||
if (trip.organizerId === session.user.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(
|
||||
trip.id,
|
||||
session.user.id
|
||||
@@ -51,15 +74,10 @@ export default async function PaymentPage({ params }: PageProps) {
|
||||
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
|
||||
}
|
||||
|
||||
const latestManualPayment = booking.payments.find(
|
||||
(p) => p.provider === "MANUAL"
|
||||
);
|
||||
|
||||
const tripIsFree = isFreeTrip(trip);
|
||||
const catMeta = categoryMeta(trip.category);
|
||||
const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
|
||||
|
||||
// Header info — sama untuk free vs paid
|
||||
const tripHeader = (
|
||||
<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">
|
||||
@@ -73,8 +91,15 @@ export default async function PaymentPage({ params }: PageProps) {
|
||||
<h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg">
|
||||
{trip.title}
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
|
||||
📅 {dateRange} · 📍 {trip.location}
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:text-sm">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CalendarDays size={13} strokeWidth={1.75} aria-hidden />
|
||||
{dateRange}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin size={13} strokeWidth={1.75} aria-hidden />
|
||||
{trip.location}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
|
||||
Organizer:{" "}
|
||||
@@ -93,8 +118,12 @@ export default async function PaymentPage({ params }: PageProps) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-6 sm:py-10">
|
||||
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm">
|
||||
<Link href={`/trips/${trip.id}`} className="hover:text-primary-600">
|
||||
← Kembali ke trip
|
||||
<Link
|
||||
href={`/trips/${trip.id}`}
|
||||
className="inline-flex items-center gap-1 hover:text-primary-600"
|
||||
>
|
||||
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
|
||||
Kembali ke trip
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -104,29 +133,19 @@ export default async function PaymentPage({ params }: PageProps) {
|
||||
<p className="mb-5 text-sm text-neutral-500">
|
||||
{tripIsFree
|
||||
? "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>
|
||||
|
||||
{tripHeader}
|
||||
|
||||
{tripIsFree ? (
|
||||
<FreeTripSection
|
||||
tripId={trip.id}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
<FreeTripSection tripId={trip.id} bookingStatus={booking.status} />
|
||||
) : (
|
||||
<PaidTripSection
|
||||
tripId={trip.id}
|
||||
organizerId={trip.organizerId}
|
||||
organizerName={trip.organizer.name}
|
||||
price={trip.price}
|
||||
bookingStatus={booking.status}
|
||||
paymentMarkedAt={
|
||||
latestManualPayment?.status === "AWAITING"
|
||||
? latestManualPayment.updatedAt
|
||||
: null
|
||||
}
|
||||
paymentPaidAt={latestManualPayment?.paidAt ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,12 +172,7 @@ function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FreeTripSection({
|
||||
tripId,
|
||||
bookingStatus,
|
||||
}: {
|
||||
tripId: string;
|
||||
bookingStatus:
|
||||
type BookingStatus =
|
||||
| "PENDING"
|
||||
| "AWAITING_PAY"
|
||||
| "PAID"
|
||||
@@ -166,11 +180,23 @@ function FreeTripSection({
|
||||
| "REFUNDED"
|
||||
| "PARTIALLY_REFUNDED"
|
||||
| "EXPIRED";
|
||||
|
||||
function FreeTripSection({
|
||||
tripId,
|
||||
bookingStatus,
|
||||
}: {
|
||||
tripId: string;
|
||||
bookingStatus: BookingStatus;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
|
||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl">
|
||||
🎉
|
||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
|
||||
<PartyPopper
|
||||
size={26}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-emerald-600"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
|
||||
Trip ini gratis
|
||||
@@ -183,10 +209,28 @@ function FreeTripSection({
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
|
||||
Status keikutsertaan
|
||||
</p>
|
||||
<p className="text-sm font-bold text-neutral-800">
|
||||
{bookingStatus === "PAID"
|
||||
? "✅ Terkonfirmasi sebagai peserta"
|
||||
: "⏳ Menunggu persetujuan organizer"}
|
||||
<p className="flex items-center gap-1.5 text-sm font-bold text-neutral-800">
|
||||
{bookingStatus === "PAID" ? (
|
||||
<>
|
||||
<CircleCheck
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
className="text-emerald-600"
|
||||
/>
|
||||
Terkonfirmasi sebagai peserta
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
className="text-amber-600"
|
||||
/>
|
||||
Menunggu persetujuan organizer
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -202,155 +246,74 @@ function FreeTripSection({
|
||||
);
|
||||
}
|
||||
|
||||
async function PaidTripSection({
|
||||
function PaidTripSection({
|
||||
tripId,
|
||||
organizerId,
|
||||
organizerName,
|
||||
price,
|
||||
bookingStatus,
|
||||
paymentMarkedAt,
|
||||
paymentPaidAt,
|
||||
}: {
|
||||
tripId: string;
|
||||
organizerId: string;
|
||||
organizerName: string;
|
||||
price: number;
|
||||
bookingStatus:
|
||||
| "PENDING"
|
||||
| "AWAITING_PAY"
|
||||
| "PAID"
|
||||
| "CANCELLED"
|
||||
| "REFUNDED"
|
||||
| "PARTIALLY_REFUNDED"
|
||||
| "EXPIRED";
|
||||
paymentMarkedAt: Date | null;
|
||||
paymentPaidAt: Date | null;
|
||||
bookingStatus: BookingStatus;
|
||||
}) {
|
||||
const verification = await organizerService.getStatusForUser(organizerId);
|
||||
const bankAvailable = verification?.status === "APPROVED";
|
||||
|
||||
const isApproved = bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
|
||||
const isApproved =
|
||||
bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
|
||||
const isPendingApproval = bookingStatus === "PENDING";
|
||||
const hasMarkedPaid = !!paymentMarkedAt || !!paymentPaidAt;
|
||||
const isFullyPaid = bookingStatus === "PAID";
|
||||
const canMarkPaid = bookingStatus === "AWAITING_PAY" && !paymentMarkedAt;
|
||||
const canPay = bookingStatus === "AWAITING_PAY";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PaymentTimeline
|
||||
approved={isApproved}
|
||||
markedPaid={hasMarkedPaid}
|
||||
confirmedPaid={isFullyPaid}
|
||||
/>
|
||||
<PaymentTimeline approved={isApproved} 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">
|
||||
<h3 className="mb-1 text-sm font-bold text-neutral-900 sm:text-base">
|
||||
Transfer ke rekening organizer
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-sm font-bold text-neutral-900 sm:text-base">
|
||||
Total Pembayaran
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-neutral-500 sm:text-sm">
|
||||
Pastikan nominal persis seperti tercantum supaya organizer mudah
|
||||
mencocokkan.
|
||||
<p className="text-lg font-bold text-primary-700 sm:text-xl">
|
||||
{formatRupiah(price)}
|
||||
</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>
|
||||
|
||||
<ul className="mt-4 space-y-1.5 text-[11px] text-neutral-500 sm:text-xs">
|
||||
<li>• Transfer dengan nominal pas, jangan dibulatkan.</li>
|
||||
<li>• Simpan bukti transfer untuk jaga-jaga jika ada konfirmasi.</li>
|
||||
<li>
|
||||
• Setelah transfer, tekan tombol <em>Saya sudah bayar</em> di bawah
|
||||
supaya organizer tahu dan bisa konfirmasi.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Pembayaran diproses oleh Midtrans (BCA VA, GoPay, QRIS, kartu, dll).
|
||||
Dana ditahan SeTrip sampai trip selesai — bukan transfer langsung
|
||||
ke organizer.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isPendingApproval && (
|
||||
<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
|
||||
dulu sebelum transfer — supaya tidak perlu refund kalau ditolak.
|
||||
dulu sebelum bayar — supaya tidak perlu refund kalau ditolak.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canMarkPaid && (
|
||||
<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>
|
||||
)}
|
||||
{canPay && <MidtransPayButton tripId={tripId} />}
|
||||
|
||||
{hasMarkedPaid && (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white p-4 text-sm text-neutral-600 shadow-sm sm:p-5">
|
||||
{isFullyPaid ? (
|
||||
{isFullyPaid && (
|
||||
<div className="flex items-start gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
|
||||
<CircleCheck
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
className="mt-0.5 shrink-0 text-emerald-600"
|
||||
/>
|
||||
<p>
|
||||
✅ Pembayaran kamu sudah dikonfirmasi oleh{" "}
|
||||
<span className="font-semibold text-neutral-800">
|
||||
{organizerName}
|
||||
</span>
|
||||
. Sampai jumpa di trip!
|
||||
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip bareng{" "}
|
||||
<span className="font-semibold">{organizerName}</span>!
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
⏳ Kamu sudah menandai sudah bayar. Menunggu organizer mengecek
|
||||
dan mengonfirmasi.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/trips/${tripId}`}
|
||||
className="text-sm text-neutral-500 hover:text-primary-600"
|
||||
className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-primary-600"
|
||||
>
|
||||
← Kembali ke detail trip
|
||||
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
|
||||
Kembali ke detail trip
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,17 +322,14 @@ async function PaidTripSection({
|
||||
|
||||
function PaymentTimeline({
|
||||
approved,
|
||||
markedPaid,
|
||||
confirmedPaid,
|
||||
}: {
|
||||
approved: boolean;
|
||||
markedPaid: boolean;
|
||||
confirmedPaid: boolean;
|
||||
}) {
|
||||
const steps = [
|
||||
{ label: "Disetujui organizer", done: approved },
|
||||
{ label: "Kamu menandai sudah bayar", done: markedPaid },
|
||||
{ label: "Organizer konfirmasi pembayaran", done: confirmedPaid },
|
||||
{ label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -387,7 +347,11 @@ function PaymentTimeline({
|
||||
: "bg-neutral-200 text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{s.done ? "✓" : i + 1}
|
||||
{s.done ? (
|
||||
<Check size={12} strokeWidth={3} aria-hidden />
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
@@ -404,37 +368,3 @@ function PaymentTimeline({
|
||||
</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,29 @@
|
||||
/** Skeleton daftar trip — tampil instan saat list masih di-fetch. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 h-8 w-56 animate-pulse rounded-lg bg-neutral-200 sm:mb-8" />
|
||||
<div className="mb-6 h-40 animate-pulse rounded-2xl bg-neutral-100" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="overflow-hidden rounded-2xl border border-neutral-200 bg-white"
|
||||
>
|
||||
<div className="h-40 animate-pulse bg-neutral-200" />
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="h-5 w-3/4 animate-pulse rounded bg-neutral-200" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-neutral-100" />
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-neutral-100" />
|
||||
<div className="mt-3 flex items-center justify-between border-t border-neutral-100 pt-3">
|
||||
<div className="h-5 w-20 animate-pulse rounded bg-neutral-200" />
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-neutral-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { siteConfig } from "@/lib/site";
|
||||
import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
|
||||
import { isVibe } from "@/lib/vibe";
|
||||
import type { GroupSize } from "@/server/repositories/trip.repo";
|
||||
import { Plus, Search, Tent } from "lucide-react";
|
||||
|
||||
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
|
||||
function isGroupSize(value: unknown): value is GroupSize {
|
||||
@@ -98,9 +99,10 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
|
||||
</div>
|
||||
<Link
|
||||
href="/create-trip"
|
||||
className="w-full rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
|
||||
>
|
||||
+ Buat Trip
|
||||
<Plus size={16} strokeWidth={2} aria-hidden />
|
||||
Buat Trip
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -113,8 +115,22 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
|
||||
|
||||
{trips.length === 0 ? (
|
||||
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
|
||||
{hasFilters ? "🔍" : "🏕️"}
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
|
||||
{hasFilters ? (
|
||||
<Search
|
||||
size={26}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
) : (
|
||||
<Tent
|
||||
size={26}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
|
||||
{hasFilters
|
||||
@@ -137,7 +153,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{trips.map((trip) => (
|
||||
{trips.map((trip, index) => (
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
id={trip.id}
|
||||
@@ -154,6 +170,9 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
|
||||
organizerName={trip.organizer.name}
|
||||
status={trip.status}
|
||||
coverImage={trip.images[0]?.url}
|
||||
// Baris pertama (3 kartu) di atas fold — muat segera supaya
|
||||
// tidak jadi LCP yang lambat.
|
||||
priority={index < 3}
|
||||
isVerifiedOrganizer={
|
||||
trip.organizer.organizerVerification?.status === "APPROVED"
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { OrganizerStatsPanel } from "@/features/profile/components/organizer-sta
|
||||
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
import { vibeMeta } from "@/lib/vibe";
|
||||
import { BadgeCheck, MapPin, AtSign } from "lucide-react";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -86,10 +87,11 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
||||
</h1>
|
||||
{isVerifiedOrganizer && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
|
||||
title="Identitas organizer telah diverifikasi (KTP & rekening)"
|
||||
>
|
||||
✅ Verified Organizer
|
||||
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
|
||||
Verified Organizer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -97,7 +99,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500">
|
||||
{profile?.city && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
📍 {profile.city}
|
||||
<MapPin size={13} strokeWidth={1.75} aria-hidden />
|
||||
{profile.city}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs">Bergabung sejak {memberSince}</span>
|
||||
@@ -141,8 +144,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<span>📸</span>
|
||||
<span>@{profile.instagram}</span>
|
||||
<AtSign size={15} strokeWidth={1.75} aria-hidden />
|
||||
<span>{profile.instagram}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,11 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Clock, RefreshCw, CircleX, ArrowLeft } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import { VerifyForm } from "@/features/organizer/components/verify-form";
|
||||
import { VerifiedBadge } from "@/components/shared/verified-badge";
|
||||
|
||||
function reuploadFieldLabel(field: string): string {
|
||||
switch (field) {
|
||||
case "ktpImage":
|
||||
return "Foto KTP";
|
||||
case "liveness":
|
||||
return "Foto liveness (pegang kertas SETRIP)";
|
||||
case "nik":
|
||||
return "NIK";
|
||||
case "bankInfo":
|
||||
return "Info rekening";
|
||||
case "address":
|
||||
return "Alamat";
|
||||
default:
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function VerifyPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
@@ -53,10 +71,11 @@ export default async function VerifyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status === "PENDING" && (
|
||||
{verification?.status === "PENDING" && !verification.reuploadRequested && (
|
||||
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
|
||||
<p className="mb-1 text-sm font-bold text-amber-800">
|
||||
⏳ Menunggu review admin
|
||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-800">
|
||||
<Clock size={15} strokeWidth={2} aria-hidden />
|
||||
Menunggu review admin
|
||||
</p>
|
||||
<p className="text-sm text-neutral-700">
|
||||
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
|
||||
@@ -64,9 +83,47 @@ export default async function VerifyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.reuploadRequested && (
|
||||
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-900">
|
||||
<RefreshCw size={15} strokeWidth={2} aria-hidden />
|
||||
Admin minta kamu upload ulang
|
||||
</p>
|
||||
{verification.reuploadNote && (
|
||||
<p className="mb-3 text-sm text-neutral-700">
|
||||
<span className="font-semibold">Catatan admin:</span>{" "}
|
||||
{verification.reuploadNote}
|
||||
</p>
|
||||
)}
|
||||
{verification.reuploadFields.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="mb-1 text-xs font-semibold text-amber-900">
|
||||
Field yang perlu di-upload ulang:
|
||||
</p>
|
||||
<ul className="ml-4 list-disc text-xs text-neutral-700">
|
||||
{verification.reuploadFields.map((f) => (
|
||||
<li key={f}>
|
||||
<span className="font-semibold">
|
||||
{reuploadFieldLabel(f)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-neutral-700">
|
||||
Submit ulang form di bawah dengan data/foto yang sudah diperbaiki.
|
||||
Setelah submit, banner ini hilang otomatis.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status === "REJECTED" && (
|
||||
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
|
||||
<p className="mb-1 text-sm font-bold text-red-800">❌ Pengajuan ditolak</p>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-red-800">
|
||||
<CircleX size={15} strokeWidth={2} aria-hidden />
|
||||
Pengajuan ditolak
|
||||
</p>
|
||||
{verification.rejectionReason && (
|
||||
<p className="text-sm text-neutral-700">
|
||||
<span className="font-semibold">Alasan:</span>{" "}
|
||||
@@ -79,13 +136,17 @@ export default async function VerifyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification?.status !== "APPROVED" && verification?.status !== "PENDING" && (
|
||||
<VerifyForm initial={initial} />
|
||||
)}
|
||||
{(verification?.status !== "APPROVED" &&
|
||||
(verification?.status !== "PENDING" ||
|
||||
verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
|
||||
|
||||
<p className="mt-6 text-center text-sm text-neutral-500">
|
||||
<Link href="/profile" className="hover:text-primary-600">
|
||||
← Kembali ke profil
|
||||
<Link
|
||||
href="/profile"
|
||||
className="inline-flex items-center gap-1 hover:text-primary-600"
|
||||
>
|
||||
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
|
||||
Kembali ke profil
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,243 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { Prisma } from "@/app/generated/prisma/client";
|
||||
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
"Refund",
|
||||
"Payout",
|
||||
"Trip",
|
||||
"User",
|
||||
"OrganizerVerification",
|
||||
"Payment",
|
||||
] as const;
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{
|
||||
entityType?: string;
|
||||
action?: string;
|
||||
reviewer?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export default async function AdminAuditLogPage({ searchParams }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/audit-log");
|
||||
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 dateFrom = parseDate(params.dateFrom);
|
||||
const dateTo = parseDate(params.dateTo);
|
||||
|
||||
const where: Prisma.AdminActionLogWhereInput = {};
|
||||
if (params.entityType && ENTITY_TYPES.includes(params.entityType as never)) {
|
||||
where.entityType = params.entityType;
|
||||
}
|
||||
if (params.action) {
|
||||
where.action = { contains: params.action, mode: "insensitive" };
|
||||
}
|
||||
if (params.reviewer) {
|
||||
where.adminEmail = params.reviewer;
|
||||
}
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {
|
||||
...(dateFrom && { gte: dateFrom }),
|
||||
...(dateTo && { lte: dateTo }),
|
||||
};
|
||||
}
|
||||
|
||||
const logs = await prisma.adminActionLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Audit Log
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Catatan semua aksi admin lintas entity (refund, payout, trip cancel,
|
||||
user suspend, dst). Append-only. Maksimal 200 baris terbaru per query
|
||||
— pakai filter untuk drill-down.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AdminFilterBar
|
||||
action="/admin/audit-log"
|
||||
values={{
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
reviewer: params.reviewer,
|
||||
}}
|
||||
reviewerOptions={listAdminEmails()}
|
||||
reviewerLabel="Admin"
|
||||
/>
|
||||
|
||||
<form method="get" className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||
<input type="hidden" name="dateFrom" value={params.dateFrom ?? ""} />
|
||||
<input type="hidden" name="dateTo" value={params.dateTo ?? ""} />
|
||||
<input type="hidden" name="reviewer" value={params.reviewer ?? ""} />
|
||||
<div>
|
||||
<label
|
||||
htmlFor="filter-entity"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Entity type
|
||||
</label>
|
||||
<select
|
||||
id="filter-entity"
|
||||
name="entityType"
|
||||
defaultValue={params.entityType ?? ""}
|
||||
className="w-full rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
|
||||
>
|
||||
<option value="">Semua</option>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="filter-action"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Action (contains)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="filter-action"
|
||||
name="action"
|
||||
defaultValue={params.action ?? ""}
|
||||
placeholder="mis. REFUND, SUSPEND, CANCEL"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Cari
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{logs.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 audit log yang cocok dengan filter ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||||
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Waktu</th>
|
||||
<th className="px-3 py-2 text-left">Admin</th>
|
||||
<th className="px-3 py-2 text-left">Action</th>
|
||||
<th className="px-3 py-2 text-left">Entity</th>
|
||||
<th className="px-3 py-2 text-left">Entity ID</th>
|
||||
<th className="px-3 py-2 text-left">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||||
{logs.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-neutral-500">
|
||||
{row.createdAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2">
|
||||
{row.adminEmail}
|
||||
{!row.adminId && (
|
||||
<span className="ml-1 text-[10px] text-amber-700">
|
||||
(deleted)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2">
|
||||
<span className="rounded bg-primary-50 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-primary-800">
|
||||
{row.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 font-medium">
|
||||
{row.entityType}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<EntityIdLink
|
||||
entityType={row.entityType}
|
||||
entityId={row.entityId}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-neutral-500">
|
||||
{row.payload ? (
|
||||
<code className="block max-w-md overflow-x-auto rounded bg-neutral-50 px-2 py-1 font-mono text-[10px]">
|
||||
{JSON.stringify(row.payload)}
|
||||
</code>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityIdLink({
|
||||
entityType,
|
||||
entityId,
|
||||
}: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
}) {
|
||||
const short = `${entityId.slice(0, 8)}…`;
|
||||
let href: string | null = null;
|
||||
if (entityType === "Trip") href = `/admin/trips/${entityId}`;
|
||||
if (entityType === "User") href = `/admin/users/${entityId}`;
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="font-mono text-[11px] text-secondary-700 hover:text-secondary-900"
|
||||
>
|
||||
{short}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-[11px]">{short}</span>;
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ArrowLeft, CalendarDays, CircleAlert, MapPin } from "lucide-react";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { AdminReconcileButton } from "@/features/booking/components/admin-reconcile-button";
|
||||
import { RawCallbackViewer } from "@/features/booking/components/raw-callback-viewer";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminBookingDetailPage({ params }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
redirect("/login?callbackUrl=/admin/bookings");
|
||||
}
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Halaman ini hanya untuk admin SeTrip.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const booking = await bookingRepo.findByIdForAdmin(id);
|
||||
if (!booking) notFound();
|
||||
|
||||
// Build chronological timeline lintas Payment + Refund + Payout.
|
||||
type TimelineEvent =
|
||||
| {
|
||||
kind: "payment";
|
||||
at: Date;
|
||||
payment: (typeof booking.payments)[number];
|
||||
}
|
||||
| {
|
||||
kind: "refund";
|
||||
at: Date;
|
||||
refund: (typeof booking.refunds)[number];
|
||||
}
|
||||
| {
|
||||
kind: "payout";
|
||||
at: Date;
|
||||
payout: NonNullable<typeof booking.payout>;
|
||||
};
|
||||
|
||||
const timeline: TimelineEvent[] = [];
|
||||
for (const p of booking.payments) {
|
||||
timeline.push({ kind: "payment", at: p.createdAt, payment: p });
|
||||
}
|
||||
for (const r of booking.refunds) {
|
||||
timeline.push({ kind: "refund", at: r.createdAt, refund: r });
|
||||
}
|
||||
if (booking.payout) {
|
||||
timeline.push({
|
||||
kind: "payout",
|
||||
at: booking.payout.createdAt,
|
||||
payout: booking.payout,
|
||||
});
|
||||
}
|
||||
timeline.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-1 hover:text-primary-600"
|
||||
>
|
||||
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/trips/${booking.tripId}`}
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
Trip terkait
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<header className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Booking
|
||||
</p>
|
||||
<h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||
{booking.trip.title}
|
||||
</h1>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
||||
<CalendarDays
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
/>
|
||||
{formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}
|
||||
<span aria-hidden>·</span>
|
||||
<MapPin
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
/>
|
||||
{booking.trip.destination}, {booking.trip.location}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<FieldRow label="Peserta" value={booking.user.name} sub={booking.user.email} />
|
||||
<FieldRow
|
||||
label="Organizer"
|
||||
value={booking.trip.organizer.name}
|
||||
sub={booking.trip.organizer.email}
|
||||
/>
|
||||
<FieldRow
|
||||
label="Nominal booking"
|
||||
value={formatRupiah(booking.amount)}
|
||||
strong
|
||||
/>
|
||||
<FieldRow label="Status booking" value={booking.status} badge />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-neutral-100 pt-3 text-[11px] text-neutral-500">
|
||||
<p>
|
||||
Booking ID:{" "}
|
||||
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700">
|
||||
{booking.id}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<h2 className="mb-4 text-sm font-bold text-neutral-900 sm:text-base">
|
||||
Timeline Money Flow ({timeline.length} event)
|
||||
</h2>
|
||||
|
||||
{timeline.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">
|
||||
Belum ada event payment / refund / payout untuk booking ini.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-4">
|
||||
{timeline.map((ev, idx) => (
|
||||
<li key={idx} className="border-l-2 border-neutral-200 pl-4">
|
||||
{ev.kind === "payment" && (
|
||||
<PaymentEventCard payment={ev.payment} />
|
||||
)}
|
||||
{ev.kind === "refund" && <RefundEventCard refund={ev.refund} />}
|
||||
{ev.kind === "payout" && <PayoutEventCard payout={ev.payout} />}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
strong,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
strong?: boolean;
|
||||
badge?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
{badge ? (
|
||||
<span className="mt-0.5 inline-block rounded-full bg-neutral-200 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-neutral-700">
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<p
|
||||
className={`mt-0.5 text-neutral-800 ${
|
||||
strong ? "text-base font-bold text-primary-700" : "text-sm font-semibold"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
{sub && (
|
||||
<p className="text-[11px] text-neutral-500">{sub}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventHeader({
|
||||
kind,
|
||||
title,
|
||||
at,
|
||||
}: {
|
||||
kind: "payment" | "refund" | "payout";
|
||||
title: string;
|
||||
at: Date;
|
||||
}) {
|
||||
const dotCls =
|
||||
kind === "payment"
|
||||
? "bg-secondary-500"
|
||||
: kind === "refund"
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${dotCls}`} />
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-neutral-700">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{at.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentEventCard({
|
||||
payment,
|
||||
}: {
|
||||
payment: {
|
||||
id: string;
|
||||
provider: string;
|
||||
method: string | null;
|
||||
amount: number;
|
||||
status: string;
|
||||
externalOrderId: string;
|
||||
externalTxId: string | null;
|
||||
snapToken: string | null;
|
||||
expiresAt: Date | null;
|
||||
paidAt: Date | null;
|
||||
failedAt: Date | null;
|
||||
rejectionReason: string | null;
|
||||
rawCallback: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
}) {
|
||||
const canReconcile = payment.provider === "MIDTRANS";
|
||||
return (
|
||||
<div>
|
||||
<EventHeader
|
||||
kind="payment"
|
||||
title={`Payment ${payment.provider}`}
|
||||
at={payment.createdAt}
|
||||
/>
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-xs text-neutral-700">
|
||||
<p>
|
||||
<span className="font-semibold">Order ID:</span>{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{payment.externalOrderId}
|
||||
</code>
|
||||
</p>
|
||||
{payment.externalTxId && (
|
||||
<p>
|
||||
<span className="font-semibold">Transaction ID:</span>{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{payment.externalTxId}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-semibold">Nominal:</span>{" "}
|
||||
{formatRupiah(payment.amount)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Status:</span>{" "}
|
||||
<StatusBadge value={payment.status} />
|
||||
{payment.method && (
|
||||
<span className="ml-2 text-neutral-500">
|
||||
via <span className="font-medium">{payment.method}</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{payment.expiresAt && (
|
||||
<p className="text-neutral-500">
|
||||
Expires:{" "}
|
||||
{payment.expiresAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{payment.paidAt && (
|
||||
<p className="text-emerald-700">
|
||||
Paid at:{" "}
|
||||
{payment.paidAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{payment.rejectionReason && (
|
||||
<p className="flex items-center gap-1 text-red-700">
|
||||
<CircleAlert
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
/>
|
||||
{payment.rejectionReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canReconcile && (
|
||||
<AdminReconcileButton orderId={payment.externalOrderId} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 border-t border-neutral-200 pt-3">
|
||||
<RawCallbackViewer payload={payment.rawCallback} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefundEventCard({
|
||||
refund,
|
||||
}: {
|
||||
refund: {
|
||||
id: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
status: string;
|
||||
adminNote: string | null;
|
||||
reportNote: string;
|
||||
createdAt: Date;
|
||||
reviewedAt: Date | null;
|
||||
succeededAt: Date | null;
|
||||
failedAt: Date | null;
|
||||
reviewedBy: { id: string; name: string; email: string } | null;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<EventHeader
|
||||
kind="refund"
|
||||
title={`Refund (${refund.reason})`}
|
||||
at={refund.createdAt}
|
||||
/>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50/60 p-3 sm:p-4">
|
||||
<div className="space-y-1 text-xs text-neutral-700">
|
||||
<p>
|
||||
<span className="font-semibold">Nominal:</span>{" "}
|
||||
{formatRupiah(refund.amount)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Status:</span>{" "}
|
||||
<StatusBadge value={refund.status} />
|
||||
</p>
|
||||
{refund.reportNote && (
|
||||
<p className="text-neutral-600">
|
||||
<span className="font-semibold">Report:</span> {refund.reportNote}
|
||||
</p>
|
||||
)}
|
||||
{refund.adminNote && (
|
||||
<p className="text-neutral-600">
|
||||
<span className="font-semibold">Admin note:</span>{" "}
|
||||
{refund.adminNote}
|
||||
</p>
|
||||
)}
|
||||
{refund.reviewedBy && (
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Reviewed by {refund.reviewedBy.email}
|
||||
{refund.reviewedAt && (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
{refund.reviewedAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PayoutEventCard({
|
||||
payout,
|
||||
}: {
|
||||
payout: {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
heldUntil: Date;
|
||||
releasedAt: Date | null;
|
||||
paidAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
adminNote: string | null;
|
||||
createdAt: Date;
|
||||
processedBy: { id: string; name: string; email: string } | null;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<EventHeader kind="payout" title="Payout ke organizer" at={payout.createdAt} />
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-3 sm:p-4">
|
||||
<div className="space-y-1 text-xs text-neutral-700">
|
||||
<p>
|
||||
<span className="font-semibold">Nominal:</span>{" "}
|
||||
{formatRupiah(payout.amount)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Status:</span>{" "}
|
||||
<StatusBadge value={payout.status} />
|
||||
</p>
|
||||
<p className="text-neutral-600">
|
||||
Held until:{" "}
|
||||
{payout.heldUntil.toLocaleDateString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
{payout.paidAt && (
|
||||
<p className="text-emerald-700">
|
||||
Paid at:{" "}
|
||||
{payout.paidAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{payout.adminNote && (
|
||||
<p className="text-neutral-600">
|
||||
<span className="font-semibold">Admin note:</span>{" "}
|
||||
{payout.adminNote}
|
||||
</p>
|
||||
)}
|
||||
{payout.processedBy && (
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Processed by {payout.processedBy.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ value }: { value: string }) {
|
||||
const finalStatuses = ["PAID", "SUCCEEDED", "RELEASED"];
|
||||
const negativeStatuses = ["FAILED", "EXPIRED", "CANCELLED", "REJECTED"];
|
||||
const cls = finalStatuses.includes(value)
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: negativeStatuses.includes(value)
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-amber-100 text-amber-800";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin · Email Log",
|
||||
description:
|
||||
"Halaman admin untuk memantau pengiriman email — antrian, kegagalan, dan retry.",
|
||||
alternates: { canonical: "/admin/emails" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AdminEmailsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { emailRepo } from "@/server/repositories/email.repo";
|
||||
import {
|
||||
RetryEmailButton,
|
||||
ResendEmailButton,
|
||||
} from "@/features/email/components/email-row-actions";
|
||||
|
||||
type Tab = "failed" | "queue" | "sent";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "failed", label: "Gagal" },
|
||||
{ key: "queue", label: "Antrian" },
|
||||
{ key: "sent", label: "Terkirim" },
|
||||
];
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string; to?: string; template?: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminEmailsPage({ searchParams }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/emails");
|
||||
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)
|
||||
: "failed";
|
||||
const filters = {
|
||||
to: params.to?.trim() || undefined,
|
||||
template: params.template?.trim() || undefined,
|
||||
};
|
||||
|
||||
const stats = await emailRepo.stats();
|
||||
const jobs =
|
||||
tab === "sent"
|
||||
? []
|
||||
: await emailRepo.listJobs(
|
||||
tab === "failed" ? ["FAILED"] : ["PENDING", "PROCESSING"],
|
||||
filters
|
||||
);
|
||||
const sent = tab === "sent" ? await emailRepo.listSent(filters) : [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Email Log
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Pantau pengiriman email transaksional. Email yang gagal dikirim bisa
|
||||
di-retry manual; email terkirim bisa di-resend kalau peserta lapor
|
||||
tidak menerima.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Kartu ringkasan */}
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label="Antri dikirim"
|
||||
value={stats.queued}
|
||||
tone={stats.queued > 0 ? "amber" : "ok"}
|
||||
hint="Job menunggu cron / retry"
|
||||
/>
|
||||
<StatCard
|
||||
label="Gagal 24 jam"
|
||||
value={stats.failed24h}
|
||||
tone={stats.failed24h > 0 ? "red" : "ok"}
|
||||
hint="Job gagal dalam sehari terakhir"
|
||||
/>
|
||||
<StatCard
|
||||
label="Perlu aksi manual"
|
||||
value={stats.deadLetter}
|
||||
tone={stats.deadLetter > 0 ? "red" : "ok"}
|
||||
hint="Gagal & habis 5 attempt — cron berhenti retry"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{TABS.map((t) => (
|
||||
<a
|
||||
key={t.key}
|
||||
href={`/admin/emails?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>
|
||||
|
||||
{/* Filter */}
|
||||
<form
|
||||
method="get"
|
||||
action="/admin/emails"
|
||||
className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm"
|
||||
>
|
||||
<input type="hidden" name="tab" value={tab} />
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<label
|
||||
htmlFor="filter-to"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Penerima (email)
|
||||
</label>
|
||||
<input
|
||||
id="filter-to"
|
||||
name="to"
|
||||
defaultValue={params.to ?? ""}
|
||||
placeholder="user@email.com"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<label
|
||||
htmlFor="filter-template"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Template
|
||||
</label>
|
||||
<input
|
||||
id="filter-template"
|
||||
name="template"
|
||||
defaultValue={params.template ?? ""}
|
||||
placeholder="mis. refund_succeeded"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Cari
|
||||
</button>
|
||||
{(filters.to || filters.template) && (
|
||||
<a
|
||||
href={`/admin/emails?tab=${tab}`}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-50"
|
||||
>
|
||||
Reset
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{tab === "sent" ? (
|
||||
<SentTable rows={sent} />
|
||||
) : (
|
||||
<JobTable rows={jobs} tab={tab} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "ok" | "amber" | "red";
|
||||
hint: string;
|
||||
}) {
|
||||
const cls =
|
||||
tone === "red"
|
||||
? "border-red-200 bg-red-50/60"
|
||||
: tone === "amber"
|
||||
? "border-amber-200 bg-amber-50/60"
|
||||
: "border-emerald-200 bg-emerald-50/50";
|
||||
const valueCls =
|
||||
tone === "red"
|
||||
? "text-red-700"
|
||||
: tone === "amber"
|
||||
? "text-amber-700"
|
||||
: "text-emerald-700";
|
||||
return (
|
||||
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
|
||||
<p className="mt-0.5 text-[11px] text-neutral-500">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobTable({
|
||||
rows,
|
||||
tab,
|
||||
}: {
|
||||
rows: Awaited<ReturnType<typeof emailRepo.listJobs>>;
|
||||
tab: "failed" | "queue";
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
tab === "failed"
|
||||
? "Tidak ada email gagal — semua pengiriman lancar. 🎉"
|
||||
: "Tidak ada email yang sedang antri."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||||
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Penerima</th>
|
||||
<th className="px-3 py-2 text-left">Template</th>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Attempt</th>
|
||||
<th className="px-3 py-2 text-left">
|
||||
{tab === "failed" ? "Error terakhir" : "Dijadwalkan"}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-3 py-2">{r.to}</td>
|
||||
<td className="px-3 py-2 font-mono">{r.template}</td>
|
||||
<td className="px-3 py-2">
|
||||
<EmailBadge value={r.status} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.attempts}
|
||||
{r.attempts >= 5 && (
|
||||
<span className="ml-1 text-[10px] font-semibold text-red-600">
|
||||
(mati)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-neutral-500">
|
||||
{tab === "failed"
|
||||
? r.lastError
|
||||
? truncate(r.lastError, 90)
|
||||
: "—"
|
||||
: formatDateTime(r.scheduledAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<RetryEmailButton jobId={r.id} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SentTable({
|
||||
rows,
|
||||
}: {
|
||||
rows: Awaited<ReturnType<typeof emailRepo.listSent>>;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState message="Belum ada email terkirim yang cocok." />;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||||
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Penerima</th>
|
||||
<th className="px-3 py-2 text-left">Template</th>
|
||||
<th className="px-3 py-2 text-left">Subject</th>
|
||||
<th className="px-3 py-2 text-left">Terkirim</th>
|
||||
<th className="px-3 py-2 text-left">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-3 py-2">{r.to}</td>
|
||||
<td className="px-3 py-2 font-mono">{r.template}</td>
|
||||
<td className="px-3 py-2">{truncate(r.subject, 60)}</td>
|
||||
<td className="px-3 py-2 text-neutral-500">
|
||||
{formatDateTime(r.sentAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<ResendEmailButton emailSentId={r.id} disabled={!r.html} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||
<p className="text-sm text-neutral-500">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailBadge({ value }: { value: string }) {
|
||||
const cls =
|
||||
value === "SUCCESS"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: value === "FAILED"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-amber-100 text-amber-800";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(d: Date): string {
|
||||
return d.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Lock } from "lucide-react";
|
||||
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">
|
||||
<Lock
|
||||
size={28}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="mx-auto text-neutral-500"
|
||||
/>
|
||||
<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,373 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
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="inline-flex items-center gap-1 text-xs text-neutral-400 group-hover:text-primary-600">
|
||||
Buka
|
||||
<ChevronRight size={14} strokeWidth={2} aria-hidden />
|
||||
</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,152 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
|
||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
|
||||
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
|
||||
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;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
reviewer?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
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, {
|
||||
dateFrom: parseDate(params.dateFrom),
|
||||
dateTo: parseDate(params.dateTo),
|
||||
processorEmail: params.reviewer || undefined,
|
||||
});
|
||||
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,
|
||||
}));
|
||||
|
||||
const exportQuery = new URLSearchParams({ status: tab });
|
||||
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
|
||||
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
|
||||
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<ExportCsvLink
|
||||
href="/api/admin/export/payouts"
|
||||
query={exportQuery.toString()}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<AdminFilterBar
|
||||
action="/admin/payouts"
|
||||
values={{
|
||||
tab,
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
reviewer: params.reviewer,
|
||||
}}
|
||||
reviewerOptions={listAdminEmails()}
|
||||
reviewerLabel="Processor"
|
||||
/>
|
||||
|
||||
<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 yang cocok dengan filter ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((p) => (
|
||||
<PayoutReviewCard key={p.id} payout={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
|
||||
import { refundRepo } from "@/server/repositories/refund.repo";
|
||||
import { CreateRefundForm } from "@/features/refund/components/create-refund-form";
|
||||
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
|
||||
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
|
||||
import {
|
||||
RefundReviewCard,
|
||||
type RefundCardData,
|
||||
@@ -19,8 +21,30 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "FAILED", label: "Gagal" },
|
||||
];
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
{ value: "USER_CANCELLATION", label: "User cancel" },
|
||||
{ value: "ORGANIZER_CANCELLED", label: "Organizer cancel" },
|
||||
{ value: "TRIP_ISSUE", label: "Trip issue" },
|
||||
{ value: "ADMIN_ADJUSTMENT", label: "Admin adjustment" },
|
||||
{ value: "DISPUTE_RESOLVED", label: "Dispute resolved" },
|
||||
] as const;
|
||||
|
||||
type ReasonValue = (typeof REASON_OPTIONS)[number]["value"];
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
searchParams: Promise<{
|
||||
tab?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
reviewer?: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
@@ -40,8 +64,19 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
const tab: Tab = TABS.some((t) => t.key === params.tab)
|
||||
? (params.tab as Tab)
|
||||
: "PENDING";
|
||||
const reason: ReasonValue | undefined = REASON_OPTIONS.some(
|
||||
(r) => r.value === params.reason
|
||||
)
|
||||
? (params.reason as ReasonValue)
|
||||
: undefined;
|
||||
|
||||
const rows = await refundRepo.listByStatus(tab, {
|
||||
dateFrom: parseDate(params.dateFrom),
|
||||
dateTo: parseDate(params.dateTo),
|
||||
reviewerEmail: params.reviewer || undefined,
|
||||
reason,
|
||||
});
|
||||
|
||||
const rows = await refundRepo.listByStatus(tab);
|
||||
const items: RefundCardData[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
amount: r.amount,
|
||||
@@ -78,9 +113,16 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
},
|
||||
}));
|
||||
|
||||
const exportQuery = new URLSearchParams({ status: tab });
|
||||
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
|
||||
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
|
||||
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
|
||||
if (reason) exportQuery.set("reason", reason);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Review Refund Manual
|
||||
</h1>
|
||||
@@ -88,10 +130,29 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
Tinjau laporan refund dari peserta dan organizer. Setiap refund harus
|
||||
melalui approval admin sebelum dieksekusi.
|
||||
</p>
|
||||
</div>
|
||||
<ExportCsvLink
|
||||
href="/api/admin/export/refunds"
|
||||
query={exportQuery.toString()}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<CreateRefundForm />
|
||||
|
||||
<AdminFilterBar
|
||||
action="/admin/refunds"
|
||||
values={{
|
||||
tab,
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
reviewer: params.reviewer,
|
||||
reason: params.reason,
|
||||
}}
|
||||
reviewerOptions={listAdminEmails()}
|
||||
reviewerLabel="Reviewer"
|
||||
reasonOptions={[...REASON_OPTIONS]}
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{TABS.map((t) => (
|
||||
<a
|
||||
@@ -111,7 +172,7 @@ export default async function AdminRefundsPage({ searchParams }: PageProps) {
|
||||
{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 refund pada status ini.
|
||||
Tidak ada refund yang cocok dengan filter ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
} from "lucide-react";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { systemHealthService } from "@/server/services/system-health.service";
|
||||
import { emailRepo } from "@/server/repositories/email.repo";
|
||||
|
||||
interface JobSummary {
|
||||
jobName: string;
|
||||
lastRun: { at: Date; status: string; errorMessage: string | null } | null;
|
||||
lastSuccess: Date | null;
|
||||
totalRuns7d: number;
|
||||
failedRuns7d: number;
|
||||
}
|
||||
|
||||
async function getJobSummary(jobName: string): Promise<JobSummary> {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const [lastRun, lastSuccessRow, totalRuns7d, failedRuns7d] =
|
||||
await Promise.all([
|
||||
prisma.cronRun.findFirst({
|
||||
where: { jobName },
|
||||
orderBy: { startedAt: "desc" },
|
||||
select: { startedAt: true, status: true, errorMessage: true },
|
||||
}),
|
||||
prisma.cronRun.findFirst({
|
||||
where: { jobName, status: "SUCCESS" },
|
||||
orderBy: { startedAt: "desc" },
|
||||
select: { startedAt: true },
|
||||
}),
|
||||
prisma.cronRun.count({
|
||||
where: { jobName, startedAt: { gte: sevenDaysAgo } },
|
||||
}),
|
||||
prisma.cronRun.count({
|
||||
where: {
|
||||
jobName,
|
||||
status: "FAILED",
|
||||
startedAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
jobName,
|
||||
lastRun: lastRun
|
||||
? {
|
||||
at: lastRun.startedAt,
|
||||
status: lastRun.status,
|
||||
errorMessage: lastRun.errorMessage,
|
||||
}
|
||||
: null,
|
||||
lastSuccess: lastSuccessRow?.startedAt ?? null,
|
||||
totalRuns7d,
|
||||
failedRuns7d,
|
||||
};
|
||||
}
|
||||
|
||||
// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
|
||||
const TRACKED_JOBS = [
|
||||
"auto-complete-trips",
|
||||
"process-email-jobs",
|
||||
"cleanup-trip-images",
|
||||
] as const;
|
||||
|
||||
function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
|
||||
if (summary.lastRun?.status === "FAILED") return "failed";
|
||||
if (!summary.lastSuccess) return "stale";
|
||||
const hoursSince =
|
||||
(Date.now() - summary.lastSuccess.getTime()) / (1000 * 60 * 60);
|
||||
// Asumsi cron daily — > 25 jam dianggap stale.
|
||||
if (hoursSince > 25) return "stale";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export default async function AdminSystemPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/system");
|
||||
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 [summaries, recentRuns, stale, emailStats] = await Promise.all([
|
||||
Promise.all(TRACKED_JOBS.map(getJobSummary)),
|
||||
prisma.cronRun.findMany({
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
systemHealthService.detectStale(),
|
||||
emailRepo.stats(),
|
||||
]);
|
||||
|
||||
const hasAnyStale =
|
||||
stale.stalePaymentsCount > 0 ||
|
||||
stale.awaitingPayPastDepartureCount > 0 ||
|
||||
stale.overduePayoutsCount > 0 ||
|
||||
stale.stuckRefundsCount > 0 ||
|
||||
emailStats.deadLetter > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
System Health
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Status cron job otomatis. Refresh halaman ini setelah trigger cron
|
||||
manual atau saat investigasi.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{hasAnyStale && (
|
||||
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
|
||||
<h2 className="mb-2 flex items-center gap-1.5 text-sm font-bold text-amber-900">
|
||||
<CircleAlert size={16} strokeWidth={2} aria-hidden />
|
||||
Stale State Alerts
|
||||
</h2>
|
||||
<ul className="space-y-1 text-xs text-amber-900">
|
||||
{stale.stalePaymentsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.stalePaymentsCount}</strong> Payment MIDTRANS
|
||||
AWAITING > 25 jam — webhook mungkin tertunda. Cek manual lalu
|
||||
reconcile.
|
||||
</li>
|
||||
)}
|
||||
{stale.awaitingPayPastDepartureCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.awaitingPayPastDepartureCount}</strong> Booking
|
||||
AWAITING_PAY tapi trip sudah lewat tanggal berangkat — peserta
|
||||
lupa bayar, butuh cleanup.
|
||||
</li>
|
||||
)}
|
||||
{stale.overduePayoutsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.overduePayoutsCount}</strong> Payout HELD lewat
|
||||
heldUntil > 1 hari — cron release mungkin tidak jalan, cek
|
||||
cron history di bawah.{" "}
|
||||
<Link
|
||||
href="/admin/payouts?tab=HELD"
|
||||
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
|
||||
>
|
||||
Lihat HELD
|
||||
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{stale.stuckRefundsCount > 0 && (
|
||||
<li>
|
||||
• <strong>{stale.stuckRefundsCount}</strong> Refund APPROVED
|
||||
> 7 hari belum di-process.{" "}
|
||||
<Link
|
||||
href="/admin/refunds?tab=APPROVED"
|
||||
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
|
||||
>
|
||||
Lihat APPROVED
|
||||
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{emailStats.deadLetter > 0 && (
|
||||
<li>
|
||||
• <strong>{emailStats.deadLetter}</strong> email gagal kirim &
|
||||
sudah habis 5 attempt — cron berhenti retry, perlu retry
|
||||
manual.{" "}
|
||||
<Link
|
||||
href="/admin/emails?tab=failed"
|
||||
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
|
||||
>
|
||||
Lihat email gagal
|
||||
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Cron Jobs
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{summaries.map((s) => {
|
||||
const health = healthOf(s);
|
||||
const cls =
|
||||
health === "ok"
|
||||
? "border-emerald-200 bg-emerald-50/50"
|
||||
: health === "stale"
|
||||
? "border-amber-200 bg-amber-50/50"
|
||||
: "border-red-200 bg-red-50/50";
|
||||
const badge =
|
||||
health === "ok"
|
||||
? {
|
||||
label: "OK",
|
||||
icon: CircleCheck,
|
||||
cls: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
: health === "stale"
|
||||
? {
|
||||
label: "STALE",
|
||||
icon: CircleAlert,
|
||||
cls: "bg-amber-100 text-amber-800",
|
||||
}
|
||||
: {
|
||||
label: "FAILED",
|
||||
icon: CircleX,
|
||||
cls: "bg-red-100 text-red-800",
|
||||
};
|
||||
const BadgeIcon = badge.icon;
|
||||
return (
|
||||
<div
|
||||
key={s.jobName}
|
||||
className={`rounded-2xl border p-4 shadow-sm sm:p-5 ${cls}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Job
|
||||
</p>
|
||||
<p className="font-mono text-sm font-bold text-neutral-800">
|
||||
{s.jobName}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`}
|
||||
>
|
||||
<BadgeIcon size={12} strokeWidth={2.25} aria-hidden />
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 space-y-1 text-xs text-neutral-700">
|
||||
<div>
|
||||
<dt className="inline font-semibold">Last run:</dt>{" "}
|
||||
<dd className="inline">
|
||||
{s.lastRun
|
||||
? `${formatDateTime(s.lastRun.at)} · ${s.lastRun.status}`
|
||||
: "Belum pernah"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-semibold">Last success:</dt>{" "}
|
||||
<dd className="inline">
|
||||
{s.lastSuccess
|
||||
? formatDateTime(s.lastSuccess)
|
||||
: "Belum pernah"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-semibold">7 hari terakhir:</dt>{" "}
|
||||
<dd className="inline">
|
||||
{s.totalRuns7d} run, {s.failedRuns7d} failed
|
||||
</dd>
|
||||
</div>
|
||||
{s.lastRun?.errorMessage && (
|
||||
<div className="mt-2 rounded-lg bg-red-100 p-2 text-[11px] text-red-800">
|
||||
Error terakhir: {s.lastRun.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Email
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<EmailStat
|
||||
label="Antri dikirim"
|
||||
value={emailStats.queued}
|
||||
tone={emailStats.queued > 0 ? "amber" : "ok"}
|
||||
/>
|
||||
<EmailStat
|
||||
label="Gagal 24 jam"
|
||||
value={emailStats.failed24h}
|
||||
tone={emailStats.failed24h > 0 ? "red" : "ok"}
|
||||
/>
|
||||
<EmailStat
|
||||
label="Perlu aksi manual"
|
||||
value={emailStats.deadLetter}
|
||||
tone={emailStats.deadLetter > 0 ? "red" : "ok"}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-neutral-500">
|
||||
<Link
|
||||
href="/admin/emails"
|
||||
className="inline-flex items-center gap-1 font-semibold text-primary-600 hover:underline"
|
||||
>
|
||||
Buka Email Log
|
||||
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Recent Runs (20 terakhir)
|
||||
</h2>
|
||||
{recentRuns.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-8 text-center">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Belum ada cron run tercatat. Setelah cron berikutnya jalan, baris
|
||||
pertama akan muncul di sini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||||
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Job</th>
|
||||
<th className="px-3 py-2 text-left">Started</th>
|
||||
<th className="px-3 py-2 text-left">Finished</th>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||||
{recentRuns.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-3 py-2 font-mono">{r.jobName}</td>
|
||||
<td className="px-3 py-2">{formatDateTime(r.startedAt)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.finishedAt ? formatDateTime(r.finishedAt) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusBadge value={r.status} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-neutral-500">
|
||||
{r.errorMessage ??
|
||||
(r.payload
|
||||
? truncate(JSON.stringify(r.payload), 80)
|
||||
: "—")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(d: Date): string {
|
||||
return d.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
|
||||
function EmailStat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "ok" | "amber" | "red";
|
||||
}) {
|
||||
const cls =
|
||||
tone === "red"
|
||||
? "border-red-200 bg-red-50/60"
|
||||
: tone === "amber"
|
||||
? "border-amber-200 bg-amber-50/60"
|
||||
: "border-emerald-200 bg-emerald-50/50";
|
||||
const valueCls =
|
||||
tone === "red"
|
||||
? "text-red-700"
|
||||
: tone === "amber"
|
||||
? "text-amber-700"
|
||||
: "text-emerald-700";
|
||||
return (
|
||||
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ value }: { value: string }) {
|
||||
const cls =
|
||||
value === "SUCCESS"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: value === "FAILED"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-amber-100 text-amber-800";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ArrowLeft, CalendarDays, MapPin } from "lucide-react";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { categoryMeta } from "@/lib/activity-category";
|
||||
import { groupItineraryByDay } from "@/lib/itinerary";
|
||||
import { AdminCancelTripButton } from "@/features/trip/components/admin-cancel-trip-button";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminTripDetailPage({ params }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
redirect("/login?callbackUrl=/admin/trips");
|
||||
}
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Halaman ini hanya untuk admin SeTrip.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
let trip;
|
||||
try {
|
||||
trip = await tripService.getTripById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const cat = categoryMeta(trip.category);
|
||||
|
||||
const activeParticipants = trip.participants.filter(
|
||||
(p) => p.status !== "CANCELLED"
|
||||
);
|
||||
const confirmedCount = activeParticipants.filter(
|
||||
(p) => p.status === "CONFIRMED"
|
||||
).length;
|
||||
const pendingCount = activeParticipants.filter(
|
||||
(p) => p.status === "PENDING"
|
||||
).length;
|
||||
const cancelledCount = trip.participants.length - activeParticipants.length;
|
||||
|
||||
const grouped = trip.itineraryItems.length
|
||||
? groupItineraryByDay(
|
||||
trip.itineraryItems.map((i) => ({
|
||||
day: i.day,
|
||||
startTime: i.startTime,
|
||||
endTime: i.endTime,
|
||||
activity: i.activity,
|
||||
order: i.order,
|
||||
}))
|
||||
)
|
||||
: null;
|
||||
|
||||
const canCancel = trip.status === "OPEN" || trip.status === "FULL";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
<Link
|
||||
href="/admin/trips"
|
||||
className="inline-flex items-center gap-1 hover:text-primary-600"
|
||||
>
|
||||
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
|
||||
Kembali ke list trips
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<header className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 font-semibold text-neutral-600">
|
||||
<span aria-hidden>{cat.icon}</span> {cat.label}
|
||||
</span>
|
||||
<StatusBadge status={trip.status} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||
{trip.title}
|
||||
</h1>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
||||
<CalendarDays
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
/>
|
||||
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
|
||||
<span aria-hidden>·</span>
|
||||
<MapPin
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
/>
|
||||
{trip.destination}, {trip.location}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Organizer:{" "}
|
||||
<Link
|
||||
href={`/u/${trip.organizer.id}`}
|
||||
className="font-semibold text-neutral-700 hover:text-primary-600"
|
||||
target="_blank"
|
||||
>
|
||||
{trip.organizer.name}
|
||||
</Link>{" "}
|
||||
<span className="text-neutral-400">({trip.organizer.email})</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Trip ID:{" "}
|
||||
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-700">
|
||||
{trip.id}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Harga
|
||||
</p>
|
||||
<p className="text-xl font-bold text-primary-700 sm:text-2xl">
|
||||
{formatRupiah(trip.price)}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-500">per orang</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mb-6 grid gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Kapasitas" value={String(trip.maxParticipants)} />
|
||||
<StatCard
|
||||
label="Confirmed"
|
||||
value={String(confirmedCount)}
|
||||
accent="emerald"
|
||||
/>
|
||||
<StatCard
|
||||
label="Pending"
|
||||
value={String(pendingCount)}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Cancelled"
|
||||
value={String(cancelledCount)}
|
||||
accent="neutral"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{canCancel && (
|
||||
<section className="mb-6 rounded-2xl border border-red-200 bg-red-50/60 p-4 sm:p-5">
|
||||
<h2 className="text-sm font-bold text-red-900">
|
||||
Intervensi Admin — Cancel Trip
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-red-900/80">
|
||||
Pakai hanya saat organizer unreachable, safety issue, atau dispute
|
||||
tidak terselesaikan. Semua booking PAID akan auto-refund (full
|
||||
amount). Booking PENDING/AWAITING_PAY langsung CANCELLED.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<AdminCancelTripButton tripId={trip.id} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{trip.description && (
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Deskripsi
|
||||
</h2>
|
||||
<p className="whitespace-pre-wrap text-sm text-neutral-700">
|
||||
{trip.description}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{grouped && (
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Itinerary
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{[...grouped.entries()].map(([day, items]) => (
|
||||
<div
|
||||
key={day}
|
||||
className="rounded-lg border border-primary-100 bg-primary-50/40 p-3"
|
||||
>
|
||||
<p className="mb-1.5 text-xs font-bold text-primary-800">
|
||||
Hari {day}
|
||||
</p>
|
||||
<ol className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.order}
|
||||
className="flex gap-3 text-xs text-neutral-700"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] font-semibold text-primary-700">
|
||||
{item.startTime}
|
||||
{item.endTime ? `–${item.endTime}` : ""}
|
||||
</span>
|
||||
<span>{item.activity}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Peserta ({activeParticipants.length})
|
||||
</h2>
|
||||
{trip.participants.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">Belum ada peserta.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-neutral-100">
|
||||
{trip.participants.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/u/${p.user.id}`}
|
||||
target="_blank"
|
||||
className="text-sm font-semibold text-neutral-800 hover:text-primary-600"
|
||||
>
|
||||
{p.user.name}
|
||||
</Link>
|
||||
{p.user.profile?.city && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[11px] text-neutral-500">
|
||||
<MapPin size={12} strokeWidth={2} aria-hidden />
|
||||
{p.user.profile.city}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ParticipantStatusBadge status={p.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === "OPEN"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: status === "FULL"
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: status === "CLOSED"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-neutral-200 text-neutral-700";
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantStatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === "CONFIRMED"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: status === "PENDING"
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: "bg-neutral-200 text-neutral-600 line-through";
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
accent = "primary",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
accent?: "primary" | "emerald" | "amber" | "neutral";
|
||||
}) {
|
||||
const map = {
|
||||
primary: "text-primary-700",
|
||||
emerald: "text-emerald-700",
|
||||
amber: "text-amber-700",
|
||||
neutral: "text-neutral-700",
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-3 sm:p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xl font-bold sm:text-2xl ${map[accent]}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { CalendarDays, MapPin } from "lucide-react";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { tripRepo } from "@/server/repositories/trip.repo";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { categoryMeta } from "@/lib/activity-category";
|
||||
|
||||
type Tab = "ALL" | "OPEN" | "FULL" | "CLOSED" | "COMPLETED";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "ALL", label: "Semua" },
|
||||
{ key: "OPEN", label: "Open" },
|
||||
{ key: "FULL", label: "Penuh" },
|
||||
{ key: "CLOSED", label: "Dibatalkan" },
|
||||
{ key: "COMPLETED", label: "Selesai" },
|
||||
];
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string; q?: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminTripsPage({ searchParams }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/trips");
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Halaman ini hanya untuk admin SeTrip.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const tab: Tab = TABS.some((t) => t.key === params.tab)
|
||||
? (params.tab as Tab)
|
||||
: "ALL";
|
||||
const q = (params.q ?? "").trim();
|
||||
|
||||
const trips = await tripRepo.searchForAdmin({
|
||||
status: tab === "ALL" ? undefined : tab,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Trip Operations
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Cari trip, lihat detail, dan intervensi (cancel + auto-refund) saat
|
||||
organizer unreachable.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form method="get" className="mb-4 flex gap-2">
|
||||
<input type="hidden" name="tab" value={tab} />
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Cari judul, destinasi, lokasi, organizer..."
|
||||
className="flex-1 rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Cari
|
||||
</button>
|
||||
{q && (
|
||||
<Link
|
||||
href={`/admin/trips?tab=${tab}`}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Reset
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{TABS.map((t) => (
|
||||
<Link
|
||||
key={t.key}
|
||||
href={`/admin/trips?tab=${t.key}${q ? `&q=${encodeURIComponent(q)}` : ""}`}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
|
||||
tab === t.key
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{trips.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||
<p className="text-sm text-neutral-500">
|
||||
{q
|
||||
? `Tidak ada trip yang cocok dengan "${q}".`
|
||||
: "Tidak ada trip pada status ini."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{trips.map((t) => {
|
||||
const cat = categoryMeta(t.category);
|
||||
return (
|
||||
<li
|
||||
key={t.id}
|
||||
className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md sm:p-5"
|
||||
>
|
||||
<Link href={`/admin/trips/${t.id}`} className="block">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 font-semibold text-neutral-600">
|
||||
<span aria-hidden>{cat.icon}</span> {cat.label}
|
||||
</span>
|
||||
<StatusBadge status={t.status} />
|
||||
</div>
|
||||
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="mt-1 flex items-center gap-1 truncate text-xs text-neutral-500 sm:text-sm">
|
||||
<CalendarDays
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
/>
|
||||
{formatTripCalendarDateRangeLong(t.date, t.endDate)}
|
||||
<span aria-hidden>·</span>
|
||||
<MapPin
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
/>
|
||||
{t.location}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
|
||||
Organizer:{" "}
|
||||
<span className="font-semibold text-neutral-700">
|
||||
{t.organizer.name}
|
||||
</span>{" "}
|
||||
<span className="text-neutral-400">
|
||||
({t.organizer.email})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-sm font-bold text-primary-700 sm:text-base">
|
||||
{formatRupiah(t.price)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-neutral-500">
|
||||
{t._count.participants}/{t.maxParticipants} peserta
|
||||
</p>
|
||||
<p className="text-[11px] text-emerald-700">
|
||||
{t._count.bookings} PAID
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === "OPEN"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: status === "FULL"
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: status === "CLOSED"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-neutral-200 text-neutral-700";
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ArrowLeft, ArrowUpRight, Ban, Check } from "lucide-react";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { userRepo } from "@/server/repositories/user.repo";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { SuspendUserButton } from "@/features/admin/components/suspend-user-button";
|
||||
import { ManualVerifyButton } from "@/features/admin/components/manual-verify-button";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminUserDetailPage({ params }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/users");
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Halaman ini hanya untuk admin SeTrip.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const user = await userRepo.findByIdForAdmin(id);
|
||||
if (!user) notFound();
|
||||
|
||||
const isSelf = user.id === session.user.id;
|
||||
const totalSpent = user.bookings
|
||||
.filter((b) => b.status === "PAID" || b.status === "PARTIALLY_REFUNDED")
|
||||
.reduce((sum, b) => sum + b.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="inline-flex items-center gap-1 hover:text-primary-600"
|
||||
>
|
||||
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
|
||||
Kembali ke list users
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<header
|
||||
className={`mb-6 rounded-2xl border p-5 shadow-sm sm:p-6 ${
|
||||
user.suspended
|
||||
? "border-red-300 bg-red-50/60"
|
||||
: "border-neutral-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
{user.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||
{user.name}
|
||||
</h1>
|
||||
{user.suspended && (
|
||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-red-800">
|
||||
Suspended
|
||||
</span>
|
||||
)}
|
||||
{user.organizerVerification?.status === "APPROVED" && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800">
|
||||
<Check size={12} strokeWidth={2.5} aria-hidden />
|
||||
Verified Organizer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-neutral-600">{user.email}</p>
|
||||
<p className="mt-0.5 text-[11px] text-neutral-500">
|
||||
User ID:{" "}
|
||||
<code className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-neutral-700">
|
||||
{user.id}
|
||||
</code>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Bergabung{" "}
|
||||
{user.createdAt.toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
{user.acceptedAt && (
|
||||
<>
|
||||
{" "}
|
||||
· Setuju T&C{" "}
|
||||
{user.acceptedAt.toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Trip dibuat" value={String(user.trips.length)} />
|
||||
<StatCard label="Booking aktif" value={String(user.bookings.length)} />
|
||||
<StatCard
|
||||
label="Total spent (PAID)"
|
||||
value={formatRupiah(totalSpent)}
|
||||
accent="emerald"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{user.suspended && (
|
||||
<section className="mb-6 rounded-2xl border border-red-300 bg-red-50 p-4 sm:p-5">
|
||||
<h2 className="flex items-center gap-1.5 text-sm font-bold text-red-900">
|
||||
<Ban size={16} strokeWidth={2} aria-hidden />
|
||||
Akun ditangguhkan
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-red-900/80">
|
||||
{user.suspendedReason ?? "Tidak ada alasan tercatat."}
|
||||
</p>
|
||||
{user.suspendedBy && (
|
||||
<p className="mt-2 text-[11px] text-red-900/70">
|
||||
Disuspend oleh {user.suspendedBy.email}
|
||||
{user.suspendedAt && (
|
||||
<>
|
||||
{" "}
|
||||
pada{" "}
|
||||
{user.suspendedAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-sm font-bold text-neutral-900">
|
||||
Aksi Admin
|
||||
</h2>
|
||||
{isSelf ? (
|
||||
<p className="text-xs text-neutral-500">
|
||||
Tidak bisa suspend / modifikasi akun sendiri.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<SuspendUserButton userId={user.id} isSuspended={user.suspended} />
|
||||
{!user.organizerVerification && (
|
||||
<ManualVerifyButton
|
||||
userId={user.id}
|
||||
defaultBankAccountName={user.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{user.profile && (
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Profil Sosial
|
||||
</h2>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
{user.profile.bio && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Bio
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap text-neutral-700">
|
||||
{user.profile.bio}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{user.profile.city && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Kota
|
||||
</dt>
|
||||
<dd className="text-neutral-700">{user.profile.city}</dd>
|
||||
</div>
|
||||
)}
|
||||
{user.profile.vibe && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Vibe
|
||||
</dt>
|
||||
<dd className="text-neutral-700">{user.profile.vibe}</dd>
|
||||
</div>
|
||||
)}
|
||||
{user.profile.interests.length > 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Minat
|
||||
</dt>
|
||||
<dd className="mt-0.5 flex flex-wrap gap-1.5">
|
||||
{user.profile.interests.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-medium text-secondary-700"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{user.profile.instagram && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Instagram
|
||||
</dt>
|
||||
<dd className="text-neutral-700">@{user.profile.instagram}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{user.organizerVerification && (
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Verifikasi Organizer
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-700">
|
||||
Status:{" "}
|
||||
<span className="font-semibold">
|
||||
{user.organizerVerification.status}
|
||||
</span>
|
||||
{" · "}
|
||||
<Link
|
||||
href={`/admin/verifications?tab=${user.organizerVerification.status}`}
|
||||
className="inline-flex items-center gap-1 text-secondary-700 hover:text-secondary-900"
|
||||
>
|
||||
Buka di /admin/verifications
|
||||
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
</p>
|
||||
{user.organizerVerification.rejectionReason && (
|
||||
<p className="mt-1 text-xs text-red-700">
|
||||
Reason: {user.organizerVerification.rejectionReason}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Trip yang dibuat ({user.trips.length})
|
||||
</h2>
|
||||
{user.trips.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">
|
||||
User ini belum pernah membuat trip.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-neutral-100">
|
||||
{user.trips.map((t) => (
|
||||
<li key={t.id} className="py-2.5">
|
||||
<Link
|
||||
href={`/admin/trips/${t.id}`}
|
||||
className="flex items-center justify-between gap-3 text-sm hover:text-primary-700"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-neutral-800">
|
||||
{t.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t.destination} ·{" "}
|
||||
{t.date.toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}{" "}
|
||||
· {t.status}
|
||||
</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-xs font-semibold text-primary-700">
|
||||
{formatRupiah(t.price)}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
Booking sebagai peserta ({user.bookings.length})
|
||||
</h2>
|
||||
{user.bookings.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">Belum ada booking.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-neutral-100">
|
||||
{user.bookings.map((b) => (
|
||||
<li key={b.id} className="py-2.5">
|
||||
<Link
|
||||
href={`/admin/bookings/${b.id}`}
|
||||
className="flex items-center justify-between gap-3 text-sm hover:text-primary-700"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-neutral-800">
|
||||
{b.trip.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{b.trip.date.toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}{" "}
|
||||
· status: <span className="font-semibold">{b.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-xs font-semibold text-primary-700">
|
||||
{formatRupiah(b.amount)}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
accent = "primary",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
accent?: "primary" | "emerald";
|
||||
}) {
|
||||
const cls = accent === "emerald" ? "text-emerald-700" : "text-primary-700";
|
||||
return (
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-3 sm:p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-lg font-bold sm:text-xl ${cls}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Check, ChartColumn } from "lucide-react";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { userRepo } from "@/server/repositories/user.repo";
|
||||
|
||||
type Tab = "ALL" | "ACTIVE" | "SUSPENDED";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "ALL", label: "Semua" },
|
||||
{ key: "ACTIVE", label: "Aktif" },
|
||||
{ key: "SUSPENDED", label: "Suspended" },
|
||||
];
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string; q?: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminUsersPage({ searchParams }: PageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/users");
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Halaman ini hanya untuk admin SeTrip.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const tab: Tab = TABS.some((t) => t.key === params.tab)
|
||||
? (params.tab as Tab)
|
||||
: "ALL";
|
||||
const q = (params.q ?? "").trim();
|
||||
|
||||
const users = await userRepo.searchForAdmin({
|
||||
q: q || undefined,
|
||||
suspended: tab === "SUSPENDED" ? true : tab === "ACTIVE" ? false : undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Cari user, lihat history booking & trip, dan suspend akun yang
|
||||
melakukan abuse (scam, harassment, TOS violation).
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/users/stats"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
>
|
||||
<ChartColumn size={16} strokeWidth={2} aria-hidden />
|
||||
Stats
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<form method="get" className="mb-4 flex gap-2">
|
||||
<input type="hidden" name="tab" value={tab} />
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Cari email atau nama..."
|
||||
className="flex-1 rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Cari
|
||||
</button>
|
||||
{q && (
|
||||
<Link
|
||||
href={`/admin/users?tab=${tab}`}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Reset
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{TABS.map((t) => (
|
||||
<Link
|
||||
key={t.key}
|
||||
href={`/admin/users?tab=${t.key}${q ? `&q=${encodeURIComponent(q)}` : ""}`}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
|
||||
tab === t.key
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
|
||||
<p className="text-sm text-neutral-500">
|
||||
{q
|
||||
? `Tidak ada user yang cocok dengan "${q}".`
|
||||
: "Tidak ada user pada tab ini."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{users.map((u) => (
|
||||
<li
|
||||
key={u.id}
|
||||
className={`rounded-2xl border bg-white p-3 shadow-sm transition-shadow hover:shadow-md sm:p-4 ${
|
||||
u.suspended ? "border-red-200" : "border-neutral-200"
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
href={`/admin/users/${u.id}`}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
{u.image ? (
|
||||
<Image
|
||||
src={u.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">
|
||||
{u.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-neutral-800">
|
||||
{u.name}
|
||||
</p>
|
||||
{u.suspended && (
|
||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-800">
|
||||
Suspended
|
||||
</span>
|
||||
)}
|
||||
{u.organizerVerification?.status === "APPROVED" && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
|
||||
<Check size={12} strokeWidth={2.5} aria-hidden />
|
||||
Organizer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-neutral-500">{u.email}</p>
|
||||
<p className="mt-0.5 text-[11px] text-neutral-400">
|
||||
Bergabung{" "}
|
||||
{u.createdAt.toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
{" · "}
|
||||
{u._count.trips} trip dibuat, {u._count.participations}{" "}
|
||||
booking
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface WeeklyBucket {
|
||||
weekStart: Date;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function thirtyDaysAgoDate(): Date {
|
||||
return new Date(Date.now() - 30 * DAY_MS);
|
||||
}
|
||||
|
||||
async function getSignupsPerWeek(weeks = 8): Promise<WeeklyBucket[]> {
|
||||
const now = new Date();
|
||||
const startMs = now.getTime() - weeks * 7 * DAY_MS;
|
||||
const startDate = new Date(startMs);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { createdAt: { gte: startDate } },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
|
||||
// Bucketize per week (Senin sebagai start, supaya konsisten dengan kalender Indonesia).
|
||||
const buckets: WeeklyBucket[] = [];
|
||||
for (let i = weeks - 1; i >= 0; i--) {
|
||||
const bucketStart = new Date(now.getTime() - (i + 1) * 7 * DAY_MS);
|
||||
bucketStart.setUTCHours(0, 0, 0, 0);
|
||||
const bucketEnd = new Date(bucketStart.getTime() + 7 * DAY_MS);
|
||||
const count = users.filter(
|
||||
(u) => u.createdAt >= bucketStart && u.createdAt < bucketEnd
|
||||
).length;
|
||||
buckets.push({
|
||||
weekStart: bucketStart,
|
||||
label: bucketStart.toLocaleDateString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}),
|
||||
count,
|
||||
});
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
export default async function AdminUserStatsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login?callbackUrl=/admin/users/stats");
|
||||
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 thirtyDaysAgo = thirtyDaysAgoDate();
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
suspendedUsers,
|
||||
verifiedOrganizers,
|
||||
activeOrganizers30d,
|
||||
paidParticipants30d,
|
||||
weekly,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { suspended: true } }),
|
||||
prisma.organizerVerification.count({ where: { status: "APPROVED" } }),
|
||||
prisma.user.count({
|
||||
where: {
|
||||
trips: { some: { createdAt: { gte: thirtyDaysAgo } } },
|
||||
},
|
||||
}),
|
||||
prisma.user.count({
|
||||
where: {
|
||||
bookings: {
|
||||
some: {
|
||||
status: "PAID",
|
||||
createdAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
getSignupsPerWeek(8),
|
||||
]);
|
||||
|
||||
const maxWeeklyCount = Math.max(1, ...weekly.map((w) => w.count));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||||
<div className="mb-4 text-xs text-neutral-500">
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="inline-flex items-center gap-1 hover:text-primary-600"
|
||||
>
|
||||
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
|
||||
Kembali ke list users
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
User Analytics
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Snapshot pertumbuhan user. Real-time read langsung dari DB — tidak
|
||||
ada cache, refresh halaman untuk angka terbaru.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="mb-8 grid gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Total Users" value={totalUsers} />
|
||||
<StatCard
|
||||
label="Suspended"
|
||||
value={suspendedUsers}
|
||||
accent="red"
|
||||
/>
|
||||
<StatCard
|
||||
label="Verified Organizers"
|
||||
value={verifiedOrganizers}
|
||||
accent="emerald"
|
||||
/>
|
||||
<StatCard
|
||||
label="Organizer Aktif (30 hari)"
|
||||
value={activeOrganizers30d}
|
||||
accent="secondary"
|
||||
sub="Bikin trip baru"
|
||||
/>
|
||||
<StatCard
|
||||
label="Peserta Aktif (30 hari)"
|
||||
value={paidParticipants30d}
|
||||
accent="primary"
|
||||
sub="Booking PAID"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<h2 className="mb-1 text-sm font-bold text-neutral-900">
|
||||
Signup per Minggu (8 minggu terakhir)
|
||||
</h2>
|
||||
<p className="mb-4 text-xs text-neutral-500">
|
||||
Tiap bar = 1 minggu (mulai hari ini mundur). Angka di atas bar = total
|
||||
signup minggu itu.
|
||||
</p>
|
||||
<div className="flex h-48 items-end gap-2">
|
||||
{weekly.map((w) => {
|
||||
const heightPct = (w.count / maxWeeklyCount) * 100;
|
||||
return (
|
||||
<div
|
||||
key={w.weekStart.toISOString()}
|
||||
className="flex flex-1 flex-col items-center gap-1"
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-neutral-700">
|
||||
{w.count}
|
||||
</span>
|
||||
<div
|
||||
className="w-full rounded-t-md bg-primary-500/80"
|
||||
style={{ height: `${Math.max(heightPct, 2)}%` }}
|
||||
title={`${w.count} signup minggu ${w.label}`}
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-500">{w.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
sub?: string;
|
||||
accent?: "neutral" | "primary" | "secondary" | "emerald" | "red";
|
||||
}) {
|
||||
const map = {
|
||||
neutral: "text-neutral-800",
|
||||
primary: "text-primary-700",
|
||||
secondary: "text-secondary-700",
|
||||
emerald: "text-emerald-700",
|
||||
red: "text-red-700",
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-2xl font-bold ${map[accent]}`}>{value}</p>
|
||||
{sub && <p className="text-[11px] text-neutral-500">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { isAdminEmail, listAdminEmails } from "@/lib/admin";
|
||||
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import { AdminFilterBar } from "@/features/admin/components/admin-filter-bar";
|
||||
import { ExportCsvLink } from "@/features/admin/components/export-csv-link";
|
||||
import { ReviewCard } from "@/features/organizer/components/review-card";
|
||||
|
||||
type Tab = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
searchParams: Promise<{
|
||||
tab?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
reviewer?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export default async function AdminVerificationsPage({ searchParams }: PageProps) {
|
||||
@@ -29,7 +42,11 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
||||
const tab: Tab =
|
||||
params.tab === "APPROVED" || params.tab === "REJECTED" ? params.tab : "PENDING";
|
||||
|
||||
const rows = await organizerRepo.listByStatus(tab);
|
||||
const rows = await organizerRepo.listByStatus(tab, {
|
||||
dateFrom: parseDate(params.dateFrom),
|
||||
dateTo: parseDate(params.dateTo),
|
||||
reviewerEmail: params.reviewer || undefined,
|
||||
});
|
||||
const items = rows.map((v) => ({
|
||||
id: v.id,
|
||||
fullName: v.fullName,
|
||||
@@ -53,9 +70,15 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
||||
{ key: "REJECTED", label: "Ditolak" },
|
||||
];
|
||||
|
||||
const exportQuery = new URLSearchParams({ status: tab });
|
||||
if (params.dateFrom) exportQuery.set("dateFrom", params.dateFrom);
|
||||
if (params.dateTo) exportQuery.set("dateTo", params.dateTo);
|
||||
if (params.reviewer) exportQuery.set("reviewer", params.reviewer);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
|
||||
<header className="mb-6">
|
||||
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||
Review Verifikasi Organizer
|
||||
</h1>
|
||||
@@ -63,8 +86,25 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
||||
Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening
|
||||
sebelum menyetujui.
|
||||
</p>
|
||||
</div>
|
||||
<ExportCsvLink
|
||||
href="/api/admin/export/verifications"
|
||||
query={exportQuery.toString()}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<AdminFilterBar
|
||||
action="/admin/verifications"
|
||||
values={{
|
||||
tab,
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
reviewer: params.reviewer,
|
||||
}}
|
||||
reviewerOptions={listAdminEmails()}
|
||||
reviewerLabel="Reviewer"
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex gap-2">
|
||||
{tabs.map((t) => (
|
||||
<a
|
||||
@@ -83,7 +123,9 @@ export default async function AdminVerificationsPage({ searchParams }: PageProps
|
||||
|
||||
{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 data.</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Tidak ada data yang cocok dengan filter ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
|
||||
import type { PayoutStatus } from "@/app/generated/prisma/enums";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_STATUS = new Set<PayoutStatus>([
|
||||
"HELD",
|
||||
"RELEASED",
|
||||
"PAID",
|
||||
"CANCELLED",
|
||||
]);
|
||||
|
||||
function parseDate(value: string | null): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const params = req.nextUrl.searchParams;
|
||||
const statusParam = params.get("status");
|
||||
if (!statusParam || !VALID_STATUS.has(statusParam as PayoutStatus)) {
|
||||
return NextResponse.json(
|
||||
{ error: "status param wajib (HELD/RELEASED/PAID/CANCELLED)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const status = statusParam as PayoutStatus;
|
||||
|
||||
const rows = await payoutRepo.listByStatus(status, {
|
||||
dateFrom: parseDate(params.get("dateFrom")),
|
||||
dateTo: parseDate(params.get("dateTo")),
|
||||
processorEmail: params.get("reviewer") || undefined,
|
||||
});
|
||||
|
||||
const csv = buildCsv(
|
||||
[
|
||||
"Payout ID",
|
||||
"Status",
|
||||
"Nominal (IDR)",
|
||||
"Currency",
|
||||
"Held until",
|
||||
"Released at",
|
||||
"Paid at",
|
||||
"Cancelled at",
|
||||
"Processor email",
|
||||
"Admin note",
|
||||
"Dibuat",
|
||||
"Organizer nama",
|
||||
"Organizer email",
|
||||
"Bank nama",
|
||||
"Bank rekening",
|
||||
"Bank atas nama",
|
||||
"Trip ID",
|
||||
"Trip judul",
|
||||
"Booking ID",
|
||||
"Peserta nama",
|
||||
],
|
||||
rows.map((p) => [
|
||||
p.id,
|
||||
p.status,
|
||||
p.amount,
|
||||
p.currency,
|
||||
csvDateJakarta(p.heldUntil),
|
||||
csvDateJakarta(p.releasedAt),
|
||||
csvDateJakarta(p.paidAt),
|
||||
csvDateJakarta(p.cancelledAt),
|
||||
p.processedBy?.email ?? "",
|
||||
p.adminNote ?? "",
|
||||
csvDateJakarta(p.createdAt),
|
||||
p.organizer.name,
|
||||
p.organizer.email,
|
||||
p.bankName ?? "",
|
||||
p.bankAccountNumber ?? "",
|
||||
p.bankAccountName ?? "",
|
||||
p.trip.id,
|
||||
p.trip.title,
|
||||
p.booking.id,
|
||||
p.booking.user.name,
|
||||
])
|
||||
);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return csvResponse(`payouts-${status}-${today}.csv`, csv);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { refundRepo } from "@/server/repositories/refund.repo";
|
||||
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_STATUS = new Set([
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"PROCESSING",
|
||||
"SUCCEEDED",
|
||||
"FAILED",
|
||||
]);
|
||||
const VALID_REASON = new Set([
|
||||
"USER_CANCELLATION",
|
||||
"ORGANIZER_CANCELLED",
|
||||
"TRIP_ISSUE",
|
||||
"ADMIN_ADJUSTMENT",
|
||||
"DISPUTE_RESOLVED",
|
||||
]);
|
||||
|
||||
function parseDate(value: string | null): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const params = req.nextUrl.searchParams;
|
||||
const statusParam = params.get("status");
|
||||
const status =
|
||||
statusParam && VALID_STATUS.has(statusParam)
|
||||
? (statusParam as
|
||||
| "PENDING"
|
||||
| "APPROVED"
|
||||
| "REJECTED"
|
||||
| "PROCESSING"
|
||||
| "SUCCEEDED"
|
||||
| "FAILED")
|
||||
: undefined;
|
||||
const reasonParam = params.get("reason");
|
||||
const reason =
|
||||
reasonParam && VALID_REASON.has(reasonParam)
|
||||
? (reasonParam as
|
||||
| "USER_CANCELLATION"
|
||||
| "ORGANIZER_CANCELLED"
|
||||
| "TRIP_ISSUE"
|
||||
| "ADMIN_ADJUSTMENT"
|
||||
| "DISPUTE_RESOLVED")
|
||||
: undefined;
|
||||
|
||||
const rows = await refundRepo.listByStatus(status, {
|
||||
dateFrom: parseDate(params.get("dateFrom")),
|
||||
dateTo: parseDate(params.get("dateTo")),
|
||||
reviewerEmail: params.get("reviewer") || undefined,
|
||||
reason,
|
||||
});
|
||||
|
||||
const csv = buildCsv(
|
||||
[
|
||||
"Refund ID",
|
||||
"Status",
|
||||
"Reason",
|
||||
"Nominal (IDR)",
|
||||
"Dilaporkan oleh",
|
||||
"Catatan laporan",
|
||||
"Catatan admin",
|
||||
"Dibuat",
|
||||
"Reviewed at",
|
||||
"Succeeded at",
|
||||
"Failed at",
|
||||
"Reviewer email",
|
||||
"Booking ID",
|
||||
"Peserta nama",
|
||||
"Peserta email",
|
||||
"Trip ID",
|
||||
"Trip judul",
|
||||
"Trip tanggal",
|
||||
],
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
r.status,
|
||||
r.reason,
|
||||
r.amount,
|
||||
r.reportedBy,
|
||||
r.reportNote,
|
||||
r.adminNote ?? "",
|
||||
csvDateJakarta(r.createdAt),
|
||||
csvDateJakarta(r.reviewedAt),
|
||||
csvDateJakarta(r.succeededAt),
|
||||
csvDateJakarta(r.failedAt),
|
||||
r.reviewedBy?.email ?? "",
|
||||
r.booking.id,
|
||||
r.booking.user.name,
|
||||
r.booking.user.email,
|
||||
r.booking.trip.id,
|
||||
r.booking.trip.title,
|
||||
csvDateJakarta(r.booking.trip.date),
|
||||
])
|
||||
);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return csvResponse(`refunds-${status ?? "all"}-${today}.csv`, csv);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||
import { buildCsv, csvDateJakarta, csvResponse } from "@/lib/csv";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_STATUS = new Set(["PENDING", "APPROVED", "REJECTED"]);
|
||||
|
||||
function parseDate(value: string | null): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const params = req.nextUrl.searchParams;
|
||||
const statusParam = params.get("status");
|
||||
const status =
|
||||
statusParam && VALID_STATUS.has(statusParam)
|
||||
? (statusParam as "PENDING" | "APPROVED" | "REJECTED")
|
||||
: undefined;
|
||||
|
||||
const rows = await organizerRepo.listByStatus(status, {
|
||||
dateFrom: parseDate(params.get("dateFrom")),
|
||||
dateTo: parseDate(params.get("dateTo")),
|
||||
reviewerEmail: params.get("reviewer") || undefined,
|
||||
});
|
||||
|
||||
// SENGAJA tidak ekspor: NIK plaintext (encrypted), ktpImageKey, livenessKey,
|
||||
// bankAccountNumber. Export ini hanya untuk metadata audit — KYC sensitive
|
||||
// info tetap di DB & cuma diakses lewat admin UI dengan auth gate.
|
||||
const csv = buildCsv(
|
||||
[
|
||||
"Verification ID",
|
||||
"Status",
|
||||
"Nama (KTP)",
|
||||
"User nama",
|
||||
"User email",
|
||||
"Bank nama",
|
||||
"Bank atas nama",
|
||||
"Dibuat",
|
||||
"Reviewed at",
|
||||
"Verified at",
|
||||
"Rejection reason",
|
||||
"Reviewer email",
|
||||
],
|
||||
rows.map((v) => [
|
||||
v.id,
|
||||
v.status,
|
||||
v.fullName,
|
||||
v.user.name,
|
||||
v.user.email,
|
||||
v.bankName,
|
||||
v.bankAccountName,
|
||||
csvDateJakarta(v.createdAt),
|
||||
csvDateJakarta(v.reviewedAt),
|
||||
csvDateJakarta(v.verifiedAt),
|
||||
v.rejectionReason ?? "",
|
||||
v.reviewedBy?.email ?? "",
|
||||
])
|
||||
);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return csvResponse(`verifications-${status ?? "all"}-${today}.csv`, csv);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { adminSearchService } from "@/server/services/admin-search.service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const q = req.nextUrl.searchParams.get("q") ?? "";
|
||||
const hits = await adminSearchService.resolve(q, 10);
|
||||
return NextResponse.json({ hits });
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { payoutService } from "@/server/services/payout.service";
|
||||
import { runCron } from "@/lib/cron-runner";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -29,22 +31,25 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await runCron("auto-complete-trips", async () => {
|
||||
const result = await tripService.autoCompletePastTrips();
|
||||
console.log("[cron/auto-complete-trips] selesai", {
|
||||
count: result.count,
|
||||
ids: result.ids,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
|
||||
// supaya admin bisa langsung transfer ke organizer. Idempotent.
|
||||
const releaseResult = await payoutService.releaseEligible();
|
||||
return {
|
||||
completed: result.count,
|
||||
ids: result.ids,
|
||||
payoutsReleased: releaseResult.releasedIds,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[cron/auto-complete-trips] gagal", err);
|
||||
|
||||
if (!outcome.ok) {
|
||||
console.error("[cron/auto-complete-trips] gagal", outcome.error);
|
||||
return NextResponse.json(
|
||||
{ error: "Gagal menjalankan auto-complete" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
console.log("[cron/auto-complete-trips] selesai", outcome.payload);
|
||||
return NextResponse.json({ ok: true, ...outcome.payload });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { runCron } from "@/lib/cron-runner";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
deleteTripImage,
|
||||
listTripImageNames,
|
||||
tripImageMtime,
|
||||
TRIP_IMAGE_URL_PREFIX,
|
||||
} from "@/lib/trip-image-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** File yang lebih tua dari ini & tak direferensikan DB dianggap yatim. */
|
||||
const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cron — hapus file gambar trip yatim.
|
||||
*
|
||||
* Form create-trip multi-step mengunggah foto SEBELUM trip tersimpan; kalau
|
||||
* user menutup form di tengah jalan, file menggantung di disk tanpa pernah
|
||||
* jadi `TripImage`. Sweep ini menghapus file >24 jam yang tidak direferensikan
|
||||
* `TripImage` mana pun. Idempotent — aman dijalankan berulang.
|
||||
*
|
||||
* Trigger: lihat docs/CRON_SETUP.md. Header wajib `Authorization: Bearer ${CRON_SECRET}`.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
console.error("[cron/cleanup-trip-images] CRON_SECRET tidak di-set");
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfigured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const outcome = await runCron("cleanup-trip-images", async () => {
|
||||
const names = await listTripImageNames();
|
||||
if (names.length === 0) return { scanned: 0, deleted: 0 };
|
||||
|
||||
const referenced = await prisma.tripImage.findMany({
|
||||
where: { url: { startsWith: TRIP_IMAGE_URL_PREFIX } },
|
||||
select: { url: true },
|
||||
});
|
||||
const referencedNames = new Set(
|
||||
referenced.map((r) => r.url.slice(TRIP_IMAGE_URL_PREFIX.length))
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
let deleted = 0;
|
||||
for (const name of names) {
|
||||
if (referencedNames.has(name)) continue;
|
||||
const mtime = await tripImageMtime(name);
|
||||
// File baru di-upload tapi trip belum tersimpan → beri tenggang 24 jam.
|
||||
if (!mtime || now - mtime.getTime() < ORPHAN_AGE_MS) continue;
|
||||
await deleteTripImage(name);
|
||||
deleted++;
|
||||
}
|
||||
return { scanned: names.length, deleted };
|
||||
});
|
||||
|
||||
if (!outcome.ok) {
|
||||
console.error("[cron/cleanup-trip-images] gagal", outcome.error);
|
||||
return NextResponse.json(
|
||||
{ error: "Gagal menjalankan cleanup" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
console.log("[cron/cleanup-trip-images] selesai", outcome.payload);
|
||||
return NextResponse.json({ ok: true, ...outcome.payload });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
import { runCron } from "@/lib/cron-runner";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Cron — proses retry queue email (jobs status PENDING/FAILED dengan
|
||||
* attempts<5 dan scheduledAt sudah lewat).
|
||||
*
|
||||
* Trigger setiap 5 menit via system crontab — lihat docs/CRON_SETUP.md.
|
||||
* Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfigured (CRON_SECRET)" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const outcome = await runCron("process-email-jobs", async () => {
|
||||
return emailService.processQueue(50);
|
||||
});
|
||||
|
||||
if (!outcome.ok) {
|
||||
return NextResponse.json({ error: outcome.error }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, ...outcome.payload });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isValidTripImageName, readTripImage } from "@/lib/trip-image-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sajikan gambar trip dari disk lokal. Publik — gambar trip memang tampil ke
|
||||
* semua pengunjung. Di-cache `immutable` selama setahun: nama file
|
||||
* content-addressed (hex acak), jadi konten untuk satu nama tidak pernah
|
||||
* berubah. Beban render = baca file kecil dari disk, tanpa fetch eksternal.
|
||||
*/
|
||||
export async function GET(_req: NextRequest, ctx: RouteCtx) {
|
||||
const { name } = await ctx.params;
|
||||
if (!isValidTripImageName(name)) {
|
||||
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
|
||||
}
|
||||
|
||||
let data: Buffer;
|
||||
try {
|
||||
data = await readTripImage(name);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
"Content-Length": String(data.length),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { requireActiveUser } from "@/lib/auth-guards";
|
||||
import {
|
||||
ALLOWED_TRIP_IMAGE_MIME,
|
||||
MAX_TRIP_IMAGE_UPLOAD_BYTES,
|
||||
processAndSaveTripImage,
|
||||
} from "@/lib/trip-image-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Upload satu foto trip. Dipanggil dari form create-trip saat user memilih
|
||||
* file — gambar langsung dikompres & disimpan, route mengembalikan URL publik
|
||||
* yang nanti ikut disubmit bersama data trip.
|
||||
*
|
||||
* File yatim (di-upload tapi trip batal dibuat) dibersihkan cron
|
||||
* `/api/cron/cleanup-trip-images`.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
await requireActiveUser(session.user.id);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: (err as Error).message },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await req.formData();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Body bukan multipart/form-data" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
|
||||
}
|
||||
if (!ALLOWED_TRIP_IMAGE_MIME.has(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Hanya menerima JPG, PNG, atau WebP" },
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_TRIP_IMAGE_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ukuran file maksimal 12MB" },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const saved = await processAndSaveTripImage(buf);
|
||||
return NextResponse.json({ url: saved.url, size: saved.size });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: (err as Error).message || "Gagal memproses gambar" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,16 @@ select:focus {
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Input wrapper isi penuh lebar parent + popper di atas konten lain */
|
||||
.react-datepicker-wrapper,
|
||||
.react-datepicker__input-container {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 50 !important;
|
||||
}
|
||||
|
||||
.react-datepicker__header {
|
||||
background: #f9fafb !important;
|
||||
border-bottom: 1px solid #e5e7eb !important;
|
||||
@@ -132,3 +142,46 @@ select:focus {
|
||||
.react-datepicker__close-icon:hover::after {
|
||||
background-color: #16a34a !important;
|
||||
}
|
||||
|
||||
/* Dropdown bulan / tahun (mode select) */
|
||||
.react-datepicker__month-select,
|
||||
.react-datepicker__year-select {
|
||||
border: 1px solid #e5e7eb !important;
|
||||
border-radius: 0.5rem !important;
|
||||
padding: 2px 4px !important;
|
||||
font-size: 0.8125rem !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
/* Pemilih jam (showTimeSelectOnly) */
|
||||
.react-datepicker__time-container {
|
||||
width: 96px !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-container
|
||||
.react-datepicker__time
|
||||
.react-datepicker__time-box {
|
||||
width: 96px !important;
|
||||
}
|
||||
|
||||
.react-datepicker-time__header {
|
||||
font-weight: 700 !important;
|
||||
color: #1f2937 !important;
|
||||
font-size: 0.8125rem !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-list-item {
|
||||
font-size: 0.8125rem !important;
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-list-item:hover {
|
||||
background: #dcfce7 !important;
|
||||
color: #15803d !important;
|
||||
}
|
||||
|
||||
.react-datepicker__time-list-item--selected {
|
||||
background: #16a34a !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
+1
-7
@@ -1,8 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
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 "./globals.css";
|
||||
|
||||
@@ -79,11 +77,7 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="flex min-h-full flex-col bg-neutral-50">
|
||||
<SessionProvider>
|
||||
<Navbar />
|
||||
<ProfileNudgeBanner />
|
||||
<main className="flex-1">{children}</main>
|
||||
</SessionProvider>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Masuk",
|
||||
description:
|
||||
"Masuk ke akun SeTrip untuk gabung open trip & aktivitas bareng dan kelola perjalananmu.",
|
||||
alternates: { canonical: "/login" },
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
|
||||
/**
|
||||
* Web app manifest — dideteksi otomatis oleh Next App Router (`<link
|
||||
* rel="manifest">` di-inject). Mendukung "Add to Home Screen" di mobile.
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: `${siteConfig.name} — ${siteConfig.slogan}`,
|
||||
short_name: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#f9fafb",
|
||||
theme_color: "#16a34a",
|
||||
icons: [
|
||||
{
|
||||
src: "/images/SeTrip.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/SeTrip.ico",
|
||||
sizes: "any",
|
||||
type: "image/x-icon",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Daftar Akun",
|
||||
description:
|
||||
"Buat akun SeTrip gratis. Cari open trip & aktivitas bareng, gabung bareng, dan mulai petualanganmu.",
|
||||
alternates: { canonical: "/register" },
|
||||
};
|
||||
|
||||
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
+8
-1
@@ -7,7 +7,14 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/api/", "/profile", "/create-trip"],
|
||||
disallow: [
|
||||
"/api/",
|
||||
"/admin",
|
||||
"/profile",
|
||||
"/create-trip",
|
||||
"/verify",
|
||||
"/trips/*/payment",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: absoluteUrl("/sitemap.xml"),
|
||||
|
||||
@@ -24,6 +24,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: "hourly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: absoluteUrl("/people"),
|
||||
lastModified: now,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: absoluteUrl("/register"),
|
||||
lastModified: now,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"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";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpRight,
|
||||
Banknote,
|
||||
Compass,
|
||||
IdCard,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Menu,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Users,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { AdminSearchBar } from "@/features/admin/components/admin-search-bar";
|
||||
|
||||
const NAV_ITEMS: { href: string; label: string; icon: LucideIcon }[] = [
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/admin/trips", label: "Trips", icon: Compass },
|
||||
{ href: "/admin/users", label: "Users", icon: Users },
|
||||
{ href: "/admin/verifications", label: "Verifikasi", icon: IdCard },
|
||||
{ href: "/admin/refunds", label: "Refund", icon: ArrowLeft },
|
||||
{ href: "/admin/payouts", label: "Payout", icon: Banknote },
|
||||
{ href: "/admin/emails", label: "Email", icon: Mail },
|
||||
{ href: "/admin/audit-log", label: "Audit Log", icon: ScrollText },
|
||||
{ href: "/admin/system", label: "System", icon: Settings },
|
||||
];
|
||||
|
||||
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 ? (
|
||||
<X size={20} strokeWidth={2} aria-hidden />
|
||||
) : (
|
||||
<Menu size={20} strokeWidth={2} aria-hidden />
|
||||
)}
|
||||
</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>
|
||||
|
||||
<div className="border-b border-neutral-100 p-3">
|
||||
<AdminSearchBar />
|
||||
</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));
|
||||
const Icon = item.icon;
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.75} aria-hidden />
|
||||
<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"
|
||||
>
|
||||
<ArrowUpRight size={16} strokeWidth={1.75} aria-hidden />
|
||||
<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,255 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Komponen pemilih tanggal & jam bersama — satu-satunya tempat aplikasi
|
||||
* memakai `react-datepicker`. Semua field tanggal/jam (form & filter) harus
|
||||
* lewat sini supaya tampilan + locale konsisten.
|
||||
*
|
||||
* - `DateField` → satu tanggal (controlled atau uncontrolled untuk form).
|
||||
* - `DateRangeField` → rentang tanggal (berangkat–pulang, filter).
|
||||
* - `TimeField` → jam "HH:mm" (itinerary).
|
||||
*
|
||||
* Tema visual di-override di `app/globals.css` (blok `.react-datepicker`).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import ReactDatePicker, { registerLocale } from "react-datepicker";
|
||||
import { id as idLocale } from "date-fns/locale";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import {
|
||||
formatLocalCalendarYmd,
|
||||
localCalendarDateFromYmd,
|
||||
} from "@/lib/trip-dates";
|
||||
import { isValidTimeFormat } from "@/lib/itinerary";
|
||||
|
||||
registerLocale("id", idLocale);
|
||||
|
||||
const FIELD_CLS =
|
||||
"w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white";
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockIcon() {
|
||||
return (
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .284.16.544.415.67l3 1.5a.75.75 0 00.67-1.34L10.75 9.54V5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface DateFieldProps {
|
||||
/** Mode controlled. Kalau `undefined`, komponen jalan uncontrolled. */
|
||||
value?: Date | null;
|
||||
/** Nilai awal untuk mode uncontrolled (mis. di dalam form GET/POST biasa). */
|
||||
defaultValue?: Date | null;
|
||||
/**
|
||||
* Nilai awal uncontrolled berupa `YYYY-MM-DD`. Dipakai saat parent adalah
|
||||
* Server Component (mis. filter admin) — string di-parse di browser supaya
|
||||
* tidak ada pergeseran timezone server↔klien.
|
||||
*/
|
||||
defaultValueYmd?: string;
|
||||
onChange?: (date: Date | null) => void;
|
||||
/** Kalau diisi, render hidden input `YYYY-MM-DD` supaya ikut ter-submit form. */
|
||||
name?: string;
|
||||
id?: string;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
/** Dropdown bulan + tahun — cocok untuk tanggal lahir. */
|
||||
withMonthYearDropdown?: boolean;
|
||||
}
|
||||
|
||||
/** Pemilih satu tanggal. */
|
||||
export function DateField({
|
||||
value,
|
||||
defaultValue = null,
|
||||
defaultValueYmd,
|
||||
onChange,
|
||||
name,
|
||||
id,
|
||||
minDate,
|
||||
maxDate,
|
||||
placeholder = "Pilih tanggal...",
|
||||
disabled = false,
|
||||
required = false,
|
||||
withMonthYearDropdown = false,
|
||||
}: DateFieldProps) {
|
||||
const isControlled = value !== undefined;
|
||||
const [internal, setInternal] = useState<Date | null>(
|
||||
() => (defaultValueYmd ? localCalendarDateFromYmd(defaultValueYmd) : defaultValue)
|
||||
);
|
||||
const current = isControlled ? value : internal;
|
||||
|
||||
function handleChange(date: Date | null) {
|
||||
if (!isControlled) setInternal(date);
|
||||
onChange?.(date);
|
||||
}
|
||||
|
||||
// Default rentang masuk akal untuk picker bulan/tahun (mis. tanggal lahir).
|
||||
const effectiveMin =
|
||||
minDate ??
|
||||
(withMonthYearDropdown
|
||||
? new Date(new Date().getFullYear() - 120, 0, 1)
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CalendarIcon />
|
||||
<ReactDatePicker
|
||||
id={id}
|
||||
selected={current ?? null}
|
||||
onChange={handleChange}
|
||||
locale="id"
|
||||
dateFormat="dd MMM yyyy"
|
||||
minDate={effectiveMin}
|
||||
maxDate={maxDate}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
placeholderText={placeholder}
|
||||
isClearable={!required && !disabled}
|
||||
showMonthDropdown={withMonthYearDropdown}
|
||||
showYearDropdown={withMonthYearDropdown}
|
||||
dropdownMode="select"
|
||||
className={FIELD_CLS}
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
{name && (
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
value={current ? formatLocalCalendarYmd(current) : ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DateRangeFieldProps {
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
onChange: (start: Date | null, end: Date | null) => void;
|
||||
minDate?: Date;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Pemilih rentang tanggal (berangkat–pulang, filter). */
|
||||
export function DateRangeField({
|
||||
startDate,
|
||||
endDate,
|
||||
onChange,
|
||||
minDate,
|
||||
placeholder = "Pilih tanggal...",
|
||||
id,
|
||||
}: DateRangeFieldProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<CalendarIcon />
|
||||
<ReactDatePicker
|
||||
id={id}
|
||||
selectsRange
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={(dates) => {
|
||||
const [start, end] = dates as [Date | null, Date | null];
|
||||
onChange(start, end);
|
||||
}}
|
||||
locale="id"
|
||||
dateFormat="dd MMM yyyy"
|
||||
minDate={minDate}
|
||||
isClearable
|
||||
placeholderText={placeholder}
|
||||
className={FIELD_CLS}
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeStringToDate(value: string): Date | null {
|
||||
if (!isValidTimeFormat(value)) return null;
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
const d = new Date();
|
||||
d.setHours(h, m, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function dateToTimeString(d: Date): string {
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const m = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
interface TimeFieldProps {
|
||||
/** Jam dalam format "HH:mm", atau "" kalau kosong. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** Tampilkan tombol clear (untuk jam opsional, mis. jam selesai). */
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
/** Pemilih jam "HH:mm" 24-jam dengan interval 15 menit. */
|
||||
export function TimeField({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
placeholder = "--:--",
|
||||
disabled = false,
|
||||
clearable = false,
|
||||
}: TimeFieldProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<ClockIcon />
|
||||
<ReactDatePicker
|
||||
id={id}
|
||||
selected={timeStringToDate(value)}
|
||||
onChange={(d: Date | null) => onChange(d ? dateToTimeString(d) : "")}
|
||||
showTimeSelect
|
||||
showTimeSelectOnly
|
||||
timeIntervals={15}
|
||||
timeCaption="Jam"
|
||||
dateFormat="HH:mm"
|
||||
timeFormat="HH:mm"
|
||||
locale="id"
|
||||
disabled={disabled}
|
||||
isClearable={clearable && !disabled}
|
||||
placeholderText={placeholder}
|
||||
className={FIELD_CLS}
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
|
||||
export function Navbar() {
|
||||
const { data: session } = useSession();
|
||||
@@ -109,29 +110,9 @@ export function Navbar() {
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? (
|
||||
<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>
|
||||
<X size={20} strokeWidth={1.75} aria-hidden />
|
||||
) : (
|
||||
<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>
|
||||
<Menu size={20} strokeWidth={1.75} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { BadgeCheck } from "lucide-react";
|
||||
|
||||
type Size = "sm" | "md";
|
||||
|
||||
export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
|
||||
const cls =
|
||||
size === "md"
|
||||
? "px-2.5 py-1 text-xs"
|
||||
: "px-2 py-0.5 text-[10px]";
|
||||
size === "md" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[10px]";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`}
|
||||
title="Organizer terverifikasi SeTrip"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className={size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 0l2.09 1.74L12.86 1.5l.64 2.78 2.5 1.5-1.5 2.5.5 2.86-2.78.64-1.5 2.5-2.72-.59L5.5 14.5 4 12 1.5 11.36 2 8.5.5 6 3 4.5l.64-2.78 2.77.24L8 0zm-1.07 9.4l4.6-4.6-1.06-1.06-3.54 3.54-1.41-1.42-1.06 1.06 2.47 2.48z" />
|
||||
</svg>
|
||||
<BadgeCheck
|
||||
size={size === "md" ? 14 : 12}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
Verified
|
||||
</span>
|
||||
);
|
||||
|
||||
+175
-21
@@ -2,13 +2,23 @@
|
||||
|
||||
Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native.
|
||||
|
||||
> **Audit trail otomatis:** semua cron yang di-wrap `runCron()` helper auto-log ke tabel `CronRun` (start/finish/error). Cek hasilnya real-time di `/admin/system` — link "System" di sidebar admin. Tidak perlu tail log untuk monitoring rutin.
|
||||
|
||||
---
|
||||
|
||||
## Daftar cron job
|
||||
|
||||
| Endpoint | Schedule | Tujuan |
|
||||
|---|---|---|
|
||||
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. |
|
||||
| # | Endpoint | Schedule | Frekuensi | Tujuan |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `GET /api/cron/auto-complete-trips` | `0 18 * * *` | Daily 01:00 WIB (18:00 UTC) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. Setelah itu, release payout HELD yang sudah lewat `heldUntil`. |
|
||||
| 2 | `GET /api/cron/process-email-jobs` | `*/5 * * * *` | Setiap 5 menit | Drain retry queue email — pick `EmailJob` status `PENDING`/`FAILED` (attempts<5), retry via Resend dengan exponential backoff. |
|
||||
| 3 | `GET /api/cron/cleanup-trip-images` | `30 18 * * *` | Daily 01:30 WIB (18:30 UTC) | Hapus file gambar trip yatim — foto yang di-upload di form create-trip tapi trip-nya batal dibuat. Hanya file >24 jam yang tak direferensikan `TripImage`. |
|
||||
|
||||
## Setup di server
|
||||
Semua cron pakai pola yang sama: header `Authorization: Bearer ${CRON_SECRET}`, idempotent, auto-log ke `CronRun`. Tambah cron baru = tambah baris di tabel ini + tabel `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx).
|
||||
|
||||
---
|
||||
|
||||
## Setup di server (one-time)
|
||||
|
||||
### 1. Set `CRON_SECRET` di env production
|
||||
|
||||
@@ -30,7 +40,17 @@ Restart PM2 supaya proses re-load env:
|
||||
pm2 restart setrip --update-env
|
||||
```
|
||||
|
||||
### 2. Daftarkan crontab
|
||||
### 2. Set env opsional untuk fitur lain yang di-trigger cron
|
||||
|
||||
| Env | Dibutuhkan oleh | Akibat kalau kosong |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | `process-email-jobs` cron | Email tetap di-queue di DB; cron skip dengan warning. Set nanti dan cron akan auto-drain queue. |
|
||||
| `EMAIL_FROM` | `process-email-jobs` cron | Pakai default `SeTrip <onboarding@resend.dev>` (cocok untuk dev/test). |
|
||||
| `ADMIN_ALERT_WEBHOOK_URL` | `runCron` (semua cron) | Tidak ada Discord push notif saat cron FAILED. Admin tetap bisa cek manual di `/admin/system`. |
|
||||
|
||||
Semua env ini ada di [.env.example](../.env.example) dengan instruksi setup masing-masing.
|
||||
|
||||
### 3. Daftarkan crontab
|
||||
|
||||
Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
|
||||
|
||||
@@ -38,11 +58,19 @@ Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
|
||||
crontab -e
|
||||
```
|
||||
|
||||
Tambah baris (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
|
||||
Tambah baris berikut (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
|
||||
|
||||
```cron
|
||||
# Setrip — auto-complete trips harian (jam 01:00 WIB)
|
||||
# === Setrip cron jobs ===
|
||||
|
||||
# 1. Auto-complete trip + release payout (daily 01:00 WIB)
|
||||
0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1
|
||||
|
||||
# 2. Drain email retry queue (setiap 5 menit)
|
||||
*/5 * * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/process-email-jobs >> /var/log/setrip-cron.log 2>&1
|
||||
|
||||
# 3. Bersihkan gambar trip yatim (daily 01:30 WIB)
|
||||
30 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/cleanup-trip-images >> /var/log/setrip-cron.log 2>&1
|
||||
```
|
||||
|
||||
Verifikasi crontab tersimpan:
|
||||
@@ -51,57 +79,183 @@ Verifikasi crontab tersimpan:
|
||||
crontab -l
|
||||
```
|
||||
|
||||
### 3. Siapkan file log
|
||||
### 4. Siapkan file log
|
||||
|
||||
```bash
|
||||
sudo touch /var/log/setrip-cron.log
|
||||
sudo chown $(whoami) /var/log/setrip-cron.log
|
||||
```
|
||||
|
||||
## Test manual
|
||||
Optional — logrotate supaya log tidak menggemuk:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/logrotate.d/setrip-cron
|
||||
```
|
||||
|
||||
Isi:
|
||||
|
||||
```
|
||||
/var/log/setrip-cron.log {
|
||||
weekly
|
||||
rotate 4
|
||||
compress
|
||||
missingok
|
||||
notifempty
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test manual (sanity check setelah deploy)
|
||||
|
||||
Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi:
|
||||
|
||||
```bash
|
||||
# Test cron 1
|
||||
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips
|
||||
|
||||
# Test cron 2
|
||||
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/process-email-jobs
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
**Expected response per cron:**
|
||||
|
||||
- Belum ada trip yang lewat: `{"ok":true,"completed":0,"ids":[]}`
|
||||
- Ada trip yang lewat: `{"ok":true,"completed":2,"ids":["clx...","cly..."]}`
|
||||
| Cron | Sukses kosong | Sukses ada pekerjaan |
|
||||
|---|---|---|
|
||||
| `auto-complete-trips` | `{"ok":true,"completed":0,"ids":[],"payoutsReleased":[]}` | `{"ok":true,"completed":2,"ids":["clx...","cly..."],"payoutsReleased":["..."]}` |
|
||||
| `process-email-jobs` | `{"ok":true,"picked":0,"succeeded":0,"failed":0}` | `{"ok":true,"picked":5,"succeeded":5,"failed":0}` |
|
||||
| `cleanup-trip-images` | `{"ok":true,"scanned":0,"deleted":0}` | `{"ok":true,"scanned":12,"deleted":3}` |
|
||||
|
||||
**Kalau dapat 401:** `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
|
||||
**Error response:**
|
||||
- **401** — `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
|
||||
- **500 "Server misconfigured"** — `CRON_SECRET` belum di-set di env.
|
||||
- **500 lain** — cek log app atau `/admin/system` untuk detail error.
|
||||
|
||||
**Kalau dapat 500:** `CRON_SECRET` belum di-set di env.
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
Tail log cron:
|
||||
### Cara utama: `/admin/system` (admin panel)
|
||||
|
||||
Buka [/admin/system](/admin/system) — tampilkan:
|
||||
- **Per-job summary card**: last run, last success, count 7 hari, error count 7 hari, health badge (🟢 OK / 🟡 STALE / 🔴 FAILED).
|
||||
- **Recent runs table**: 20 cron run terakhir lintas semua job (waktu, status, payload, error).
|
||||
- **Stale state alerts**: banner kuning kalau ada Payment AWAITING > 25h / Payout HELD overdue / Refund APPROVED > 7d.
|
||||
|
||||
Cek setiap pagi sebelum mulai kerja — kalau semua 🟢 OK, tidak ada incident.
|
||||
|
||||
### Cara backup: tail log file
|
||||
|
||||
```bash
|
||||
tail -f /var/log/setrip-cron.log
|
||||
```
|
||||
|
||||
Cek log app PM2 (untuk `console.log` dari endpoint):
|
||||
PM2 log untuk `console.log` dari endpoint:
|
||||
|
||||
```bash
|
||||
pm2 logs setrip --lines 100 | grep cron
|
||||
```
|
||||
|
||||
### Discord push notif (opsional)
|
||||
|
||||
Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set ke Discord webhook URL, `runCron` otomatis kirim 🚨 message saat cron FAILED — admin bisa react langsung tanpa harus cek dashboard.
|
||||
|
||||
Cara setup: Discord channel → Edit Channel → Integrations → Webhooks → New → copy URL → set di env.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Cron jalan tapi tidak ada efek di DB:**
|
||||
- Cek `pm2 logs setrip` untuk error.
|
||||
- Verifikasi waktu server: `date` (output harus UTC kalau pakai schedule UTC).
|
||||
- Cek `/admin/system` — kalau status SUCCESS dengan `payload: { completed: 0 }`, memang tidak ada pekerjaan saat ini.
|
||||
- Cek `pm2 logs setrip` untuk error runtime.
|
||||
- Verifikasi waktu server: `date -u` (output harus UTC kalau pakai schedule UTC).
|
||||
|
||||
**Cron tidak jalan sama sekali:**
|
||||
- Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS).
|
||||
- Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`.
|
||||
- Cek `/admin/system` — kalau "Last run" jauh dari ekspektasi (mis. > 25 jam untuk daily cron), schedule mungkin tidak ke-trigger.
|
||||
|
||||
**`process-email-jobs` SUCCESS tapi email tidak terkirim:**
|
||||
- Cek `RESEND_API_KEY` di env. Tanpa env, cron return early dengan warning di log.
|
||||
- Cek dashboard Resend untuk delivery status / bounce.
|
||||
- Cek tabel `EmailJob` di DB: status `PENDING`/`FAILED` + `lastError` field.
|
||||
|
||||
**`auto-complete-trips` SUCCESS tapi trip masih OPEN:**
|
||||
- Cek `Trip.endDate` (kalau ada) atau `Trip.date` — harus lewat cutoff (`utcStartOfDay(now)`).
|
||||
- Trip dengan status `CLOSED` sengaja tidak di-touch (organizer eksplisit batalkan).
|
||||
|
||||
**Secret bocor:**
|
||||
- Generate ulang `CRON_SECRET`, update di `.env` + crontab line, restart PM2.
|
||||
- Generate ulang `CRON_SECRET`, update di `.env` + semua baris crontab, restart PM2.
|
||||
|
||||
## Hari kalau pindah ke Vercel / PaaS lain
|
||||
**Cron FAILED berturut-turut:**
|
||||
- `/admin/system` akan tampilkan badge 🔴 FAILED.
|
||||
- Kalau env `ADMIN_ALERT_WEBHOOK_URL` di-set, Discord channel akan dapat notif.
|
||||
- Klik "Last error" di card cron untuk lihat stack trace, atau cek tabel `CronRun.errorMessage` langsung.
|
||||
|
||||
Tinggal hapus crontab line + bikin `vercel.json` (atau equivalent platform). Endpoint sudah platform-agnostic — proteksinya sama (header `Authorization: Bearer <CRON_SECRET>`).
|
||||
---
|
||||
|
||||
## Saat menambah cron baru (developer note)
|
||||
|
||||
Checklist:
|
||||
|
||||
1. Buat route handler di `app/api/cron/<name>/route.ts` dengan pola standar (CRON_SECRET check + `runCron(jobName, fn)` wrapper).
|
||||
2. Tambah entry di tabel **Daftar cron job** di doc ini.
|
||||
3. Tambah baris di `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx) supaya muncul di health card.
|
||||
4. Brief ops: tambah baris di server crontab dengan schedule yang sesuai.
|
||||
|
||||
Pattern minimal cron handler:
|
||||
|
||||
```ts
|
||||
// app/api/cron/<name>/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { runCron } from "@/lib/cron-runner";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
|
||||
}
|
||||
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const outcome = await runCron("<jobName>", async () => {
|
||||
// ... actual work; return value masuk ke CronRun.payload
|
||||
return { processedCount: 0 };
|
||||
});
|
||||
|
||||
if (!outcome.ok) {
|
||||
return NextResponse.json({ error: outcome.error }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, ...outcome.payload });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kalau pindah ke Vercel / PaaS lain
|
||||
|
||||
Bikin `vercel.json` di root:
|
||||
|
||||
```json
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/auto-complete-trips",
|
||||
"schedule": "0 18 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/process-email-jobs",
|
||||
"schedule": "*/5 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Vercel Cron otomatis kirim header `Authorization: Bearer <VERCEL_CRON_SECRET>` — sesuaikan logic auth check di route handler (atau pakai env yang sama). Endpoint sudah platform-agnostic — tidak ada code change yang diperlukan.
|
||||
|
||||
> **Catatan:** Vercel Cron free tier limit 2 cron/project + minimum schedule 1 jam. Untuk `process-email-jobs` yang 5 menit, perlu upgrade Vercel Pro atau pertahankan VPS untuk cron.
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,84 @@
|
||||
# Setrip — Admin Audit & Investigation Roadmap (ARCHIVED — DELIVERED 2026-05-18)
|
||||
|
||||
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
|
||||
|
||||
---
|
||||
|
||||
## Status delivery
|
||||
|
||||
| Phase | Status | Catatan |
|
||||
|---|---|---|
|
||||
| Phase 1 — Filter & Search Enhancements | ✅ Delivered | Filter date range + reviewer di refunds/payouts/verifications via `AdminFilterBar` reusable. Reason filter di refunds. |
|
||||
| Phase 2 — Global Search | ✅ Delivered | Search bar di sidebar admin dispatch by pattern (email/order_id/cuid/fuzzy). Endpoint `/api/admin/search`. |
|
||||
| Phase 3 — CSV Export | ✅ Delivered | 3 endpoint export (refunds/payouts/verifications) dengan UTF-8 BOM untuk Excel. Tombol "⬇️ Export CSV" di tiap halaman list. |
|
||||
| Phase 4 — Generic Admin Audit Log | ✅ Delivered | Model `AdminActionLog` (polymorphic, append-only). Helper `auditLog.record()` di-wire ke semua admin server action. Halaman `/admin/audit-log` dengan filter. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Filter & Search Enhancements ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Filter date range (`dateFrom`, `dateTo`) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
|
||||
| 1.2 | Filter `reviewer` (admin email dropdown) di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
|
||||
| 1.3 | Filter `reason` di `/admin/refunds` | ✅ | [app/admin/refunds/page.tsx](../../app/admin/refunds/page.tsx) |
|
||||
| 1.4 | Filter date range + `processor` di `/admin/payouts` | ✅ | [app/admin/payouts/page.tsx](../../app/admin/payouts/page.tsx) |
|
||||
| 1.5 | Filter date range + `reviewer` di `/admin/verifications` | ✅ | [app/admin/verifications/page.tsx](../../app/admin/verifications/page.tsx) |
|
||||
| 1.6 | Komponen reusable `AdminFilterBar` | ✅ | [features/admin/components/admin-filter-bar.tsx](../../features/admin/components/admin-filter-bar.tsx) |
|
||||
| 1.7 | Filter params di `refundRepo`/`payoutRepo`/`organizerRepo` `listByStatus` | ✅ | `server/repositories/*.ts` |
|
||||
| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | ✅ | [lib/admin.ts](../../lib/admin.ts) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Global Search ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 2.1 | `adminSearchService.resolve(q)` — dispatch by pattern (email exact, order_id prefix, cuid, fuzzy) | ✅ | [server/services/admin-search.service.ts](../../server/services/admin-search.service.ts) |
|
||||
| 2.2 | Route handler `/api/admin/search?q=...` (guard isAdmin) | ✅ | [app/api/admin/search/route.ts](../../app/api/admin/search/route.ts) |
|
||||
| 2.3 | Component `AdminSearchBar` — debounced 250ms, dropdown hasil dengan type badge | ✅ | [features/admin/components/admin-search-bar.tsx](../../features/admin/components/admin-search-bar.tsx) |
|
||||
| 2.4 | Wire di admin sidebar (di bawah logo header) | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
|
||||
| 2.5 | Page `/admin/search?q=...` full results | ⏳ | Skip — dropdown limit 10 hit cukup; jarang butuh full page. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — CSV Export ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 3.1 | Helper `lib/csv.ts` — `buildCsv`, `escapeCsvCell`, `csvResponse` dengan UTF-8 BOM | ✅ | [lib/csv.ts](../../lib/csv.ts) |
|
||||
| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ✅ | [app/api/admin/export/refunds/route.ts](../../app/api/admin/export/refunds/route.ts) |
|
||||
| 3.3 | Route `/api/admin/export/payouts` | ✅ | [app/api/admin/export/payouts/route.ts](../../app/api/admin/export/payouts/route.ts) |
|
||||
| 3.4 | Route `/api/admin/export/verifications` — TANPA NIK/KTP key/bank account number (privasi) | ✅ | [app/api/admin/export/verifications/route.ts](../../app/api/admin/export/verifications/route.ts) |
|
||||
| 3.5 | Komponen `ExportCsvLink` + tombol di tiap admin list page (filter preserved) | ✅ | [features/admin/components/export-csv-link.tsx](../../features/admin/components/export-csv-link.tsx) |
|
||||
|
||||
**Tindakan manual:** test di staging dulu — pastikan tidak ada data sensitif yang ter-leak (NIK plaintext, foto KYC key, dst).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Generic Admin Audit Log ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 4.1 | Model `AdminActionLog` (polymorphic, append-only) + migration | ✅ | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518180000_add_admin_action_log/` |
|
||||
| 4.2 | Helper `auditLog.record({ admin, action, entityType, entityId, payload? })` | ✅ | [server/services/audit-log.service.ts](../../server/services/audit-log.service.ts) |
|
||||
| 4.3 | Wire di semua admin server action: refund approve/reject/mark/create, payout markPaid, verification approve/reject/reopen, trip admin-cancel, payment reconcile, user suspend/unsuspend | ✅ | `features/*/actions.ts` |
|
||||
| 4.4 | Page `/admin/audit-log` dengan filter (date range, admin email, entity type, action contains) + pagination basic (max 200) | ✅ | [app/admin/audit-log/page.tsx](../../app/admin/audit-log/page.tsx) |
|
||||
| 4.5 | Link "Audit Log" di sidebar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
|
||||
|
||||
**Daftar action yang ter-log:**
|
||||
|
||||
| Action | Entity | Source |
|
||||
|---|---|---|
|
||||
| `USER_SUSPEND` / `USER_UNSUSPEND` | User | [features/admin/actions.ts](../../features/admin/actions.ts) |
|
||||
| `TRIP_ADMIN_CANCEL` | Trip | [features/trip/actions.ts](../../features/trip/actions.ts) |
|
||||
| `PAYMENT_RECONCILE` | Payment (orderId) | [features/booking/actions.ts](../../features/booking/actions.ts) |
|
||||
| `VERIFICATION_APPROVE` / `VERIFICATION_REJECT` / `VERIFICATION_REOPEN` | OrganizerVerification | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
|
||||
| `REFUND_CREATE` / `REFUND_APPROVE` / `REFUND_REJECT` / `REFUND_SUCCEEDED` / `REFUND_FAILED` | Refund | [features/refund/actions.ts](../../features/refund/actions.ts) |
|
||||
| `PAYOUT_MARK_PAID` | Payout | [features/payout/actions.ts](../../features/payout/actions.ts) |
|
||||
|
||||
`adminId` nullable + `adminEmail` snapshot — log entry tetap auditable kalau admin dihapus.
|
||||
|
||||
**Tindakan manual ops:**
|
||||
1. Apply migration: `npx prisma migrate deploy`.
|
||||
2. Brief admin: setiap aksi mereka di panel akan tercatat di `/admin/audit-log` dengan email mereka — pakai sebagai bukti compliance saat audit eksternal.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Setrip — Admin Payment Operations Roadmap (ARCHIVED — DELIVERED 2026-05-18)
|
||||
|
||||
Admin perlu visibilitas + kontrol penuh atas alur uang: payment Midtrans, refund, payout. Saat webhook gagal atau status mismatch, admin harus bisa reconcile tanpa edit DB.
|
||||
|
||||
> **Skenario nyata:** webhook Midtrans drop di production. `Booking.status = AWAITING_PAY` padahal user sudah bayar (confirm email dari Midtrans). User komplain via WhatsApp. Saat ini admin harus query DB manual lalu update via Prisma Studio.
|
||||
|
||||
---
|
||||
|
||||
## Status delivery
|
||||
|
||||
| Phase | Status | Catatan |
|
||||
|---|---|---|
|
||||
| Phase 1 — Booking + Payment Detail View | ✅ Delivered | Timeline lintas Payment + Refund + Payout dengan raw callback viewer. |
|
||||
| Phase 2 — Admin Midtrans Reconcile UI | ✅ Delivered | Tombol reconcile per Payment Midtrans, panggil Core API + apply state-machine. Bulk reconcile deferred. |
|
||||
| Phase 3 — Dispute & Chargeback Tracking | ⏳ Deferred | Enum `DISPUTE_RESOLVED` sudah ada — admin bisa pakai existing flow refund. UI filter khusus bisa ditambah nanti. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Booking + Payment Detail View ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1.1 | `bookingRepo.findByIdForAdmin(id)` — include payments (with raw), refunds, payout, trip, user | ✅ | [server/repositories/booking.repo.ts](../../server/repositories/booking.repo.ts) |
|
||||
| 1.2 | Page `/admin/bookings/[id]` — header booking + timeline events | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
|
||||
| 1.3 | Inline timeline (Payment + Refund + Payout) sorted by createdAt — implemented inline di page | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
|
||||
| 1.4 | Component `RawCallbackViewer` — collapsible JSON pretty-printed | ✅ | [features/booking/components/raw-callback-viewer.tsx](../../features/booking/components/raw-callback-viewer.tsx) |
|
||||
| 1.5 | Link "Lihat timeline" dari `/admin/refunds` ke `/admin/bookings/[id]` | ✅ | [features/refund/components/refund-review-card.tsx](../../features/refund/components/refund-review-card.tsx) |
|
||||
| 1.6 | Link "Lihat timeline" dari `/admin/payouts` ke `/admin/bookings/[id]` | ✅ | [features/payout/components/payout-review-card.tsx](../../features/payout/components/payout-review-card.tsx) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Admin Midtrans Reconciliation UI ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 2.1 | `paymentService.adminReconcile(orderId)` — variant tanpa ownership check, reuse `applyGatewayStatus` helper | ✅ | [server/services/payment.service.ts](../../server/services/payment.service.ts) |
|
||||
| 2.2 | Server action `adminReconcileMidtransAction(orderId)` (guard isAdmin) | ✅ | [features/booking/actions.ts](../../features/booking/actions.ts) |
|
||||
| 2.3 | Component `AdminReconcileButton` per Payment Midtrans di timeline | ✅ | [features/booking/components/admin-reconcile-button.tsx](../../features/booking/components/admin-reconcile-button.tsx) |
|
||||
| 2.4 | Tampilkan `Payment.rejectionReason` (amount mismatch log) di card payment | ✅ | [app/admin/bookings/[id]/page.tsx](../../app/admin/bookings/[id]/page.tsx) |
|
||||
| 2.5 | Bulk reconcile: `/admin/payments/stale` — list Payment AWAITING > 6 jam | ⏳ | Deferred — admin bisa filter manual via list saat itu butuh, tidak ada incident concrete yang minta bulk. |
|
||||
|
||||
**Tindakan manual yang masih perlu dilakukan ops:**
|
||||
1. Brief admin: kapan pakai tombol "Reconcile Midtrans" — saat peserta lapor "sudah bayar tapi status belum update".
|
||||
2. Tombol idempotent — aman ditekan berkali-kali. Tidak menggandakan payment.
|
||||
3. Pakai `RawCallbackViewer` untuk inspeksi error gateway / metadata transaksi saat investigasi dispute.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Dispute & Chargeback Tracking ⏳ (deferred)
|
||||
|
||||
Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md). Akan diangkat kembali kalau volume chargeback membesar.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Setrip — Admin System Health Roadmap (ARCHIVED — DELIVERED 2026-05-18)
|
||||
|
||||
Admin perlu visibilitas atas job otomatis (cron) dan alert untuk state stale.
|
||||
|
||||
---
|
||||
|
||||
## Status delivery
|
||||
|
||||
| Phase | Status | Catatan |
|
||||
|---|---|---|
|
||||
| Phase 1 — Cron Run Log | ✅ Delivered | Model `CronRun`, helper `runCron()`, wire ke cron existing. |
|
||||
| Phase 2 — System Status Page | ✅ Delivered | `/admin/system` tampilkan summary cron + 20 run terakhir + health badge. |
|
||||
| Phase 3 — Stale State Alerts | ✅ Delivered | `systemHealthService.detectStale()` cek 4 kategori (Payment AWAITING > 25h, AWAITING_PAY past departure, Payout HELD overdue, Refund APPROVED > 7d). Banner di `/admin/system`. |
|
||||
| Phase 4 — Discord Webhook Notify | ✅ Delivered | `notifyAdmins()` POST ke `ADMIN_ALERT_WEBHOOK_URL`. Trigger otomatis saat cron FAILED via `runCron`. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Cron Run Log ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Model `CronRun` + enum `CronRunStatus` + migration | ✅ | [prisma/schema.prisma](../../prisma/schema.prisma) + `prisma/migrations/20260518170000_add_cron_run/` |
|
||||
| 1.2 | Helper `runCron(jobName, fn)` — auto create RUNNING row → SUCCESS/FAILED | ✅ | [lib/cron-runner.ts](../../lib/cron-runner.ts) |
|
||||
| 1.3 | Wire `runCron` di `auto-complete-trips` cron | ✅ | [app/api/cron/auto-complete-trips/route.ts](../../app/api/cron/auto-complete-trips/route.ts) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — System Status Page ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 2.1 | Per-job summary: last run, last success, count 7d, error count 7d | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
|
||||
| 2.2 | 20 cron run terakhir di table bawah | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
|
||||
| 2.3 | Health badge (🟢 OK < 25h, 🟡 STALE, 🔴 FAILED) | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
|
||||
| 2.4 | Link "System" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Stale State Alerts ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 3.1 | `systemHealthService.detectStale()` return 4 count | ✅ | [server/services/system-health.service.ts](../../server/services/system-health.service.ts) |
|
||||
| 3.2 | Banner alerts kuning di `/admin/system` kalau ada count > 0 | ✅ | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
|
||||
| 3.3 | Link tiap alert ke filtered list page yang relevan | ✅ (untuk Payout HELD & Refund APPROVED) | [app/admin/system/page.tsx](../../app/admin/system/page.tsx) |
|
||||
|
||||
**Threshold draft (review setelah jalan 1-2 minggu):**
|
||||
- Payment MIDTRANS `AWAITING` createdAt > 25 jam — suspect webhook tertunda
|
||||
- Booking `AWAITING_PAY` dengan trip.date < today — peserta lupa bayar
|
||||
- Payout `HELD` dengan heldUntil > 1 hari lewat — cron release tidak jalan
|
||||
- Refund `APPROVED` reviewedAt > 7 hari — admin lupa process
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Discord Webhook Notify ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 4.1 | Helper `notifyAdmins(message)` — POST ke Discord webhook URL dari env | ✅ | [lib/admin-notify.ts](../../lib/admin-notify.ts) |
|
||||
| 4.2 | Trigger notify di `runCron` saat FAILED (fire-and-forget) | ✅ | [lib/cron-runner.ts](../../lib/cron-runner.ts) |
|
||||
| 4.3 | Trigger notify dari `systemHealthService.detectStale` rate-limited | ⏳ | Skip — admin sudah lihat banner di `/admin/system` saat buka pagi. Push notif harian baru worth it kalau admin sering miss; bisa ditambah belakangan. |
|
||||
|
||||
**Tindakan manual ops:**
|
||||
1. Apply migration (sudah di Phase 1).
|
||||
2. (Opsional) Buat Discord channel internal + webhook URL → set env `ADMIN_ALERT_WEBHOOK_URL` di PM2/server. Tanpa env, `notifyAdmins` no-op.
|
||||
3. Test alert: trigger cron secara sengaja fail (mis. matikan DB sebentar) → cek Discord channel menerima 🚨 message.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Setrip — Admin Trip Operations Roadmap (ARCHIVED — DELIVERED 2026-05-18)
|
||||
|
||||
Admin perlu visibilitas penuh atas trip dan bisa intervensi (cancel + auto-refund) saat organizer unreachable atau ada masalah safety.
|
||||
|
||||
> **Skenario nyata:** peserta lapor trip berjalan tidak sesuai itinerary. Organizer tidak responsif. Hari berikutnya peserta minta refund. Saat ini admin harus refund satu-satu manual via `/admin/refunds` tanpa konteks trip atau cara cancel trip-nya.
|
||||
|
||||
---
|
||||
|
||||
## Status delivery
|
||||
|
||||
| Phase | Status | Catatan |
|
||||
|---|---|---|
|
||||
| Phase 1 — List + Detail View | ✅ Delivered | Filter dasar `status` + search `q`. Filter advanced (date range, organizer dropdown, kategori) belum dipakai — bisa ditambah di iterasi berikut kalau ada kebutuhan konkret. |
|
||||
| Phase 2 — Admin Force-Cancel | ✅ Delivered | `tripService.closeTrip` di-refactor terima actor union (ORGANIZER \| ADMIN). Migration menambah `Trip.cancelledByAdminId` + `cancelledReason`. |
|
||||
| Phase 3 — Trip Edit Override | ⏳ Deferred | Opsional, skip MVP. Evaluasi ulang kalau ada keluhan konkret. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Trip List + Detail View ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1.1 | `tripRepo.searchForAdmin({ q?, status? })` | ✅ | [server/repositories/trip.repo.ts](../../server/repositories/trip.repo.ts) |
|
||||
| 1.2 | Page `/admin/trips` — list + tab status (ALL/OPEN/FULL/CLOSED/COMPLETED) + search bar | ✅ | [app/admin/trips/page.tsx](../../app/admin/trips/page.tsx) |
|
||||
| 1.3 | Filter: tanggal range, organizer dropdown, kategori | ⏳ | Deferred — search `q` sudah cover sebagian (search organizer name/email). |
|
||||
| 1.4 | Page `/admin/trips/[id]` — full detail (trip core + itinerary items + participants) | ✅ | [app/admin/trips/[id]/page.tsx](../../app/admin/trips/[id]/page.tsx) |
|
||||
| 1.5 | Stat cards: kapasitas, confirmed, pending, cancelled | ✅ | [app/admin/trips/[id]/page.tsx](../../app/admin/trips/[id]/page.tsx) |
|
||||
| 1.6 | Link "Trips" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Admin Force-Cancel Trip ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 2.1 | Migration: `cancelledByAdminId` (FK User, ON DELETE SET NULL) + `cancelledReason` | ✅ | `prisma/migrations/20260518150000_add_trip_admin_cancel/` |
|
||||
| 2.2 | Refactor `tripService.closeTrip` terima `actor: { type: "ORGANIZER" \| "ADMIN", ... }` | ✅ | [server/services/trip.service.ts](../../server/services/trip.service.ts) |
|
||||
| 2.3 | Server action `adminCancelTripAction(tripId, reason)` — guard isAdmin, reason min 10 char | ✅ | [features/trip/actions.ts](../../features/trip/actions.ts) |
|
||||
| 2.4 | UI: tombol "Cancel Trip (Admin)" di `/admin/trips/[id]` dengan modal reason wajib + summary impact | ✅ | [features/trip/components/admin-cancel-trip-button.tsx](../../features/trip/components/admin-cancel-trip-button.tsx) |
|
||||
| 2.5 | Badge "Dibatalkan admin" + reason di public trip detail | ⏳ | Deferred — kolom DB sudah ada, tinggal tambah UI saat ada kebutuhan transparansi. |
|
||||
|
||||
**Tindakan manual yang sudah dilakukan:** none — admin tinggal pakai.
|
||||
|
||||
**Tindakan manual yang masih perlu dilakukan ops:**
|
||||
1. Jalankan migration di production: `npx prisma migrate deploy`.
|
||||
2. Brief admin: kriteria reason yang valid (organizer unreachable >7 hari, dispute peserta tidak terselesaikan, safety issue). Reason wajib min 10 karakter untuk audit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Trip Edit Override ⏳ (deferred)
|
||||
|
||||
Skip MVP. Lihat versi awal di [ADMIN_ROADMAP.md](../../ADMIN_ROADMAP.md) kalau perlu re-open.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Setrip — Admin User Management Roadmap (ARCHIVED — DELIVERED 2026-05-18, fully done)
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Status delivery
|
||||
|
||||
| Phase | Status | Catatan |
|
||||
|---|---|---|
|
||||
| Phase 1 — User List & Detail | ✅ Delivered | Search by email/name, filter tab (ALL/ACTIVE/SUSPENDED), stats (trip dibuat, booking, total spent). |
|
||||
| Phase 2 — User Suspension | ✅ Delivered | Schema baru `User.suspended`, auth gate sign-in + helper `requireActiveUser` di mutating actions, trip public list otomatis sembunyikan organizer suspended. |
|
||||
| Phase 3 — User Analytics | ✅ Delivered | Page `/admin/users/stats` dengan stats card (total/suspended/verified-organizer/active-organizer-30d/paid-participant-30d) + bar chart signup per minggu (8 minggu terakhir, inline SVG-free). |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — User List & Detail ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1.1 | `userRepo.searchForAdmin({ q?, suspended? })` | ✅ | [server/repositories/user.repo.ts](../../server/repositories/user.repo.ts) |
|
||||
| 1.2 | Page `/admin/users` — list + search + tab filter (ALL/ACTIVE/SUSPENDED) | ✅ | [app/admin/users/page.tsx](../../app/admin/users/page.tsx) |
|
||||
| 1.3 | Page `/admin/users/[id]` — detail dengan trip dibuat + booking history + profile + verification | ✅ | [app/admin/users/[id]/page.tsx](../../app/admin/users/[id]/page.tsx) |
|
||||
| 1.4 | Stats cards: trip dibuat, booking aktif, total spent (PAID) | ✅ | [app/admin/users/[id]/page.tsx](../../app/admin/users/[id]/page.tsx) |
|
||||
| 1.5 | Link "Users" di admin navbar | ✅ | [components/admin/admin-sidebar.tsx](../../components/admin/admin-sidebar.tsx) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — User Suspension ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 2.1 | Migration: `suspended Boolean`, `suspendedAt`, `suspendedReason`, `suspendedById` (FK User SET NULL) | ✅ | `prisma/migrations/20260518160000_add_user_suspension/` |
|
||||
| 2.2 | `userService.suspendUser` + `unsuspendUser` (idempotent + cek tidak suspend diri sendiri + reason min 10 char) | ✅ | [server/services/user.service.ts](../../server/services/user.service.ts) |
|
||||
| 2.3 | Block sign-in di NextAuth `signIn` callback (email-based, jalan untuk Credentials + OAuth) | ✅ | [lib/auth.ts](../../lib/auth.ts) |
|
||||
| 2.4 | Helper `requireActiveUser(userId)` — lookup fresh dari DB | ✅ | [lib/auth-guards.ts](../../lib/auth-guards.ts) |
|
||||
| 2.5 | Wire `requireActiveUser` di `createTripAction` + `joinTripAction` | ✅ | [features/trip/actions.ts](../../features/trip/actions.ts) |
|
||||
| 2.6 | Filter trip public list: `organizer: { suspended: false }` di `findOpen` | ✅ | [server/repositories/trip.repo.ts](../../server/repositories/trip.repo.ts) |
|
||||
| 2.7 | UI: tombol Suspend/Unsuspend di `/admin/users/[id]` dengan modal reason wajib | ✅ | [features/admin/components/suspend-user-button.tsx](../../features/admin/components/suspend-user-button.tsx) |
|
||||
| 2.8 | Badge "SUSPENDED" di user list + detail header (red border accent) | ✅ | list & detail pages |
|
||||
| 2.9 | Server actions `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | ✅ | [features/admin/actions.ts](../../features/admin/actions.ts) |
|
||||
|
||||
**Tindakan manual ops:**
|
||||
1. Apply migration: `npx prisma migrate deploy`.
|
||||
2. Brief admin: kriteria suspend (scam, harassment, repeated TOS violation). Reason wajib min 10 char.
|
||||
3. Wire `requireActiveUser` ke action mutating lain saat dibuat (`createReviewAction`, dst).
|
||||
4. Pertimbangkan: bikin halaman info "Akun ditangguhkan" untuk UX saat suspended user coba login.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — User Analytics ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 3.1 | Stats: total users, suspended, verified organizers, active organizer 30d (bikin trip), paid participant 30d | ✅ | [app/admin/users/stats/page.tsx](../../app/admin/users/stats/page.tsx) |
|
||||
| 3.2 | Bar chart signup per minggu (8 minggu terakhir, pakai inline div height % — no chart library) | ✅ | [app/admin/users/stats/page.tsx](../../app/admin/users/stats/page.tsx) |
|
||||
| 3.3 | Link "📊 Stats" di header `/admin/users` | ✅ | [app/admin/users/page.tsx](../../app/admin/users/page.tsx) |
|
||||
@@ -0,0 +1,66 @@
|
||||
# Setrip — Admin Verification Roadmap (ARCHIVED — DELIVERED 2026-05-18)
|
||||
|
||||
Enhancement KYC organizer verification: reopen REJECTED, request re-upload, history, manual override.
|
||||
|
||||
---
|
||||
|
||||
## Status delivery
|
||||
|
||||
| Phase | Status | Catatan |
|
||||
|---|---|---|
|
||||
| Phase 1 — Reopen Rejected | ✅ Delivered | Tombol "Buka kembali ke PENDING" di REJECTED card dengan note wajib. |
|
||||
| Phase 2 — Re-upload Request | ✅ Delivered | Admin pilih checkbox field (KTP/liveness/NIK/bank/alamat) + note. Organizer dapat banner kuning di `/verify` dengan highlight field yang diminta. Auto-clear saat submit ulang. |
|
||||
| Phase 3 — Submission History | ✅ Delivered | Field `submissionCount` di-bump tiap submit ulang. `previousRejections` JSON array menyimpan rejection lama (waktu + reason + submission ke-N) sebelum overwrite. |
|
||||
| Phase 4 — Manual Override | ✅ Delivered | Admin verify user tanpa upload KYC (partner trusted). Flag `isManualOverride = true` untuk audit transparansi. UI di `/admin/users/[id]`. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Reopen Rejected ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1.1 | `organizerService.reopenVerification` | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
|
||||
| 1.2 | `organizerRepo.reopen` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
|
||||
| 1.3 | `reopenVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
|
||||
| 1.4 | UI tombol di REJECTED card | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Re-upload Request ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 2.1 | Migration: `reuploadRequested`, `reuploadFields String[]`, `reuploadNote` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
|
||||
| 2.2 | `organizerService.requestReupload(verifId, adminId, fields, note)` + `REUPLOAD_FIELDS` enum-like const | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
|
||||
| 2.3 | `organizerRepo.requestReupload` (set status PENDING + flag + clear review) | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
|
||||
| 2.4 | `requestReuploadAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
|
||||
| 2.5 | UI admin di PENDING card: tombol "🔄 Minta re-upload" + multi-checkbox field + note | ✅ | [features/organizer/components/review-card.tsx](../../features/organizer/components/review-card.tsx) |
|
||||
| 2.6 | Banner kuning di `/verify` saat `reuploadRequested = true` + list field yang diminta | ✅ | [app/(public)/verify/page.tsx](../../app/(public)/verify/page.tsx) |
|
||||
| 2.7 | Auto-clear flag saat organizer submit ulang (logic di `submitVerification`) | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Submission History ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 3.1 | Migration: `submissionCount Int @default(1)`, `previousRejections Json?` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
|
||||
| 3.2 | Bump `submissionCount` + archive rejection lama saat submit ulang (helper `buildArchivedRejections`) | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
|
||||
|
||||
> _Catatan: snapshot full data (Phase 3.1 di roadmap awal) di-skip — `submissionCount` + `previousRejections` (array waktu/reason) cukup untuk audit "berapa kali verify, apa reason ditolak sebelumnya". UI history detail bisa ditambah saat ada permintaan konkret._
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Manual Override ✅
|
||||
|
||||
| # | Item | Status | File |
|
||||
|---|---|---|---|
|
||||
| 4.1 | Migration: `isManualOverride Boolean @default(false)`, `manualOverrideById`, `manualOverrideNote` | ✅ | `prisma/migrations/20260518190000_verification_enhancements/` |
|
||||
| 4.2 | `organizerService.manualOverrideVerification(input)` — bikin row APPROVED dengan placeholder KYC | ✅ | [server/services/organizer.service.ts](../../server/services/organizer.service.ts) |
|
||||
| 4.3 | `organizerRepo.createManualOverride` | ✅ | [server/repositories/organizer.repo.ts](../../server/repositories/organizer.repo.ts) |
|
||||
| 4.4 | `manualOverrideVerificationAction` (guard isAdmin + audit log) | ✅ | [features/organizer/actions.ts](../../features/organizer/actions.ts) |
|
||||
| 4.5 | UI: `ManualVerifyButton` di `/admin/users/[id]` (hanya tampil kalau user belum punya verification) | ✅ | [features/admin/components/manual-verify-button.tsx](../../features/admin/components/manual-verify-button.tsx) |
|
||||
|
||||
**Tindakan manual ops:**
|
||||
1. Apply migration: `npx prisma migrate deploy`.
|
||||
2. Brief admin: re-upload request lebih lembut dari reject (organizer tidak perlu ulang dari nol). Manual override hanya untuk partner trusted dengan ref konkret di note (mis. nomor kontrak).
|
||||
@@ -0,0 +1,103 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { userService } from "@/server/services/user.service";
|
||||
import { auditLog } from "@/server/services/audit-log.service";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
import { userRepo } from "@/server/repositories/user.repo";
|
||||
|
||||
export async function suspendUserAction(userId: string, reason: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return { error: "Hanya admin yang bisa melakukan aksi ini" };
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.suspendUser({
|
||||
userId,
|
||||
adminId: session.user.id,
|
||||
reason,
|
||||
});
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "USER_SUSPEND",
|
||||
entityType: "User",
|
||||
entityId: userId,
|
||||
payload: { reason: reason.trim() },
|
||||
});
|
||||
|
||||
// Notif email user — due process: kasih tahu alasan + cara appeal.
|
||||
void notifySuspended(userId, reason.trim());
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
return { success: true as const };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
async function notifySuspended(userId: string, reason: string) {
|
||||
const target = await userRepo.findById(userId);
|
||||
if (!target) return;
|
||||
await emailService.send({
|
||||
to: target.email,
|
||||
// Suspend bisa di-trigger berulang — sertakan timestamp supaya tiap suspend
|
||||
// baru dapat email baru.
|
||||
idempotencyKey: `account_suspended-${userId}-${Date.now()}`,
|
||||
template: {
|
||||
template: "account_suspended",
|
||||
data: { userName: target.name, reason },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.8 — kabari user kalau penangguhan akunnya sudah dicabut. */
|
||||
async function notifyUnsuspended(userId: string) {
|
||||
const target = await userRepo.findById(userId);
|
||||
if (!target) return;
|
||||
await emailService.send({
|
||||
to: target.email,
|
||||
// Sertakan timestamp supaya tiap siklus suspend→unsuspend dapat email baru.
|
||||
idempotencyKey: `account_unsuspended-${userId}-${Date.now()}`,
|
||||
template: {
|
||||
template: "account_unsuspended",
|
||||
data: { userName: target.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function unsuspendUserAction(userId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return { error: "Hanya admin yang bisa melakukan aksi ini" };
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.unsuspendUser({ userId, adminId: session.user.id });
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "USER_UNSUSPEND",
|
||||
entityType: "User",
|
||||
entityId: userId,
|
||||
});
|
||||
|
||||
// Notif email user — kabari akun sudah aktif kembali.
|
||||
void notifyUnsuspended(userId);
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
return { success: true as const };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { DateField } from "@/components/shared/date-picker";
|
||||
|
||||
interface AdminFilterBarProps {
|
||||
/** URL base (mis. `/admin/refunds`) yang menerima query params. */
|
||||
action: string;
|
||||
/** Nilai current dari searchParams. */
|
||||
values: {
|
||||
tab?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
reviewer?: string;
|
||||
reason?: string;
|
||||
};
|
||||
/** Daftar admin email untuk dropdown reviewer/processor. */
|
||||
reviewerOptions: string[];
|
||||
/** Label dropdown reviewer (mis. "Reviewer", "Processor"). */
|
||||
reviewerLabel?: string;
|
||||
/** Kalau diisi, tampilkan dropdown reason dengan opsi-opsi tersebut. */
|
||||
reasonOptions?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter bar reusable untuk admin list pages. Pakai GET form supaya URL
|
||||
* shareable dan tidak perlu state client.
|
||||
*/
|
||||
export function AdminFilterBar({
|
||||
action,
|
||||
values,
|
||||
reviewerOptions,
|
||||
reviewerLabel = "Reviewer",
|
||||
reasonOptions,
|
||||
}: AdminFilterBarProps) {
|
||||
return (
|
||||
<form
|
||||
method="get"
|
||||
action={action}
|
||||
className="mb-4 rounded-2xl border border-neutral-200 bg-white p-3 shadow-sm sm:p-4"
|
||||
>
|
||||
{/* Preserve tab via hidden input */}
|
||||
{values.tab && <input type="hidden" name="tab" value={values.tab} />}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="filter-dateFrom"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Dari tanggal
|
||||
</label>
|
||||
<DateField
|
||||
id="filter-dateFrom"
|
||||
name="dateFrom"
|
||||
defaultValueYmd={values.dateFrom}
|
||||
placeholder="Dari tanggal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="filter-dateTo"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Sampai tanggal
|
||||
</label>
|
||||
<DateField
|
||||
id="filter-dateTo"
|
||||
name="dateTo"
|
||||
defaultValueYmd={values.dateTo}
|
||||
placeholder="Sampai tanggal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="filter-reviewer"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
{reviewerLabel}
|
||||
</label>
|
||||
<select
|
||||
id="filter-reviewer"
|
||||
name="reviewer"
|
||||
defaultValue={values.reviewer ?? ""}
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
|
||||
>
|
||||
<option value="">Semua</option>
|
||||
{reviewerOptions.map((email) => (
|
||||
<option key={email} value={email}>
|
||||
{email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{reasonOptions ? (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="filter-reason"
|
||||
className="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||
>
|
||||
Reason
|
||||
</label>
|
||||
<select
|
||||
id="filter-reason"
|
||||
name="reason"
|
||||
defaultValue={values.reason ?? ""}
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400 focus:bg-white"
|
||||
>
|
||||
<option value="">Semua</option>
|
||||
{reasonOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Terapkan
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reasonOptions && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-primary-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Terapkan
|
||||
</button>
|
||||
<a
|
||||
href={`${action}${values.tab ? `?tab=${values.tab}` : ""}`}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-4 py-1.5 text-sm font-semibold text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Reset
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Hit {
|
||||
type: "user" | "trip" | "booking";
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search bar global untuk admin sidebar. Debounced 250ms supaya tidak spam
|
||||
* server. Hits dispatch berdasarkan pola input — lihat
|
||||
* `adminSearchService.resolve` di server.
|
||||
*/
|
||||
export function AdminSearchBar() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<Hit[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Debounced fetch — guard inside async block supaya tidak setState langsung
|
||||
// di effect synchronous (react-hooks/set-state-in-effect).
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
if (q.length < 2) {
|
||||
setHits([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : { hits: [] }))
|
||||
.then((json: { hits: Hit[] }) => {
|
||||
setHits(json.hits ?? []);
|
||||
})
|
||||
.catch(() => setHits([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function onClick(e: MouseEvent) {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="Cari email, ID, order_id, judul..."
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-xs text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400 focus:bg-white"
|
||||
/>
|
||||
{open && query.trim().length >= 2 && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-y-auto rounded-xl border border-neutral-200 bg-white shadow-xl">
|
||||
{loading && (
|
||||
<p className="px-3 py-2 text-[11px] text-neutral-500">Mencari...</p>
|
||||
)}
|
||||
{!loading && hits.length === 0 && (
|
||||
<p className="px-3 py-2 text-[11px] text-neutral-500">
|
||||
Tidak ada hasil.
|
||||
</p>
|
||||
)}
|
||||
{!loading && hits.length > 0 && (
|
||||
<ul className="py-1">
|
||||
{hits.map((h) => (
|
||||
<li key={`${h.type}-${h.id}`}>
|
||||
<Link
|
||||
href={h.href}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-neutral-50"
|
||||
>
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide ${
|
||||
h.type === "user"
|
||||
? "bg-primary-100 text-primary-700"
|
||||
: h.type === "trip"
|
||||
? "bg-secondary-100 text-secondary-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{h.type}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-neutral-800">
|
||||
{h.title}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-neutral-500">
|
||||
{h.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
interface ExportCsvLinkProps {
|
||||
/** URL endpoint export, mis. `/api/admin/export/refunds`. */
|
||||
href: string;
|
||||
/** Query string current filter (tanpa leading `?`). */
|
||||
query?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombol download CSV — anchor biasa supaya browser tangani download via
|
||||
* `Content-Disposition: attachment` header dari server.
|
||||
*/
|
||||
export function ExportCsvLink({
|
||||
href,
|
||||
query,
|
||||
label = "Export CSV",
|
||||
}: ExportCsvLinkProps) {
|
||||
const url = query ? `${href}?${query}` : href;
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
download
|
||||
>
|
||||
<Download size={16} strokeWidth={2} aria-hidden />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lock } from "lucide-react";
|
||||
import { manualOverrideVerificationAction } from "@/features/organizer/actions";
|
||||
|
||||
interface ManualVerifyButtonProps {
|
||||
userId: string;
|
||||
defaultBankAccountName: string;
|
||||
}
|
||||
|
||||
export function ManualVerifyButton({
|
||||
userId,
|
||||
defaultBankAccountName,
|
||||
}: ManualVerifyButtonProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [bankName, setBankName] = useState("");
|
||||
const [bankAccountNumber, setBankAccountNumber] = useState("");
|
||||
const [bankAccountName, setBankAccountName] = useState(defaultBankAccountName);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await manualOverrideVerificationAction({
|
||||
userId,
|
||||
note,
|
||||
bankName,
|
||||
bankAccountNumber,
|
||||
bankAccountName,
|
||||
});
|
||||
setLoading(false);
|
||||
if ("error" in res && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setNote("");
|
||||
setBankName("");
|
||||
setBankAccountNumber("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-secondary-300 bg-white px-4 py-2 text-sm font-bold text-secondary-700 hover:bg-secondary-50"
|
||||
>
|
||||
<Lock size={18} strokeWidth={2} aria-hidden />
|
||||
Manual verify (tanpa KYC)
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-secondary-200 bg-secondary-50/60 p-4">
|
||||
<p className="text-xs text-secondary-900">
|
||||
Manual override: bikin verifikasi APPROVED tanpa upload KYC. Pakai HANYA
|
||||
untuk partner trusted referral atau kasus khusus. Ter-flag jelas di
|
||||
admin UI sebagai "manual override".
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-secondary-900">
|
||||
Alasan / referensi (min 10 char)
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
placeholder="contoh: Partner referral dari acara X, kontrak signed #PR-2026-15."
|
||||
className="w-full rounded-xl border border-secondary-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-secondary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<input
|
||||
type="text"
|
||||
value={bankName}
|
||||
onChange={(e) => setBankName(e.target.value)}
|
||||
placeholder="Nama bank"
|
||||
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={bankAccountNumber}
|
||||
onChange={(e) => setBankAccountNumber(e.target.value)}
|
||||
placeholder="Nomor rekening"
|
||||
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={bankAccountName}
|
||||
onChange={(e) => setBankAccountName(e.target.value)}
|
||||
placeholder="Atas nama"
|
||||
className="rounded-lg border border-secondary-200 bg-white px-3 py-1.5 text-sm focus:border-secondary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
loading ||
|
||||
note.trim().length < 10 ||
|
||||
!bankName.trim() ||
|
||||
!bankAccountNumber.trim() ||
|
||||
!bankAccountName.trim()
|
||||
}
|
||||
className="rounded-xl bg-secondary-600 px-4 py-2 text-sm font-bold text-white hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Konfirmasi Manual Verify"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setNote("");
|
||||
}}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
suspendUserAction,
|
||||
unsuspendUserAction,
|
||||
} from "@/features/admin/actions";
|
||||
|
||||
interface SuspendUserButtonProps {
|
||||
userId: string;
|
||||
isSuspended: boolean;
|
||||
}
|
||||
|
||||
export function SuspendUserButton({
|
||||
userId,
|
||||
isSuspended,
|
||||
}: SuspendUserButtonProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSuspend() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await suspendUserAction(userId, reason);
|
||||
setLoading(false);
|
||||
if ("error" in res && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleUnsuspend() {
|
||||
if (!confirm("Buka kembali akun ini? User akan langsung bisa login.")) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await unsuspendUserAction(userId);
|
||||
setLoading(false);
|
||||
if ("error" in res && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (isSuspended) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnsuspend}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-emerald-300 bg-white px-4 py-2 text-sm font-bold text-emerald-700 hover:bg-emerald-50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Buka Suspend"}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700"
|
||||
>
|
||||
Suspend User
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-red-200 bg-red-50/60 p-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="suspend-reason"
|
||||
className="mb-1 block text-xs font-semibold text-red-900"
|
||||
>
|
||||
Alasan suspend (wajib min 10 karakter — untuk audit)
|
||||
</label>
|
||||
<textarea
|
||||
id="suspend-reason"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
maxLength={500}
|
||||
placeholder="contoh: User membuat 5 trip palsu dengan alias, lapor masuk dari peserta korban (ticket #123)."
|
||||
className="w-full rounded-xl border border-red-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-red-400"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-red-900/70">
|
||||
{reason.trim().length}/500 karakter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSuspend}
|
||||
disabled={loading || reason.trim().length < 10}
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Konfirmasi Suspend"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
setError("");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+66
-32
@@ -2,30 +2,12 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { paymentService } from "@/server/services/payment.service";
|
||||
import { bookingService } from "@/server/services/booking.service";
|
||||
import { refundService } from "@/server/services/refund.service";
|
||||
import { absoluteUrl } from "@/lib/site";
|
||||
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 =
|
||||
| { error: string }
|
||||
| {
|
||||
@@ -33,6 +15,7 @@ export type StartMidtransResponse =
|
||||
snapToken: string;
|
||||
snapJsUrl: string;
|
||||
clientKey: string;
|
||||
orderId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -58,39 +41,90 @@ export async function startMidtransPaymentAction(
|
||||
|
||||
const result = await paymentService.startMidtransPayment(
|
||||
booking.id,
|
||||
session.user.id
|
||||
session.user.id,
|
||||
{ finishUrl: absoluteUrl(`/trips/${tripId}/payment`) }
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
snapToken: result.snapToken,
|
||||
snapJsUrl: result.snapJsUrl,
|
||||
clientKey: result.clientKey,
|
||||
orderId: result.orderId,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmParticipantPaymentAction(
|
||||
tripId: string,
|
||||
participantId: string
|
||||
) {
|
||||
/**
|
||||
* Admin variant reconcile — skip ownership check, dipakai dari panel admin
|
||||
* `/admin/bookings/[id]` saat investigasi.
|
||||
*/
|
||||
export async function adminReconcileMidtransAction(orderId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
const { isAdminEmail } = await import("@/lib/admin");
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return { error: "Hanya admin yang bisa melakukan aksi ini" };
|
||||
}
|
||||
if (!orderId || typeof orderId !== "string") {
|
||||
return { error: "order_id tidak valid" };
|
||||
}
|
||||
|
||||
try {
|
||||
await tripService.confirmParticipantPayment(
|
||||
tripId,
|
||||
participantId,
|
||||
const result = await paymentService.adminReconcile(orderId);
|
||||
if (!result.ok) {
|
||||
if (result.reason === "not_found") {
|
||||
return { error: "Order tidak ditemukan di sistem" };
|
||||
}
|
||||
return { error: "Status pembayaran tidak cocok dengan tagihan" };
|
||||
}
|
||||
const { auditLog } = await import("@/server/services/audit-log.service");
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "PAYMENT_RECONCILE",
|
||||
entityType: "Payment",
|
||||
entityId: orderId,
|
||||
payload: { outcome: result.status },
|
||||
});
|
||||
return { success: true as const, status: result.status };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
|
||||
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
|
||||
* `?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);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
if (!orderId || typeof orderId !== "string") {
|
||||
return { error: "order_id tidak valid" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await paymentService.reconcileFromGateway(
|
||||
orderId,
|
||||
session.user.id
|
||||
);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
if (!result.ok) {
|
||||
if (result.reason === "forbidden") {
|
||||
return { error: "Order ini bukan milikmu" };
|
||||
}
|
||||
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) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
import { adminReconcileMidtransAction } from "@/features/booking/actions";
|
||||
|
||||
interface AdminReconcileButtonProps {
|
||||
orderId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AdminReconcileButton({
|
||||
orderId,
|
||||
disabled,
|
||||
}: AdminReconcileButtonProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setStatus(null);
|
||||
const res = await adminReconcileMidtransAction(orderId);
|
||||
setLoading(false);
|
||||
if ("error" in res && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
if ("status" in res && res.status) {
|
||||
setStatus(res.status);
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading || disabled}
|
||||
className="rounded-lg bg-secondary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Reconciling..." : "Reconcile Midtrans"}
|
||||
</button>
|
||||
{status && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700">
|
||||
<Check size={12} strokeWidth={2.5} aria-hidden />
|
||||
{reconcileOutcomeLabel(status)}
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="text-[11px] font-medium text-red-600">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function reconcileOutcomeLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "updated":
|
||||
return "Status di-update";
|
||||
case "skipped":
|
||||
return "Sudah final (tidak ada perubahan)";
|
||||
case "ignored":
|
||||
return "Tidak dikenali (mungkin sudah dihapus)";
|
||||
case "booking_conflict":
|
||||
return "Gateway PAID tapi booking di state konflik — perlu review manual";
|
||||
case "not_found":
|
||||
return "Order tidak ditemukan di Midtrans";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { cancelBookingWithRefundAction } from "@/features/booking/actions";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
|
||||
@@ -112,9 +113,17 @@ export function CancelBookingButton({ tripId, preview }: CancelBookingButtonProp
|
||||
Tier: {preview.tierLabel}
|
||||
</p>
|
||||
{noRefund ? (
|
||||
<p className="mt-2 text-xs text-red-700">
|
||||
⚠️ Di luar window refund — uang tidak dikembalikan. Booking akan
|
||||
<p className="mt-2 flex items-start gap-1.5 text-xs text-red-700">
|
||||
<CircleAlert
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
Di luar window refund — uang tidak dikembalikan. Booking akan
|
||||
di-cancel langsung.
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-neutral-600">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string;
|
||||
@@ -24,9 +25,19 @@ export function CopyButton({ value, label = "Salin" }: CopyButtonProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
{copied ? "✓ Tersalin" : label}
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={13} strokeWidth={2.5} aria-hidden className="text-emerald-600" />
|
||||
Tersalin
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={13} strokeWidth={1.75} aria-hidden />
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { useRouter } from "next/navigation";
|
||||
import { startMidtransPaymentAction } from "@/features/booking/actions";
|
||||
import {
|
||||
reconcileMidtransPaymentAction,
|
||||
startMidtransPaymentAction,
|
||||
} from "@/features/booking/actions";
|
||||
|
||||
interface SnapCallbacks {
|
||||
onSuccess?: (result: unknown) => void;
|
||||
@@ -86,23 +89,25 @@ export function MidtransPayButton({ tripId }: MidtransPayButtonProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.snap.pay(result.snapToken, {
|
||||
onSuccess: () => {
|
||||
// Webhook server akan tetap jadi sumber kebenaran. Refresh page untuk pull state baru.
|
||||
const orderId = result.orderId;
|
||||
// Tarik status terkini dari Midtrans server-side, lalu refresh halaman.
|
||||
// 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();
|
||||
},
|
||||
onPending: () => router.refresh(),
|
||||
}
|
||||
|
||||
window.snap.pay(result.snapToken, {
|
||||
onSuccess: reconcileAndRefresh,
|
||||
onPending: reconcileAndRefresh,
|
||||
onError: () => {
|
||||
setError(
|
||||
"Pembayaran gagal diproses. Coba lagi atau pakai metode lain."
|
||||
);
|
||||
router.refresh();
|
||||
},
|
||||
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();
|
||||
void reconcileAndRefresh();
|
||||
},
|
||||
onClose: reconcileAndRefresh,
|
||||
});
|
||||
|
||||
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,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface RawCallbackViewerProps {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export function RawCallbackViewer({ payload }: RawCallbackViewerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (payload == null) {
|
||||
return (
|
||||
<p className="text-[11px] italic text-neutral-400">
|
||||
Belum ada callback dari gateway.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
|
||||
>
|
||||
{open ? "▼ Sembunyikan raw callback" : "▶ Lihat raw callback JSON"}
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="mt-2 max-h-96 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-relaxed text-neutral-700">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
import { auditLog } from "@/server/services/audit-log.service";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return null;
|
||||
}
|
||||
return session.user;
|
||||
}
|
||||
|
||||
/** E5.2 — admin retry satu EmailJob yang gagal/antri, kirim ulang langsung. */
|
||||
export async function retryEmailJobAction(jobId: string) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return { error: "Tidak memiliki akses admin" };
|
||||
if (!jobId) return { error: "jobId tidak valid" };
|
||||
|
||||
const result = await emailService.retryJob(jobId);
|
||||
if (!result.ok) {
|
||||
return { error: result.error ?? "Gagal mengirim ulang email" };
|
||||
}
|
||||
await auditLog.record({
|
||||
admin: { id: admin.id, email: admin.email },
|
||||
action: "EMAIL_JOB_RETRY",
|
||||
entityType: "EmailJob",
|
||||
entityId: jobId,
|
||||
});
|
||||
revalidatePath("/admin/emails");
|
||||
revalidatePath("/admin/system");
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
/** E5.3 — admin resend email yang sudah pernah terkirim (mis. user lapor tidak terima). */
|
||||
export async function resendEmailAction(emailSentId: string) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return { error: "Tidak memiliki akses admin" };
|
||||
if (!emailSentId) return { error: "emailSentId tidak valid" };
|
||||
|
||||
const result = await emailService.resendEmail(emailSentId);
|
||||
if (!result.ok) {
|
||||
return { error: result.error ?? "Gagal mengirim ulang email" };
|
||||
}
|
||||
await auditLog.record({
|
||||
admin: { id: admin.id, email: admin.email },
|
||||
action: "EMAIL_RESEND",
|
||||
entityType: "EmailSent",
|
||||
entityId: emailSentId,
|
||||
});
|
||||
revalidatePath("/admin/emails");
|
||||
return { success: true as const };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
retryEmailJobAction,
|
||||
resendEmailAction,
|
||||
} from "@/features/email/actions";
|
||||
|
||||
const BTN_CLS =
|
||||
"rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
/** E5.2 — tombol kirim ulang untuk satu EmailJob (antri / gagal). */
|
||||
export function RetryEmailButton({ jobId }: { jobId: string }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleRetry() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await retryEmailJobAction(jobId);
|
||||
setLoading(false);
|
||||
if ("error" in res) {
|
||||
setError(res.error ?? "Gagal");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={loading}
|
||||
className={BTN_CLS}
|
||||
>
|
||||
{loading ? "Mengirim…" : "Kirim ulang"}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** E5.3 — tombol resend untuk email yang sudah terkirim. */
|
||||
export function ResendEmailButton({
|
||||
emailSentId,
|
||||
disabled,
|
||||
}: {
|
||||
emailSentId: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
async function handleResend() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await resendEmailAction(emailSentId);
|
||||
setLoading(false);
|
||||
if ("error" in res) {
|
||||
setError(res.error ?? "Gagal");
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] text-neutral-400"
|
||||
title="Body email lama tidak tersimpan"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={loading || done}
|
||||
className={`${BTN_CLS} inline-flex items-center gap-1`}
|
||||
>
|
||||
{loading ? (
|
||||
"Mengirim…"
|
||||
) : done ? (
|
||||
<>
|
||||
<Check size={12} strokeWidth={2.5} aria-hidden />
|
||||
Terkirim
|
||||
</>
|
||||
) : (
|
||||
"Resend"
|
||||
)}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,16 @@ import { getServerSession } from "next-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import {
|
||||
isReuploadField,
|
||||
organizerService,
|
||||
type ReuploadField,
|
||||
} from "@/server/services/organizer.service";
|
||||
import { auditLog } from "@/server/services/audit-log.service";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
import { organizerRepo } from "@/server/repositories/organizer.repo";
|
||||
import { userRepo } from "@/server/repositories/user.repo";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
|
||||
|
||||
export async function submitVerificationAction(formData: FormData) {
|
||||
@@ -35,6 +44,7 @@ export async function submitVerificationAction(formData: FormData) {
|
||||
...result.data,
|
||||
birthDate: new Date(result.data.birthDate),
|
||||
});
|
||||
void notifyKycSubmitted(session.user.id);
|
||||
revalidatePath("/verify");
|
||||
revalidatePath("/profile");
|
||||
revalidatePath("/admin/verifications");
|
||||
@@ -68,6 +78,23 @@ export async function reviewVerificationAction(formData: FormData) {
|
||||
rejectionReason: result.data.rejectionReason,
|
||||
reviewerId: session.user.id,
|
||||
});
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action:
|
||||
result.data.decision === "APPROVED"
|
||||
? "VERIFICATION_APPROVE"
|
||||
: "VERIFICATION_REJECT",
|
||||
entityType: "OrganizerVerification",
|
||||
entityId: result.data.verificationId,
|
||||
payload:
|
||||
result.data.decision === "REJECTED"
|
||||
? { rejectionReason: result.data.rejectionReason ?? null }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Notif email — fire and forget, jangan blok response.
|
||||
void notifyVerificationDecision(result.data.verificationId, result.data.decision, result.data.rejectionReason);
|
||||
|
||||
revalidatePath("/admin/verifications");
|
||||
revalidatePath("/verify");
|
||||
revalidatePath("/profile");
|
||||
@@ -76,3 +103,229 @@ export async function reviewVerificationAction(formData: FormData) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyVerificationDecision(
|
||||
verificationId: string,
|
||||
decision: "APPROVED" | "REJECTED",
|
||||
rejectionReason?: string
|
||||
) {
|
||||
const verification = await organizerRepo.findById(verificationId);
|
||||
if (!verification) return;
|
||||
const user = await userRepo.findById(verification.userId);
|
||||
if (!user) return;
|
||||
|
||||
if (decision === "APPROVED") {
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
idempotencyKey: `kyc_approved-${verificationId}`,
|
||||
template: {
|
||||
template: "kyc_approved",
|
||||
data: { userName: user.name },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
// submissionCount supaya kalau reject berulang masing-masing dapat email.
|
||||
idempotencyKey: `kyc_rejected-${verificationId}-${verification.submissionCount}`,
|
||||
template: {
|
||||
template: "kyc_rejected",
|
||||
data: {
|
||||
userName: user.name,
|
||||
rejectionReason: rejectionReason ?? "(tidak ada alasan tercatat)",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** E3.9 — kabari user kalau pengajuan verifikasi-nya sudah masuk antrian. */
|
||||
async function notifyKycSubmitted(userId: string) {
|
||||
const verification = await prisma.organizerVerification.findUnique({
|
||||
where: { userId },
|
||||
select: { submissionCount: true },
|
||||
});
|
||||
const user = await userRepo.findById(userId);
|
||||
if (!verification || !user) return;
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
idempotencyKey: `kyc_submitted-${userId}-${verification.submissionCount}`,
|
||||
template: {
|
||||
template: "kyc_submitted",
|
||||
data: { userName: user.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.11 — kabari user kalau pengajuan verifikasi-nya dibuka kembali admin. */
|
||||
async function notifyKycReopened(verificationId: string) {
|
||||
const verification = await organizerRepo.findById(verificationId);
|
||||
if (!verification) return;
|
||||
const user = await userRepo.findById(verification.userId);
|
||||
if (!user) return;
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
idempotencyKey: `kyc_reopened-${verificationId}-${verification.submissionCount}`,
|
||||
template: {
|
||||
template: "kyc_reopened",
|
||||
data: { userName: user.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** E3.10 — kabari user kalau admin verifikasi dia secara manual override. */
|
||||
async function notifyKycManualOverride(userId: string, verificationId: string) {
|
||||
const user = await userRepo.findById(userId);
|
||||
if (!user) return;
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
idempotencyKey: `kyc_manual_override-${verificationId}`,
|
||||
template: {
|
||||
template: "kyc_manual_override",
|
||||
data: { userName: user.name },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin reopen pengajuan REJECTED ke PENDING — supaya organizer bisa
|
||||
* di-review ulang tanpa drop & recreate row. Note wajib min 10 char untuk audit.
|
||||
*/
|
||||
export async function reopenVerificationAction(
|
||||
verificationId: string,
|
||||
note: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return { error: "Tidak memiliki akses admin" };
|
||||
}
|
||||
|
||||
try {
|
||||
await organizerService.reopenVerification({
|
||||
verificationId,
|
||||
adminId: session.user.id,
|
||||
note,
|
||||
});
|
||||
void notifyKycReopened(verificationId);
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "VERIFICATION_REOPEN",
|
||||
entityType: "OrganizerVerification",
|
||||
entityId: verificationId,
|
||||
payload: { note: note.trim() },
|
||||
});
|
||||
revalidatePath("/admin/verifications");
|
||||
revalidatePath("/verify");
|
||||
revalidatePath("/profile");
|
||||
return { success: true as const };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: admin minta organizer upload ulang field tertentu — daripada
|
||||
* reject penuh, set flag `reuploadRequested` + daftar field + note.
|
||||
*/
|
||||
export async function requestReuploadAction(
|
||||
verificationId: string,
|
||||
fields: string[],
|
||||
note: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return { error: "Tidak memiliki akses admin" };
|
||||
}
|
||||
const valid = fields.filter(isReuploadField) as ReuploadField[];
|
||||
|
||||
try {
|
||||
await organizerService.requestReupload({
|
||||
verificationId,
|
||||
adminId: session.user.id,
|
||||
fields: valid,
|
||||
note,
|
||||
});
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "VERIFICATION_REQUEST_REUPLOAD",
|
||||
entityType: "OrganizerVerification",
|
||||
entityId: verificationId,
|
||||
payload: { fields: valid, note: note.trim() },
|
||||
});
|
||||
|
||||
// Notif email organizer — urgent, action required.
|
||||
void notifyReuploadRequest(verificationId, valid, note.trim());
|
||||
|
||||
revalidatePath("/admin/verifications");
|
||||
revalidatePath("/verify");
|
||||
return { success: true as const };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyReuploadRequest(
|
||||
verificationId: string,
|
||||
fields: ReuploadField[],
|
||||
note: string
|
||||
) {
|
||||
const verification = await organizerRepo.findById(verificationId);
|
||||
if (!verification) return;
|
||||
const user = await userRepo.findById(verification.userId);
|
||||
if (!user) return;
|
||||
await emailService.send({
|
||||
to: user.email,
|
||||
// Allow re-trigger kalau admin minta lagi setelah submit ulang.
|
||||
idempotencyKey: `kyc_reupload_request-${verificationId}-${verification.submissionCount}`,
|
||||
template: {
|
||||
template: "kyc_reupload_request",
|
||||
data: {
|
||||
userName: user.name,
|
||||
fields,
|
||||
note,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4: admin verify user tanpa upload KYC (partner trusted referral).
|
||||
* Bikin row APPROVED dengan flag `isManualOverride = true`.
|
||||
*/
|
||||
export async function manualOverrideVerificationAction(input: {
|
||||
userId: string;
|
||||
note: string;
|
||||
bankName: string;
|
||||
bankAccountNumber: string;
|
||||
bankAccountName: string;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return { error: "Tidak memiliki akses admin" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await organizerService.manualOverrideVerification({
|
||||
userId: input.userId,
|
||||
adminId: session.user.id,
|
||||
note: input.note,
|
||||
bankName: input.bankName,
|
||||
bankAccountNumber: input.bankAccountNumber,
|
||||
bankAccountName: input.bankAccountName,
|
||||
});
|
||||
void notifyKycManualOverride(input.userId, result.id);
|
||||
await auditLog.record({
|
||||
admin: { id: session.user.id, email: session.user.email },
|
||||
action: "VERIFICATION_MANUAL_OVERRIDE",
|
||||
entityType: "OrganizerVerification",
|
||||
entityId: result.id,
|
||||
payload: { userId: input.userId, note: input.note.trim() },
|
||||
});
|
||||
revalidatePath("/admin/verifications");
|
||||
revalidatePath(`/admin/users/${input.userId}`);
|
||||
revalidatePath("/verify");
|
||||
return { success: true as const, verificationId: result.id };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { reviewVerificationAction } from "@/features/organizer/actions";
|
||||
import { CircleCheck, CircleX, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
reopenVerificationAction,
|
||||
requestReuploadAction,
|
||||
reviewVerificationAction,
|
||||
} from "@/features/organizer/actions";
|
||||
|
||||
const REUPLOAD_FIELD_LABELS: { value: string; label: string }[] = [
|
||||
{ value: "ktpImage", label: "Foto KTP" },
|
||||
{ value: "liveness", label: "Foto liveness (pegang kertas SETRIP)" },
|
||||
{ value: "nik", label: "NIK" },
|
||||
{ value: "bankInfo", label: "Info rekening" },
|
||||
{ value: "address", label: "Alamat" },
|
||||
];
|
||||
|
||||
type Verification = {
|
||||
id: string;
|
||||
@@ -33,10 +46,42 @@ function formatDate(d: Date): string {
|
||||
export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
const router = useRouter();
|
||||
const [showReject, setShowReject] = useState(false);
|
||||
const [showReopen, setShowReopen] = useState(false);
|
||||
const [showReupload, setShowReupload] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
const [reopenNote, setReopenNote] = useState("");
|
||||
const [reuploadNote, setReuploadNote] = useState("");
|
||||
const [reuploadFields, setReuploadFields] = useState<string[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function toggleReuploadField(value: string) {
|
||||
setReuploadFields((prev) =>
|
||||
prev.includes(value)
|
||||
? prev.filter((v) => v !== value)
|
||||
: [...prev, value]
|
||||
);
|
||||
}
|
||||
|
||||
async function requestReupload() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const result = await requestReuploadAction(
|
||||
verification.id,
|
||||
reuploadFields,
|
||||
reuploadNote
|
||||
);
|
||||
setLoading(false);
|
||||
if ("error" in result && result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setShowReupload(false);
|
||||
setReuploadFields([]);
|
||||
setReuploadNote("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function decide(decision: "APPROVED" | "REJECTED") {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
@@ -55,6 +100,20 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function reopen() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const result = await reopenVerificationAction(verification.id, reopenNote);
|
||||
setLoading(false);
|
||||
if ("error" in result && result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setShowReopen(false);
|
||||
setReopenNote("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<header className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4">
|
||||
@@ -110,6 +169,63 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{verification.status === "REJECTED" && (
|
||||
<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>
|
||||
)}
|
||||
{!showReopen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReopen(true)}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={18} strokeWidth={2} aria-hidden />
|
||||
Buka kembali ke PENDING
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
|
||||
<label className="block text-xs font-semibold text-amber-900">
|
||||
Catatan reopen (min 10 karakter — akan disimpan di rejection
|
||||
reason sebagai history)
|
||||
</label>
|
||||
<textarea
|
||||
value={reopenNote}
|
||||
onChange={(e) => setReopenNote(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="contoh: Organizer kirim ulang foto KTP jelas via email, siap di-review ulang."
|
||||
className="w-full rounded-xl border border-amber-200 bg-white px-3 py-2 text-sm focus:bg-white focus:border-amber-400"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reopen}
|
||||
disabled={loading || reopenNote.trim().length < 10}
|
||||
className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Konfirmasi Reopen"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowReopen(false);
|
||||
setReopenNote("");
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification.status === "PENDING" && (
|
||||
<div className="mt-5 border-t border-neutral-100 pt-4">
|
||||
{error && (
|
||||
@@ -117,26 +233,7 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!showReject ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("APPROVED")}
|
||||
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"
|
||||
>
|
||||
✅ Setujui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReject(true)}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
❌ Tolak
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
{showReject ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={rejectionReason}
|
||||
@@ -166,6 +263,105 @@ export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : showReupload ? (
|
||||
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-amber-900">
|
||||
Field yang perlu di-upload ulang (min 1)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REUPLOAD_FIELD_LABELS.map((f) => {
|
||||
const checked = reuploadFields.includes(f.value);
|
||||
return (
|
||||
<label
|
||||
key={f.value}
|
||||
className={`cursor-pointer rounded-full border px-3 py-1 text-xs font-medium ${
|
||||
checked
|
||||
? "border-amber-600 bg-amber-600 text-white"
|
||||
: "border-amber-200 bg-white text-amber-800 hover:bg-amber-100"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleReuploadField(f.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{f.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-amber-900">
|
||||
Catatan untuk organizer (min 10 char — akan dilihat user)
|
||||
</label>
|
||||
<textarea
|
||||
value={reuploadNote}
|
||||
onChange={(e) => setReuploadNote(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="contoh: Foto KTP terlalu buram, tolong foto ulang dengan pencahayaan lebih baik."
|
||||
className="w-full rounded-xl border border-amber-200 bg-white px-3 py-2 text-sm focus:border-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestReupload}
|
||||
disabled={
|
||||
loading ||
|
||||
reuploadFields.length === 0 ||
|
||||
reuploadNote.trim().length < 10
|
||||
}
|
||||
className="rounded-xl bg-amber-600 px-4 py-2 text-sm font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Kirim Permintaan"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowReupload(false);
|
||||
setReuploadFields([]);
|
||||
setReuploadNote("");
|
||||
}}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("APPROVED")}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
<CircleCheck size={18} strokeWidth={2} aria-hidden />
|
||||
Setujui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReupload(true)}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={18} strokeWidth={2} aria-hidden />
|
||||
Minta re-upload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReject(true)}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
<CircleX size={18} strokeWidth={2} aria-hidden />
|
||||
Tolak
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IdCard, Image as ImageIcon, Landmark, Check } from "lucide-react";
|
||||
import { submitVerificationAction } from "@/features/organizer/actions";
|
||||
import { DateField } from "@/components/shared/date-picker";
|
||||
|
||||
type Initial = {
|
||||
fullName: string;
|
||||
@@ -21,23 +23,31 @@ type UploadKind = "ktp" | "liveness";
|
||||
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function toYmd(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? "");
|
||||
const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? "");
|
||||
// `birthDate` dari DB tersimpan sebagai tengah malam UTC — baca pakai getter
|
||||
// UTC supaya hari kalender yang tampil di picker tidak bergeser.
|
||||
const [birthDate, setBirthDate] = useState<Date | null>(
|
||||
initial
|
||||
? new Date(
|
||||
initial.birthDate.getUTCFullYear(),
|
||||
initial.birthDate.getUTCMonth(),
|
||||
initial.birthDate.getUTCDate()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (!birthDate) {
|
||||
setError("Tanggal lahir wajib diisi");
|
||||
return;
|
||||
}
|
||||
if (!ktpKey || !livenessKey) {
|
||||
setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah");
|
||||
return;
|
||||
@@ -70,7 +80,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-base font-bold text-neutral-900">📇 Data KTP</h2>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
|
||||
<IdCard
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
Data KTP
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
@@ -102,15 +120,21 @@ export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
<label
|
||||
htmlFor="birthDate"
|
||||
className="mb-1.5 block text-sm font-semibold text-neutral-700"
|
||||
>
|
||||
Tanggal Lahir
|
||||
</label>
|
||||
<input
|
||||
<DateField
|
||||
id="birthDate"
|
||||
name="birthDate"
|
||||
type="date"
|
||||
value={birthDate}
|
||||
onChange={setBirthDate}
|
||||
maxDate={new Date()}
|
||||
withMonthYearDropdown
|
||||
required
|
||||
defaultValue={initial ? toYmd(new Date(initial.birthDate)) : ""}
|
||||
className={inputCls}
|
||||
placeholder="Pilih tanggal lahir"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
@@ -130,7 +154,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-base font-bold text-neutral-900">🖼️ Foto</h2>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
|
||||
<ImageIcon
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
Foto
|
||||
</h2>
|
||||
<p className="mb-3 text-xs text-neutral-500">
|
||||
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
|
||||
tim admin saat review. Maks 5MB, JPG/PNG/WebP.
|
||||
@@ -163,7 +195,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-base font-bold text-neutral-900">🏦 Rekening Bank</h2>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
|
||||
<Landmark
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
className="text-primary-600"
|
||||
/>
|
||||
Rekening Bank
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
@@ -297,7 +337,10 @@ function FileUpload({
|
||||
/>
|
||||
</label>
|
||||
{value && !busy && (
|
||||
<span className="text-xs text-neutral-500">✓ Terunggah</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
|
||||
<Check size={13} strokeWidth={2.5} aria-hidden />
|
||||
Terunggah
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{previewUrl && (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"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 { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
import { auditLog } from "@/server/services/audit-log.service";
|
||||
import { emailService } from "@/lib/email/send";
|
||||
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,
|
||||
});
|
||||
void notifyPayoutPaid(parsed.data.payoutId, parsed.data.adminNote);
|
||||
await auditLog.record({
|
||||
admin: { id: admin.id, email: admin.email },
|
||||
action: "PAYOUT_MARK_PAID",
|
||||
entityType: "Payout",
|
||||
entityId: parsed.data.payoutId,
|
||||
payload: { adminNote: parsed.data.adminNote },
|
||||
});
|
||||
revalidatePath("/admin/payouts");
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** E3.7 — kabari organizer kalau payout-nya sudah ditransfer admin. */
|
||||
async function notifyPayoutPaid(payoutId: string, adminNote: string) {
|
||||
const payout = await payoutRepo.findById(payoutId);
|
||||
if (!payout) return;
|
||||
await emailService.send({
|
||||
to: payout.organizer.email,
|
||||
idempotencyKey: `payout_paid-${payout.id}`,
|
||||
template: {
|
||||
template: "payout_paid",
|
||||
data: {
|
||||
organizerName: payout.organizer.name,
|
||||
tripTitle: payout.trip.title,
|
||||
amount: payout.amount,
|
||||
adminNote,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user