canina/frontend/admin-panel/src/pages/Wiki.tsx
parsa aghaei b2b80849ea
All checks were successful
Deploy Canina / deploy (push) Successful in 41s
fix: resolve variable declaration order, modal state and lint errors
2026-08-23 10:55:09 +03:30

367 lines
15 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';
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>
);
}