Vitron-Front/app/requestHandler/use-admin-hooks.ts
Arda Samadi 97948767a0 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>
2026-07-25 15:05:07 +03:30

596 lines
16 KiB
TypeScript

import {
useQuery,
useMutation,
useQueryClient,
keepPreviousData,
} from "@tanstack/react-query";
import { useAuthToken } from "~/hooks/use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
/* ------------------------------------------------------------------ *
* Admin panel data hooks (superuser only). The admin API is a new,
* fast-moving surface, so these call it directly with fetch (same
* pattern as use-seller-hooks' useMyStores) rather than the generated
* client. Responses are camelCased by the backend renderer.
* ------------------------------------------------------------------ */
export interface AdminOverview {
generatedAt: string;
users: {
total: number;
active: number;
sellers: number;
new7d: number;
new30d: number;
};
sellers: {
total: number;
pending: number;
approved: number;
rejected: number;
verified: number;
};
products: { total: number; active: number };
orders: {
total: number;
pending: number;
confirmed: number;
shipped: number;
delivered: number;
cancelled: number;
paidCount: number;
revenueTotal: number;
ordersToday: number;
revenueToday: number;
};
money: {
walletHeld: number;
sellerPayable: number;
payoutsPendingCount: number;
payoutsPendingAmount: number;
};
ordersSeries: Array<{ date: string; orders: number; revenue: number }>;
}
async function adminGet<T>(path: string, token: string): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error(`Admin request failed (${res.status})`);
}
return res.json();
}
async function adminPatch<T>(
path: string,
body: Record<string, unknown>,
token: string
): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Admin update failed (${res.status})`);
}
return res.json();
}
export function useAdminOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "overview"],
enabled: !!token,
refetchInterval: 60_000,
queryFn: () => adminGet<AdminOverview>("overview/", token as string),
});
}
/* ------------------------------ boards ------------------------------ */
export interface Paginated<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export type AdminListParams = Record<
string,
string | number | boolean | undefined
>;
function toQuery(params: AdminListParams): string {
const usp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== "" && v !== null) usp.set(k, String(v));
});
const s = usp.toString();
return s ? `?${s}` : "";
}
function useAdminList<T>(key: string, params: AdminListParams) {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", key, params],
enabled: !!token,
placeholderData: keepPreviousData,
queryFn: () =>
adminGet<Paginated<T>>(`${key}/${toQuery(params)}`, token as string),
});
}
export interface AdminOrderRow {
id: string;
createdAt: string;
status: string;
paymentStatus: string;
totalPrice: number;
userPhone: string | null;
userName: string | null;
sellerName: string | null;
sellerUsername: string | null;
}
export interface AdminSellerRow {
id: string;
username: string;
storeName: string;
storeLogo: string | null;
status: string;
isVerified: boolean;
platformFee: number;
productCount: number;
followerCount: number;
reviewAverage: number;
createdAt: string;
userPhone: string | null;
}
export interface AdminUserRow {
id: string;
phoneNumber: string;
username: string | null;
email: string | null;
isActive: boolean;
isSeller: boolean;
isStaff: boolean;
isSuperuser: boolean;
otpVerified: boolean;
createdAt: string;
}
export interface AdminProductRow {
id: string;
title: string;
basePrice: number;
isActive: boolean;
createdAt: string;
sellerName: string | null;
sellerUsername: string | null;
categoryName: string | null;
primaryImage: string | null;
}
export const useAdminOrders = (params: AdminListParams) =>
useAdminList<AdminOrderRow>("orders", params);
export const useAdminSellers = (params: AdminListParams) =>
useAdminList<AdminSellerRow>("sellers", params);
export const useAdminUsers = (params: AdminListParams) =>
useAdminList<AdminUserRow>("users", params);
export const useAdminProducts = (params: AdminListParams) =>
useAdminList<AdminProductRow>("products", params);
/* ----------------------------- actions ------------------------------ */
function useAdminUpdate(resource: "sellers" | "users" | "products") {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`${resource}/${id}/`, data, token as string),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", resource] });
qc.invalidateQueries({ queryKey: ["admin", "overview"] });
},
});
}
export const useAdminSellerUpdate = () => useAdminUpdate("sellers");
export const useAdminUserUpdate = () => useAdminUpdate("users");
export const useAdminProductUpdate = () => useAdminUpdate("products");
/* ------------------------------ ledger ------------------------------ */
export interface LedgerOverview {
walletHeld: number;
sellerPayable: number;
depositsTotal: number;
purchasesTotal: number;
payouts: {
pending: { amount: number; count: number };
completed: { amount: number; count: number };
rejected: { amount: number; count: number };
};
reconciliation: {
completedPayoutsNotDebited: number;
sellerPayableRecorded: number;
sellerPayableEstimatedTrue: number;
};
// Slice 2c — balances derived from the append-only journal.
journal: {
sellerPayable: number;
walletHeld: number;
platformRevenue: number;
sellerPayableDrift: number;
walletHeldDrift: number;
};
}
export interface LedgerEntryRow {
id: string;
createdAt: string;
entryType: string;
account: string;
delta: number;
sellerName: string | null;
userPhone: string | null;
order: string | null;
note: string | null;
}
export interface LedgerTransactionRow {
id: string;
transactionType: string;
amount: number;
userPhone: string | null;
order: string | null;
authority: string | null;
createdAt: string;
}
export interface LedgerPayoutRow {
id: string;
sellerName: string | null;
sellerUsername: string | null;
amount: number;
status: string;
requestedAt: string;
processedAt: string | null;
iban: string | null;
bankName: string | null;
accountHolder: string | null;
shomareMarja: string | null;
shomareErja: string | null;
}
export interface LedgerSellerRow {
sellerId: string;
storeName: string | null;
username: string | null;
availableFunds: number;
completedPayouts: number;
pendingPayouts: number;
}
export interface LedgerSellerDetail {
seller: { id: string; storeName: string; username: string; platformFee: number };
recordedBalance: number;
accruedFromOrders: number;
completedPayouts: number;
pendingPayouts: number;
derivedBalance: number;
discrepancy: number;
journalBalance: number;
journalDiscrepancy: number;
bankAccounts: Array<{
id: string;
accountHolder: string;
iban: string;
cardNumber: string;
bankName: string;
isVerified: boolean;
}>;
payouts: LedgerPayoutRow[];
journal: LedgerEntryRow[];
}
export function useLedgerOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ledger", "overview"],
enabled: !!token,
queryFn: () => adminGet<LedgerOverview>("ledger/overview/", token as string),
});
}
export const useLedgerTransactions = (params: AdminListParams) =>
useAdminList<LedgerTransactionRow>("ledger/transactions", params);
export const useLedgerPayouts = (params: AdminListParams) =>
useAdminList<LedgerPayoutRow>("ledger/payouts", params);
export const useLedgerSellers = (params: AdminListParams) =>
useAdminList<LedgerSellerRow>("ledger/sellers", params);
export const useLedgerJournal = (params: AdminListParams) =>
useAdminList<LedgerEntryRow>("ledger/journal", params);
/* ------------------------------ audit log ------------------------------ */
export interface AuditLogRow {
id: string;
createdAt: string;
actorPhone: string | null;
action: string;
targetType: string;
targetId: string | null;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
note: string;
}
export const useAuditLog = (params: AdminListParams) =>
useAdminList<AuditLogRow>("audit", params);
/* -------------------- discounts & collections (3b) -------------------- */
export interface AdminDiscountRow {
id: string;
name: string;
code: number;
discountType: string;
value: number;
minPurchaseAmount: number | null;
maxDiscountAmount: number | null;
validFrom: string;
validTo: string;
isActive: boolean;
eligibleUsers: string[] | null;
maxCount: number | null;
isValid: boolean;
}
export interface AdminCollectionRow {
id: string;
title: string;
description: string | null;
creatorType: string;
creatorPhone: string | null;
isPublic: boolean;
coverImageUrl: string | null;
productCount: number;
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) ---------------------- */
interface FailureRow {
id: string;
error: string;
createdAt?: string;
updatedAt?: string;
productId?: string;
sellerId?: string;
}
export interface AiHealthOverview {
generatedAt: string;
tryon: {
total: number;
byStatus: Record<string, number>;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
metadata: {
total: number;
byStatus: Record<string, number>;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
instagram: {
accountsTotal: number;
accountsActive: number;
postsTotal: number;
postsPendingAi: number;
postsConverted: number;
importedLast7d: number;
recentImports: { account: string; date: string; importedCount: number }[];
};
apgs: {
total: number;
success: number;
failed: number;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
}
export function useAiHealth() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ai-health"],
enabled: !!token,
refetchInterval: 60_000,
queryFn: () => adminGet<AiHealthOverview>("ai-health/overview/", token as string),
});
}
export const useAdminDiscounts = (params: AdminListParams) =>
useAdminList<AdminDiscountRow>("discounts", params);
export const useAdminCollections = (params: AdminListParams) =>
useAdminList<AdminCollectionRow>("collections", params);
export function useAdminDiscountCreate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost("discounts/", data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminDiscountUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`discounts/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminDiscountDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`discounts/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminCollectionUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`collections/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "collections"] }),
});
}
export function useAdminCollectionDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`collections/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "collections"] }),
});
}
export function useLedgerSellerDetail(id: string | undefined) {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ledger", "seller", id],
enabled: !!token && !!id,
queryFn: () =>
adminGet<LedgerSellerDetail>(`ledger/sellers/${id}/`, token as string),
});
}
async function adminPost<T>(
path: string,
body: Record<string, unknown>,
token: string
): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = `Admin action failed (${res.status})`;
try {
const j = await res.json();
if (j?.detail) detail = j.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return res.json();
}
async function adminDelete(path: string, token: string): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok && res.status !== 204) {
let detail = `Admin delete failed (${res.status})`;
try {
const j = await res.json();
if (j?.detail) detail = j.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
}
/** Approve (complete → debits balance + journals) or reject a pending payout. */
export function useLedgerPayoutAction() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, action }: { id: string; action: "complete" | "reject" }) =>
adminPost(`ledger/payouts/${id}/action/`, { action }, token as string),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "ledger"] });
qc.invalidateQueries({ queryKey: ["admin", "overview"] });
},
});
}