555 lines
23 KiB
TypeScript
555 lines
23 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { Plus, Search, Edit2, Trash2, UserCheck, Star, X, ImageIcon, Phone, Mail, FileText, CheckCircle2, ShieldCheck, Globe } from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
import api, { BASE_DOMAIN } from '../services/api';
|
||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||
import MediaSelector from '../components/ui/MediaSelector';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Button from '../components/ui/Button';
|
||
import Modal from '../components/ui/Modal';
|
||
|
||
|
||
export interface Doctor {
|
||
id: string;
|
||
name: string;
|
||
title?: string;
|
||
clinicName?: string;
|
||
avatarUrl?: string;
|
||
bio?: string;
|
||
cv?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
instagram?: string;
|
||
isActive: boolean;
|
||
order: number;
|
||
_count?: {
|
||
videos: number;
|
||
};
|
||
createdAt: string;
|
||
}
|
||
|
||
export default function DoctorsManager() {
|
||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [editingDoctor, setEditingDoctor] = useState<Doctor | null>(null);
|
||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||
const [isAvatarMediaOpen, setIsAvatarMediaOpen] = useState(false);
|
||
const [isCvDetailsOpen, setIsCvDetailsOpen] = useState<Doctor | null>(null);
|
||
|
||
const [formData, setFormData] = useState({
|
||
name: '',
|
||
title: '',
|
||
clinicName: '',
|
||
avatarUrl: '',
|
||
bio: '',
|
||
cv: '',
|
||
phone: '',
|
||
email: '',
|
||
instagram: '',
|
||
isActive: true,
|
||
order: 0,
|
||
});
|
||
|
||
const fetchDoctors = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get('/doctors', {
|
||
params: { search: searchTerm, limit: 100 },
|
||
});
|
||
const raw = res.data;
|
||
const list = Array.isArray(raw?.doctors)
|
||
? raw.doctors
|
||
: Array.isArray(raw?.data?.doctors)
|
||
? raw.data.doctors
|
||
: Array.isArray(raw?.data)
|
||
? raw.data
|
||
: Array.isArray(raw)
|
||
? raw
|
||
: [];
|
||
setDoctors(list);
|
||
} catch (err) {
|
||
console.error('Failed to fetch doctors:', err);
|
||
toast.error('خطا در دریافت لیست پزشکان');
|
||
setDoctors([]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [searchTerm]);
|
||
|
||
useEffect(() => {
|
||
fetchDoctors();
|
||
}, [fetchDoctors]);
|
||
|
||
const handleOpenModal = (doc?: Doctor) => {
|
||
if (doc) {
|
||
setEditingDoctor(doc);
|
||
setFormData({
|
||
name: doc.name || '',
|
||
title: doc.title || '',
|
||
clinicName: doc.clinicName || '',
|
||
avatarUrl: doc.avatarUrl || '',
|
||
bio: doc.bio || '',
|
||
cv: doc.cv || '',
|
||
phone: doc.phone || '',
|
||
email: doc.email || '',
|
||
instagram: doc.instagram || '',
|
||
isActive: doc.isActive !== false,
|
||
order: doc.order || 0,
|
||
});
|
||
} else {
|
||
setEditingDoctor(null);
|
||
setFormData({
|
||
name: '',
|
||
title: 'دامپزشک و متخصص تغذیه',
|
||
clinicName: '',
|
||
avatarUrl: '',
|
||
bio: '',
|
||
cv: '',
|
||
phone: '',
|
||
email: '',
|
||
instagram: '',
|
||
isActive: true,
|
||
order: doctors.length + 1,
|
||
});
|
||
}
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!formData.name.trim()) {
|
||
toast.error('لطفاً نام پزشک را وارد کنید');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (editingDoctor) {
|
||
await api.put(`/doctors/${editingDoctor.id}`, formData);
|
||
toast.success('اطلاعات پزشک با موفقیت بروزرسانی شد');
|
||
} else {
|
||
await api.post('/doctors', formData);
|
||
toast.success('پزشک جدید با موفقیت افزوده شد');
|
||
}
|
||
setIsModalOpen(false);
|
||
await fetchDoctors();
|
||
} catch (err) {
|
||
console.error('Save failed:', err);
|
||
toast.error('خطا در ذخیرهسازی اطلاعات پزشک');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!deleteTargetId) return;
|
||
try {
|
||
await api.delete(`/doctors/${deleteTargetId}`);
|
||
toast.success('پزشک با موفقیت حذف شد');
|
||
setDeleteTargetId(null);
|
||
await fetchDoctors();
|
||
} catch (err) {
|
||
console.error('Delete failed:', err);
|
||
toast.error('خطا در حذف پزشک');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="p-6 space-y-6 font-vazir">
|
||
{/* Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-6 rounded-3xl border border-gray-100 shadow-xs">
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-1">
|
||
<div className="w-10 h-10 rounded-2xl bg-purple-100 text-purple-600 flex items-center justify-center shadow-xs">
|
||
<UserCheck className="w-5 h-5" />
|
||
</div>
|
||
<h1 className="text-2xl font-black text-gray-900">مدیریت پزشکان و کادر علمی</h1>
|
||
</div>
|
||
<p className="text-sm text-gray-500 mr-13 font-medium">
|
||
ثبت اطلاعات و رزومه (CV) دامپزشکان جهت انتساب به ویدئوها، مشاورهها و معرفی کادر تخصصی
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
startIcon={Plus}
|
||
onClick={() => handleOpenModal()}
|
||
>
|
||
افزودن پزشک جدید
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Filter / Search */}
|
||
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-xs flex items-center justify-between gap-4">
|
||
<div className="relative flex-1 max-w-md">
|
||
<Search className="w-5 h-5 absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو بر اساس نام، تخصص، کلینیک، رزومه..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full pl-4 pr-11 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-600 transition-colors"
|
||
/>
|
||
</div>
|
||
<div className="text-xs text-gray-400 font-bold">
|
||
مجموع: {doctors.length} نفر
|
||
</div>
|
||
</div>
|
||
|
||
{/* Grid List */}
|
||
{isLoading ? (
|
||
<div className="flex justify-center items-center py-20">
|
||
<Spinner size="lg" className="text-purple-600" />
|
||
</div>
|
||
) : doctors.length === 0 ? (
|
||
<div className="bg-white rounded-3xl p-16 text-center border border-dashed border-gray-200">
|
||
<div className="w-16 h-16 bg-gray-50 text-gray-400 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||
<UserCheck className="w-8 h-8" />
|
||
</div>
|
||
<h3 className="text-lg font-bold text-gray-800 mb-1">پزشکی یافت نشد</h3>
|
||
<p className="text-sm text-gray-400 mb-6">اولین پزشک یا متخصص کادر علمی را به سامانه اضافه کنید.</p>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
startIcon={Plus}
|
||
onClick={() => handleOpenModal()}
|
||
>
|
||
افزودن اولین پزشک
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{doctors.map((doc) => (
|
||
<div
|
||
key={doc.id}
|
||
className="bg-white rounded-3xl p-6 border border-gray-100 shadow-xs hover:shadow-md transition-all relative flex flex-col justify-between group"
|
||
>
|
||
<div>
|
||
{/* Status Badge */}
|
||
<div className="flex justify-between items-start mb-4">
|
||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${doc.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||
{doc.isActive ? 'فعال' : 'غیرفعال'}
|
||
</span>
|
||
<div className="flex items-center gap-1.5 opacity-80 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={() => handleOpenModal(doc)}
|
||
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-xl transition-colors cursor-pointer"
|
||
title="ویرایش اطلاعات"
|
||
>
|
||
<Edit2 className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setDeleteTargetId(doc.id)}
|
||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors cursor-pointer"
|
||
title="حذف پزشک"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Avatar & Main Info */}
|
||
<div className="flex items-center gap-4 mb-4">
|
||
<div className="w-16 h-16 rounded-2xl bg-purple-50 border border-purple-100 overflow-hidden flex items-center justify-center shrink-0">
|
||
{doc.avatarUrl ? (
|
||
<img
|
||
src={doc.avatarUrl.startsWith('http') ? doc.avatarUrl : `${BASE_DOMAIN}${doc.avatarUrl}`}
|
||
alt={doc.name}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<UserCheck className="w-8 h-8 text-purple-400" />
|
||
)}
|
||
</div>
|
||
<div>
|
||
<h4 className="font-extrabold text-gray-900 text-base">{doc.name}</h4>
|
||
<p className="text-xs text-purple-600 font-bold mt-0.5">{doc.title || 'پزشک متخصص'}</p>
|
||
{doc.clinicName && (
|
||
<p className="text-[11px] text-gray-400 mt-0.5 flex items-center gap-1">
|
||
<ShieldCheck className="w-3 h-3 text-gray-400" />
|
||
{doc.clinicName}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bio Summary */}
|
||
{doc.bio && (
|
||
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-4 bg-gray-50/50 p-2.5 rounded-xl border border-gray-100">
|
||
{doc.bio}
|
||
</p>
|
||
)}
|
||
|
||
{/* Contact & CV Trigger */}
|
||
<div className="flex items-center gap-2 pt-2 border-t border-gray-50 text-xs text-gray-400">
|
||
{doc.phone && (
|
||
<span className="flex items-center gap-1 bg-gray-50 px-2 py-1 rounded-lg">
|
||
<Phone className="w-3 h-3 text-purple-500" />
|
||
<span className="font-mono text-[11px]">{doc.phone}</span>
|
||
</span>
|
||
)}
|
||
{doc.instagram && (
|
||
<a
|
||
href={`https://instagram.com/${doc.instagram.replace('@', '')}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="flex items-center gap-1 bg-pink-50 text-pink-600 hover:bg-pink-100 px-2 py-1 rounded-lg transition-colors"
|
||
>
|
||
<Globe className="w-3 h-3" />
|
||
<span className="font-mono text-[11px]">{doc.instagram}</span>
|
||
</a>
|
||
)}
|
||
{doc.cv && (
|
||
<button
|
||
onClick={() => setIsCvDetailsOpen(doc)}
|
||
className="mr-auto flex items-center gap-1 text-purple-600 hover:text-purple-700 bg-purple-50 hover:bg-purple-100 px-2.5 py-1 rounded-lg font-bold transition-colors cursor-pointer text-[11px]"
|
||
>
|
||
<FileText className="w-3 h-3" />
|
||
مشاهده رزومه
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Add/Edit */}
|
||
{isModalOpen && (
|
||
<Modal
|
||
isOpen={isModalOpen}
|
||
onClose={() => setIsModalOpen(false)}
|
||
title={editingDoctor ? 'ویرایش اطلاعات پزشک' : 'افزودن پزشک جدید به کادر علمی'}
|
||
icon={UserCheck}
|
||
maxWidth="2xl"
|
||
footer={
|
||
<div className="flex justify-end gap-2.5 w-full">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
type="submit"
|
||
form="doctorForm"
|
||
>
|
||
{editingDoctor ? 'ذخیره تغییرات' : 'افزودن پزشک'}
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-6 text-right font-vazir">
|
||
<form id="doctorForm" onSubmit={handleSave} className="space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1.5">نام و نام خانوادگی *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
placeholder="مثال: دکتر سارا صادقی"
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs focus:outline-none focus:border-purple-600 font-bold"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1.5">عنوان / تخصص *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
placeholder="مثال: متخصص تغذیه و فیزیولوژی حیوانات خانگی"
|
||
value={formData.title}
|
||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs focus:outline-none focus:border-purple-600 font-bold"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1.5">نام کلینیک / بیمارستان</label>
|
||
<input
|
||
type="text"
|
||
placeholder="مثال: بیمارستان دامپزشکی تهران"
|
||
value={formData.clinicName}
|
||
onChange={(e) => setFormData({ ...formData, clinicName: e.target.value })}
|
||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs focus:outline-none focus:border-purple-600"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1.5">ترتیب نمایش در سایت</label>
|
||
<input
|
||
type="number"
|
||
value={formData.order}
|
||
onChange={(e) => setFormData({ ...formData, order: parseInt(e.target.value) || 0 })}
|
||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs focus:outline-none focus:border-purple-600 font-bold"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Avatar Picker */}
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1.5">تصویر آواتار / عکس پرتره</label>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-12 h-12 rounded-xl bg-gray-100 border border-gray-200 overflow-hidden flex items-center justify-center shrink-0">
|
||
{formData.avatarUrl ? (
|
||
<img
|
||
src={formData.avatarUrl.startsWith('http') ? formData.avatarUrl : `${BASE_DOMAIN}${formData.avatarUrl}`}
|
||
alt="Avatar preview"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<ImageIcon className="w-5 h-5 text-gray-400" />
|
||
)}
|
||
</div>
|
||
<input
|
||
type="text"
|
||
placeholder="آدرس تصویر یا انتخاب از رسانهها..."
|
||
value={formData.avatarUrl}
|
||
onChange={(e) => setFormData({ ...formData, avatarUrl: e.target.value })}
|
||
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-mono"
|
||
dir="ltr"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setIsAvatarMediaOpen(true)}
|
||
>
|
||
گالری رسانه
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bio & CV */}
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1.5">معرفی کوتاه (Bio)</label>
|
||
<textarea
|
||
rows={2}
|
||
placeholder="خلاصهای از سوابق و حوزه کاری..."
|
||
value={formData.bio}
|
||
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
|
||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl text-xs focus:outline-none focus:border-purple-600 leading-relaxed"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1.5">رزومه کامل و مقالات علمی (CV)</label>
|
||
<textarea
|
||
rows={4}
|
||
placeholder="سوابق تحصیلی، مقالات چاپشده، مدارک بینالمللی و تجربیات کلینیکی..."
|
||
value={formData.cv}
|
||
onChange={(e) => setFormData({ ...formData, cv: e.target.value })}
|
||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl text-xs focus:outline-none focus:border-purple-600 leading-relaxed font-sans"
|
||
/>
|
||
</div>
|
||
|
||
{/* Social & Contact */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">تلفن تماس کلینیک</label>
|
||
<input
|
||
type="text"
|
||
dir="ltr"
|
||
placeholder="02188888888"
|
||
value={formData.phone}
|
||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||
className="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-xl text-xs font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">ایمیل</label>
|
||
<input
|
||
type="email"
|
||
dir="ltr"
|
||
placeholder="dr.sadeghi@canina.ir"
|
||
value={formData.email}
|
||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||
className="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-xl text-xs font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-700 mb-1">آیدی اینستاگرام</label>
|
||
<input
|
||
type="text"
|
||
dir="ltr"
|
||
placeholder="@dr.sadeghi"
|
||
value={formData.instagram}
|
||
onChange={(e) => setFormData({ ...formData, instagram: e.target.value })}
|
||
className="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-xl text-xs font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status & Order */}
|
||
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.isActive}
|
||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||
className="w-4 h-4 text-purple-600 rounded-md"
|
||
/>
|
||
<span className="text-xs font-bold text-gray-700">پزشک فعال است و در لیست نمایش داده شود</span>
|
||
</label>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
{/* CV Detail Modal */}
|
||
{isCvDetailsOpen && (
|
||
<Modal
|
||
isOpen={!!isCvDetailsOpen}
|
||
onClose={() => setIsCvDetailsOpen(null)}
|
||
title={`رزومه علمی — ${isCvDetailsOpen.name}`}
|
||
icon={FileText}
|
||
maxWidth="lg"
|
||
footer={
|
||
<div className="flex justify-end w-full">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setIsCvDetailsOpen(null)}
|
||
>
|
||
بستن
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="bg-gray-50 p-4 rounded-2xl max-h-96 overflow-y-auto whitespace-pre-wrap text-xs text-gray-700 leading-relaxed font-sans text-right font-vazir">
|
||
{isCvDetailsOpen.cv}
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
|
||
{/* Media Selector Modal for Avatar */}
|
||
<MediaSelector
|
||
isOpen={isAvatarMediaOpen}
|
||
onClose={() => setIsAvatarMediaOpen(false)}
|
||
onSelect={(url) => {
|
||
setFormData(prev => ({ ...prev, avatarUrl: url }));
|
||
setIsAvatarMediaOpen(false);
|
||
}}
|
||
selectedUrl={formData.avatarUrl}
|
||
/>
|
||
|
||
{/* Confirm Delete Modal */}
|
||
<ConfirmModal
|
||
isOpen={!!deleteTargetId}
|
||
onClose={() => setDeleteTargetId(null)}
|
||
onConfirm={handleDelete}
|
||
title="حذف پزشک"
|
||
message="آیا از حذف این پزشک از سامانه مطمئن هستید؟ ویدئوهای مرتبط با این پزشک بدون تغییر باقی خواهند ماند."
|
||
/>
|
||
</div>
|
||
);
|
||
}
|