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) =>
|
||||
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();
|
||||
|
||||
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,
|
||||
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 },
|
||||
];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user