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