Vitron-Front/app/routes/admin.discounts.tsx
Arda Samadi c1118f70d6 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>
2026-07-25 14:48:50 +03:30

336 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}