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

555 lines
23 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 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>
);
}