);
+
+ expect(screen.getByText('داشبورد')).toBeInTheDocument();
+ });
+
+ it('پزشکِ عضو با مجوزِ همان صفحه، به واریانتِ clinic راه دارد', () => {
+ setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: true } } });
+ renderGate(
+
محتوا
,
+ );
+
+ expect(screen.getByText('محتوا')).toBeInTheDocument();
+ });
+
+ it('پزشکِ عضو بدون آن مجوز، راه ندارد', () => {
+ setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: false } } });
+ renderGate(
+
محتوا
,
+ );
+
+ expect(screen.getByText('داشبورد')).toBeInTheDocument();
+ });
+
+ it('صفحهٔ بدون اعلامِ مجوز برای پزشکِ عضو باز نمیشود', () => {
+ // وگرنه هر صفحهٔ مخصوصِ مالک برای هر پزشکِ عضو باز میشد.
+ setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: true } } });
+ renderGate(
محتوا
);
+
+ expect(screen.getByText('داشبورد')).toBeInTheDocument();
+ });
+
+ it('پزشکِ مطب شخصی از واریانتِ clinic رد میشود، حتی با مجوزِ باز', () => {
+ // محیط شخصی permissions ندارد و `can` آزاد برمیگردد؛ scope باید جلویش را بگیرد.
+ setUser({ role: 'doctor', scope: null });
+ renderGate(
+
محتوا
,
+ );
+
+ expect(screen.getByText('داشبورد')).toBeInTheDocument();
+ });
+});
diff --git a/assets/admin/components/ClinicDoctorsManager.test.tsx b/assets/admin/components/ClinicDoctorsManager.test.tsx
index 4be1df24..2e9154b1 100644
--- a/assets/admin/components/ClinicDoctorsManager.test.tsx
+++ b/assets/admin/components/ClinicDoctorsManager.test.tsx
@@ -1,48 +1,129 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
-
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
-vi.mock('../lib/api', () => ({
- api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
- ApiError: class extends Error {},
-}));
-
import { api } from '../lib/api';
import ClinicDoctorsManager from './ClinicDoctorsManager';
-const get = api.get as ReturnType;
+vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }));
-beforeEach(() => {
- get.mockReset();
- get.mockImplementation((url: string) => {
- if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
- { id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
- img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
- ] });
- if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
- { uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
- status: 'pending', token_used: false, invited_at: 1, expires_at: 9_999_999_999,
- responded_at: null, doctor: null },
- ], meta: { totalRecords: 1, totalPages: 1, currentPage: 1 } });
- return Promise.resolve({ success: true, data: [] });
+const DOCTOR = {
+ id: '1',
+ uuid: 'doc-1',
+ name: 'پزشک دعوتشده',
+ gender: null,
+ degree: null,
+ img: [],
+ specialties: [],
+ active: false, // نوبتدهی آنلاینِ خودِ پزشک خاموش است
+};
+
+/** پاسخها بر اساس مسیر تفکیک میشوند، چون سه کوئری موازی میروند. */
+function mockApi({ doctors = [DOCTOR], total = 1, permissions = [] as any[], failDoctors = false } = {}) {
+ (api.get as any).mockImplementation((url: string) => {
+ if (url.includes('/doctor-permissions')) return Promise.resolve({ data: permissions });
+ if (url.includes('/invitations')) return Promise.resolve({ data: [] });
+ if (url.includes('/doctor-list/')) {
+ return failDoctors
+ ? Promise.reject(new Error('boom'))
+ : Promise.resolve({ data: doctors, meta: { totalRecords: total, totalPages: 1, currentPage: 1 } });
+ }
+ return Promise.resolve({ data: [] });
});
-});
+}
describe('ClinicDoctorsManager', () => {
- it('lists clinic doctors and shows management controls by default', async () => {
- renderWithProviders(, { route: '/admin/settings/clinic-doctors' });
- expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
- expect(screen.getByText('قلب')).toBeInTheDocument();
- // manager controls
- expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
- expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
+ beforeEach(() => vi.clearAllMocks());
+
+ it('صفحه و سقف را به سرور میفرستد — وگرنه backend سر ۱۰ پزشک بیصدا میبرد', async () => {
+ mockApi();
+ renderWithProviders();
+
+ await waitFor(() => expect(api.get).toHaveBeenCalled());
+ const listCall = (api.get as any).mock.calls.find((c: any[]) => c[0].includes('/doctor-list/'));
+ expect(listCall[0]).toContain('page=1');
+ expect(listCall[0]).toContain('limit=10');
});
- it('hides every mutating control when readOnly', async () => {
- renderWithProviders(, { route: '/admin/settings/clinic-doctors' });
- expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
- expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
- expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
+ it('شمارندهٔ تب از meta میآید، نه از طول صفحهٔ جاری', async () => {
+ mockApi({ doctors: [DOCTOR], total: 25 });
+ renderWithProviders();
+
+ expect(await screen.findByText(/پزشکان \(۲۵\)/)).toBeInTheDocument();
+ });
+
+ it('«نوبتدهی آنلاین» و «دسترسی در کلینیک» دو ستون جدا هستند', async () => {
+ mockApi();
+ renderWithProviders();
+
+ expect(await screen.findByText('دسترسی در کلینیک')).toBeInTheDocument();
+ expect(screen.getByText('نوبتدهی آنلاین')).toBeInTheDocument();
+ });
+
+ it('شمار مجوزها زیر همان بجِ دسترسی میآید، نه در ستون جدا', async () => {
+ mockApi({
+ permissions: [{
+ doctor_uuid: 'doc-1',
+ active: true,
+ permissions: { resources: { appointments: { view: true, create: false } } },
+ }],
+ });
+ renderWithProviders();
+
+ expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
+ expect(screen.queryByText('مجوزها')).not.toBeInTheDocument();
+ });
+
+ it('پزشکِ بدون ردیف مجوز، «پیشفرض» و دسترسی فعال میگیرد', async () => {
+ mockApi({ permissions: [] });
+ renderWithProviders();
+
+ expect(await screen.findByText('پیشفرض')).toBeInTheDocument();
+ });
+
+ it('ردیف مجوزِ خاموش، «غیرفعال» نشان میدهد و شمار مجوزها را میآورد', async () => {
+ mockApi({
+ permissions: [{
+ doctor_uuid: 'doc-1',
+ active: false,
+ permissions: { resources: { appointments: { view: true, create: false } } },
+ }],
+ });
+ renderWithProviders();
+
+ expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
+ // دو «غیرفعال»: یکی دسترسیِ کلینیک، یکی نوبتدهی آنلاین. پیش از این یک بج جای
+ // هر دو مینشست و همین ابهام، پزشکِ سالم را «غیرفعال» نشان میداد.
+ expect(screen.getAllByText('غیرفعال')).toHaveLength(2);
+ });
+
+ it('دسترسیِ فعال با نوبتدهیِ خاموش قاطی نمیشود', async () => {
+ mockApi({
+ permissions: [{
+ doctor_uuid: 'doc-1',
+ active: true,
+ permissions: { resources: { appointments: { view: true } } },
+ }],
+ });
+ renderWithProviders();
+
+ // دسترسی فعال است…
+ expect(await screen.findByText('فعال')).toBeInTheDocument();
+ // …ولی نوبتدهی آنلاینِ خودِ پزشک همچنان خاموش.
+ expect(screen.getByText('غیرفعال')).toBeInTheDocument();
+ });
+
+ it('خطای فهرست، پیام خطا میدهد نه حالت خالی', async () => {
+ mockApi({ failDoctors: true });
+ renderWithProviders();
+
+ expect(await screen.findByText('فهرست پزشکان بارگذاری نشد')).toBeInTheDocument();
+ expect(screen.queryByText('هیچ پزشکی به این کلینیک متصل نیست')).not.toBeInTheDocument();
+ });
+
+ it('فهرست خالی، پیام خالی میدهد', async () => {
+ mockApi({ doctors: [], total: 0 });
+ renderWithProviders();
+
+ expect(await screen.findByText('هیچ پزشکی به این کلینیک متصل نیست')).toBeInTheDocument();
});
});
diff --git a/assets/admin/components/ClinicDoctorsManager.tsx b/assets/admin/components/ClinicDoctorsManager.tsx
index 65b57e14..7502bfd1 100644
--- a/assets/admin/components/ClinicDoctorsManager.tsx
+++ b/assets/admin/components/ClinicDoctorsManager.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -11,8 +11,11 @@ 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;
@@ -35,6 +38,29 @@ export interface ClinicInvitation {
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' },
@@ -72,10 +98,30 @@ export default function ClinicDoctorsManager({
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],
- queryFn: () => api.get>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
+ 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,
});
@@ -85,11 +131,30 @@ export default function ClinicDoctorsManager({
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({
@@ -114,6 +179,75 @@ export default function ClinicDoctorsManager({
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 (
+