From ea63f56e975c3c007213593204ad701e94e8df78 Mon Sep 17 00:00:00 2001 From: arifal Date: Mon, 18 May 2026 20:09:22 +0700 Subject: [PATCH] admin roadmap csv export, adminactionlog, global search --- ADMIN_AUDIT_ROADMAP.md | 107 -------- ADMIN_ROADMAP.md | 60 +++-- app/admin/audit-log/page.tsx | 243 ++++++++++++++++++ app/admin/payouts/page.tsx | 30 ++- app/admin/refunds/page.tsx | 29 ++- app/admin/verifications/page.tsx | 28 +- app/api/admin/export/payouts/route.ts | 96 +++++++ app/api/admin/export/refunds/route.ts | 114 ++++++++ app/api/admin/export/verifications/route.ts | 74 ++++++ app/api/admin/search/route.ts | 19 ++ components/admin/admin-sidebar.tsx | 6 + docs/archive/ADMIN_AUDIT_ROADMAP.md | 84 ++++++ features/admin/actions.ts | 14 + .../admin/components/admin-search-bar.tsx | 131 ++++++++++ features/admin/components/export-csv-link.tsx | 29 +++ features/booking/actions.ts | 8 + features/organizer/actions.ts | 21 ++ features/payout/actions.ts | 8 + features/refund/actions.ts | 21 +- features/trip/actions.ts | 13 + lib/csv.ts | 67 +++++ .../migration.sql | 29 +++ prisma/schema.prisma | 28 ++ server/services/admin-search.service.ts | 179 +++++++++++++ server/services/audit-log.service.ts | 50 ++++ 25 files changed, 1330 insertions(+), 158 deletions(-) delete mode 100644 ADMIN_AUDIT_ROADMAP.md create mode 100644 app/admin/audit-log/page.tsx create mode 100644 app/api/admin/export/payouts/route.ts create mode 100644 app/api/admin/export/refunds/route.ts create mode 100644 app/api/admin/export/verifications/route.ts create mode 100644 app/api/admin/search/route.ts create mode 100644 docs/archive/ADMIN_AUDIT_ROADMAP.md create mode 100644 features/admin/components/admin-search-bar.tsx create mode 100644 features/admin/components/export-csv-link.tsx create mode 100644 lib/csv.ts create mode 100644 prisma/migrations/20260518180000_add_admin_action_log/migration.sql create mode 100644 server/services/admin-search.service.ts create mode 100644 server/services/audit-log.service.ts diff --git a/ADMIN_AUDIT_ROADMAP.md b/ADMIN_AUDIT_ROADMAP.md deleted file mode 100644 index 4baaf40..0000000 --- a/ADMIN_AUDIT_ROADMAP.md +++ /dev/null @@ -1,107 +0,0 @@ -# Setrip โ€” Admin Audit & Investigation Roadmap - -> **Status keseluruhan:** ๐Ÿšง Partial โ€” Phase 1 delivered, Phase 2-4 pending. - -Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute. - -> **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal. - ---- - -## Baseline - -- โœ… Data audit sudah ada di schema: `Refund.reviewedBy/reviewedAt/adminNote`, `Payout.processedBy/processedAt/adminNote`, `OrganizerVerification.reviewedBy/reviewedAt/rejectionReason`. -- โœ… Existing list pages (`/admin/refunds`, `/admin/payouts`, `/admin/verifications`) sudah grouping by status tab. -- โŒ Tidak ada filter date range / reviewer / amount / reason. -- โŒ Tidak ada kolom "reviewer email" di list โ€” harus klik detail. -- โŒ Tidak ada global search (cari berdasarkan email user, order id, trip id). -- โŒ Tidak ada CSV export. -- โŒ Tidak ada audit log untuk action admin di entity lain (User suspension, Trip force-cancel, Verification reopen). - ---- - -## Phase 1 โ€” Filter & Search Enhancements โœ… DELIVERED - -| # | 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` (date range + reviewer dropdown + optional reason) | โœ… | [features/admin/components/admin-filter-bar.tsx](features/admin/components/admin-filter-bar.tsx) | -| 1.7 | Repo helper: filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | โœ… | `server/repositories/*.ts` | -| 1.8 | Helper `listAdminEmails()` untuk dropdown reviewer | โœ… | [lib/admin.ts](lib/admin.ts) | - -**Tindakan manual:** tidak ada. - -> _Catatan: reviewer email column di list belum ditambah โ€” info sudah ada di refund/payout/verification card detail (`Diproses oleh ...`)._ Bisa ditambah saat dibutuhkan untuk skim cepat._ - ---- - -## Phase 2 โ€” Global Search โณ - -Satu search box yang resolve ke entity detail page paling relevan. - -**Keputusan asumsi:** -- Input string user, prefix-based dispatch: - - Email format (`@`) โ†’ user search โ†’ redirect ke `/admin/users/[id]` - - Mulai `midtrans-` / `manual-` โ†’ payment lookup by `externalOrderId` โ†’ `/admin/bookings/[bookingId]` - - Mulai `cm` (cuid pattern) + length 25 โ†’ coba lookup berurutan: trip โ†’ booking โ†’ user - - Else: full-text search di trip title/destination -- Pakai server action atau route handler `/api/admin/search` โ€” return list hasil + jenis entity. -- UI: searchbar di admin layout (top-right) yang dropdown hasil. - -| # | Item | Status | File | -|---|---|---|---| -| 2.1 | `adminSearchService.resolve(query)` โ€” dispatch ke repo lookup yang tepat | โณ | `server/services/admin-search.service.ts` | -| 2.2 | Route handler `/api/admin/search?q=...` (GET, guard isAdmin) | โณ | `app/api/admin/search/route.ts` | -| 2.3 | Component `AdminSearchBar` di admin layout โ€” debounced, dropdown hasil | โณ | `features/admin/components/admin-search-bar.tsx` | -| 2.4 | Page `/admin/search?q=...` untuk full results kalau dropdown limit terlampaui | โณ | `app/admin/search/page.tsx` | - -**Tindakan manual:** tidak ada. - ---- - -## Phase 3 โ€” CSV Export โณ - -Export untuk laporan keuangan & compliance. - -**Keputusan asumsi:** -- Stream CSV via route handler โ€” jangan load semua ke memory. -- Pakai filter yang sama dengan list page โ€” admin pakai URL filter lalu klik "Export". -- Header CSV: human-readable bahasa Indonesia (mis. "Tanggal Approve", "Email Peserta", "Nominal Refund"). -- Tidak ada Excel/xlsx โ€” CSV cukup, mudah dibuka di Sheets/Excel. - -| # | Item | Status | File | -|---|---|---|---| -| 3.1 | Helper `lib/csv.ts` โ€” `streamCsv(headers, rows)` return Response | โณ | `lib/csv.ts` | -| 3.2 | Route `/api/admin/export/refunds` โ€” pakai filter dari query string | โณ | `app/api/admin/export/refunds/route.ts` | -| 3.3 | Route `/api/admin/export/payouts` | โณ | `app/api/admin/export/payouts/route.ts` | -| 3.4 | Route `/api/admin/export/verifications` (tanpa NIK / KTP โ€” hanya metadata) | โณ | `app/api/admin/export/verifications/route.ts` | -| 3.5 | Tombol "Export CSV" di tiap admin list page | โณ | semua `app/admin/*/page.tsx` | - -**Tindakan manual:** -1. Test export di staging โ€” pastikan tidak leak data sensitif (NIK harus tetap encrypted/excluded). -2. Update kebijakan privasi: data export hanya untuk internal compliance. - ---- - -## Phase 4 โ€” Generic Admin Audit Log โณ - -Tabel `AdminActionLog` untuk action di entity yang belum punya audit field (User suspend, Trip force-cancel, Verification reopen, dst). - -**Keputusan asumsi:** -- Single tabel polymorphic: `AdminActionLog { adminId, action, entityType, entityId, payload Json?, createdAt }`. -- Append-only, never update/delete. -- Service helper `auditLog.record(...)` dipanggil eksplisit di setiap action admin (tidak via Prisma middleware โ€” terlalu magic). -- View page `/admin/audit-log` dengan filter `adminId`, `entityType`, `action`, date range. - -| # | Item | Status | File | -|---|---|---|---| -| 4.1 | Model `AdminActionLog` + migration | โณ | [prisma/schema.prisma](prisma/schema.prisma) | -| 4.2 | Helper `auditLog.record({ adminId, action, entityType, entityId, payload? })` | โณ | `server/services/audit-log.service.ts` | -| 4.3 | Wire `auditLog.record` di semua admin server action existing (refund approve/reject/mark, payout markPaid, verification approve/reject) | โณ | `features/*/actions.ts` | -| 4.4 | Page `/admin/audit-log` dengan filter + pagination | โณ | `app/admin/audit-log/page.tsx` | - -**Tindakan manual:** tidak ada. diff --git a/ADMIN_ROADMAP.md b/ADMIN_ROADMAP.md index 13ee075..39b6eb5 100644 --- a/ADMIN_ROADMAP.md +++ b/ADMIN_ROADMAP.md @@ -6,21 +6,25 @@ Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplika --- -## Baseline (yang BISA admin lakukan sekarang) +## 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) | +| **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 (active/suspended); detail dengan trip + booking history; suspend/unsuspend | [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**; filter date range + reviewer | [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 | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) | -| Payout | View per status, mark PAID; filter date/processor; link ke booking timeline | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) | +| **Verifikasi KYC** | Approve / Reject / Reopen REJECTED; 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 (refund, payout, trip cancel, suspend, dst); 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, last success, 7d stats), 20 recent runs, health badge | [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 verification/refund, mark payout PAID, reopen verification) 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). --- @@ -29,47 +33,53 @@ Auth admin: env `ADMIN_EMAILS` โ†’ cek di [lib/admin.ts](lib/admin.ts), dipassin | Roadmap | Prioritas | Status | File | |---|---|---|---| -| Trip Operations (search, view, cancel manual) | ๐Ÿ”ด HIGH | โœ… **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) | -| Payment Operations (booking detail, reconcile, dispute) | ๐Ÿ”ด HIGH | โœ… **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) | -| Audit & Investigation (search, filter, export) | ๐Ÿ”ด HIGH | ๐Ÿšง Phase 1 done ยท Phase 2-4 pending | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) | -| User Management (search, suspend/ban) | ๐ŸŸก MEDIUM | โœ… **Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) | -| Verification (reopen, re-upload request) | ๐ŸŸก MEDIUM | ๐Ÿšง Phase 1 done ยท Phase 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) | -| System Health (cron monitor, stale state alerts) | ๐ŸŸก MEDIUM | ๐Ÿšง Phase 1-2 done ยท Phase 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) | +| Trip Operations | ๐Ÿ”ด HIGH | โœ… **Delivered** | [docs/archive/ADMIN_TRIP_OPS_ROADMAP.md](docs/archive/ADMIN_TRIP_OPS_ROADMAP.md) | +| Payment Operations | ๐Ÿ”ด HIGH | โœ… **Delivered** | [docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md](docs/archive/ADMIN_PAYMENT_OPS_ROADMAP.md) | +| Audit & Investigation | ๐Ÿ”ด HIGH | โœ… **Delivered** | [docs/archive/ADMIN_AUDIT_ROADMAP.md](docs/archive/ADMIN_AUDIT_ROADMAP.md) | +| User Management | ๐ŸŸก MEDIUM | โœ… **Delivered** | [docs/archive/ADMIN_USER_MGMT_ROADMAP.md](docs/archive/ADMIN_USER_MGMT_ROADMAP.md) | +| Verification | ๐ŸŸก MEDIUM | ๐Ÿšง Phase 1 done ยท 2-4 deferred | [docs/archive/ADMIN_VERIFICATION_ROADMAP.md](docs/archive/ADMIN_VERIFICATION_ROADMAP.md) | +| System Health | ๐ŸŸก MEDIUM | ๐Ÿšง Phase 1-2 done ยท 3-4 deferred | [docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md](docs/archive/ADMIN_SYSTEM_HEALTH_ROADMAP.md) | **Legend status:** โณ belum mulai ยท ๐Ÿšง partial ยท โœ… selesai --- -## Sisa pekerjaan +## Sisa pekerjaan (semua deferred โ€” low priority) -Hampir semua kapabilitas dasar admin sudah delivered. Yang tersisa hanya enhancement non-blocking: +- **Verification Phase 2** Re-upload request flow (butuh schema + organizer-side UI) +- **Verification Phase 3** Verification history (audit trail multi-submission) +- **Verification Phase 4** Manual override (admin verify tanpa upload, untuk referral) +- **System Health Phase 3** Stale state alerts (Payment AWAITING > 25h, Payout HELD overdue) +- **System Health Phase 4** External alerting (Discord webhook) +- **User Mgmt Phase 3** Bulk analytics dashboard -**Audit Phase 2-4** (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)): -- Phase 2 โ€” Global Search (admin search bar resolve email/order_id/cuid) -- Phase 3 โ€” CSV Export untuk refunds/payouts/verifications -- Phase 4 โ€” Generic `AdminActionLog` model untuk audit action lintas entity - -**Lainnya yang di-defer** (di archive masing-masing): -- Verification: re-upload request flow, verification history, manual override -- System Health: stale state alerts (Payment AWAITING > 25h, Payout HELD overdue), external alerting (Discord webhook) -- User Mgmt: bulk analytics dashboard +Tidak ada yang blocking. Diangkat saat ada incident konkret atau permintaan stakeholder. --- -## Tindakan manual setelah deploy versi terakhir +## Tindakan manual setelah deploy + +Untuk versi yang berisi delivery 6 roadmap admin: ```bash -# Apply 3 migration baru: add_trip_admin_cancel, add_user_suspension, add_cron_run +# Apply 4 migration baru npx prisma migrate deploy +# - 20260518150000_add_trip_admin_cancel +# - 20260518160000_add_user_suspension +# - 20260518170000_add_cron_run +# - 20260518180000_add_admin_action_log # Restart Next.js / PM2 supaya Prisma client baru ter-load pm2 restart setrip --update-env ``` Brief admin tentang kapabilitas baru: +- **Global search** di sidebar โ€” ketik email, order_id, atau cuid; auto-detect ke detail page yang tepat. - **Force-cancel trip** di `/admin/trips/[id]` โ€” pakai saat organizer unreachable / dispute, reason wajib min 10 char. - **Reconcile Midtrans** di `/admin/bookings/[id]` โ€” pakai saat peserta lapor "sudah bayar tapi status belum update". Idempotent. - **Suspend user** di `/admin/users/[id]` โ€” pakai untuk scam/harassment. Suspended user diblokir sign-in dan aksi mutatif. - **Reopen verification** di `/admin/verifications` (tab REJECTED) โ€” saat organizer kirim ulang foto via email/WA. - **System status** di `/admin/system` โ€” cek setiap pagi, pastikan cron jalan (๐ŸŸข OK). -- **Filter date range + reviewer** di refunds/payouts/verifications โ€” untuk investigasi & compliance. +- **Audit log** di `/admin/audit-log` โ€” bukti compliance saat audit eksternal; semua aksi admin tercatat dengan email + payload. +- **CSV export** di refunds/payouts/verifications โ€” download untuk laporan keuangan/compliance. +- **Filter date range + reviewer** di refunds/payouts/verifications โ€” untuk investigasi. diff --git a/app/admin/audit-log/page.tsx b/app/admin/audit-log/page.tsx new file mode 100644 index 0000000..cceb907 --- /dev/null +++ b/app/admin/audit-log/page.tsx @@ -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 ( +
+

