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:
parent
7ad3fccd6e
commit
c1118f70d6
@ -335,6 +335,89 @@ export interface AuditLogRow {
|
|||||||
export const useAuditLog = (params: AdminListParams) =>
|
export const useAuditLog = (params: AdminListParams) =>
|
||||||
useAdminList<AuditLogRow>("audit", params);
|
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) {
|
export function useLedgerSellerDetail(id: string | undefined) {
|
||||||
const token = useAuthToken();
|
const token = useAuthToken();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -371,6 +454,23 @@ async function adminPost<T>(
|
|||||||
return res.json();
|
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. */
|
/** Approve (complete → debits balance + journals) or reject a pending payout. */
|
||||||
export function useLedgerPayoutAction() {
|
export function useLedgerPayoutAction() {
|
||||||
const token = useAuthToken();
|
const token = useAuthToken();
|
||||||
|
|||||||
153
app/routes/admin.collections.tsx
Normal file
153
app/routes/admin.collections.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
app/routes/admin.discounts.tsx
Normal file
335
app/routes/admin.discounts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,8 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Users,
|
Users,
|
||||||
Package,
|
Package,
|
||||||
|
Ticket,
|
||||||
|
LayoutGrid,
|
||||||
Landmark,
|
Landmark,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@ -28,6 +30,8 @@ const NAV: NavItem[] = [
|
|||||||
{ label: "فروشگاهها", to: "/admin/sellers", icon: Store },
|
{ label: "فروشگاهها", to: "/admin/sellers", icon: Store },
|
||||||
{ label: "کاربران", to: "/admin/users", icon: Users },
|
{ label: "کاربران", to: "/admin/users", icon: Users },
|
||||||
{ label: "محصولات", to: "/admin/products", icon: Package },
|
{ 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/ledger", icon: Landmark },
|
||||||
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
|
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
|
||||||
];
|
];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user