canina/frontend/admin-panel/src/pages/Wiki.tsx

308 lines
14 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, 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';
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 [terms, setTerms] = useState<WikiTerm[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(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 [formData, setFormData] = useState({
key: '',
term: '',
definition: '',
relatedProducts: [] as string[],
wikiId: 'general',
metaTitle: '',
metaDescription: '',
keywords: ''
});
const fetchTerms = useCallback(async () => {
try {
setIsLoading(true);
const res = await api.get('/admin/wiki', { params: { page, limit, search } });
if (res.data?.data) {
setTerms(res.data.data);
setTotalPages(res.data.meta?.lastPage || 1);
}
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}, [page, search]);
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('اصطلاح علمی جدید با موفقیت ایجاد شد');
}
setIsModalOpen(false);
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));
}, []);
const openModal = (term: WikiTerm | null = null) => {
if (term) {
setEditingTerm(term);
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);
setFormData({
key: '', term: '', definition: '', relatedProducts: [], wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
});
}
setIsModalOpen(true);
};
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
onClick={() => openModal()}
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
>
<Plus className="w-5 h-5" />
مفهوم جدید
</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 onClick={() => openModal(t)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => setDeleteTargetKey(t.key)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
</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 && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[95vh] flex flex-col shadow-2xl animate-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-xl font-bold text-gray-900">
{editingTerm ? 'ویرایش دانشنامه' : 'افزودن مفهوم جدید'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1">
<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 (TASK-2.23) */}
<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">محصولات مرتبط با این مقاله علمی (TASK-2.23)</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"
>
×
</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>
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">انصراف</button>
<button type="submit" form="wikiForm" className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-2.5 rounded-xl font-bold transition-all shadow-md shadow-purple-200">ذخیره</button>
</div>
</div>
</div>
)}
<ConfirmModal
isOpen={!!deleteTargetKey}
title="حذف اصطلاح علمی"
message="آیا از حذف این مورد مطمئن هستید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetKey(null)}
/>
</div>
);
}