- 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>
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|