diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index a84b0c4d..cedf0392 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -2353,6 +2353,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil const [deletingAddrId, setDeletingAddrId] = useState(null); const [editGender, setEditGender] = useState<'man' | 'woman' | ''>(''); const [editActivityDate, setEditActivityDate] = useState(''); + const [repModalOpen, setRepModalOpen] = useState(false); + const [selRepId, setSelRepId] = useState(null); + + const isAdmin = primaryRole === 'admin'; // ── Queries ── @@ -2476,6 +2480,29 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil onError: (e: Error) => toast.error(e.message), }); + const repsQuery = useQuery({ + queryKey: ['representations-select'], + queryFn: () => api.get>('/api/v1/admin/representations?limit=200'), + enabled: isAdmin && repModalOpen, + staleTime: 60_000, + }); + const repOptions: { value: number; label: string }[] = useMemo(() => { + const rows = repsQuery.data?.data ?? []; + return (rows as any[]).map((r) => ({ value: r.id as number, label: `${r.full_name}${r.city ? ` — ${r.city}` : ''}` })); + }, [repsQuery.data]); + + const setRepMut = useMutation({ + mutationFn: (representationId: number | null) => + api.put>(`/api/v1/admin/doctors/${uuid}/representation`, { representation_id: representationId }), + onSuccess: () => { + toast.success('نماینده‌ی پزشک به‌روزرسانی شد'); + setRepModalOpen(false); + qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] }); + qc.invalidateQueries({ queryKey: ['admin-doctors'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + const deleteAddrMut = useMutation({ mutationFn: (id: string) => api.delete>(`/api/v1/clinic-pro/doctor-address/${id}`), onSuccess: () => { @@ -2703,9 +2730,19 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil navigate(`/admin/representations/${doctor.representation!.uuid}`)}>{doctor.representation.full_name ?? 'نماینده'} - : بدون نماینده} + value={ + + {doctor.representation + ? + : بدون نماینده} + {isAdmin && ( + + )} + + } /> @@ -3042,6 +3079,28 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending} onConfirm={() => deleteMut.mutate()} onCancel={() => setDeleteOpen(false)} /> + + setRepModalOpen(false)} + footer={ + <> + + {doctor.representation && ( + + )} + + + } + > + + setSelRepId(v === null || v === '' ? null : Number(v))} + placeholder={repsQuery.isLoading ? 'در حال بارگذاری…' : 'جستجوی نماینده…'} + /> + ); } diff --git a/assets/admin/pages/RepresentationDetailPage.tsx b/assets/admin/pages/RepresentationDetailPage.tsx index 4ab6726b..55775401 100644 --- a/assets/admin/pages/RepresentationDetailPage.tsx +++ b/assets/admin/pages/RepresentationDetailPage.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { Representation, City } from '../types'; -import { formatDate, formatNumber } from '../lib/utils'; +import { formatDate, formatDateTime, formatNumber } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; import { ActiveBadge } from '../components/ui/StatusBadge'; import ConfirmDialog from '../components/ui/ConfirmDialog'; @@ -21,10 +21,28 @@ interface RepDoctor { medical_code: string | null; is_active: boolean; owner_status?: string; - appointment_count: number; + past_count: number; + upcoming_count: number; created_at: string; } +interface RepAppointment { + uuid: string; + slot_start: number; + slot_end: number; + status: string; + patient_name: string | null; + doctor_uuid: string; + doctor_name: string; +} + +interface UnassignedDoctor { + uuid: string; + name: string; + medical_code: string | null; + mobile: string | null; +} + function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { return (
@@ -41,6 +59,9 @@ export default function RepresentationDetailPage() { const [deleteOpen, setDeleteOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [formData, setFormData] = useState({ full_name: '', city_id: '', mobile_number: '', commission_percent: '' }); + const [attachOpen, setAttachOpen] = useState(false); + const [attachSearch, setAttachSearch] = useState(''); + const [apptScope, setApptScope] = useState<'upcoming' | 'past'>('upcoming'); const citiesQuery = useQuery({ queryKey: ['cities-select'], @@ -65,6 +86,34 @@ export default function RepresentationDetailPage() { const repDoctors: RepDoctor[] = doctorsQuery.data?.data ?? []; const repDoctorsTotal = doctorsQuery.data?.meta?.totalRecords ?? repDoctors.length; + const appointmentsQuery = useQuery({ + queryKey: ['representation-appointments', uuid, apptScope], + queryFn: () => api.get>(`/api/v1/admin/representations/${uuid}/appointments?scope=${apptScope}&limit=50`), + enabled: !!uuid, + }); + const repAppointments: RepAppointment[] = appointmentsQuery.data?.data ?? []; + const repAppointmentsTotal = appointmentsQuery.data?.meta?.totalRecords ?? repAppointments.length; + + const unassignedQuery = useQuery({ + queryKey: ['unassigned-doctors', attachSearch], + queryFn: () => api.get>(`/api/v1/admin/doctors?unassigned=1&limit=20${attachSearch ? `&search=${encodeURIComponent(attachSearch)}` : ''}`), + enabled: attachOpen, + }); + const unassignedDoctors: UnassignedDoctor[] = unassignedQuery.data?.data ?? []; + + const attachMutation = useMutation({ + mutationFn: (doctorUuid: string) => + api.post>(`/api/v1/admin/representations/${uuid}/doctors`, { doctor_uuid: doctorUuid }), + onSuccess: () => { + toast.success('پزشک به نماینده متصل شد'); + setAttachOpen(false); + setAttachSearch(''); + qc.invalidateQueries({ queryKey: ['representation-doctors', uuid] }); + qc.invalidateQueries({ queryKey: ['representations'] }); + }, + onError: (err: Error) => toast.error(err.message), + }); + const updateMutation = useMutation({ mutationFn: (d: Partial) => api.patch>(`/api/v1/representation/${uuid}`, d), @@ -237,8 +286,11 @@ export default function RepresentationDetailPage() { {/* Doctors of this representation */}
-

پزشکان این نماینده

- {formatNumber(repDoctorsTotal)} پزشک +
+

پزشکان این نماینده

+ {formatNumber(repDoctorsTotal)} پزشک +
+
{doctorsQuery.isLoading ? (

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

@@ -251,7 +303,8 @@ export default function RepresentationDetailPage() { نام کد نظام - تعداد نوبت + نوبت گذشته + نوبت آینده وضعیت @@ -260,7 +313,8 @@ export default function RepresentationDetailPage() { navigate(`/admin/doctors/${d.uuid}`)}> {d.name} {d.medical_code ?? '—'} - {formatNumber(d.appointment_count)} + {formatNumber(d.past_count)} + {formatNumber(d.upcoming_count)} ))} @@ -270,6 +324,82 @@ export default function RepresentationDetailPage() { )}
+ {/* Appointments of this representation */} +
+
+

نوبت‌های این نماینده

+
+ + +
+
+ {appointmentsQuery.isLoading ? ( +

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

+ ) : repAppointments.length === 0 ? ( +

+ {apptScope === 'upcoming' ? 'نوبت آینده‌ای ثبت نشده' : 'نوبت گذشته‌ای وجود ندارد'} +

+ ) : ( + <> +
{formatNumber(repAppointmentsTotal)} نوبت
+
+ + + + + + + + + + + {repAppointments.map((a) => ( + navigate(`/admin/doctors/${a.doctor_uuid}`)}> + + + + + + ))} + +
پزشکبیمارزمانوضعیت
{a.doctor_name}{a.patient_name ?? '—'}{formatDateTime(a.slot_start)}{a.status}
+
+ + )} +
+ + {/* Attach Doctor Modal */} + { setAttachOpen(false); setAttachSearch(''); }} + footer={} + > + setAttachSearch(e.target.value)} + /> + {unassignedQuery.isLoading ? ( +

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

+ ) : unassignedDoctors.length === 0 ? ( +

پزشکِ بدون نماینده‌ای یافت نشد

+ ) : ( +
+ {unassignedDoctors.map((d) => ( +
+
+ {d.name} +
{d.medical_code ?? d.mobile ?? '—'}
+
+ +
+ ))} +
+ )} +
+ {/* Edit Modal */} setEditOpen(false)} diff --git a/assets/admin/pages/UsersPage.tsx b/assets/admin/pages/UsersPage.tsx index 979b51bd..c315f8ea 100644 --- a/assets/admin/pages/UsersPage.tsx +++ b/assets/admin/pages/UsersPage.tsx @@ -44,18 +44,24 @@ interface UserStats { const HUES_LIST = [256, 205, 162, 295, 272]; const ROLE_META: Record = { - admin: { label: 'ادمین', badgeCls: 'violet' }, - doctor: { label: 'پزشک', badgeCls: 'blue' }, - secretary: { label: 'منشی', badgeCls: 'amber' }, - clinic: { label: 'کلینیک', badgeCls: 'green' }, - patient: { label: 'بیمار', badgeCls: 'gray' }, + admin: { label: 'ادمین', badgeCls: 'violet' }, + doctor: { label: 'پزشک', badgeCls: 'blue' }, + secretary: { label: 'منشی', badgeCls: 'amber' }, + clinic: { label: 'کلینیک', badgeCls: 'green' }, + representation: { label: 'نماینده', badgeCls: 'red' }, + patient: { label: 'بیمار', badgeCls: 'gray' }, }; +// نقش‌هایی که از این صفحه قابل تغییرِ مستقیم‌اند (backend `roleMap`). «نماینده» از این‌جا ست نمی‌شود +// چون ساخت نماینده رکورد Representation هم می‌خواهد؛ فقط برای نمایش badge/فیلتر تعریف شده است. +const ASSIGNABLE_ROLES = ['admin', 'doctor', 'secretary', 'clinic', 'patient']; + function getPrimaryRole(roles: string[]): string { - if (roles.includes('ROLE_ADMIN')) return 'admin'; - if (roles.includes('ROLE_DOCTOR')) return 'doctor'; - if (roles.includes('ROLE_SECRETARY')) return 'secretary'; - if (roles.includes('ROLE_CLINIC')) return 'clinic'; + if (roles.includes('ROLE_ADMIN')) return 'admin'; + if (roles.includes('ROLE_DOCTOR')) return 'doctor'; + if (roles.includes('ROLE_SECRETARY')) return 'secretary'; + if (roles.includes('ROLE_CLINIC')) return 'clinic'; + if (roles.includes('ROLE_REPRESENTATION')) return 'representation'; return 'patient'; } @@ -114,7 +120,7 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {

{user.name || user.mobile_number}

- {Object.entries(ROLE_META).map(([key, meta]) => ( + {Object.entries(ROLE_META).filter(([key]) => ASSIGNABLE_ROLES.includes(key)).map(([key, meta]) => (