- 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>
248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|