diff --git a/.claude/skills/redesign-page/driver.mjs b/.claude/skills/redesign-page/driver.mjs index 8a640495..e4adc4e3 100644 --- a/.claude/skills/redesign-page/driver.mjs +++ b/.claude/skills/redesign-page/driver.mjs @@ -26,7 +26,7 @@ * `ds` prints the design system — tokens from styles.css and the shared * components — so a redesign starts from what exists. */ -import { spawn } from 'node:child_process'; +import { spawn, execSync } from 'node:child_process'; import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -82,6 +82,33 @@ function cdp(ws) { }); } +/** + * ورود با OTP — در dev کد ثابتِ `12345` است (OtpService::sendCode)، پس کل زنجیرهٔ + * send-code → verify-code → otp-login اسکریپت‌پذیر است. + * + * راهِ نجاتِ دیتابیسی که seed نشده: کاربر واقعیِ چنین دیتابیسی ممکن است اصلاً + * `password_hash` نداشته باشد یا رمزش را ندانیم، ولی شمارهٔ موبایلش کافی است. + */ +async function otpLogin(mobile) { + const post = async (path, body) => { + const r = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return r.json(); + }; + + const sent = await post('/api/v1/user/send-code', { mobile }); + if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 160)}`); + + const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' }); + const grant = ver?.data?.grant; + if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 160)}`); + + return post('/api/v1/user/otp-login', { grant }); +} + async function login() { const r = await fetch(`${BASE}/api/v1/user/login`, { method: 'POST', @@ -89,14 +116,30 @@ async function login() { body: JSON.stringify({ mobile_number: USER, password: PASS }), }); const j = await r.json(); - if (!j.access_token) { - throw new Error( - `login failed for ${USER}: ${JSON.stringify(j).slice(0, 160)}\n` - + ' → seeded users are in TEST_USERS.md; rebuild them with\n' - + ' ddev exec php bin/console app:seed-scenarios --reset -n', + if (j.access_token) return j; + + // رمز نخورد؛ با OTP امتحان کن. دیتابیس‌های واقعی (نه seed) رمزِ مستندشده ندارند و + // بدون این، تنها راه یا reset کردن دیتابیس کاربر بود یا نوشتنِ رمز روی حسابش. + const otp = await otpLogin(USER).catch((e) => ({ _err: e.message })); + if (otp.access_token) return otp; + + // `send-code` سقف ۵ بار در ساعت دارد. وقتی سوخت، به‌جای شل‌کردن یک محدودیتِ واقعیِ + // محصول برای تست، توکن را با کامندِ خودِ اپ می‌سازیم — همان کلید و همان claimها. + try { + const out = execSync( + `ddev exec 'php bin/console lexik:jwt:generate-token ${USER} --user-class="App\\\\Auth\\\\Entity\\\\User"'`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, ); - } - return j; + const minted = out.trim().split('\n').pop().trim(); + if (minted.startsWith('ey')) return { access_token: minted, refresh_token: null }; + } catch { /* کامند در دسترس نیست؛ می‌افتیم روی خطای زیر */ } + + throw new Error( + `login failed for ${USER}: ${JSON.stringify(j).slice(0, 160)}\n` + + ` → OTP fallback also failed: ${otp._err ?? JSON.stringify(otp).slice(0, 120)}\n` + + ' → CLINICPRO_USER را روی شمارهٔ یک کاربر واقعیِ همین دیتابیس بگذار،\n' + + ' یا حساب‌های تست را بساز: ddev exec php bin/console app:seed-scenarios --reset -n', + ); } /** @@ -104,12 +147,10 @@ async function login() { * می‌نشیند و صفحه‌های کلینیک خالی می‌آیند — که شبیه باگ است ولی نیست. */ async function switchContext(token, kind) { - const r = await fetch(`${BASE}/api/v1/auth/contexts`, { - headers: { Authorization: `Bearer ${token}` }, - }).catch(() => null); - - // اندپوینت فهرست محیط‌ها عمومی نیست؛ uuid را از /api/v1/user/me می‌گیریم. - const me = await (await fetch(`${BASE}/api/v1/user/me`, { + // فهرست محیط‌ها فقط در `/oauth/userinfo` است. مسیر قبلی (`/api/v1/user/me`) اصلاً + // وجود ندارد و ۴۰۴ می‌داد، پس `--context clinic` همیشه بی‌صدا نادیده گرفته می‌شد و + // اسکرین‌شاتِ محیطِ اشتباه گرفته می‌شد. + const me = await (await fetch(`${BASE}/oauth/userinfo`, { headers: { Authorization: `Bearer ${token}` }, })).json().catch(() => ({})); diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 99e9b4e0..e9742d5e 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -144,7 +144,7 @@ function PublicRoute({ children }: { children: React.ReactNode }) { return isAuthenticated ? : <>{children}; } -function RoleRoute({ roles, blockClinicScope, permission, children }: { +export function RoleRoute({ roles, blockClinicScope, permission, children }: { roles: string[]; blockClinicScope?: boolean; /** @@ -159,7 +159,23 @@ function RoleRoute({ roles, blockClinicScope, permission, children }: { const context = useAuthStore((s) => s.context); const { can } = usePermissions(); if (!primaryRole) return
در حال بارگذاری...
; - if (!roles.includes(primaryRole)) return ; + + /** + * پزشکِ عضو در محیط کلینیک برای گِیتِ نقشی همان «clinic» حساب می‌شود — ولی **فقط** + * وقتی صفحه مجوزی اعلام کرده و کلینیک آن مجوز را به او داده باشد. + * + * بدون این، منو و route با هم نمی‌خواندند: `settingsMenu` واریانتِ نقشیِ همین پزشک را + * با `clinic` تطبیق می‌داد و آیتم را نشان می‌داد، ولی اینجا `primaryRole` او `doctor` + * بود و کلیک به داشبورد می‌پرید — یعنی منویی که کار نمی‌کرد. + * + * قید «مجوز الزامی» عمدی است: بدون آن، هر صفحهٔ مخصوصِ مالک برای هر پزشکِ عضو باز + * می‌شد. با آن، دسترسی همان چیزی می‌ماند که مالک کلینیک صراحتاً داده است. + */ + const clinicScopedDoctor = primaryRole === 'doctor' && context?.scope === 'clinic'; + const roleOk = roles.includes(primaryRole) + || (clinicScopedDoctor && roles.includes('clinic') && !!permission && can(permission[0], permission[1])); + + if (!roleOk) return ; // منشیِ بدون مجوزِ این صفحه نباید با ورود مستقیم URL هم بازش کند. if (primaryRole === 'secretary' && permission && !can(permission[0], permission[1])) { return ; @@ -297,7 +313,10 @@ export default function App() { } /> } /> } /> - } /> + {/* مجوزش `clinic_info` است نه `appointment_settings`: صفحه حوزهٔ فعالیت را با + PATCH /api/v1/clinic/{uuid} ذخیره می‌کند و backend همان‌جا `clinic_info.update` + را می‌سنجد. گِیتِ قبلی منبع دیگری را می‌پرسید و با اجبارِ واقعی نمی‌خواند. */} + } /> } /> } /> } /> diff --git a/assets/admin/RoleRoute.test.tsx b/assets/admin/RoleRoute.test.tsx new file mode 100644 index 00000000..a2c3af67 --- /dev/null +++ b/assets/admin/RoleRoute.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import { Routes, Route } from 'react-router'; +import { renderWithProviders } from './test/utils'; +import { useAuthStore } from './stores/authStore'; +import { RoleRoute } from './App'; + +/** + * گِیتِ نقشیِ صفحات پنل — با تمرکز روی پزشکِ عضو در محیط کلینیک. + * + * پیش از این `settingsMenu` و `RoleRoute` دو قاعدهٔ متفاوت داشتند: منو واریانتِ + * `clinic` را به پزشکِ scope=clinic نشان می‌داد و route با `primaryRole === 'doctor'` + * ردش می‌کرد. نتیجه یک آیتم منوی مرده بود که به داشبورد می‌پرید. + */ +function renderGate(node: React.ReactNode) { + return renderWithProviders( + + داشبورد} /> + + , + { route: '/admin/page' }, + ); +} + +function setUser(opts: { + role: string; + scope?: string | null; + permissions?: Record>; +}) { + useAuthStore.setState({ + primaryRole: opts.role, + context: { + type: opts.scope === 'clinic' ? 'clinic' : 'doctor', + db_uuid: 'x', + name: 'محیط', + scope: opts.scope ?? null, + permissions: opts.permissions ? { resources: opts.permissions } : null, + }, + } as any); +} + +describe('RoleRoute', () => { + beforeEach(() => useAuthStore.setState({ primaryRole: null, context: null } as any)); + + it('نقشِ مجاز عبور می‌کند', () => { + setUser({ role: 'clinic' }); + renderGate(
محتوا
); + + expect(screen.getByText('محتوا')).toBeInTheDocument(); + }); + + it('نقشِ غیرمجاز به داشبورد می‌رود', () => { + setUser({ role: 'doctor' }); + renderGate(
محتوا
); + + 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 ( +
+ + {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}`), @@ -131,8 +265,9 @@ export default function ClinicDoctorsManager({ {/* Card header */}
+ {/* شمارنده از meta می‌آید نه از طول آرایه؛ طول آرایه فقط صفحهٔ جاری است. */}
) : ( -
- {doctorList.map(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('، ')}
- )} -
-
- - {doc.active ? 'فعال' : 'غیرفعال'} - + <> + + columns={doctorColumns} + data={doctorList} + loading={doctorsQ.isLoading} + searchValue={search} + onSearchChange={setSearch} + searchPlaceholder="جستجوی نام پزشک" + emptyMessage={debouncedSearch ? 'پزشکی با این نام پیدا نشد' : 'هیچ پزشکی به این کلینیک متصل نیست'} + actions={doc => ( +
+ + {!readOnly && canUpdate && ( - {!readOnly && canUpdate && ( - - )} - {!readOnly && canDelete && ( - - )} -
+ )} + {!readOnly && canDelete && ( + + )}
- ); - })} -
+ )} + /> + + ) )} {/* Invitations tab */} {doctorsTab === 'invitations' && ( - invitationList.length === 0 ? ( + invitationsQ.isLoading ? ( +
+

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

+
+ ) : invitationsQ.isError ? ( +
+

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

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

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

@@ -305,7 +449,11 @@ export default function ClinicDoctorsManager({ clinicUuid={clinicUuid} doctorUuid={permissionsFor.uuid} doctorName={permissionsFor.name} - onClose={() => setPermissionsFor(null)} + onClose={() => { + setPermissionsFor(null); + // ستون «دسترسی در کلینیک» و «مجوزها» باید تغییرِ همین مودال را نشان دهند. + qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid] }); + }} /> )} diff --git a/assets/admin/components/layout/settingsMenu.ts b/assets/admin/components/layout/settingsMenu.ts index defbf3cb..18440143 100644 --- a/assets/admin/components/layout/settingsMenu.ts +++ b/assets/admin/components/layout/settingsMenu.ts @@ -58,7 +58,8 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ // ── مطب و کلینیک — چه کسی اینجا کار می‌کند و چه چیزی ارائه می‌شود ─────────── { key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, group: 'practice', to: '/admin/profile', roles: ['doctor'] }, { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, group: 'practice', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, - { key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, group: 'practice', to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, + // مجوزش `clinic_info` است چون ذخیره‌اش روی PATCH /api/v1/clinic/{uuid} می‌نشیند. + { key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, group: 'practice', to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['clinic_info', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, group: 'practice', to: '/admin/my-secretaries' }, { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, group: 'practice', to: '/admin/staff', perm: ['staff', 'view'] }, diff --git a/assets/admin/pages/ClinicDoctorsPage.tsx b/assets/admin/pages/ClinicDoctorsPage.tsx index 54d3a831..cb910111 100644 --- a/assets/admin/pages/ClinicDoctorsPage.tsx +++ b/assets/admin/pages/ClinicDoctorsPage.tsx @@ -4,7 +4,7 @@ import { PencilIcon } from '@heroicons/react/24/outline'; import { useAuthStore } from '../stores/authStore'; import { usePermissions } from '../hooks/usePermissions'; import SettingsLayout from '../components/layout/SettingsLayout'; -import BackButton from '../components/ui/BackButton'; +import PageHeader from '../components/ui/PageHeader'; import ClinicDoctorsManager from '../components/ClinicDoctorsManager'; /** @@ -45,18 +45,17 @@ function ClinicDoctorsContent() { return (
-
- -
-
-
-

پزشکان کلینیک

-
مدیریت پزشکان و دعوت‌نامه‌های کلینیک
-
- - ویرایش اطلاعات کلینیک - -
+ {/* عنوان و بازگشت و اکشن هر سه در PageHeader — همان قاعدهٔ بقیهٔ صفحات پنل. */} + + ویرایش اطلاعات کلینیک + + } + /> s.context?.db_uuid ?? null); + const { can } = usePermissions(); + const canUpdate = can('clinic_info', 'update'); const { data: domainsData, isLoading } = useQuery({ queryKey: ['practice-domains'], @@ -86,10 +89,18 @@ export default function PracticeDomainSettingsPage() { )} + {/* پزشکِ عضو ممکن است «مشاهده» داشته باشد و «ویرایش» نه؛ دکمهٔ فعالی که + حتماً ۴۰۳ می‌گیرد، خطای کاربر نیست — خطای رابط است. */} + {!canUpdate && ( + + برای تغییر حوزهٔ فعالیت، دسترسی «ویرایش اطلاعات کلینیک» لازم است. + + )} +