admin roadmap csv export, adminactionlog, global search
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user