367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { Search, Plus, Edit2, Trash2, BookOpen, XCircle } from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
import api from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Pagination from '../components/ui/Pagination';
|
||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||
import Button from '../components/ui/Button';
|
||
import Modal from '../components/ui/Modal';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
|
||
|
||
export interface WikiTerm {
|
||
key: string;
|
||
term: string;
|
||
definition: string;
|
||
relatedProducts?: string[];
|
||
wikiId?: string;
|
||
metaTitle?: string;
|
||
metaDescription?: string;
|
||
keywords?: string;
|
||
}
|
||
|
||
export interface ProductItem {
|
||
id: string;
|
||
nameFa: string;
|
||
artNo: string;
|
||
}
|
||
|
||
export default function Wiki() {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const [terms, setTerms] = useState<WikiTerm[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [search, setSearch] = useState(() => searchParams.get('search') || '');
|
||
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const limit = 10;
|
||
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [editingTerm, setEditingTerm] = useState<WikiTerm | null>(null);
|
||
const [deleteTargetKey, setDeleteTargetKey] = useState<string | null>(null);
|
||
const [productsList, setProductsList] = useState<ProductItem[]>([]);
|
||
|
||
const updateUrlParams = (paramsObj: Record<string, string | number | undefined | null>) => {
|
||
const current = Object.fromEntries(searchParams.entries());
|
||
const merged = { ...current, ...paramsObj };
|
||
const cleaned: Record<string, string> = {};
|
||
Object.entries(merged).forEach(([k, v]) => {
|
||
if (v !== undefined && v !== null && String(v).trim() !== '') cleaned[k] = String(v);
|
||
});
|
||
setSearchParams(cleaned, { replace: true });
|
||
};
|
||
|
||
const [formData, setFormData] = useState({
|
||
key: '',
|
||
term: '',
|
||
definition: '',
|
||
relatedProducts: [] as string[],
|
||
wikiId: 'general',
|
||
metaTitle: '',
|
||
metaDescription: '',
|
||
keywords: ''
|
||
});
|
||
|
||
const openModal = (term: WikiTerm | null = null) => {
|
||
if (term) {
|
||
setEditingTerm(term);
|
||
updateUrlParams({ modal: 'edit', termKey: term.key });
|
||
setFormData({
|
||
key: term.key,
|
||
term: term.term,
|
||
definition: term.definition,
|
||
relatedProducts: Array.isArray(term.relatedProducts) ? term.relatedProducts : [],
|
||
wikiId: term.wikiId || 'general',
|
||
metaTitle: term.metaTitle || '',
|
||
metaDescription: term.metaDescription || '',
|
||
keywords: term.keywords || ''
|
||
});
|
||
} else {
|
||
setEditingTerm(null);
|
||
updateUrlParams({ modal: 'create', termKey: undefined });
|
||
setFormData({
|
||
key: '', term: '', definition: '', relatedProducts: [], wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
|
||
});
|
||
}
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const closeModal = () => {
|
||
setIsModalOpen(false);
|
||
updateUrlParams({ modal: undefined, termKey: undefined });
|
||
};
|
||
|
||
const fetchTerms = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get('/admin/wiki', { params: { page, limit, search } });
|
||
if (res.data?.data) {
|
||
const termList: WikiTerm[] = res.data.data;
|
||
setTerms(termList);
|
||
setTotalPages(res.data.meta?.lastPage || 1);
|
||
|
||
const modalParam = searchParams.get('modal');
|
||
const termKey = searchParams.get('termKey');
|
||
if (modalParam === 'create') {
|
||
openModal(null);
|
||
} else if (modalParam === 'edit' && termKey) {
|
||
const found = termList.find((t) => t.key === termKey);
|
||
if (found) openModal(found);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [page, search, searchParams]);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
fetchTerms();
|
||
}, 500);
|
||
return () => clearTimeout(timer);
|
||
}, [fetchTerms]);
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
try {
|
||
if (editingTerm) {
|
||
await api.put(`/admin/wiki/${editingTerm.key}`, formData);
|
||
toast.success('اصطلاح علمی با موفقیت ویرایش شد');
|
||
} else {
|
||
await api.post('/admin/wiki', formData);
|
||
toast.success('اصطلاح علمی جدید با موفقیت ایجاد شد');
|
||
}
|
||
closeModal();
|
||
fetchTerms();
|
||
} catch (error) {
|
||
console.error('Save failed', error);
|
||
toast.error('خطا در ذخیره اصطلاح علمی. ممکن است کلید تکراری باشد.');
|
||
}
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!deleteTargetKey) return;
|
||
try {
|
||
await api.delete(`/admin/wiki/${deleteTargetKey}`);
|
||
toast.success('اصطلاح علمی با موفقیت حذف شد');
|
||
fetchTerms();
|
||
} catch (error) {
|
||
console.error('Delete failed', error);
|
||
toast.error('خطا در حذف اصطلاح علمی');
|
||
} finally {
|
||
setDeleteTargetKey(null);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
api.get('/admin/products', { params: { limit: 100 } })
|
||
.then(res => setProductsList(res.data?.data || []))
|
||
.catch(err => console.error(err));
|
||
}, []);
|
||
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||
<BookOpen className="w-6 h-6 text-purple-600" />
|
||
دانشنامه کنینا (Wiki)
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">مدیریت اصطلاحات علمی، مواد تشکیلدهنده و خواص درمانی</p>
|
||
</div>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
startIcon={Plus}
|
||
onClick={() => openModal()}
|
||
>
|
||
مفهوم جدید
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||
<div className="relative flex-1">
|
||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو در اصطلاحات..."
|
||
value={search}
|
||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-right">
|
||
<thead className="bg-gray-50 border-b border-gray-100">
|
||
<tr>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">شناسه (Key)</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">اصطلاح علمی</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">محتوا (چکیده)</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-24">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{isLoading ? (
|
||
<tr><td colSpan={4} className="py-12 text-center"><Spinner size="lg" className="mx-auto text-purple-600" /></td></tr>
|
||
) : terms.length === 0 ? (
|
||
<tr><td colSpan={4} className="py-12 text-center text-gray-500 font-medium">هیچ رکوردی یافت نشد</td></tr>
|
||
) : (
|
||
terms.map((t) => (
|
||
<tr key={t.key} className="hover:bg-gray-50/50 transition-colors">
|
||
<td className="py-4 px-6 text-gray-500 font-mono text-sm">{t.key}</td>
|
||
<td className="py-4 px-6 font-bold text-gray-900">{t.term}</td>
|
||
<td className="py-4 px-6 text-gray-500 text-sm max-w-xs truncate">{t.definition}</td>
|
||
<td className="py-4 px-6">
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
startIcon={Edit2}
|
||
onClick={() => openModal(t)}
|
||
className="text-blue-600 hover:bg-blue-50"
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
startIcon={Trash2}
|
||
onClick={() => setDeleteTargetKey(t.key)}
|
||
className="text-red-600 hover:bg-red-50"
|
||
/>
|
||
</div>
|
||
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{!isLoading && totalPages > 1 && (
|
||
<div className="p-4 border-t border-gray-100 flex justify-center bg-gray-50/50">
|
||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isModalOpen && (
|
||
<Modal
|
||
isOpen={isModalOpen}
|
||
onClose={closeModal}
|
||
title={editingTerm ? 'ویرایش دانشنامه' : 'افزودن مفهوم جدید'}
|
||
icon={BookOpen}
|
||
maxWidth="2xl"
|
||
footer={
|
||
<div className="flex justify-end gap-2.5 w-full">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
type="button"
|
||
onClick={closeModal}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
type="submit"
|
||
form="wikiForm"
|
||
>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-6 text-right font-vazir">
|
||
<form id="wikiForm" onSubmit={handleSave} className="space-y-6">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">شناسه یکتا (Key) *</label>
|
||
<input required type="text" value={formData.key} disabled={!!editingTerm} dir="ltr" onChange={(e) => setFormData({ ...formData, key: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm disabled:bg-gray-100" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">نام اصطلاح (Term) *</label>
|
||
<input required type="text" value={formData.term} onChange={(e) => setFormData({ ...formData, term: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">توضیح علمی *</label>
|
||
<textarea required value={formData.definition} onChange={(e) => setFormData({ ...formData, definition: e.target.value })} rows={5} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||
</div>
|
||
|
||
{/* Related Products Selector */}
|
||
<div className="space-y-2 border border-gray-200 p-4 rounded-xl bg-purple-50/40">
|
||
<label className="text-xs font-bold text-gray-800 block">محصولات مرتبط با این مقاله علمی</label>
|
||
<select
|
||
onChange={(e) => {
|
||
const prodId = e.target.value;
|
||
if (prodId && !formData.relatedProducts.includes(prodId)) {
|
||
setFormData({ ...formData, relatedProducts: [...formData.relatedProducts, prodId] });
|
||
}
|
||
}}
|
||
className="w-full px-3 py-2 border rounded-xl text-xs bg-white"
|
||
>
|
||
<option value="">+ انتخاب محصول جهت افزودن به لیست مرتبط</option>
|
||
{productsList.map(p => (
|
||
<option key={p.id} value={p.id}>{p.nameFa} ({p.artNo})</option>
|
||
))}
|
||
</select>
|
||
|
||
<div className="flex flex-wrap gap-2 mt-2">
|
||
{formData.relatedProducts.map(pId => {
|
||
const prod = productsList.find(p => p.id === pId);
|
||
return (
|
||
<span key={pId} className="inline-flex items-center gap-1.5 px-3 py-1 bg-purple-100 text-purple-700 text-xs font-bold rounded-full">
|
||
<span>{prod ? prod.nameFa : pId}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormData({ ...formData, relatedProducts: formData.relatedProducts.filter(id => id !== pId) })}
|
||
className="hover:text-red-600 font-black text-sm cursor-pointer"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 border border-gray-200 p-4 rounded-xl bg-gray-50/50">
|
||
<h4 className="font-bold text-purple-700 text-sm mb-2">تنظیمات سئو (SEO)</h4>
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-gray-700">عنوان متا</label>
|
||
<input type="text" value={formData.metaTitle} onChange={(e) => setFormData({ ...formData, metaTitle: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-gray-700">کلمات کلیدی</label>
|
||
<input type="text" value={formData.keywords} onChange={(e) => setFormData({ ...formData, keywords: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-gray-700">توضیحات متا</label>
|
||
<textarea rows={2} value={formData.metaDescription} onChange={(e) => setFormData({ ...formData, metaDescription: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm"></textarea>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
|
||
<ConfirmModal
|
||
isOpen={!!deleteTargetKey}
|
||
title="حذف اصطلاح علمی"
|
||
message="آیا از حذف این مورد مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => setDeleteTargetKey(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|