admin roadmap csv export, adminactionlog, global search

This commit is contained in:
2026-05-18 20:09:22 +07:00
parent 244a6da9bb
commit ea63f56e97
25 changed files with 1330 additions and 158 deletions
+14
View File
@@ -5,6 +5,7 @@ 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";
export async function suspendUserAction(userId: string, reason: string) {
const session = await getServerSession(authOptions);
@@ -21,6 +22,13 @@ export async function suspendUserAction(userId: string, reason: string) {
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() },
});
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
@@ -40,6 +48,12 @@ export async function unsuspendUserAction(userId: string) {
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,
});
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { success: true as const };
@@ -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,29 @@
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
>
<span aria-hidden></span>
<span>{label}</span>
</a>
);
}
+8
View File
@@ -81,6 +81,14 @@ export async function adminReconcileMidtransAction(orderId: string) {
}
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 };
+21
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { organizerService } from "@/server/services/organizer.service";
import { auditLog } from "@/server/services/audit-log.service";
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
export async function submitVerificationAction(formData: FormData) {
@@ -68,6 +69,19 @@ 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,
});
revalidatePath("/admin/verifications");
revalidatePath("/verify");
revalidatePath("/profile");
@@ -96,6 +110,13 @@ export async function reopenVerificationAction(
adminId: session.user.id,
note,
});
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");
+8
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service";
import { auditLog } from "@/server/services/audit-log.service";
import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() {
@@ -33,6 +34,13 @@ export async function markPayoutPaidAction(formData: FormData) {
adminId: admin.id,
adminNote: 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");
+20 -1
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { refundService } from "@/server/services/refund.service";
import { auditLog } from "@/server/services/audit-log.service";
import { createRefundSchema, refundDecisionSchema } from "./schemas";
async function requireAdmin() {
@@ -31,7 +32,7 @@ export async function createRefundAction(formData: FormData) {
}
try {
await refundService.requestRefund({
const refund = await refundService.requestRefund({
bookingId: parsed.data.bookingId,
reason: parsed.data.reason,
reportedBy: parsed.data.reportedBy,
@@ -39,6 +40,17 @@ export async function createRefundAction(formData: FormData) {
amount: parsed.data.amount,
initiatedByAdminId: admin.id,
});
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: "REFUND_CREATE",
entityType: "Refund",
entityId: refund.id,
payload: {
bookingId: parsed.data.bookingId,
amount: parsed.data.amount,
reason: parsed.data.reason,
},
});
revalidatePath("/admin/refunds");
return { success: true };
} catch (err) {
@@ -91,6 +103,13 @@ export async function decideRefundAction(formData: FormData) {
adminNote: adminNote!,
});
}
await auditLog.record({
admin: { id: admin.id, email: admin.email },
action: `REFUND_${decision}`,
entityType: "Refund",
entityId: refundId,
payload: adminNote ? { adminNote } : undefined,
});
revalidatePath("/admin/refunds");
return { success: true };
} catch (err) {
+13
View File
@@ -12,6 +12,7 @@ import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
import { requireActiveUser } from "@/lib/auth-guards";
import { auditLog } from "@/server/services/audit-log.service";
export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions);
@@ -263,6 +264,18 @@ export async function adminCancelTripAction(tripId: string, reason: string) {
adminId: session.user.id,
reason: trimmedReason,
});
await auditLog.record({
admin: { id: session.user.id, email: session.user.email },
action: "TRIP_ADMIN_CANCEL",
entityType: "Trip",
entityId: tripId,
payload: {
reason: trimmedReason,
refundsCreated: result.refundsCreated.length,
cancelledBookings: result.cancelledBookings.length,
skippedBookings: result.skippedBookings.length,
},
});
revalidatePath(`/trips/${tripId}`);
revalidatePath(`/admin/trips/${tripId}`);
revalidatePath("/admin/trips");