+ Halaman ini hanya untuk admin SeTrip. +

+
+ ); + } + + 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 ( +
+
+

+ Audit Log +

+

+ 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. +

+
+ + + +
+ + + +
+ + +
+
+ +
+ + +
+
+
+ + {logs.length === 0 ? ( +
+

+ Tidak ada audit log yang cocok dengan filter ini. +

+
+ ) : ( +
+ + + + + + + + + + + + + {logs.map((row) => ( + + + + + + + + + ))} + +
WaktuAdminActionEntityEntity IDPayload
+ {row.createdAt.toLocaleString("id-ID", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + {row.adminEmail} + {!row.adminId && ( + + (deleted) + + )} + + + {row.action} + + + {row.entityType} + + + + {row.payload ? ( + + {JSON.stringify(row.payload)} + + ) : ( + "โ€”" + )} +
+
+ )} +
+ ); +} + +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 ( + + {short} + + ); + } + return {short}; +} diff --git a/app/admin/payouts/page.tsx b/app/admin/payouts/page.tsx index 27f97fd..67500a9 100644 --- a/app/admin/payouts/page.tsx +++ b/app/admin/payouts/page.tsx @@ -4,6 +4,7 @@ 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, @@ -81,17 +82,28 @@ export default async function AdminPayoutsPage({ searchParams }: PageProps) { 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 (
-
-

- Payout Organizer -

-

- Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah - status Siap transfer, admin transfer manual ke - rekening organizer lalu tandai sudah dibayar. -

+
+
+

+ Payout Organizer +

+

+ Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah + status Siap transfer, admin transfer manual ke + rekening organizer lalu tandai sudah dibayar. +

+
+
-
-

- Review Refund Manual -

-

- Tinjau laporan refund dari peserta dan organizer. Setiap refund harus - melalui approval admin sebelum dieksekusi. -

+
+
+

+ Review Refund Manual +

+

+ Tinjau laporan refund dari peserta dan organizer. Setiap refund harus + melalui approval admin sebelum dieksekusi. +

+
+
diff --git a/app/admin/verifications/page.tsx b/app/admin/verifications/page.tsx index 3caa285..1a103ec 100644 --- a/app/admin/verifications/page.tsx +++ b/app/admin/verifications/page.tsx @@ -5,6 +5,7 @@ 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"; @@ -69,16 +70,27 @@ 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 (
-
-

- Review Verifikasi Organizer -

-

- Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening - sebelum menyetujui. -

+
+
+

+ Review Verifikasi Organizer +

+

+ Periksa data KTP, foto liveness (memegang kertas SETRIP), dan rekening + sebelum menyetujui. +

+
+
([ + "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); +} diff --git a/app/api/admin/export/refunds/route.ts b/app/api/admin/export/refunds/route.ts new file mode 100644 index 0000000..cc69c4a --- /dev/null +++ b/app/api/admin/export/refunds/route.ts @@ -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); +} diff --git a/app/api/admin/export/verifications/route.ts b/app/api/admin/export/verifications/route.ts new file mode 100644 index 0000000..e926acc --- /dev/null +++ b/app/api/admin/export/verifications/route.ts @@ -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); +} diff --git a/app/api/admin/search/route.ts b/app/api/admin/search/route.ts new file mode 100644 index 0000000..14b6b91 --- /dev/null +++ b/app/api/admin/search/route.ts @@ -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 }); +} diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index 97237bd..63abc1f 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import Image from "next/image"; import { signOut } from "next-auth/react"; +import { AdminSearchBar } from "@/features/admin/components/admin-search-bar"; const NAV_ITEMS: { href: string; label: string; icon: string }[] = [ { href: "/admin", label: "Dashboard", icon: "๐Ÿ“Š" }, @@ -13,6 +14,7 @@ const NAV_ITEMS: { href: string; label: string; icon: string }[] = [ { href: "/admin/verifications", label: "Verifikasi", icon: "๐Ÿชช" }, { href: "/admin/refunds", label: "Refund", icon: "โ†ฉ๏ธ" }, { href: "/admin/payouts", label: "Payout", icon: "๐Ÿ’ธ" }, + { href: "/admin/audit-log", label: "Audit Log", icon: "๐Ÿ“œ" }, { href: "/admin/system", label: "System", icon: "โš™๏ธ" }, ]; @@ -93,6 +95,10 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
+
+ +
+