admin: Slice 3d moderation UI (comments + chat)

- hooks: useAdminComments/ChatMessages, useAdminCommentModerate/Delete,
  AdminCommentRow/AdminChatMessageRow types.
- /admin/moderation: tabbed page — comments (hide/unhide/delete, filter by
  visibility & top/reply) and read-only chat browse.
- sidebar "مدیریت محتوا" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-07-25 15:05:07 +03:30
parent b3189db773
commit 97948767a0
3 changed files with 283 additions and 0 deletions

View File

@ -365,6 +365,59 @@ export interface AdminCollectionRow {
createdAt: string; createdAt: string;
} }
/* --------------------------- moderation (3d) --------------------------- */
export interface AdminCommentRow {
id: string;
productId: string;
productTitle: string | null;
userPhone: string | null;
comment: string;
parentId: string | null;
isReply: boolean;
repliesCount: number;
isHidden: boolean;
isVerifiedBuyer: boolean;
createdAt: string;
}
export interface AdminChatMessageRow {
id: string;
content: string;
senderPhone: string | null;
threadId: string | null;
productId: string | null;
imageUrl: string | null;
isDeleted: boolean;
createdAt: string;
}
export const useAdminComments = (params: AdminListParams) =>
useAdminList<AdminCommentRow>("moderation/comments", params);
export const useAdminChatMessages = (params: AdminListParams) =>
useAdminList<AdminChatMessageRow>("moderation/chat", params);
export function useAdminCommentModerate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, isHidden }: { id: string; isHidden: boolean }) =>
adminPatch(`moderation/comments/${id}/`, { isHidden }, token as string),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "moderation/comments"] }),
});
}
export function useAdminCommentDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`moderation/comments/${id}/`, token as string),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "moderation/comments"] }),
});
}
/* ---------------------- AI pipelines health (3c) ---------------------- */ /* ---------------------- AI pipelines health (3c) ---------------------- */
interface FailureRow { interface FailureRow {

View File

@ -0,0 +1,228 @@
import { useState } from "react";
import { Eye, EyeOff, Trash2 } from "lucide-react";
import {
useAdminComments,
useAdminChatMessages,
useAdminCommentModerate,
useAdminCommentDelete,
type AdminCommentRow,
type AdminChatMessageRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
export default function AdminModeration() {
const [tab, setTab] = useState<"comments" | "chat">("comments");
return (
<div>
<AdminPageTitle>مدیریت محتوا</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{(["comments", "chat"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t === "comments" ? "نظرات" : "گفتگوها"}
</button>
))}
</div>
{tab === "comments" ? <CommentsTab /> : <ChatTab />}
</div>
);
}
function CommentsTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [hidden, setHidden] = useState("");
const [kind, setKind] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminComments({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_hidden: hidden,
kind,
});
const moderate = useAdminCommentModerate();
const del = useAdminCommentDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const onDelete = (r: AdminCommentRow) => {
if (confirm("حذف این نظر؟ این عمل بازگشت‌پذیر نیست.")) del.mutate(r.id);
};
const columns: Column<AdminCommentRow>[] = [
{
header: "نظر",
render: (r) => (
<div className="max-w-[340px]">
<p className={cn("text-[13px] break-words", r.isHidden && "text-GRAY line-through")}>
{r.comment}
</p>
<p className="text-GRAY text-[11px] mt-0.5 truncate">
{r.productTitle || "—"}
{r.isReply && <span className="text-VITROWN_BLUE"> · پاسخ</span>}
{!r.isReply && r.repliesCount > 0 && <span> · {r.repliesCount} پاسخ</span>}
</p>
</div>
),
},
{
header: "کاربر",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.userPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isHidden ? (
<StatusBadge label="پنهان" tone="red" />
) : (
<StatusBadge label="نمایش" tone="green" />
),
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => moderate.mutate({ id: r.id, isHidden: !r.isHidden })}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label={r.isHidden ? "نمایش" : "پنهان کردن"}
title={r.isHidden ? "نمایش" : "پنهان کردن"}
>
{r.isHidden ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<button
onClick={() => onDelete(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن نظر / کاربر…" />
<AdminSelect
value={hidden}
onChange={(v) => reset(() => setHidden(v))}
options={[
{ value: "", label: "همه" },
{ value: "false", label: "نمایش‌داده‌شده" },
{ value: "true", label: "پنهان‌شده" },
]}
/>
<AdminSelect
value={kind}
onChange={(v) => reset(() => setKind(v))}
options={[
{ value: "", label: "همه" },
{ value: "top", label: "نظر اصلی" },
{ value: "reply", label: "پاسخ" },
]}
/>
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="نظری ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function ChatTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [deleted, setDeleted] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminChatMessages({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_deleted: deleted,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AdminChatMessageRow>[] = [
{
header: "پیام",
render: (r) => (
<div className="max-w-[360px]">
<p className={cn("text-[13px] break-words", r.isDeleted && "text-GRAY line-through")}>
{r.content || (r.imageUrl ? "🖼 تصویر" : "—")}
</p>
</div>
),
},
{
header: "فرستنده",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.senderPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isDeleted ? <StatusBadge label="حذف‌شده" tone="gray" /> : <StatusBadge label="فعال" tone="green" />,
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن پیام / فرستنده…" />
<AdminSelect
value={deleted}
onChange={(v) => reset(() => setDeleted(v))}
options={[
{ value: "", label: "همه" },
{ value: "false", label: "فعال" },
{ value: "true", label: "حذف‌شده" },
]}
/>
</AdminToolbar>
<p className="text-GRAY text-[12px] mb-2">مشاهدهی فقطخواندنی گفتگوها.</p>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="پیامی یافت نشد" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -9,6 +9,7 @@ import {
LayoutGrid, LayoutGrid,
Landmark, Landmark,
Activity, Activity,
MessageSquareWarning,
ScrollText, ScrollText,
ArrowRight, ArrowRight,
ShieldCheck, ShieldCheck,
@ -35,6 +36,7 @@ const NAV: NavItem[] = [
{ label: "مجموعه‌ها", to: "/admin/collections", icon: LayoutGrid }, { label: "مجموعه‌ها", to: "/admin/collections", icon: LayoutGrid },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark }, { label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity }, { label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
{ label: "مدیریت محتوا", to: "/admin/moderation", icon: MessageSquareWarning },
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText }, { label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
]; ];