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 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 {
|
||||||
|
|||||||
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,
|
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 },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user