Vitron-Front/app/routes/admin.catalog.tsx
Arda Samadi ec5e4f6c78 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>
2026-07-25 16:01:01 +03:30

248 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}