import { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon, } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import { formatNumber } from '../lib/utils'; import ConfirmDialog from './ui/ConfirmDialog'; import InviteDoctorModal from './ui/InviteDoctorModal'; import DoctorPermissionsModal from './ui/DoctorPermissionsModal'; import DataTable, { type Column } from './ui/DataTable'; import Pagination from './ui/Pagination'; const HUES_LIST = [256, 205, 162, 295, 272]; const PAGE_SIZE = 10; export interface ClinicDoctorItem { id: string; uuid: string; name: string; gender: string | null; degree: string | null; img: { url: string }[]; specialties: { id: string; name: string }[]; active: boolean; } export interface ClinicInvitation { uuid: string; mobile: string; invited_name: string | null; invited_specialty: string | null; status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed'; token_used: boolean; invited_at: number; expires_at: number; responded_at: number | null; doctor: { uuid: string; name: string } | null; } /** یک ردیف از `GET /api/v1/admin/clinic/{uuid}/doctor-permissions`. */ interface DoctorPermissionRow { doctor_uuid: string; active: boolean; permissions: { resources: Record> }; } /** چند اکشن از کل اکشن‌های تعریف‌شده به این پزشک داده شده. */ function grantedCount(row?: DoctorPermissionRow): { granted: number; total: number } { const resources = row?.permissions?.resources ?? {}; let granted = 0; let total = 0; for (const actions of Object.values(resources)) { for (const allowed of Object.values(actions)) { total += 1; if (allowed) granted += 1; } } return { granted, total }; } const INV_STATUS_MAP: Record = { pending: { label: 'در انتظار', cls: 'amber' }, accepted: { label: 'پذیرفته‌شده', cls: 'green' }, rejected: { label: 'رد شده', cls: 'gray' }, suspended: { label: 'تعلیق', cls: 'violet' }, removed: { label: 'حذف‌شده', cls: 'gray' }, }; /** * ClinicDoctorsManager — self-contained management of a clinic's doctors and * pending invitations (list, invite, resend, suspend, delete invitation, detach * doctor). Reused by both the admin ClinicDetailPage and the clinic-owner * settings tab (ClinicDoctorsPage). `readOnly` hides every mutating control. * `canCreate`/`canUpdate`/`canDelete` allow a caller (e.g. a secretary-scoped * page) to gate each action group; they default to `true` so unrestricted * callers (owner/admin) are unaffected. */ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false, canCreate = true, canUpdate = true, canDelete = true, }: { clinicUuid: string; readOnly?: boolean; canCreate?: boolean; canUpdate?: boolean; canDelete?: boolean; }) { const navigate = useNavigate(); const qc = useQueryClient(); const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors'); const [inviteOpen, setInviteOpen] = useState(false); const [detachDoctorConfirm, setDetachDoctorConfirm] = useState(null); const [permissionsFor, setPermissionsFor] = useState(null); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); // فیلدِ جستجو محلی می‌ماند و فقط مقدارِ آرام‌شده به کوئری می‌رود؛ وگرنه هر حرف یک // درخواست به سرور می‌زند. useEffect(() => { const t = setTimeout(() => { setDebouncedSearch(search); setPage(1); }, 350); return () => clearTimeout(t); }, [search]); /** * صفحه و جستجو به سرور می‌روند. * * پیش از این هیچ‌کدام فرستاده نمی‌شد و backend سقف پیش‌فرضِ ۱۰ را اعمال می‌کرد * (`DoctorRepository::findByClinicWithFilters`)، پس کلینیکِ یازده‌پزشکه بی‌هیچ نشانه‌ای * یک پزشک را گم می‌کرد. */ const doctorsQ = useQuery({ queryKey: ['clinic-doctors', clinicUuid, page, debouncedSearch], queryFn: () => api.get>( `/api/v1/clinic/doctor-list/${clinicUuid}?page=${page}&limit=${PAGE_SIZE}` + (debouncedSearch ? `&name=${encodeURIComponent(debouncedSearch)}` : ''), ), enabled: !!clinicUuid, }); const invitationsQ = useQuery({ queryKey: ['clinic-invitations', clinicUuid], queryFn: () => api.get>(`/api/v1/admin/clinic/${clinicUuid}/invitations?limit=50`), enabled: !!clinicUuid, }); /** * مجوزهای همهٔ پزشکان کلینیک با **یک** درخواست. * * اندپوینت تک‌پزشکی از قبل بود ولی برای فهرست یعنی N درخواست؛ نسخهٔ گروهی هم از قبل * وجود داشت و فقط مصرف نمی‌شد. */ const permissionsQ = useQuery({ queryKey: ['clinic-doctor-permissions', clinicUuid], queryFn: () => api.get>(`/api/v1/admin/clinic/${clinicUuid}/doctor-permissions`), enabled: !!clinicUuid && canUpdate, }); const doctorList: ClinicDoctorItem[] = useMemo(() => { const raw = doctorsQ.data?.data; return (raw as any)?.data ?? raw ?? []; }, [doctorsQ.data]); const doctorTotal = doctorsQ.data?.meta?.totalRecords ?? doctorList.length; const permissionByDoctor = useMemo(() => { const rows = permissionsQ.data?.data ?? []; return new Map((Array.isArray(rows) ? rows : []).map(r => [r.doctor_uuid, r])); }, [permissionsQ.data]); const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? []; const resendInvMut = useMutation({ mutationFn: (invUuid: string) => api.post>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}), onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); }, onError: (e: Error) => toast.error(e.message), }); const changeInvStatusMut = useMutation({ mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) => api.patch>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }), onSuccess: (_d, v) => { toast.success(v.status === 'pending' ? 'دعوتنامه فعال و پیامک مجدداً ارسال شد' : 'دعوتنامه تعلیق شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); }, onError: (e: Error) => toast.error(e.message), }); const deleteInvMut = useMutation({ mutationFn: (invUuid: string) => api.delete>(`/api/v1/admin/clinic/invitation/${invUuid}`), onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); }, onError: (e: Error) => toast.error(e.message), }); /** * ستون‌ها دو «فعال بودن» را از هم جدا می‌کنند، چون دو چیز متفاوت‌اند و پیش از این * یکی جای هر دو می‌نشست: * * • «دسترسی در کلینیک» → `ClinicDoctorPermission::active` — کلیدِ خودِ مالک کلینیک، * و خاموش‌بودنش یعنی `can()` همه‌چیز را رد می‌کند. * • «نوبت‌دهی آنلاین» → `Doctor::$activeDoctorAppointment && has_schedule` — حالِ * پروفایلِ خودِ پزشک و ربطی به عضویتش ندارد. * * بجِ قبلی دومی را نشان می‌داد با متنِ «فعال/غیرفعال»، پس پزشکِ سالمِ بدون برنامهٔ * هفتگی «غیرفعال» خوانده می‌شد و مالک ممکن بود بی‌دلیل جدایش کند. */ const doctorColumns: Column[] = useMemo(() => [ { key: 'name', header: 'پزشک', render: (doc) => { const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length]; const img = doc.img?.[0]?.url; return (
{img ? :
{doc.name?.[0] ?? '?'}
}
{doc.name}
{doc.specialties?.length > 0 && (
{doc.specialties.map(s => s.name).join('، ')}
)}
); }, }, { // بج و شمارِ مجوز یک ستون‌اند نه دو: هر دو یک سؤال را جواب می‌دهند («این پزشک // چقدر دسترسی دارد؟») و ستونِ کمتر یعنی جدولی که در موبایل هم جا می‌شود. key: 'clinic_access', header: 'دسترسی در کلینیک', render: (doc) => { // ردیفِ نبوده یعنی هنوز چیزی تنظیم نشده و پیش‌فرض‌ها برقرارند. const row = permissionByDoctor.get(doc.uuid); const on = row?.active ?? true; const { granted, total } = grantedCount(row); return (
{on ? 'فعال' : 'غیرفعال'} {row ? `${formatNumber(granted)} از ${formatNumber(total)} مجوز` : 'پیش‌فرض'}
); }, }, { key: 'online_booking', header: 'نوبت‌دهی آنلاین', render: (doc) => ( {doc.active ? 'فعال' : 'غیرفعال'} ), }, ], [permissionByDoctor]); const detachDoctorMut = useMutation({ mutationFn: (doctorUuid: string) => api.delete>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`), onSuccess: () => { toast.success('پزشک از کلینیک جدا شد'); qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] }); qc.invalidateQueries({ queryKey: ['clinic-detail', clinicUuid] }); }, onError: (e: Error) => toast.error(e.message), }); return ( <>
{/* Card header */}
{/* شمارنده از meta می‌آید نه از طول آرایه؛ طول آرایه فقط صفحهٔ جاری است. */}
{!readOnly && canCreate && ( )}
{/* Doctors tab */} {doctorsTab === 'doctors' && ( doctorsQ.isError ? (

فهرست پزشکان بارگذاری نشد

) : ( <> columns={doctorColumns} data={doctorList} loading={doctorsQ.isLoading} searchValue={search} onSearchChange={setSearch} searchPlaceholder="جستجوی نام پزشک" emptyMessage={debouncedSearch ? 'پزشکی با این نام پیدا نشد' : 'هیچ پزشکی به این کلینیک متصل نیست'} actions={doc => (
{!readOnly && canUpdate && ( )} {!readOnly && canDelete && ( )}
)} /> ) )} {/* Invitations tab */} {doctorsTab === 'invitations' && ( invitationsQ.isLoading ? (

در حال بارگذاری…

) : invitationsQ.isError ? (

فهرست دعوتنامه‌ها بارگذاری نشد

) : invitationList.length === 0 ? (

هیچ دعوتنامه‌ای ارسال نشده

) : (
{invitationList.map(inv => { const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' }; const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at; return (
{inv.invited_name ?? inv.mobile}
{inv.mobile} {inv.invited_specialty && ( {inv.invited_specialty} )} {inv.doctor && ( )}
{isExpired ? 'منقضی' : statusInfo.label} {!readOnly && (canUpdate || canDelete) && (
{canUpdate && inv.status === 'pending' && ( )} {canUpdate && inv.status !== 'removed' && inv.status !== 'accepted' && ( )} {canDelete && ( )}
)}
); })}
) )}
{/* Detach Doctor From Clinic Confirm */} { if (detachDoctorConfirm) detachDoctorMut.mutate(detachDoctorConfirm.uuid); setDetachDoctorConfirm(null); }} onCancel={() => setDetachDoctorConfirm(null)} /> {/* Per-doctor clinic permissions */} {permissionsFor && ( { setPermissionsFor(null); // ستون «دسترسی در کلینیک» و «مجوزها» باید تغییرِ همین مودال را نشان دهند. qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid] }); }} /> )} {/* Invite doctor modal */} {inviteOpen && clinicUuid && ( setInviteOpen(false)} onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); setDoctorsTab('invitations'); }} /> )} ); }