admin: Slice 3g catalog attributes UI

- hooks: flat categories, attributes + attribute-values list/CRUD; row types.
- /admin/catalog: attributes master (create/edit modal with category
  multi-select, delete) → per-attribute values panel (inline add/edit/delete).
- sidebar "ویژگی‌ها" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-07-25 16:01:01 +03:30
parent ef7804df0e
commit ec5e4f6c78
3 changed files with 312 additions and 0 deletions

View File

@ -578,6 +578,69 @@ export const useHomeBannerMutations = () => useHomeMutations("banners");
export const useHomeCategoryMutations = () => useHomeMutations("categories"); export const useHomeCategoryMutations = () => useHomeMutations("categories");
export const useHomeSellerTileMutations = () => useHomeMutations("tiles"); export const useHomeSellerTileMutations = () => useHomeMutations("tiles");
/* ------------------------ catalog attributes (3g) ------------------------ */
export interface CategoryLite {
id: string;
name: string;
}
export interface AttributeRow {
id: string;
name: string;
categories: string[];
categoryNames: string[];
valueCount: number;
}
export interface AttributeValueRow {
id: string;
attribute: string;
attributeName: string;
value: string;
info: unknown;
}
export function useCatalogCategories() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "catalog", "categories"],
enabled: !!token,
queryFn: () => adminGet<CategoryLite[]>("catalog/categories/", token as string),
});
}
export const useAttributes = (params: AdminListParams) =>
useAdminList<AttributeRow>("catalog/attributes", params);
export const useAttributeValues = (params: AdminListParams) =>
useAdminList<AttributeValueRow>("catalog/attribute-values", params);
function useCatalogMutations(kind: "attributes" | "attribute-values", invalidateKey: string) {
const token = useAuthToken();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["admin", invalidateKey] });
const create = useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost(`catalog/${kind}/`, data, token as string),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`catalog/${kind}/${id}/`, data, token as string),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => adminDelete(`catalog/${kind}/${id}/`, token as string),
onSuccess: invalidate,
});
return { create, update, remove };
}
export const useAttributeMutations = () =>
useCatalogMutations("attributes", "catalog/attributes");
export const useAttributeValueMutations = () =>
useCatalogMutations("attribute-values", "catalog/attribute-values");
/* ---------------------- AI pipelines health (3c) ---------------------- */ /* ---------------------- AI pipelines health (3c) ---------------------- */
interface FailureRow { interface FailureRow {

View File

@ -0,0 +1,247 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X, Tag, ChevronLeft } from "lucide-react";
import {
useCatalogCategories,
useAttributes,
useAttributeValues,
useAttributeMutations,
useAttributeValueMutations,
type AttributeRow,
type AttributeValueRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminPageTitle,
StatusBadge,
faNum,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
export default function AdminCatalog() {
const [selected, setSelected] = useState<AttributeRow | null>(null);
return (
<div>
<AdminPageTitle>ویژگیهای کاتالوگ</AdminPageTitle>
{selected ? (
<ValuesPanel attribute={selected} onBack={() => setSelected(null)} />
) : (
<AttributesPanel onSelect={setSelected} />
)}
</div>
);
}
/* ------------------------------ attributes ------------------------------ */
function AttributesPanel({ onSelect }: { onSelect: (a: AttributeRow) => void }) {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<AttributeRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAttributes({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const { remove } = useAttributeMutations();
const columns: Column<AttributeRow>[] = [
{ header: "نام", render: (r) => <span className="font-semibold">{r.name}</span> },
{
header: "دسته‌ها",
render: (r) => (
<div className="flex flex-wrap gap-1 max-w-[260px]">
{r.categoryNames.length ? (
r.categoryNames.map((c) => <StatusBadge key={c} label={c} tone="gray" />)
) : (
<span className="text-GRAY text-[11px]"></span>
)}
</div>
),
},
{
header: "مقادیر",
render: (r) => (
<button onClick={() => onSelect(r)} className="flex items-center gap-1 text-VITROWN_BLUE text-[12px] font-semibold">
{faNum(r.valueCount)} مقدار <ChevronLeft size={14} />
</button>
),
},
{
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={() => confirm(`حذف ویژگی «${r.name}»؟ مقادیر و انتساب‌های محصولات نیز حذف می‌شود.`) && remove.mutate(r.id)}
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 justify-end mb-3">
<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) => { setSearch(v); setPage(1); }} placeholder="جستجوی نام ویژگی…" />
</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) && <AttributeModal row={editing} onClose={() => { setCreating(false); setEditing(null); }} />}
</div>
);
}
function AttributeModal({ row, onClose }: { row: AttributeRow | null; onClose: () => void }) {
const [name, setName] = useState(row?.name ?? "");
const [cats, setCats] = useState<string[]>(row?.categories ?? []);
const [error, setError] = useState("");
const { data: categories } = useCatalogCategories();
const { create, update } = useAttributeMutations();
const busy = create.isPending || update.isPending;
const toggleCat = (id: string) =>
setCats((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
const submit = () => {
setError("");
if (!name.trim()) return setError("نام الزامی است.");
const payload = { name: name.trim(), categories: cats };
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 sm:p-4" onClick={onClose}>
<div className="bg-WHITE w-full sm:max-w-md 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 flex flex-col gap-3">
<div>
<label className="block text-[12px] text-GRAY mb-1">نام</label>
<input className="w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="block text-[12px] text-GRAY mb-1">دستههای مرتبط</label>
<div className="max-h-[220px] overflow-y-auto hide-scrollbar rounded-xl border border-WHITE3 p-2 flex flex-col gap-0.5">
{(categories || []).map((c) => (
<label key={c.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-WHITE2 cursor-pointer">
<input type="checkbox" checked={cats.includes(c.id)} onChange={() => toggleCat(c.id)} className="h-4 w-4" />
<span className="text-[13px]">{c.name}</span>
</label>
))}
{!categories?.length && <p className="text-GRAY text-[12px] p-2">دستهای موجود نیست.</p>}
</div>
</div>
{error && <p className="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>
);
}
/* --------------------------- attribute values --------------------------- */
function ValuesPanel({ attribute, onBack }: { attribute: AttributeRow; onBack: () => void }) {
const [page, setPage] = useState(1);
const { data, isLoading } = useAttributeValues({ page, page_size: PAGE_SIZE, attribute: attribute.id });
const { create, update, remove } = useAttributeValueMutations();
const [newValue, setNewValue] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const add = () => {
if (!newValue.trim()) return;
create.mutate({ attribute: attribute.id, value: newValue.trim() }, { onSuccess: () => setNewValue("") });
};
const saveEdit = (id: string) => {
update.mutate({ id, data: { value: editingText.trim() } }, { onSuccess: () => setEditingId(null) });
};
const columns: Column<AttributeValueRow>[] = [
{
header: "مقدار",
render: (r) =>
editingId === r.id ? (
<input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveEdit(r.id)}
className="rounded-lg border border-GRAY px-2 py-1 text-[13px]"
/>
) : (
<span className="font-semibold">{r.value}</span>
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
{editingId === r.id ? (
<button onClick={() => saveEdit(r.id)} className="rounded-lg bg-BLACK text-white px-3 h-8 text-[12px] font-semibold">ذخیره</button>
) : (
<button onClick={() => { setEditingId(r.id); setEditingText(r.value); }} 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={() => confirm(`حذف مقدار «${r.value}»؟`) && remove.mutate(r.id)}
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>
<button onClick={onBack} className="flex items-center gap-1 text-GRAY text-[13px] mb-3 hover:text-BLACK">
<ChevronLeft size={16} className="rotate-180" /> بازگشت به ویژگیها
</button>
<div className="flex items-center gap-2 mb-4">
<div className="h-9 w-9 rounded-xl bg-BLACK text-white grid place-items-center"><Tag size={16} /></div>
<h2 className="font-extrabold text-[17px]">مقادیر «{attribute.name}»</h2>
</div>
<div className="flex gap-2 mb-4">
<input
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="مقدار جدید (مثلاً قرمز)…"
className="flex-1 rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY"
/>
<button onClick={add} disabled={create.isPending} className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-4 text-[13px] font-semibold hover:opacity-90 disabled:opacity-50">
<Plus size={16} /> افزودن
</button>
</div>
<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>
);
}

View File

@ -5,6 +5,7 @@ import {
Store, Store,
Users, Users,
Package, Package,
Tags,
Ticket, Ticket,
LayoutGrid, LayoutGrid,
Landmark, Landmark,
@ -34,6 +35,7 @@ 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/catalog", icon: Tags },
{ label: "تخفیف‌ها", to: "/admin/discounts", icon: Ticket }, { label: "تخفیف‌ها", to: "/admin/discounts", icon: Ticket },
{ label: "مجموعه‌ها", to: "/admin/collections", icon: LayoutGrid }, { label: "مجموعه‌ها", to: "/admin/collections", icon: LayoutGrid },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark }, { label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },