diff --git a/app/requestHandler/use-admin-hooks.ts b/app/requestHandler/use-admin-hooks.ts index 51465f6..0dc79eb 100644 --- a/app/requestHandler/use-admin-hooks.ts +++ b/app/requestHandler/use-admin-hooks.ts @@ -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("catalog/categories/", token as string), + }); +} + +export const useAttributes = (params: AdminListParams) => + useAdminList("catalog/attributes", params); +export const useAttributeValues = (params: AdminListParams) => + useAdminList("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) => + adminPost(`catalog/${kind}/`, data, token as string), + onSuccess: invalidate, + }); + const update = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + 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 { diff --git a/app/routes/admin.catalog.tsx b/app/routes/admin.catalog.tsx new file mode 100644 index 0000000..ad2de67 --- /dev/null +++ b/app/routes/admin.catalog.tsx @@ -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(null); + return ( +
+ ویژگی‌های کاتالوگ + {selected ? ( + setSelected(null)} /> + ) : ( + + )} +
+ ); +} + +/* ------------------------------ attributes ------------------------------ */ +function AttributesPanel({ onSelect }: { onSelect: (a: AttributeRow) => void }) { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [editing, setEditing] = useState(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[] = [ + { header: "نام", render: (r) => {r.name} }, + { + header: "دسته‌ها", + render: (r) => ( +
+ {r.categoryNames.length ? ( + r.categoryNames.map((c) => ) + ) : ( + + )} +
+ ), + }, + { + header: "مقادیر", + render: (r) => ( + + ), + }, + { + header: "", + render: (r) => ( +
+ + +
+ ), + }, + ]; + + return ( +
+
+ +
+ + { setSearch(v); setPage(1); }} placeholder="جستجوی نام ویژگی…" /> + + r.id} emptyText="ویژگی‌ای ثبت نشده است" /> + + {(creating || editing) && { setCreating(false); setEditing(null); }} />} +
+ ); +} + +function AttributeModal({ row, onClose }: { row: AttributeRow | null; onClose: () => void }) { + const [name, setName] = useState(row?.name ?? ""); + const [cats, setCats] = useState(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 ( +
+
e.stopPropagation()}> +
+

{row ? "ویرایش ویژگی" : "ویژگی جدید"}

+ +
+
+
+ + setName(e.target.value)} /> +
+
+ +
+ {(categories || []).map((c) => ( + + ))} + {!categories?.length &&

دسته‌ای موجود نیست.

} +
+
+ {error &&

{error}

} +
+
+ + +
+
+
+ ); +} + +/* --------------------------- 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(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[] = [ + { + header: "مقدار", + render: (r) => + editingId === r.id ? ( + setEditingText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && saveEdit(r.id)} + className="rounded-lg border border-GRAY px-2 py-1 text-[13px]" + /> + ) : ( + {r.value} + ), + }, + { + header: "", + render: (r) => ( +
+ {editingId === r.id ? ( + + ) : ( + + )} + +
+ ), + }, + ]; + + return ( +
+ +
+
+

مقادیر «{attribute.name}»

+
+
+ 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" + /> + +
+ r.id} emptyText="مقداری ثبت نشده است" /> + +
+ ); +} diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index abaf67a..30de2d2 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -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 },