- hooks: useAdminDiscounts/Create/Update/Delete, useAdminCollections/Update/ Delete, AdminDiscountRow/AdminCollectionRow types, adminDelete helper. - /admin/discounts: table + create/edit modal form + delete + validity badge. - /admin/collections: table + is_public toggle + delete (moderation). - sidebar "تخفیفها" + "مجموعهها" nav links. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
487 lines
13 KiB
TypeScript
487 lines
13 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;
|
|
}
|
|
|
|
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"] });
|
|
},
|
|
});
|
|
}
|