diff --git a/app/requestHandler/use-admin-hooks.ts b/app/requestHandler/use-admin-hooks.ts index c07f7ea..c87ae66 100644 --- a/app/requestHandler/use-admin-hooks.ts +++ b/app/requestHandler/use-admin-hooks.ts @@ -335,6 +335,89 @@ export interface AuditLogRow { export const useAuditLog = (params: AdminListParams) => useAdminList("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("discounts", params); +export const useAdminCollections = (params: AdminListParams) => + useAdminList("collections", params); + +export function useAdminDiscountCreate() { + const token = useAuthToken(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: Record) => + 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 }) => + 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 }) => + 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( return res.json(); } +async function adminDelete(path: string, token: string): Promise { + 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(); diff --git a/app/routes/admin.collections.tsx b/app/routes/admin.collections.tsx new file mode 100644 index 0000000..4517684 --- /dev/null +++ b/app/routes/admin.collections.tsx @@ -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 = { + 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[] = [ + { + header: "عنوان", + render: (r) => ( +
+ {r.coverImageUrl ? ( + + ) : ( +
+ )} +

{r.title}

+
+ ), + }, + { header: "سازنده", render: (r) => {CREATOR[r.creatorType] || r.creatorType} }, + { + header: "محصولات", + render: (r) => {faNum(r.productCount)}, + }, + { + header: "نمایش", + render: (r) => + r.isPublic ? ( + + ) : ( + + ), + }, + { header: "تاریخ", render: (r) => {faDate(r.createdAt)} }, + { + header: "", + render: (r) => ( +
+ + +
+ ), + }, + ]; + + return ( +
+ مجموعه‌ها + + reset(() => setSearch(v))} + placeholder="جستجوی عنوان / تلفن سازنده…" + /> + reset(() => setCreatorType(v))} + options={[ + { value: "", label: "همه سازنده‌ها" }, + { value: "seller", label: "فروشنده" }, + { value: "admin", label: "مدیر" }, + { value: "user", label: "کاربر" }, + ]} + /> + reset(() => setIsPublic(v))} + options={[ + { value: "", label: "همه" }, + { value: "true", label: "عمومی" }, + { value: "false", label: "خصوصی" }, + ]} + /> + + r.id} + emptyText="مجموعه‌ای ثبت نشده است" + /> + +
+ ); +} diff --git a/app/routes/admin.discounts.tsx b/app/routes/admin.discounts.tsx new file mode 100644 index 0000000..058cd97 --- /dev/null +++ b/app/routes/admin.discounts.tsx @@ -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 = { + 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(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[] = [ + { + header: "نام", + render: (r) => ( +
+

{r.name}

+

+ #{faNum(r.code)} +

+
+ ), + }, + { header: "نوع", render: (r) => {TYPE[r.discountType] || r.discountType} }, + { + header: "مقدار", + render: (r) => {faNum(r.value)}, + }, + { + header: "اعتبار", + render: (r) => ( + + {faDate(r.validFrom)} — {faDate(r.validTo)} + + ), + }, + { + header: "وضعیت", + render: (r) => + r.isValid ? ( + + ) : r.isActive ? ( + + ) : ( + + ), + }, + { + header: "", + render: (r) => ( +
+ + +
+ ), + }, + ]; + + return ( +
+
+ تخفیف‌ها + +
+ + + reset(() => setSearch(v))} + placeholder="جستجوی نام تخفیف…" + /> + reset(() => setActive(v))} + options={[ + { value: "", label: "همه" }, + { value: "true", label: "فعال" }, + { value: "false", label: "غیرفعال" }, + ]} + /> + + + r.id} + emptyText="تخفیفی ثبت نشده است" + /> + + + {(creating || editing) && ( + { + setCreating(false); + setEditing(null); + }} + /> + )} +
+ ); +} + +function DiscountModal({ row, onClose }: { row: AdminDiscountRow | null; onClose: () => void }) { + const [form, setForm] = useState(row ? fromRow(row) : EMPTY); + const [error, setError] = useState(""); + const create = useAdminDiscountCreate(); + const update = useAdminDiscountUpdate(); + const busy = create.isPending || update.isPending; + + const set = (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 ( +
+
e.stopPropagation()} + > +
+

{row ? "ویرایش تخفیف" : "تخفیف جدید"}

+ +
+ +
+ + set("name", e.target.value)} /> + + + set("code", e.target.value)} dir="ltr" /> + + + + + + set("value", e.target.value)} dir="ltr" /> + + + set("maxCount", e.target.value)} dir="ltr" /> + + + set("minPurchaseAmount", e.target.value)} dir="ltr" /> + + + set("maxDiscountAmount", e.target.value)} dir="ltr" /> + + + set("validFrom", e.target.value)} dir="ltr" /> + + + set("validTo", e.target.value)} dir="ltr" /> + + + + {error &&

{error}

} +
+ +
+ + +
+
+
+ ); +} + +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 ( +
+ + {children} +
+ ); +} diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index 4cccf10..4969bd2 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -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 }, ];