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:
parent
ef7804df0e
commit
ec5e4f6c78
@ -578,6 +578,69 @@ export const useHomeBannerMutations = () => useHomeMutations("banners");
|
||||
export const useHomeCategoryMutations = () => useHomeMutations("categories");
|
||||
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) ---------------------- */
|
||||
|
||||
interface FailureRow {
|
||||
|
||||
247
app/routes/admin.catalog.tsx
Normal file
247
app/routes/admin.catalog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import {
|
||||
Store,
|
||||
Users,
|
||||
Package,
|
||||
Tags,
|
||||
Ticket,
|
||||
LayoutGrid,
|
||||
Landmark,
|
||||
@ -34,6 +35,7 @@ const NAV: NavItem[] = [
|
||||
{ label: "فروشگاهها", to: "/admin/sellers", icon: Store },
|
||||
{ label: "کاربران", to: "/admin/users", icon: Users },
|
||||
{ label: "محصولات", to: "/admin/products", icon: Package },
|
||||
{ label: "ویژگیها", to: "/admin/catalog", icon: Tags },
|
||||
{ label: "تخفیفها", to: "/admin/discounts", icon: Ticket },
|
||||
{ label: "مجموعهها", to: "/admin/collections", icon: LayoutGrid },
|
||||
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user