feat: implement OTP login flow and enhance role-based access control
- Added OTP login functionality in driver.mjs to handle user authentication with a fixed code in dev environment. - Enhanced RoleRoute component in App.tsx to support clinic-scoped doctor roles and permissions. - Updated ClinicDoctorsManager component to include pagination and search functionality for better user experience. - Refactored tests for ClinicDoctorsManager to cover new features and ensure proper API mocking. - Adjusted permissions in settingsMenu.ts and PracticeDomainSettingsPage.tsx to align with updated backend requirements. - Created RoleRoute.test.tsx to validate role-based access logic for different user scenarios.
This commit is contained in:
@@ -26,7 +26,7 @@
|
|||||||
* `ds` prints the design system — tokens from styles.css and the shared
|
* `ds` prints the design system — tokens from styles.css and the shared
|
||||||
* components — so a redesign starts from what exists.
|
* 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 { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
|
||||||
import { resolve, dirname } from 'node:path';
|
import { resolve, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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() {
|
async function login() {
|
||||||
const r = await fetch(`${BASE}/api/v1/user/login`, {
|
const r = await fetch(`${BASE}/api/v1/user/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -89,14 +116,30 @@ async function login() {
|
|||||||
body: JSON.stringify({ mobile_number: USER, password: PASS }),
|
body: JSON.stringify({ mobile_number: USER, password: PASS }),
|
||||||
});
|
});
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (!j.access_token) {
|
if (j.access_token) return j;
|
||||||
throw new Error(
|
|
||||||
`login failed for ${USER}: ${JSON.stringify(j).slice(0, 160)}\n`
|
// رمز نخورد؛ با OTP امتحان کن. دیتابیسهای واقعی (نه seed) رمزِ مستندشده ندارند و
|
||||||
+ ' → seeded users are in TEST_USERS.md; rebuild them with\n'
|
// بدون این، تنها راه یا reset کردن دیتابیس کاربر بود یا نوشتنِ رمز روی حسابش.
|
||||||
+ ' ddev exec php bin/console app:seed-scenarios --reset -n',
|
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'] },
|
||||||
);
|
);
|
||||||
}
|
const minted = out.trim().split('\n').pop().trim();
|
||||||
return j;
|
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) {
|
async function switchContext(token, kind) {
|
||||||
const r = await fetch(`${BASE}/api/v1/auth/contexts`, {
|
// فهرست محیطها فقط در `/oauth/userinfo` است. مسیر قبلی (`/api/v1/user/me`) اصلاً
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
// وجود ندارد و ۴۰۴ میداد، پس `--context clinic` همیشه بیصدا نادیده گرفته میشد و
|
||||||
}).catch(() => null);
|
// اسکرینشاتِ محیطِ اشتباه گرفته میشد.
|
||||||
|
const me = await (await fetch(`${BASE}/oauth/userinfo`, {
|
||||||
// اندپوینت فهرست محیطها عمومی نیست؛ uuid را از /api/v1/user/me میگیریم.
|
|
||||||
const me = await (await fetch(`${BASE}/api/v1/user/me`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})).json().catch(() => ({}));
|
})).json().catch(() => ({}));
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -144,7 +144,7 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
export function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
||||||
roles: string[];
|
roles: string[];
|
||||||
blockClinicScope?: boolean;
|
blockClinicScope?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -159,7 +159,23 @@ function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
|||||||
const context = useAuthStore((s) => s.context);
|
const context = useAuthStore((s) => s.context);
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
|
||||||
|
/**
|
||||||
|
* پزشکِ عضو در محیط کلینیک برای گِیتِ نقشی همان «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 <Navigate to="/admin/dashboard" replace />;
|
||||||
// منشیِ بدون مجوزِ این صفحه نباید با ورود مستقیم URL هم بازش کند.
|
// منشیِ بدون مجوزِ این صفحه نباید با ورود مستقیم URL هم بازش کند.
|
||||||
if (primaryRole === 'secretary' && permission && !can(permission[0], permission[1])) {
|
if (primaryRole === 'secretary' && permission && !can(permission[0], permission[1])) {
|
||||||
return <Navigate to="/admin/dashboard" replace />;
|
return <Navigate to="/admin/dashboard" replace />;
|
||||||
@@ -297,7 +313,10 @@ export default function App() {
|
|||||||
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
|
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
|
||||||
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
|
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
|
||||||
<Route path="treatment-cases" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} permission={['treatment', 'view']}><TreatmentCasesPage /></RoleRoute>} />
|
<Route path="treatment-cases" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} permission={['treatment', 'view']}><TreatmentCasesPage /></RoleRoute>} />
|
||||||
<Route path="settings/practice-domain" element={<RoleRoute roles={['clinic']} permission={['appointment_settings', 'view']}><PracticeDomainSettingsPage /></RoleRoute>} />
|
{/* مجوزش `clinic_info` است نه `appointment_settings`: صفحه حوزهٔ فعالیت را با
|
||||||
|
PATCH /api/v1/clinic/{uuid} ذخیره میکند و backend همانجا `clinic_info.update`
|
||||||
|
را میسنجد. گِیتِ قبلی منبع دیگری را میپرسید و با اجبارِ واقعی نمیخواند. */}
|
||||||
|
<Route path="settings/practice-domain" element={<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><PracticeDomainSettingsPage /></RoleRoute>} />
|
||||||
<Route path="practice-domains" element={<RoleRoute roles={['admin']}><AdminPracticeDomainsPage /></RoleRoute>} />
|
<Route path="practice-domains" element={<RoleRoute roles={['admin']}><AdminPracticeDomainsPage /></RoleRoute>} />
|
||||||
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
||||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcesPage /></RoleRoute>} />
|
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/dashboard" element={<div>داشبورد</div>} />
|
||||||
|
<Route path="/admin/page" element={node} />
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/admin/page' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser(opts: {
|
||||||
|
role: string;
|
||||||
|
scope?: string | null;
|
||||||
|
permissions?: Record<string, Record<string, boolean>>;
|
||||||
|
}) {
|
||||||
|
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(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
|
||||||
|
|
||||||
|
expect(screen.getByText('محتوا')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('نقشِ غیرمجاز به داشبورد میرود', () => {
|
||||||
|
setUser({ role: 'doctor' });
|
||||||
|
renderGate(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
|
||||||
|
|
||||||
|
expect(screen.getByText('داشبورد')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پزشکِ عضو با مجوزِ همان صفحه، به واریانتِ clinic راه دارد', () => {
|
||||||
|
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: true } } });
|
||||||
|
renderGate(
|
||||||
|
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('محتوا')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پزشکِ عضو بدون آن مجوز، راه ندارد', () => {
|
||||||
|
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: false } } });
|
||||||
|
renderGate(
|
||||||
|
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('داشبورد')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('صفحهٔ بدون اعلامِ مجوز برای پزشکِ عضو باز نمیشود', () => {
|
||||||
|
// وگرنه هر صفحهٔ مخصوصِ مالک برای هر پزشکِ عضو باز میشد.
|
||||||
|
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: true } } });
|
||||||
|
renderGate(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
|
||||||
|
|
||||||
|
expect(screen.getByText('داشبورد')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پزشکِ مطب شخصی از واریانتِ clinic رد میشود، حتی با مجوزِ باز', () => {
|
||||||
|
// محیط شخصی permissions ندارد و `can` آزاد برمیگردد؛ scope باید جلویش را بگیرد.
|
||||||
|
setUser({ role: 'doctor', scope: null });
|
||||||
|
renderGate(
|
||||||
|
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('داشبورد')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,48 +1,129 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { renderWithProviders } from '../test/utils';
|
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 { api } from '../lib/api';
|
||||||
import ClinicDoctorsManager from './ClinicDoctorsManager';
|
import ClinicDoctorsManager from './ClinicDoctorsManager';
|
||||||
|
|
||||||
const get = api.get as ReturnType<typeof vi.fn>;
|
vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }));
|
||||||
|
|
||||||
beforeEach(() => {
|
const DOCTOR = {
|
||||||
get.mockReset();
|
id: '1',
|
||||||
get.mockImplementation((url: string) => {
|
uuid: 'doc-1',
|
||||||
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
|
name: 'پزشک دعوتشده',
|
||||||
{ id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
|
gender: null,
|
||||||
img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
|
degree: null,
|
||||||
] });
|
img: [],
|
||||||
if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
|
specialties: [],
|
||||||
{ uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
|
active: false, // نوبتدهی آنلاینِ خودِ پزشک خاموش است
|
||||||
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: [] });
|
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', () => {
|
describe('ClinicDoctorsManager', () => {
|
||||||
it('lists clinic doctors and shows management controls by default', async () => {
|
beforeEach(() => vi.clearAllMocks());
|
||||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" />, { route: '/admin/settings/clinic-doctors' });
|
|
||||||
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
|
it('صفحه و سقف را به سرور میفرستد — وگرنه backend سر ۱۰ پزشک بیصدا میبرد', async () => {
|
||||||
expect(screen.getByText('قلب')).toBeInTheDocument();
|
mockApi();
|
||||||
// manager controls
|
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
|
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 () => {
|
it('شمارندهٔ تب از meta میآید، نه از طول صفحهٔ جاری', async () => {
|
||||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" readOnly />, { route: '/admin/settings/clinic-doctors' });
|
mockApi({ doctors: [DOCTOR], total: 25 });
|
||||||
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
|
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
|
expect(await screen.findByText(/پزشکان \(۲۵\)/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«نوبتدهی آنلاین» و «دسترسی در کلینیک» دو ستون جدا هستند', async () => {
|
||||||
|
mockApi();
|
||||||
|
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
|
|
||||||
|
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(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('مجوزها')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پزشکِ بدون ردیف مجوز، «پیشفرض» و دسترسی فعال میگیرد', async () => {
|
||||||
|
mockApi({ permissions: [] });
|
||||||
|
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('پیشفرض')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ردیف مجوزِ خاموش، «غیرفعال» نشان میدهد و شمار مجوزها را میآورد', async () => {
|
||||||
|
mockApi({
|
||||||
|
permissions: [{
|
||||||
|
doctor_uuid: 'doc-1',
|
||||||
|
active: false,
|
||||||
|
permissions: { resources: { appointments: { view: true, create: false } } },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
|
|
||||||
|
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(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
|
|
||||||
|
// دسترسی فعال است…
|
||||||
|
expect(await screen.findByText('فعال')).toBeInTheDocument();
|
||||||
|
// …ولی نوبتدهی آنلاینِ خودِ پزشک همچنان خاموش.
|
||||||
|
expect(screen.getByText('غیرفعال')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('خطای فهرست، پیام خطا میدهد نه حالت خالی', async () => {
|
||||||
|
mockApi({ failDoctors: true });
|
||||||
|
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('فهرست پزشکان بارگذاری نشد')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('هیچ پزشکی به این کلینیک متصل نیست')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('فهرست خالی، پیام خالی میدهد', async () => {
|
||||||
|
mockApi({ doctors: [], total: 0 });
|
||||||
|
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('هیچ پزشکی به این کلینیک متصل نیست')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +11,11 @@ import { formatNumber } from '../lib/utils';
|
|||||||
import ConfirmDialog from './ui/ConfirmDialog';
|
import ConfirmDialog from './ui/ConfirmDialog';
|
||||||
import InviteDoctorModal from './ui/InviteDoctorModal';
|
import InviteDoctorModal from './ui/InviteDoctorModal';
|
||||||
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
|
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 HUES_LIST = [256, 205, 162, 295, 272];
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
export interface ClinicDoctorItem {
|
export interface ClinicDoctorItem {
|
||||||
id: string; uuid: string; name: string;
|
id: string; uuid: string; name: string;
|
||||||
@@ -35,6 +38,29 @@ export interface ClinicInvitation {
|
|||||||
doctor: { uuid: string; name: string } | 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<string, Record<string, boolean>> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** چند اکشن از کل اکشنهای تعریفشده به این پزشک داده شده. */
|
||||||
|
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<string, { label: string; cls: string }> = {
|
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||||
pending: { label: 'در انتظار', cls: 'amber' },
|
pending: { label: 'در انتظار', cls: 'amber' },
|
||||||
accepted: { label: 'پذیرفتهشده', cls: 'green' },
|
accepted: { label: 'پذیرفتهشده', cls: 'green' },
|
||||||
@@ -72,10 +98,30 @@ export default function ClinicDoctorsManager({
|
|||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
|
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
|
||||||
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
|
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(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({
|
const doctorsQ = useQuery({
|
||||||
queryKey: ['clinic-doctors', clinicUuid],
|
queryKey: ['clinic-doctors', clinicUuid, page, debouncedSearch],
|
||||||
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
|
queryFn: () => api.get<PaginatedResponse<ClinicDoctorItem>>(
|
||||||
|
`/api/v1/clinic/doctor-list/${clinicUuid}?page=${page}&limit=${PAGE_SIZE}`
|
||||||
|
+ (debouncedSearch ? `&name=${encodeURIComponent(debouncedSearch)}` : ''),
|
||||||
|
),
|
||||||
enabled: !!clinicUuid,
|
enabled: !!clinicUuid,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,11 +131,30 @@ export default function ClinicDoctorsManager({
|
|||||||
enabled: !!clinicUuid,
|
enabled: !!clinicUuid,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مجوزهای همهٔ پزشکان کلینیک با **یک** درخواست.
|
||||||
|
*
|
||||||
|
* اندپوینت تکپزشکی از قبل بود ولی برای فهرست یعنی N درخواست؛ نسخهٔ گروهی هم از قبل
|
||||||
|
* وجود داشت و فقط مصرف نمیشد.
|
||||||
|
*/
|
||||||
|
const permissionsQ = useQuery({
|
||||||
|
queryKey: ['clinic-doctor-permissions', clinicUuid],
|
||||||
|
queryFn: () => api.get<ApiResponse<DoctorPermissionRow[]>>(`/api/v1/admin/clinic/${clinicUuid}/doctor-permissions`),
|
||||||
|
enabled: !!clinicUuid && canUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
const doctorList: ClinicDoctorItem[] = useMemo(() => {
|
const doctorList: ClinicDoctorItem[] = useMemo(() => {
|
||||||
const raw = doctorsQ.data?.data;
|
const raw = doctorsQ.data?.data;
|
||||||
return (raw as any)?.data ?? raw ?? [];
|
return (raw as any)?.data ?? raw ?? [];
|
||||||
}, [doctorsQ.data]);
|
}, [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 invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
|
||||||
|
|
||||||
const resendInvMut = useMutation({
|
const resendInvMut = useMutation({
|
||||||
@@ -114,6 +179,75 @@ export default function ClinicDoctorsManager({
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ستونها دو «فعال بودن» را از هم جدا میکنند، چون دو چیز متفاوتاند و پیش از این
|
||||||
|
* یکی جای هر دو مینشست:
|
||||||
|
*
|
||||||
|
* • «دسترسی در کلینیک» → `ClinicDoctorPermission::active` — کلیدِ خودِ مالک کلینیک،
|
||||||
|
* و خاموشبودنش یعنی `can()` همهچیز را رد میکند.
|
||||||
|
* • «نوبتدهی آنلاین» → `Doctor::$activeDoctorAppointment && has_schedule` — حالِ
|
||||||
|
* پروفایلِ خودِ پزشک و ربطی به عضویتش ندارد.
|
||||||
|
*
|
||||||
|
* بجِ قبلی دومی را نشان میداد با متنِ «فعال/غیرفعال»، پس پزشکِ سالمِ بدون برنامهٔ
|
||||||
|
* هفتگی «غیرفعال» خوانده میشد و مالک ممکن بود بیدلیل جدایش کند.
|
||||||
|
*/
|
||||||
|
const doctorColumns: Column<ClinicDoctorItem>[] = 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 (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||||
|
{img
|
||||||
|
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
|
||||||
|
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
|
||||||
|
}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{doc.name}</div>
|
||||||
|
{doc.specialties?.length > 0 && (
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// بج و شمارِ مجوز یک ستوناند نه دو: هر دو یک سؤال را جواب میدهند («این پزشک
|
||||||
|
// چقدر دسترسی دارد؟») و ستونِ کمتر یعنی جدولی که در موبایل هم جا میشود.
|
||||||
|
key: 'clinic_access',
|
||||||
|
header: 'دسترسی در کلینیک',
|
||||||
|
render: (doc) => {
|
||||||
|
// ردیفِ نبوده یعنی هنوز چیزی تنظیم نشده و پیشفرضها برقرارند.
|
||||||
|
const row = permissionByDoctor.get(doc.uuid);
|
||||||
|
const on = row?.active ?? true;
|
||||||
|
const { granted, total } = grantedCount(row);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
|
||||||
|
<span className={`badge ${on ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||||
|
<span className="bdot" />{on ? 'فعال' : 'غیرفعال'}
|
||||||
|
</span>
|
||||||
|
<span className="muted" style={{ fontSize: 11 }}>
|
||||||
|
{row ? `${formatNumber(granted)} از ${formatNumber(total)} مجوز` : 'پیشفرض'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'online_booking',
|
||||||
|
header: 'نوبتدهی آنلاین',
|
||||||
|
render: (doc) => (
|
||||||
|
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||||
|
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [permissionByDoctor]);
|
||||||
|
|
||||||
const detachDoctorMut = useMutation({
|
const detachDoctorMut = useMutation({
|
||||||
mutationFn: (doctorUuid: string) =>
|
mutationFn: (doctorUuid: string) =>
|
||||||
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
|
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
|
||||||
@@ -131,8 +265,9 @@ export default function ClinicDoctorsManager({
|
|||||||
{/* Card header */}
|
{/* Card header */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
<div className="seg">
|
<div className="seg">
|
||||||
|
{/* شمارنده از meta میآید نه از طول آرایه؛ طول آرایه فقط صفحهٔ جاری است. */}
|
||||||
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
|
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
|
||||||
پزشکان ({formatNumber(doctorList.length)})
|
پزشکان ({formatNumber(doctorTotal)})
|
||||||
</button>
|
</button>
|
||||||
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
|
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
|
||||||
دعوتنامهها ({formatNumber(invitationList.length)})
|
دعوتنامهها ({formatNumber(invitationList.length)})
|
||||||
@@ -147,67 +282,76 @@ export default function ClinicDoctorsManager({
|
|||||||
|
|
||||||
{/* Doctors tab */}
|
{/* Doctors tab */}
|
||||||
{doctorsTab === 'doctors' && (
|
{doctorsTab === 'doctors' && (
|
||||||
doctorList.length === 0 ? (
|
doctorsQ.isError ? (
|
||||||
<div className="empty" style={{ padding: '20px 0' }}>
|
<div className="empty" style={{ padding: '20px 0' }}>
|
||||||
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
|
<p className="muted">فهرست پزشکان بارگذاری نشد</p>
|
||||||
|
<button className="btn secondary sm" onClick={() => doctorsQ.refetch()}>تلاش دوباره</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<>
|
||||||
{doctorList.map(doc => {
|
<DataTable<ClinicDoctorItem>
|
||||||
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
columns={doctorColumns}
|
||||||
const img = doc.img?.[0]?.url;
|
data={doctorList}
|
||||||
return (
|
loading={doctorsQ.isLoading}
|
||||||
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
|
searchValue={search}
|
||||||
{img
|
onSearchChange={setSearch}
|
||||||
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
|
searchPlaceholder="جستجوی نام پزشک"
|
||||||
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
|
emptyMessage={debouncedSearch ? 'پزشکی با این نام پیدا نشد' : 'هیچ پزشکی به این کلینیک متصل نیست'}
|
||||||
}
|
actions={doc => (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
|
<button
|
||||||
{doc.specialties?.length > 0 && (
|
className="mini-btn"
|
||||||
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
|
title="مشاهده پروفایل"
|
||||||
)}
|
aria-label={`مشاهده پروفایل ${doc.name}`}
|
||||||
</div>
|
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
>
|
||||||
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||||
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
|
</button>
|
||||||
</span>
|
{!readOnly && canUpdate && (
|
||||||
<button
|
<button
|
||||||
className="mini-btn"
|
className="mini-btn"
|
||||||
title="مشاهده پروفایل"
|
title="مدیریت دسترسیها"
|
||||||
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
|
aria-label={`مدیریت دسترسیهای ${doc.name}`}
|
||||||
|
onClick={() => setPermissionsFor(doc)}
|
||||||
>
|
>
|
||||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
||||||
</button>
|
</button>
|
||||||
{!readOnly && canUpdate && (
|
)}
|
||||||
<button
|
{!readOnly && canDelete && (
|
||||||
className="mini-btn"
|
<button
|
||||||
title="مدیریت دسترسیها"
|
className="mini-btn danger"
|
||||||
onClick={() => setPermissionsFor(doc)}
|
title="جداسازی از کلینیک"
|
||||||
>
|
aria-label={`جداسازی ${doc.name} از کلینیک`}
|
||||||
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
onClick={() => setDetachDoctorConfirm(doc)}
|
||||||
</button>
|
>
|
||||||
)}
|
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||||
{!readOnly && canDelete && (
|
</button>
|
||||||
<button
|
)}
|
||||||
className="mini-btn danger"
|
|
||||||
title="جداسازی از کلینیک"
|
|
||||||
onClick={() => setDetachDoctorConfirm(doc)}
|
|
||||||
>
|
|
||||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
/>
|
||||||
</div>
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
total={doctorTotal}
|
||||||
|
limit={PAGE_SIZE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Invitations tab */}
|
{/* Invitations tab */}
|
||||||
{doctorsTab === 'invitations' && (
|
{doctorsTab === 'invitations' && (
|
||||||
invitationList.length === 0 ? (
|
invitationsQ.isLoading ? (
|
||||||
|
<div className="empty" style={{ padding: '20px 0' }}>
|
||||||
|
<p className="muted">در حال بارگذاری…</p>
|
||||||
|
</div>
|
||||||
|
) : invitationsQ.isError ? (
|
||||||
|
<div className="empty" style={{ padding: '20px 0' }}>
|
||||||
|
<p className="muted">فهرست دعوتنامهها بارگذاری نشد</p>
|
||||||
|
<button className="btn secondary sm" onClick={() => invitationsQ.refetch()}>تلاش دوباره</button>
|
||||||
|
</div>
|
||||||
|
) : invitationList.length === 0 ? (
|
||||||
<div className="empty" style={{ padding: '20px 0' }}>
|
<div className="empty" style={{ padding: '20px 0' }}>
|
||||||
<EnvelopeIcon style={{ width: 30, height: 30 }} />
|
<EnvelopeIcon style={{ width: 30, height: 30 }} />
|
||||||
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
|
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
|
||||||
@@ -305,7 +449,11 @@ export default function ClinicDoctorsManager({
|
|||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
doctorUuid={permissionsFor.uuid}
|
doctorUuid={permissionsFor.uuid}
|
||||||
doctorName={permissionsFor.name}
|
doctorName={permissionsFor.name}
|
||||||
onClose={() => setPermissionsFor(null)}
|
onClose={() => {
|
||||||
|
setPermissionsFor(null);
|
||||||
|
// ستون «دسترسی در کلینیک» و «مجوزها» باید تغییرِ همین مودال را نشان دهند.
|
||||||
|
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid] });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
|||||||
// ── مطب و کلینیک — چه کسی اینجا کار میکند و چه چیزی ارائه میشود ───────────
|
// ── مطب و کلینیک — چه کسی اینجا کار میکند و چه چیزی ارائه میشود ───────────
|
||||||
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, group: 'practice', to: '/admin/profile', roles: ['doctor'] },
|
{ 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: '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: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, group: 'practice', to: '/admin/my-secretaries' },
|
||||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, group: 'practice', to: '/admin/staff', perm: ['staff', 'view'] },
|
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, group: 'practice', to: '/admin/staff', perm: ['staff', 'view'] },
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PencilIcon } from '@heroicons/react/24/outline';
|
|||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
|
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,18 +45,17 @@ function ClinicDoctorsContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||||
<div>
|
{/* عنوان و بازگشت و اکشن هر سه در PageHeader — همان قاعدهٔ بقیهٔ صفحات پنل. */}
|
||||||
<BackButton fallback="/admin/settings-menu" />
|
<PageHeader
|
||||||
</div>
|
title="پزشکان کلینیک"
|
||||||
<div className="card-title-row">
|
description="مدیریت پزشکان و دعوتنامههای کلینیک"
|
||||||
<div>
|
backTo="/admin/settings-menu"
|
||||||
<h1 className="section-title">پزشکان کلینیک</h1>
|
action={
|
||||||
<div className="muted">مدیریت پزشکان و دعوتنامههای کلینیک</div>
|
<Link className="btn secondary sm" to={`/admin/clinics/${clinicUuid}`}>
|
||||||
</div>
|
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش اطلاعات کلینیک
|
||||||
<Link className="btn ghost sm" to={`/admin/clinics/${clinicUuid}`}>
|
</Link>
|
||||||
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش اطلاعات کلینیک
|
}
|
||||||
</Link>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ClinicDoctorsManager
|
<ClinicDoctorsManager
|
||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api, ApiError, type ApiResponse } from '../lib/api';
|
|||||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import type { PracticeDomain } from '../types';
|
import type { PracticeDomain } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +19,8 @@ export default function PracticeDomainSettingsPage() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
// محیط جاری با `db_uuid` شناخته میشود؛ همان uuid کلینیک است.
|
// محیط جاری با `db_uuid` شناخته میشود؛ همان uuid کلینیک است.
|
||||||
const clinicUuid = useAuthStore((s) => s.context?.db_uuid ?? null);
|
const clinicUuid = useAuthStore((s) => s.context?.db_uuid ?? null);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const canUpdate = can('clinic_info', 'update');
|
||||||
|
|
||||||
const { data: domainsData, isLoading } = useQuery({
|
const { data: domainsData, isLoading } = useQuery({
|
||||||
queryKey: ['practice-domains'],
|
queryKey: ['practice-domains'],
|
||||||
@@ -86,10 +89,18 @@ export default function PracticeDomainSettingsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* پزشکِ عضو ممکن است «مشاهده» داشته باشد و «ویرایش» نه؛ دکمهٔ فعالی که
|
||||||
|
حتماً ۴۰۳ میگیرد، خطای کاربر نیست — خطای رابط است. */}
|
||||||
|
{!canUpdate && (
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
برای تغییر حوزهٔ فعالیت، دسترسی «ویرایش اطلاعات کلینیک» لازم است.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
disabled={save.isPending || !clinicUuid}
|
disabled={save.isPending || !clinicUuid || !canUpdate}
|
||||||
onClick={() => save.mutate(selected)}
|
onClick={() => save.mutate(selected)}
|
||||||
style={{ justifySelf: 'start' }}
|
style={{ justifySelf: 'start' }}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user