355 lines
17 KiB
TypeScript
355 lines
17 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { Search, Trash2, Heart, Image as ImageIcon, Eye, Clock, Activity, FileText, X, Dog, Cat, ShieldCheck } from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
import api, { BASE_DOMAIN } from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Pagination from '../components/ui/Pagination';
|
||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||
|
||
export interface Pet {
|
||
id: string;
|
||
name: string;
|
||
species?: string;
|
||
type?: string;
|
||
breed?: string;
|
||
age?: number;
|
||
weight?: number;
|
||
activityLevel?: string;
|
||
imageUrl?: string;
|
||
avatarUrl?: string;
|
||
user?: {
|
||
firstName?: string;
|
||
lastName?: string;
|
||
name?: string;
|
||
mobile?: string;
|
||
email?: string;
|
||
};
|
||
medicalConditions?: Array<{ condition: string }>;
|
||
reminders?: Array<{
|
||
id: string;
|
||
title: string;
|
||
time: string;
|
||
frequency: string;
|
||
}>;
|
||
healthLogs?: Array<{
|
||
id: string;
|
||
appetite: string;
|
||
energy: string;
|
||
digestion: string;
|
||
note?: string;
|
||
loggedDate: string;
|
||
createdAt: string;
|
||
}>;
|
||
prescriptions?: Array<{
|
||
id: string;
|
||
status: string;
|
||
notes?: string;
|
||
adminNotes?: string;
|
||
createdAt: string;
|
||
}>;
|
||
ownerName?: string;
|
||
createdAt?: string;
|
||
}
|
||
|
||
export default function Pets() {
|
||
const [pets, setPets] = useState<Pet[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [search, setSearch] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const limit = 10;
|
||
|
||
const [selectedPetForView, setSelectedPetForView] = useState<Pet | null>(null);
|
||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||
|
||
const fetchPets = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get('/admin/pets', { params: { page, limit, search } });
|
||
if (res.data?.data) {
|
||
setPets(res.data.data);
|
||
setTotalPages(res.data.meta?.lastPage || 1);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [page, search]);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
fetchPets();
|
||
}, 500);
|
||
return () => clearTimeout(timer);
|
||
}, [fetchPets]);
|
||
|
||
const confirmDelete = async () => {
|
||
if (!deleteTargetId) return;
|
||
try {
|
||
await api.delete(`/admin/pets/${deleteTargetId}`);
|
||
toast.success('پروفایل حیوان خانگی با موفقیت حذف شد');
|
||
fetchPets();
|
||
} catch (error) {
|
||
console.error('Delete failed', error);
|
||
toast.error('خطا در حذف پروفایل');
|
||
} finally {
|
||
setDeleteTargetId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6 font-vazir text-right" dir="rtl">
|
||
<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">
|
||
<Heart className="w-6 h-6 text-purple-600 fill-purple-100" />
|
||
حیوانات خانگی کاربران (Pets)
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">مشاهده و پایش آنلاین سلامت، یادآورها، گزارشها و نسخههای پزشکی پتها</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-4 rounded-2xl shadow-xs 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 text-xs font-bold"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl shadow-xs 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-xs font-black text-gray-500">تصویر</th>
|
||
<th className="py-4 px-6 text-xs font-black text-gray-500">نام و نژاد</th>
|
||
<th className="py-4 px-6 text-xs font-black text-gray-500">صاحب حیوان</th>
|
||
<th className="py-4 px-6 text-xs font-black text-gray-500">مشخصات فیزیکی</th>
|
||
<th className="py-4 px-6 text-xs font-black text-gray-500">سوابق سلامت</th>
|
||
<th className="py-4 px-6 text-xs font-black text-gray-500 w-28">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{isLoading ? (
|
||
<tr><td colSpan={6} className="py-12 text-center"><Spinner size="lg" className="mx-auto text-purple-600" /></td></tr>
|
||
) : pets.length === 0 ? (
|
||
<tr><td colSpan={6} className="py-12 text-center text-gray-500 font-medium">هیچ حیوانی یافت نشد</td></tr>
|
||
) : (
|
||
pets.map((pet) => (
|
||
<tr key={pet.id} className="hover:bg-gray-50/50 transition-colors">
|
||
<td className="py-3 px-6">
|
||
{pet.imageUrl ? (
|
||
<img src={pet.imageUrl.startsWith('http') ? pet.imageUrl : `${BASE_DOMAIN}${pet.imageUrl}`} alt={pet.name} className="w-12 h-12 rounded-2xl object-cover border-2 border-purple-100 shadow-xs" />
|
||
) : (
|
||
<div className="w-12 h-12 rounded-2xl bg-purple-50 flex items-center justify-center text-purple-400 border border-purple-100">
|
||
{pet.type === 'گربه' ? <Cat className="w-6 h-6" /> : <Dog className="w-6 h-6" />}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<div className="font-black text-gray-900 flex items-center gap-1.5">
|
||
<span>{pet.name}</span>
|
||
<span className="text-[10px] px-2 py-0.5 bg-purple-50 text-purple-700 rounded-md font-bold">{pet.type || 'سگ'}</span>
|
||
</div>
|
||
<div className="text-gray-500 text-xs mt-0.5">{pet.breed || 'نژاد نامشخص'}</div>
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<div className="font-bold text-gray-800 text-xs">{pet.user?.firstName || ''} {pet.user?.lastName || ''}</div>
|
||
<div className="text-gray-400 text-[11px] font-mono mt-0.5">{pet.user?.mobile || pet.user?.email || '-'}</div>
|
||
</td>
|
||
<td className="py-4 px-6 text-gray-600 text-xs">
|
||
{pet.age ? `${pet.age} ساله` : 'سن نامشخص'}، {pet.weight ? `${pet.weight} کیلوگرم` : 'وزن نامشخص'}<br />
|
||
<span className="text-[11px] text-gray-400 font-medium">تحرک: {pet.activityLevel || 'متوسط'}</span>
|
||
</td>
|
||
<td className="py-4 px-6 text-xs">
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-[11px] text-gray-600 font-bold">
|
||
{pet.reminders?.length || 0} یادآور • {pet.healthLogs?.length || 0} گزارش
|
||
</span>
|
||
{pet.prescriptions && pet.prescriptions.length > 0 && (
|
||
<span className="text-[10px] text-purple-700 font-bold">
|
||
{pet.prescriptions.length} نسخه پزشکی ثبت شده
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedPetForView(pet)}
|
||
className="p-2 text-purple-600 hover:bg-purple-50 rounded-xl transition-colors cursor-pointer"
|
||
title="مشاهده پرونده کامل سلامت"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDeleteTargetId(pet.id)}
|
||
className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-colors cursor-pointer"
|
||
title="حذف پت"
|
||
>
|
||
<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>
|
||
|
||
{/* Pet Medical Dossier Details Modal */}
|
||
{selectedPetForView && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs font-vazir" dir="rtl">
|
||
<div className="bg-white rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl border border-gray-100 flex flex-col">
|
||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-purple-100 text-purple-700 flex items-center justify-center font-black">
|
||
{selectedPetForView.type === 'گربه' ? <Cat className="w-5 h-5" /> : <Dog className="w-5 h-5" />}
|
||
</div>
|
||
<div>
|
||
<h3 className="text-base font-black text-gray-900">پرونده پزشکی و پایش سلامت: {selectedPetForView.name}</h3>
|
||
<p className="text-xs text-gray-400 font-mono">شناسه: {selectedPetForView.id}</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedPetForView(null)}
|
||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-colors cursor-pointer"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-6">
|
||
{/* Pet Info Banner */}
|
||
<div className="p-4 bg-purple-50/60 rounded-2xl border border-purple-100 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 text-xs">
|
||
<div>
|
||
<div className="font-black text-gray-900 text-sm">{selectedPetForView.name} ({selectedPetForView.breed || selectedPetForView.type})</div>
|
||
<div className="text-gray-600 mt-1">
|
||
سرپرست: {selectedPetForView.user?.firstName} {selectedPetForView.user?.lastName} | همراه: <span className="font-mono">{selectedPetForView.user?.mobile || '-'}</span>
|
||
</div>
|
||
</div>
|
||
<div className="text-purple-900 font-bold bg-white px-3 py-1.5 rounded-xl border border-purple-200">
|
||
{selectedPetForView.age} سال | {selectedPetForView.weight} کیلوگرم | تحرک {selectedPetForView.activityLevel || 'متوسط'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Medical Conditions */}
|
||
<div>
|
||
<h4 className="text-xs font-black text-gray-900 flex items-center gap-2 mb-2">
|
||
<ShieldCheck className="w-4 h-4 text-purple-600" />
|
||
سوابق و حساسیتهای پزشکی (Medical Conditions)
|
||
</h4>
|
||
{selectedPetForView.medicalConditions && selectedPetForView.medicalConditions.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedPetForView.medicalConditions.map((mc, idx) => (
|
||
<span key={idx} className="px-3 py-1 bg-gray-100 text-gray-800 text-xs font-bold rounded-xl border border-gray-200">
|
||
{mc.condition}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-gray-400">مورد پزشکی خاصی ثبت نشده است.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Active Reminders */}
|
||
<div>
|
||
<h4 className="text-xs font-black text-gray-900 flex items-center gap-2 mb-2">
|
||
<Clock className="w-4 h-4 text-purple-600" />
|
||
یادآورهای فعال (Reminders)
|
||
</h4>
|
||
{selectedPetForView.reminders && selectedPetForView.reminders.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{selectedPetForView.reminders.map((rem) => (
|
||
<div key={rem.id} className="p-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center justify-between text-xs">
|
||
<span className="font-bold text-gray-800">{rem.title}</span>
|
||
<span className="text-purple-700 font-mono font-bold bg-white px-2 py-0.5 rounded-md border border-purple-100">
|
||
ساعت {rem.time} ({rem.frequency})
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-gray-400">یادآوری فعالی ثبت نشده است.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Health Logs */}
|
||
<div>
|
||
<h4 className="text-xs font-black text-gray-900 flex items-center gap-2 mb-2">
|
||
<Activity className="w-4 h-4 text-purple-600" />
|
||
آخرین گزارشات روزانه سلامت (Health Logs)
|
||
</h4>
|
||
{selectedPetForView.healthLogs && selectedPetForView.healthLogs.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{selectedPetForView.healthLogs.map((log) => (
|
||
<div key={log.id} className="p-3 bg-gray-50 rounded-xl border border-gray-200 text-xs space-y-1">
|
||
<div className="flex items-center justify-between text-gray-500 font-bold">
|
||
<span>تاریخ: {new Date(log.loggedDate || log.createdAt).toLocaleDateString('fa-IR')}</span>
|
||
<span className="text-gray-700 font-black">اشتها: {log.appetite} | انرژی: {log.energy} | گوارش: {log.digestion}</span>
|
||
</div>
|
||
{log.note && <p className="text-gray-700 font-medium">{log.note}</p>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-gray-400">گزارش سلامتی ثبت نشده است.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Prescriptions */}
|
||
<div>
|
||
<h4 className="text-xs font-black text-gray-900 flex items-center gap-2 mb-2">
|
||
<FileText className="w-4 h-4 text-purple-600" />
|
||
نسخههای پزشکی این همدم (Prescriptions)
|
||
</h4>
|
||
{selectedPetForView.prescriptions && selectedPetForView.prescriptions.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{selectedPetForView.prescriptions.map((rx) => (
|
||
<div key={rx.id} className="p-3 bg-purple-50/50 rounded-xl border border-purple-100 text-xs space-y-1">
|
||
<div className="flex items-center justify-between font-bold">
|
||
<span className="font-mono">نسخه #{rx.id.slice(0, 8)}</span>
|
||
<span className="text-purple-700 font-black">{rx.status}</span>
|
||
</div>
|
||
{rx.notes && <p className="text-gray-600">یادداشت کاربر: {rx.notes}</p>}
|
||
{rx.adminNotes && <p className="text-purple-900 font-bold">دستور پزشک: {rx.adminNotes}</p>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-gray-400">نسخهای برای این پت ثبت نشده است.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ConfirmModal
|
||
isOpen={!!deleteTargetId}
|
||
title="حذف حیوان خانگی"
|
||
message="آیا از حذف پروفایل این حیوان خانگی مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => setDeleteTargetId(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|