admin: Slice 3b discounts & collections UI

- 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>
This commit is contained in:
Arda Samadi 2026-07-25 14:48:50 +03:30
parent 7ad3fccd6e
commit c1118f70d6
4 changed files with 592 additions and 0 deletions

View File

@ -335,6 +335,89 @@ export interface AuditLogRow {
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({
@ -371,6 +454,23 @@ async function adminPost<T>(
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();

View File

@ -0,0 +1,153 @@
import { useState } from "react";
import { Trash2, Eye, EyeOff } from "lucide-react";
import {
useAdminCollections,
useAdminCollectionUpdate,
useAdminCollectionDelete,
type AdminCollectionRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const CREATOR: Record<string, string> = {
seller: "فروشنده",
admin: "مدیر",
user: "کاربر",
};
export default function AdminCollections() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [creatorType, setCreatorType] = useState("");
const [isPublic, setIsPublic] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminCollections({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
creator_type: creatorType,
is_public: isPublic,
});
const update = useAdminCollectionUpdate();
const del = useAdminCollectionDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const toggle = (r: AdminCollectionRow) =>
update.mutate({ id: r.id, data: { isPublic: !r.isPublic } });
const onDelete = (r: AdminCollectionRow) => {
if (confirm(`حذف مجموعه «${r.title}»؟`)) del.mutate(r.id);
};
const columns: Column<AdminCollectionRow>[] = [
{
header: "عنوان",
render: (r) => (
<div className="flex items-center gap-2 min-w-0">
{r.coverImageUrl ? (
<img src={r.coverImageUrl} alt="" className="h-9 w-9 rounded-lg object-cover shrink-0" />
) : (
<div className="h-9 w-9 rounded-lg bg-WHITE3 shrink-0" />
)}
<p className="font-semibold truncate">{r.title}</p>
</div>
),
},
{ header: "سازنده", render: (r) => <span className="text-[12px]">{CREATOR[r.creatorType] || r.creatorType}</span> },
{
header: "محصولات",
render: (r) => <span className="tabular-nums">{faNum(r.productCount)}</span>,
},
{
header: "نمایش",
render: (r) =>
r.isPublic ? (
<StatusBadge label="عمومی" tone="green" />
) : (
<StatusBadge label="خصوصی" tone="gray" />
),
},
{ 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={() => toggle(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label={r.isPublic ? "خصوصی کردن" : "عمومی کردن"}
title={r.isPublic ? "خصوصی کردن" : "عمومی کردن"}
>
{r.isPublic ? <EyeOff size={14} /> : <Eye 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>
<AdminPageTitle>مجموعهها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی عنوان / تلفن سازنده…"
/>
<AdminSelect
value={creatorType}
onChange={(v) => reset(() => setCreatorType(v))}
options={[
{ value: "", label: "همه سازنده‌ها" },
{ value: "seller", label: "فروشنده" },
{ value: "admin", label: "مدیر" },
{ value: "user", label: "کاربر" },
]}
/>
<AdminSelect
value={isPublic}
onChange={(v) => reset(() => setIsPublic(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "عمومی" },
{ value: "false", 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>
);
}

View File

@ -0,0 +1,335 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X } from "lucide-react";
import {
useAdminDiscounts,
useAdminDiscountCreate,
useAdminDiscountUpdate,
useAdminDiscountDelete,
type AdminDiscountRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const TYPE: Record<string, string> = {
user_based: "کاربر-محور",
count_based: "تعداد-محور",
value_based: "مبلغ-محور",
};
type FormState = {
name: string;
code: string;
discountType: string;
value: string;
minPurchaseAmount: string;
maxDiscountAmount: string;
validFrom: string;
validTo: string;
maxCount: string;
isActive: boolean;
};
const EMPTY: FormState = {
name: "",
code: "",
discountType: "value_based",
value: "",
minPurchaseAmount: "",
maxDiscountAmount: "",
validFrom: "",
validTo: "",
maxCount: "",
isActive: true,
};
const toLocal = (iso: string | null) => (iso ? iso.slice(0, 16) : "");
function fromRow(r: AdminDiscountRow): FormState {
return {
name: r.name,
code: String(r.code),
discountType: r.discountType,
value: String(r.value),
minPurchaseAmount: r.minPurchaseAmount != null ? String(r.minPurchaseAmount) : "",
maxDiscountAmount: r.maxDiscountAmount != null ? String(r.maxDiscountAmount) : "",
validFrom: toLocal(r.validFrom),
validTo: toLocal(r.validTo),
maxCount: r.maxCount != null ? String(r.maxCount) : "",
isActive: r.isActive,
};
}
export default function AdminDiscounts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const [editing, setEditing] = useState<AdminDiscountRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminDiscounts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
});
const del = useAdminDiscountDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const onDelete = (r: AdminDiscountRow) => {
if (confirm(`حذف تخفیف «${r.name}»؟`)) del.mutate(r.id);
};
const columns: Column<AdminDiscountRow>[] = [
{
header: "نام",
render: (r) => (
<div>
<p className="font-semibold">{r.name}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
#{faNum(r.code)}
</p>
</div>
),
},
{ header: "نوع", render: (r) => <span className="text-[12px]">{TYPE[r.discountType] || r.discountType}</span> },
{
header: "مقدار",
render: (r) => <span className="tabular-nums whitespace-nowrap">{faNum(r.value)}</span>,
},
{
header: "اعتبار",
render: (r) => (
<span className="text-GRAY text-[11px] whitespace-nowrap">
{faDate(r.validFrom)} {faDate(r.validTo)}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isValid ? (
<StatusBadge label="فعال" tone="green" />
) : r.isActive ? (
<StatusBadge label="خارج از بازه" tone="yellow" />
) : (
<StatusBadge label="غیرفعال" tone="gray" />
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => setEditing(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label="ویرایش"
>
<Pencil 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>
<div className="flex items-center justify-between gap-3">
<AdminPageTitle>تخفیفها</AdminPageTitle>
<button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-3.5 py-2 text-[13px] font-semibold hover:opacity-90"
>
<Plus size={16} /> تخفیف جدید
</button>
</div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی نام تخفیف…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", 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} />
{(creating || editing) && (
<DiscountModal
row={editing}
onClose={() => {
setCreating(false);
setEditing(null);
}}
/>
)}
</div>
);
}
function DiscountModal({ row, onClose }: { row: AdminDiscountRow | null; onClose: () => void }) {
const [form, setForm] = useState<FormState>(row ? fromRow(row) : EMPTY);
const [error, setError] = useState("");
const create = useAdminDiscountCreate();
const update = useAdminDiscountUpdate();
const busy = create.isPending || update.isPending;
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const submit = () => {
setError("");
if (!form.name || !form.code || !form.value || !form.validFrom || !form.validTo) {
setError("نام، کد، مقدار و بازه اعتبار الزامی است.");
return;
}
const payload = {
name: form.name,
code: Number(form.code),
discountType: form.discountType,
value: Number(form.value),
minPurchaseAmount: form.minPurchaseAmount ? Number(form.minPurchaseAmount) : null,
maxDiscountAmount: form.maxDiscountAmount ? Number(form.maxDiscountAmount) : null,
validFrom: form.validFrom,
validTo: form.validTo,
maxCount: form.maxCount ? Number(form.maxCount) : null,
isActive: form.isActive,
};
const onErr = (e: unknown) => setError(e instanceof Error ? e.message : "خطا در ذخیره");
if (row) {
update.mutate({ id: row.id, data: payload }, { onSuccess: onClose, onError: onErr });
} else {
create.mutate(payload, { onSuccess: onClose, onError: onErr });
}
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-BLACK/40 p-0 sm:p-4" onClick={onClose}>
<div
className="bg-WHITE w-full sm:max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[92vh] overflow-y-auto hide-scrollbar"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-WHITE flex items-center justify-between px-5 py-4 border-b border-WHITE3">
<h3 className="font-extrabold text-[16px]">{row ? "ویرایش تخفیف" : "تخفیف جدید"}</h3>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg hover:bg-WHITE2">
<X size={18} />
</button>
</div>
<div className="p-5 grid grid-cols-2 gap-3">
<Field label="نام" className="col-span-2">
<input className={inputCls} value={form.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="کد (عدد یکتا)">
<input type="number" className={inputCls} value={form.code} onChange={(e) => set("code", e.target.value)} dir="ltr" />
</Field>
<Field label="نوع">
<select className={inputCls} value={form.discountType} onChange={(e) => set("discountType", e.target.value)}>
<option value="value_based">مبلغ-محور</option>
<option value="user_based">کاربر-محور</option>
<option value="count_based">تعداد-محور</option>
</select>
</Field>
<Field label="مقدار">
<input type="number" className={inputCls} value={form.value} onChange={(e) => set("value", e.target.value)} dir="ltr" />
</Field>
<Field label="حداکثر دفعات (اختیاری)">
<input type="number" className={inputCls} value={form.maxCount} onChange={(e) => set("maxCount", e.target.value)} dir="ltr" />
</Field>
<Field label="حداقل مبلغ خرید (اختیاری)">
<input type="number" className={inputCls} value={form.minPurchaseAmount} onChange={(e) => set("minPurchaseAmount", e.target.value)} dir="ltr" />
</Field>
<Field label="حداکثر مبلغ تخفیف (اختیاری)">
<input type="number" className={inputCls} value={form.maxDiscountAmount} onChange={(e) => set("maxDiscountAmount", e.target.value)} dir="ltr" />
</Field>
<Field label="از تاریخ">
<input type="datetime-local" className={inputCls} value={form.validFrom} onChange={(e) => set("validFrom", e.target.value)} dir="ltr" />
</Field>
<Field label="تا تاریخ">
<input type="datetime-local" className={inputCls} value={form.validTo} onChange={(e) => set("validTo", e.target.value)} dir="ltr" />
</Field>
<label className="col-span-2 flex items-center gap-2 mt-1 cursor-pointer">
<input type="checkbox" checked={form.isActive} onChange={(e) => set("isActive", e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">فعال</span>
</label>
{error && <p className="col-span-2 text-RED text-[12px]">{error}</p>}
</div>
<div className="sticky bottom-0 bg-WHITE flex gap-2 px-5 py-4 border-t border-WHITE3">
<button onClick={onClose} className="flex-1 rounded-xl border border-WHITE3 py-2.5 text-[13.5px] font-semibold hover:bg-WHITE2">
انصراف
</button>
<button
onClick={submit}
disabled={busy}
className="flex-1 rounded-xl bg-BLACK text-white py-2.5 text-[13.5px] font-semibold hover:opacity-90 disabled:opacity-50"
>
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
);
}
const inputCls =
"w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY";
function Field({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={className}>
<label className="block text-[12px] text-GRAY mb-1">{label}</label>
{children}
</div>
);
}

View File

@ -5,6 +5,8 @@ import {
Store,
Users,
Package,
Ticket,
LayoutGrid,
Landmark,
ScrollText,
ArrowRight,
@ -28,6 +30,8 @@ const NAV: NavItem[] = [
{ label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store },
{ label: "کاربران", to: "/admin/users", icon: Users },
{ label: "محصولات", to: "/admin/products", icon: Package },
{ label: "تخفیف‌ها", to: "/admin/discounts", icon: Ticket },
{ label: "مجموعه‌ها", to: "/admin/collections", icon: LayoutGrid },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
];