feat(permissions): render both permission forms from the catalog, fix borrowed gates
The three hardcoded resource lists in the admin panel are gone. MySecretariesPage, SecretariesPage and DoctorPermissionsModal now render from GET /api/v1/permission-catalog, so a resource added to the backend registry shows up in all of them with no frontend change. Each has a test that proves exactly that by adding a resource to the mock and asserting it renders. SecretaryPermissions was an interface with a field per resource, which made "dynamic" impossible in TypeScript — every new resource would have been a compile error. It is now an open map. Only two files consumed it. The borrowed gates are corrected: - five resource pages moved off appointment_settings onto their own 'resources' - treatment-cases moved off appointments onto 'treatment' - service-categories moved onto 'services', which is what ServiceCatalogController actually manages (categories, item groups, service relations) — not resources TreatmentCaseController had no permission gate at all, only IS_AUTHENTICATED_FULLY, so any secretary could read and edit treatment cases. All seven of its actions are now gated on treatment view/update. ResourcePermissionTrait takes the resource from an overridable method instead of hardcoding appointment_settings. HolidayController overrides it back, since the holidays page really is appointment settings. The booking gate keeps its appointments.view fallback so a secretary who may book is not blocked by a resource-config permission. Defaults were picked to preserve today's effective access, so no role gains or loses a page from this move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -297,16 +297,16 @@ export default function App() {
|
|||||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
|
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
|
||||||
<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={['appointments', '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>} />
|
<Route path="settings/practice-domain" element={<RoleRoute roles={['clinic']} permission={['appointment_settings', '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={['appointment_settings', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
||||||
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></RoleRoute>} />
|
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></RoleRoute>} />
|
||||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><SkillsPage /></RoleRoute>} />
|
||||||
<Route path="resources/pools" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcePoolsPage /></RoleRoute>} />
|
<Route path="resources/pools" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcePoolsPage /></RoleRoute>} />
|
||||||
<Route path="resources/:resourceUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceDetailPage /></RoleRoute>} />
|
<Route path="resources/:resourceUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourceDetailPage /></RoleRoute>} />
|
||||||
{/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینکهای قدیمی نباید بشکنند. */}
|
{/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینکهای قدیمی نباید بشکنند. */}
|
||||||
<Route path="resources/:resourceUuid/calendar" element={<ResourceCalendarRedirect />} />
|
<Route path="resources/:resourceUuid/calendar" element={<ResourceCalendarRedirect />} />
|
||||||
{/* تقویم رسمی کشور — فقط مدیر سیستم. /holidays مالِ محیط است و فقط استثنا میزند. */}
|
{/* تقویم رسمی کشور — فقط مدیر سیستم. /holidays مالِ محیط است و فقط استثنا میزند. */}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../../test/utils';
|
||||||
|
|
||||||
|
vi.mock('../../lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import DoctorPermissionsModal from './DoctorPermissionsModal';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const resource = (key: string, label: string, actions: [string, string][]) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
clinic_only: false,
|
||||||
|
actions: actions.map(([k, l]) => ({ key: k, label: l })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BASE_CATALOG = [
|
||||||
|
resource('appointments', 'مدیریت نوبتها', [['view', 'مشاهده'], ['create', 'ایجاد']]),
|
||||||
|
resource('patients', 'پرونده بیماران', [['view', 'مشاهده'], ['delete', 'حذف']]),
|
||||||
|
resource('services', 'خدمات و تعرفهها', [['view', 'مشاهده']]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const permissionPayload = {
|
||||||
|
uuid: 'p1',
|
||||||
|
clinic_uuid: 'c1',
|
||||||
|
doctor_uuid: 'd1',
|
||||||
|
doctor_name: 'دکتر تست',
|
||||||
|
active: true,
|
||||||
|
permissions: { version: 1, resources: { appointments: { view: true, create: false } } },
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockApi(catalog: unknown[]) {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('permission-catalog')) {
|
||||||
|
return Promise.resolve({ success: true, data: { version: 1, resources: catalog } });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: permissionPayload });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(catalog: unknown[] = BASE_CATALOG) {
|
||||||
|
mockApi(catalog);
|
||||||
|
return renderWithProviders(
|
||||||
|
<DoctorPermissionsModal clinicUuid="c1" doctorUuid="d1" doctorName="دکتر تست" onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DoctorPermissionsModal', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('renders one row per catalog resource', async () => {
|
||||||
|
render();
|
||||||
|
|
||||||
|
expect(await screen.findByText('مدیریت نوبتها')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('پرونده بیماران')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('خدمات و تعرفهها')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قلبِ «داینامیک بودن»: منبع تازه فقط به mock اضافه میشود و هیچ خطی از کد
|
||||||
|
* کامپوننت عوض نمیشود. اگر این تست بشکند یعنی فهرست دوباره هاردکد شده.
|
||||||
|
*/
|
||||||
|
it('shows a resource added to the catalog with no code change', async () => {
|
||||||
|
render([...BASE_CATALOG, resource('brand_new_page', 'صفحهٔ کاملاً تازه', [['view', 'مشاهده']])]);
|
||||||
|
|
||||||
|
expect(await screen.findByText('صفحهٔ کاملاً تازه')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a switch only for actions the resource actually has', async () => {
|
||||||
|
render();
|
||||||
|
|
||||||
|
// patients در کاتالوگ view و delete دارد، ولی create ندارد.
|
||||||
|
expect(await screen.findByLabelText('پرونده بیماران — حذف')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('پرونده بیماران — ایجاد')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects stored values and defaults missing ones to off', async () => {
|
||||||
|
render();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('مدیریت نوبتها — مشاهده')).toBeChecked());
|
||||||
|
expect(screen.getByLabelText('مدیریت نوبتها — ایجاد')).not.toBeChecked();
|
||||||
|
// services اصلاً در JSONِ ذخیرهشده نیست
|
||||||
|
expect(screen.getByLabelText('خدمات و تعرفهها — مشاهده')).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading rather than an empty table while the catalog is in flight', () => {
|
||||||
|
get.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<DoctorPermissionsModal clinicUuid="c1" doctorUuid="d1" doctorName="دکتر تست" onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('در حال بارگذاری...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { api } from '../../lib/api';
|
|||||||
import type { ApiResponse } from '../../lib/api';
|
import type { ApiResponse } from '../../lib/api';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import Switch from './Switch';
|
import Switch from './Switch';
|
||||||
|
import { usePermissionCatalog } from '../../hooks/usePermissionCatalog';
|
||||||
|
|
||||||
/** envelope کامل — همان چیزی که بکاند برمیگرداند، بدون flatten. */
|
/** envelope کامل — همان چیزی که بکاند برمیگرداند، بدون flatten. */
|
||||||
export interface PermissionEnvelope {
|
export interface PermissionEnvelope {
|
||||||
@@ -21,61 +22,7 @@ export interface ClinicDoctorPermissionPayload {
|
|||||||
permissions: PermissionEnvelope;
|
permissions: PermissionEnvelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, string> }> = {
|
/** ستونهای ثابتِ جدول؛ هر منبع فقط ستونهایی را پر میکند که کاتالوگ برایش داده. */
|
||||||
appointments: {
|
|
||||||
label: 'نوبتها',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' },
|
|
||||||
},
|
|
||||||
appointment_settings: {
|
|
||||||
label: 'تنظیمات نوبتدهی',
|
|
||||||
actions: { view: 'مشاهده', update: 'ویرایش' },
|
|
||||||
},
|
|
||||||
patients: {
|
|
||||||
label: 'پرونده بیماران',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
payments: {
|
|
||||||
label: 'پرداختها',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
label: 'خدمات',
|
|
||||||
actions: { view: 'مشاهده', update: 'ویرایش' },
|
|
||||||
},
|
|
||||||
clinic_info: {
|
|
||||||
label: 'اطلاعات کلینیک',
|
|
||||||
actions: { view: 'مشاهده', update: 'ویرایش' },
|
|
||||||
},
|
|
||||||
insurances: {
|
|
||||||
label: 'بیمهها',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
addresses: {
|
|
||||||
label: 'آدرسها',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
inventory: {
|
|
||||||
label: 'انبارداری',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
label: 'تگها',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
staff: {
|
|
||||||
label: 'پرسنل',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
discounts: {
|
|
||||||
label: 'تخفیفها',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
sms: {
|
|
||||||
label: 'پیامکها',
|
|
||||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
||||||
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
||||||
|
|
||||||
@@ -86,6 +33,7 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const catalog = usePermissionCatalog();
|
||||||
const [resources, setResources] = useState<PermissionEnvelope['resources']>({});
|
const [resources, setResources] = useState<PermissionEnvelope['resources']>({});
|
||||||
const [active, setActive] = useState(true);
|
const [active, setActive] = useState(true);
|
||||||
|
|
||||||
@@ -135,7 +83,7 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
|
|||||||
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
|
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
|
||||||
<button
|
<button
|
||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
disabled={saveMut.isPending || permQ.isLoading}
|
disabled={saveMut.isPending || permQ.isLoading || catalog.isLoading}
|
||||||
onClick={() => saveMut.mutate()}
|
onClick={() => saveMut.mutate()}
|
||||||
>
|
>
|
||||||
ذخیره
|
ذخیره
|
||||||
@@ -143,8 +91,10 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{permQ.isLoading ? (
|
{permQ.isLoading || catalog.isLoading ? (
|
||||||
<p className="muted">در حال بارگذاری...</p>
|
<p className="muted">در حال بارگذاری...</p>
|
||||||
|
) : catalog.isError ? (
|
||||||
|
<p className="muted">فهرست دسترسیها خوانده نشد. صفحه را دوباره باز کنید.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
@@ -167,13 +117,13 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Object.keys(RESOURCE_LABELS).map(resource => {
|
{catalog.resources.map(resource => {
|
||||||
const config = RESOURCE_LABELS[resource];
|
const available = new Set(resource.actions.map(a => a.key));
|
||||||
return (
|
return (
|
||||||
<tr key={resource}>
|
<tr key={resource.key}>
|
||||||
<td><b>{config.label}</b></td>
|
<td><b>{resource.label}</b></td>
|
||||||
{ACTION_COLUMNS.map(action => {
|
{ACTION_COLUMNS.map(action => {
|
||||||
if (!config.actions[action]) {
|
if (!available.has(action)) {
|
||||||
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -183,9 +133,9 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
|
|||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Switch
|
<Switch
|
||||||
disabled={!active}
|
disabled={!active}
|
||||||
checked={resources[resource]?.[action] ?? false}
|
checked={resources[resource.key]?.[action] ?? false}
|
||||||
onChange={() => toggle(resource, action)}
|
onChange={() => toggle(resource.key, action)}
|
||||||
ariaLabel={`${config.label} — ${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
|
ariaLabel={`${resource.label} — ${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import type { ApiResponse } from '../lib/api';
|
||||||
|
|
||||||
|
export interface CatalogAction {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogResource {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** فقط در محیط کلینیک معنا دارد — پزشک مستقل نباید ببیندش. */
|
||||||
|
clinic_only: boolean;
|
||||||
|
actions: CatalogAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogPayload {
|
||||||
|
version: number;
|
||||||
|
resources: CatalogResource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فهرستِ منابعِ قابلمجوزدهی — منبعِ واحدِ هر دو فرمِ مجوز (منشی و پزشکِ عضو کلینیک).
|
||||||
|
*
|
||||||
|
* تا پیش از این همین فهرست در سه فایل UI هاردکد بود و با بکاند واگرا میشد، پس
|
||||||
|
* صفحهٔ تازه مجوزِ صفحهٔ دیگری را قرض میگرفت. حالا افزودن یک ردیف به
|
||||||
|
* PermissionCatalog در بکاند کافی است.
|
||||||
|
*
|
||||||
|
* پاسخ به کاربر بستگی ندارد و تا وقتی رجیستری عوض نشود ثابت است.
|
||||||
|
*/
|
||||||
|
export function usePermissionCatalog() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['permission-catalog'],
|
||||||
|
queryFn: () => api.get<ApiResponse<CatalogPayload>>('/api/v1/permission-catalog'),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// مرجعِ آرایه باید پایدار بماند: مصرفکنندهها آن را در وابستگیِ useEffect
|
||||||
|
// میگذارند و یک `?? []` تازه در هر رندر، حلقهٔ بیپایان میسازد.
|
||||||
|
const resources = useMemo(
|
||||||
|
() => query.data?.data?.resources ?? [],
|
||||||
|
[query.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
resources,
|
||||||
|
version: query.data?.data?.version ?? 1,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isError: query.isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** شکلِ کاملِ کاتالوگ با همهٔ اکشنها خاموش — مبنای فرمِ «هیچ دسترسی». */
|
||||||
|
export function blankPermissions(resources: CatalogResource[]): Record<string, Record<string, boolean>> {
|
||||||
|
const out: Record<string, Record<string, boolean>> = {};
|
||||||
|
for (const resource of resources) {
|
||||||
|
out[resource.key] = {};
|
||||||
|
for (const action of resource.actions) {
|
||||||
|
out[resource.key][action.key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مقدارِ ذخیرهشده را روی شکلِ کاتالوگ مینشاند.
|
||||||
|
*
|
||||||
|
* قرینهٔ PermissionCatalog::merge در بکاند: کلیدِ نبوده `false` میشود تا سوییچ
|
||||||
|
* از uncontrolled به controlled نپرد، و کلیدِ خارج از کاتالوگ نمایش داده نمیشود.
|
||||||
|
*/
|
||||||
|
export function alignPermissions(
|
||||||
|
stored: Record<string, Record<string, boolean>> | undefined | null,
|
||||||
|
resources: CatalogResource[],
|
||||||
|
): Record<string, Record<string, boolean>> {
|
||||||
|
const out: Record<string, Record<string, boolean>> = {};
|
||||||
|
for (const resource of resources) {
|
||||||
|
out[resource.key] = {};
|
||||||
|
for (const action of resource.actions) {
|
||||||
|
out[resource.key][action.key] = Boolean(stored?.[resource.key]?.[action.key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ function flatten(nodes: CatalogCategory[], depth = 0): Row[] {
|
|||||||
export default function CatalogCategoriesPage() {
|
export default function CatalogCategoriesPage() {
|
||||||
const { tree, loading, create, update, remove } = useCatalogCategories();
|
const { tree, loading, create, update, remove } = useCatalogCategories();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('services', 'update');
|
||||||
|
|
||||||
const [urlState, setUrlState] = useUrlState({ search: '' });
|
const [urlState, setUrlState] = useUrlState({ search: '' });
|
||||||
const [editing, setEditing] = useState<{ open: boolean; category: CatalogCategory | null }>({ open: false, category: null });
|
const [editing, setEditing] = useState<{ open: boolean; category: CatalogCategory | null }>({ open: false, category: null });
|
||||||
|
|||||||
@@ -50,8 +50,27 @@ const previousSecretary = {
|
|||||||
national_code: "9999999999",
|
national_code: "9999999999",
|
||||||
};
|
};
|
||||||
|
|
||||||
function mockData(rows = [activeSecretary, previousSecretary]) {
|
/** فهرست بخشها از GET /api/v1/permission-catalog میآید، نه از کد کامپوننت. */
|
||||||
|
const CATALOG = [
|
||||||
|
{
|
||||||
|
key: "appointments", label: "مدیریت نوبتها", clinic_only: false,
|
||||||
|
actions: [{ key: "view", label: "مشاهده نوبتها" }, { key: "create", label: "ایجاد نوبت" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "patients", label: "پرونده بیماران", clinic_only: false,
|
||||||
|
actions: [{ key: "view", label: "مشاهده بیماران" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "clinic_doctors", label: "مدیریت پزشکان کلینیک", clinic_only: true,
|
||||||
|
actions: [{ key: "view", label: "مشاهده پزشکان" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function mockData(rows = [activeSecretary, previousSecretary], catalog: unknown[] = CATALOG) {
|
||||||
get.mockImplementation((url: string) => {
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("permission-catalog")) {
|
||||||
|
return Promise.resolve({ success: true, data: { version: 1, resources: catalog } });
|
||||||
|
}
|
||||||
if (url.includes("/secretaries/")) return Promise.resolve({ success: true, data: rows });
|
if (url.includes("/secretaries/")) return Promise.resolve({ success: true, data: rows });
|
||||||
return Promise.resolve({ success: true, data: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
@@ -94,7 +113,7 @@ describe("MySecretariesPage", () => {
|
|||||||
expect(await screen.findByText("هنوز منشی فعالی اضافه نشده است")).toBeInTheDocument();
|
expect(await screen.findByText("هنوز منشی فعالی اضافه نشده است")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the add modal with permission sections based on existing pages", async () => {
|
it("opens the add modal with permission sections from the catalog", async () => {
|
||||||
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
|
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
|
||||||
await screen.findAllByText("سارا احمدی");
|
await screen.findAllByText("سارا احمدی");
|
||||||
|
|
||||||
@@ -102,9 +121,35 @@ describe("MySecretariesPage", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("اضافه کردن منشی جدید")).toBeInTheDocument();
|
expect(await screen.findByText("اضافه کردن منشی جدید")).toBeInTheDocument();
|
||||||
expect(screen.getByText("مجوزهای دسترسی")).toBeInTheDocument();
|
expect(screen.getByText("مجوزهای دسترسی")).toBeInTheDocument();
|
||||||
expect(screen.getByText("مدیریت نوبتها")).toBeInTheDocument();
|
expect(await screen.findByText("مدیریت نوبتها")).toBeInTheDocument();
|
||||||
expect(screen.getByText("پرونده بیماران")).toBeInTheDocument();
|
expect(screen.getByText("پرونده بیماران")).toBeInTheDocument();
|
||||||
expect(screen.getByText("مدیریت پرداختها")).toBeInTheDocument();
|
});
|
||||||
expect(screen.getByText("مدیریت بیمهها")).toBeInTheDocument();
|
|
||||||
|
/**
|
||||||
|
* همان تضمینِ «داینامیک بودن»: منبع تازه فقط به mock اضافه میشود و هیچ خطی از
|
||||||
|
* کد کامپوننت عوض نمیشود.
|
||||||
|
*/
|
||||||
|
it("shows a resource added to the catalog with no code change", async () => {
|
||||||
|
mockData(undefined, [
|
||||||
|
...CATALOG,
|
||||||
|
{ key: "brand_new_page", label: "صفحهٔ کاملاً تازه", clinic_only: false, actions: [{ key: "view", label: "مشاهده" }] },
|
||||||
|
]);
|
||||||
|
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
|
||||||
|
await screen.findAllByText("سارا احمدی");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("اضافه کردن منشی"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("صفحهٔ کاملاً تازه")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** منبعِ clinic_only برای پزشکِ مستقل (primaryRole=doctor) نباید دیده شود. */
|
||||||
|
it("hides clinic-only resources for a standalone doctor", async () => {
|
||||||
|
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
|
||||||
|
await screen.findAllByText("سارا احمدی");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("اضافه کردن منشی"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("مدیریت نوبتها")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("مدیریت پزشکان کلینیک")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { formatDate, digitsOnly, IRAN_MOBILE_RE, IRAN_NATIONAL_CODE_RE } from ".
|
|||||||
import { useSubscription } from "../hooks/useSubscription";
|
import { useSubscription } from "../hooks/useSubscription";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import type { Secretary, SecretaryPermissions } from "../types";
|
import type { Secretary, SecretaryPermissions } from "../types";
|
||||||
|
import { usePermissionCatalog, blankPermissions, alignPermissions } from "../hooks/usePermissionCatalog";
|
||||||
|
import type { CatalogResource } from "../hooks/usePermissionCatalog";
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
|
||||||
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
|
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
|
||||||
@@ -67,190 +69,18 @@ function Avatar({ name, size = 32 }: { name?: string; size?: number }) {
|
|||||||
|
|
||||||
// ── Permission sections (based on existing clinicpro pages) ─────────────────
|
// ── Permission sections (based on existing clinicpro pages) ─────────────────
|
||||||
|
|
||||||
const EMPTY_PERMISSIONS: SecretaryPermissions = {
|
|
||||||
appointments: { view: false, create: false, cancel: false, update_status: false },
|
|
||||||
patients: { view: false, create: false, update: false, delete: false },
|
|
||||||
payments: { view: false, create: false, update: false, delete: false },
|
|
||||||
insurances: { view: false, create: false, update: false, delete: false },
|
|
||||||
addresses: { view: false, create: false, update: false, delete: false },
|
|
||||||
clinic_info: { view: false, update: false },
|
|
||||||
inventory: { view: false, create: false, update: false, delete: false },
|
|
||||||
tags: { view: false, create: false, update: false, delete: false },
|
|
||||||
services: { view: false, create: false, update: false, delete: false },
|
|
||||||
staff: { view: false, create: false, update: false, delete: false },
|
|
||||||
discounts: { view: false, create: false, update: false, delete: false },
|
|
||||||
sms: { view: false, create: false, update: false, delete: false },
|
|
||||||
appointment_settings: { view: false, update: false },
|
|
||||||
clinic_doctors: { view: false, create: false, update: false, delete: false },
|
|
||||||
subscription: { view: false, create: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
type PermSection = keyof SecretaryPermissions;
|
|
||||||
|
|
||||||
const PERMISSION_SECTIONS: {
|
|
||||||
key: PermSection;
|
|
||||||
title: string;
|
|
||||||
/** فقط برای مالکِ کلینیک نمایش داده میشود (پزشک مستقل نه toggle نه منو). */
|
|
||||||
clinicOnly?: boolean;
|
|
||||||
items: { key: string; label: string }[];
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
key: "appointments",
|
|
||||||
title: "مدیریت نوبتها",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده نوبتها" },
|
|
||||||
{ key: "create", label: "ایجاد نوبت" },
|
|
||||||
{ key: "cancel", label: "لغو نوبت" },
|
|
||||||
{ key: "update_status", label: "تغییر وضعیت نوبت" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "patients",
|
|
||||||
title: "پرونده بیماران",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده بیماران" },
|
|
||||||
{ key: "create", label: "ایجاد بیمار" },
|
|
||||||
{ key: "update", label: "ویرایش بیمار" },
|
|
||||||
{ key: "delete", label: "حذف بیمار" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "payments",
|
|
||||||
title: "مدیریت پرداختها",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده پرداختها" },
|
|
||||||
{ key: "create", label: "ثبت پرداخت" },
|
|
||||||
{ key: "update", label: "ویرایش پرداخت" },
|
|
||||||
{ key: "delete", label: "حذف پرداخت" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "insurances",
|
|
||||||
title: "مدیریت بیمهها",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده بیمهها" },
|
|
||||||
{ key: "create", label: "ایجاد بیمه" },
|
|
||||||
{ key: "update", label: "ویرایش بیمه" },
|
|
||||||
{ key: "delete", label: "حذف بیمه" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "addresses",
|
|
||||||
title: "آدرسها",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده آدرسها" },
|
|
||||||
{ key: "create", label: "ایجاد آدرس" },
|
|
||||||
{ key: "update", label: "ویرایش آدرس" },
|
|
||||||
{ key: "delete", label: "حذف آدرس" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "clinic_info",
|
|
||||||
title: "اطلاعات کلینیک",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده اطلاعات" },
|
|
||||||
{ key: "update", label: "ویرایش اطلاعات" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "inventory",
|
|
||||||
title: "انبار",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده انبار" },
|
|
||||||
{ key: "create", label: "ایجاد کالا/بسته" },
|
|
||||||
{ key: "update", label: "ویرایش انبار" },
|
|
||||||
{ key: "delete", label: "حذف از انبار" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "tags",
|
|
||||||
title: "تگها",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده تگها" },
|
|
||||||
{ key: "create", label: "ایجاد تگ" },
|
|
||||||
{ key: "update", label: "ویرایش تگ" },
|
|
||||||
{ key: "delete", label: "حذف تگ" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "services",
|
|
||||||
title: "خدمات و تعرفهها",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده خدمات" },
|
|
||||||
{ key: "create", label: "ایجاد خدمت" },
|
|
||||||
{ key: "update", label: "ویرایش خدمت" },
|
|
||||||
{ key: "delete", label: "حذف خدمت" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "staff",
|
|
||||||
title: "پرسنل",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده پرسنل" },
|
|
||||||
{ key: "create", label: "افزودن پرسنل" },
|
|
||||||
{ key: "update", label: "ویرایش پرسنل" },
|
|
||||||
{ key: "delete", label: "حذف پرسنل" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "discounts",
|
|
||||||
title: "تخفیفها",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده تخفیفها" },
|
|
||||||
{ key: "create", label: "ایجاد تخفیف" },
|
|
||||||
{ key: "update", label: "ویرایش تخفیف" },
|
|
||||||
{ key: "delete", label: "حذف تخفیف" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "sms",
|
|
||||||
title: "پیامکها",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده پیامک/کیف پول" },
|
|
||||||
{ key: "create", label: "شارژ/ارسال" },
|
|
||||||
{ key: "update", label: "ویرایش تنظیمات" },
|
|
||||||
{ key: "delete", label: "حذف" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "appointment_settings",
|
|
||||||
title: "تنظیمات نوبتدهی",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده تنظیمات" },
|
|
||||||
{ key: "update", label: "ویرایش تنظیمات" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "clinic_doctors",
|
|
||||||
title: "مدیریت پزشکان کلینیک",
|
|
||||||
clinicOnly: true,
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده پزشکان" },
|
|
||||||
{ key: "create", label: "افزودن پزشک" },
|
|
||||||
{ key: "update", label: "ویرایش پزشک" },
|
|
||||||
{ key: "delete", label: "حذف پزشک" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "subscription",
|
|
||||||
title: "خرید اشتراک",
|
|
||||||
items: [
|
|
||||||
{ key: "view", label: "مشاهده اشتراک" },
|
|
||||||
{ key: "create", label: "خرید/فعالسازی اشتراک" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function PermissionAccordions({
|
function PermissionAccordions({
|
||||||
permissions,
|
permissions,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
isClinic,
|
isClinic,
|
||||||
|
resources,
|
||||||
}: {
|
}: {
|
||||||
permissions: SecretaryPermissions;
|
permissions: SecretaryPermissions;
|
||||||
onChange: (section: PermSection, item: string, value: boolean) => void;
|
onChange: (section: string, item: string, value: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isClinic: boolean;
|
isClinic: boolean;
|
||||||
|
resources: CatalogResource[];
|
||||||
}) {
|
}) {
|
||||||
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||||
new Set(["appointments", "patients"]),
|
new Set(["appointments", "patients"]),
|
||||||
@@ -264,8 +94,8 @@ function PermissionAccordions({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// منابع clinicOnly (مثل مدیریت پزشکان کلینیک) فقط برای مالکِ کلینیک دیده میشوند.
|
// منابع clinic_only (مثل مدیریت پزشکان کلینیک) فقط برای مالکِ کلینیک دیده میشوند.
|
||||||
const sections = PERMISSION_SECTIONS.filter((s) => !s.clinicOnly || isClinic);
|
const sections = resources.filter((r) => !r.clinic_only || isClinic);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -275,7 +105,7 @@ function PermissionAccordions({
|
|||||||
<div className="flex flex-col gap-[8px] w-full items-stretch">
|
<div className="flex flex-col gap-[8px] w-full items-stretch">
|
||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
const open = openKeys.has(section.key);
|
const open = openKeys.has(section.key);
|
||||||
const sectionPerm = permissions[section.key] as Record<string, boolean>;
|
const sectionPerm = permissions[section.key];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={section.key}
|
key={section.key}
|
||||||
@@ -287,14 +117,14 @@ function PermissionAccordions({
|
|||||||
className="w-full flex items-center justify-between px-[16px] min-h-[64px] cursor-pointer"
|
className="w-full flex items-center justify-between px-[16px] min-h-[64px] cursor-pointer"
|
||||||
>
|
>
|
||||||
<p className="text-[var(--text)] text-[14px] font-medium">
|
<p className="text-[var(--text)] text-[14px] font-medium">
|
||||||
{section.title}
|
{section.label}
|
||||||
</p>
|
</p>
|
||||||
<ChevronDown open={open} />
|
<ChevronDown open={open} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="px-[16px] pt-[8px] pb-[16px]">
|
<div className="px-[16px] pt-[8px] pb-[16px]">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-[16px] gap-y-0">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-[16px] gap-y-0">
|
||||||
{section.items.map((item) => (
|
{section.actions.map((item) => (
|
||||||
<label
|
<label
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className="flex items-center gap-[11px] cursor-pointer py-[9px] min-h-[42px]"
|
className="flex items-center gap-[11px] cursor-pointer py-[9px] min-h-[42px]"
|
||||||
@@ -403,21 +233,24 @@ function SecretaryModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (form: FormState, doctorUuids: string[]) => void;
|
onSubmit: (form: FormState, doctorUuids: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const catalog = usePermissionCatalog();
|
||||||
const [form, setForm] = useState<FormState>({
|
const [form, setForm] = useState<FormState>({
|
||||||
name: "",
|
name: "",
|
||||||
family: "",
|
family: "",
|
||||||
telephone: "",
|
telephone: "",
|
||||||
national_code: "",
|
national_code: "",
|
||||||
address: "",
|
address: "",
|
||||||
permission: EMPTY_PERMISSIONS,
|
permission: {},
|
||||||
});
|
});
|
||||||
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
|
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
|
||||||
|
|
||||||
// انتخاب چند پزشک برای منشیِ کلینیک، هم در افزودن و هم در ویرایش
|
// انتخاب چند پزشک برای منشیِ کلینیک، هم در افزودن و هم در ویرایش
|
||||||
const showDoctorPicker = isClinic && mode !== "view";
|
const showDoctorPicker = isClinic && mode !== "view";
|
||||||
|
|
||||||
|
// شکلِ فرم را کاتالوگ میدهد؛ تا نیامده مقداردهی نمیشود، وگرنه سوییچها از
|
||||||
|
// uncontrolled به controlled میپرند و مقدارِ ذخیرهشده پاک میشود.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open || catalog.isLoading) return;
|
||||||
setDoctorUuids([]);
|
setDoctorUuids([]);
|
||||||
if ((mode === "edit" || mode === "view") && data) {
|
if ((mode === "edit" || mode === "view") && data) {
|
||||||
const secretary = data.primary;
|
const secretary = data.primary;
|
||||||
@@ -429,7 +262,7 @@ function SecretaryModal({
|
|||||||
telephone: secretary.mobile_number ?? "",
|
telephone: secretary.mobile_number ?? "",
|
||||||
national_code: secretary.national_code ?? "",
|
national_code: secretary.national_code ?? "",
|
||||||
address: secretary.address ?? "",
|
address: secretary.address ?? "",
|
||||||
permission: { ...EMPTY_PERMISSIONS, ...(secretary.permissions ?? {}) },
|
permission: alignPermissions(secretary.permissions, catalog.resources),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
@@ -438,10 +271,10 @@ function SecretaryModal({
|
|||||||
telephone: "",
|
telephone: "",
|
||||||
national_code: "",
|
national_code: "",
|
||||||
address: "",
|
address: "",
|
||||||
permission: EMPTY_PERMISSIONS,
|
permission: blankPermissions(catalog.resources),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [open, mode, data]);
|
}, [open, mode, data, catalog.isLoading, catalog.resources]);
|
||||||
|
|
||||||
const disabled = mode === "view";
|
const disabled = mode === "view";
|
||||||
const title =
|
const title =
|
||||||
@@ -454,12 +287,12 @@ function SecretaryModal({
|
|||||||
const setField = (field: keyof FormState, value: string) =>
|
const setField = (field: keyof FormState, value: string) =>
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
const setPermission = (section: PermSection, item: string, value: boolean) =>
|
const setPermission = (section: string, item: string, value: boolean) =>
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
permission: {
|
permission: {
|
||||||
...prev.permission,
|
...prev.permission,
|
||||||
[section]: { ...(prev.permission[section] as Record<string, boolean>), [item]: value },
|
[section]: { ...prev.permission[section], [item]: value },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -557,7 +390,13 @@ function SecretaryModal({
|
|||||||
<DefaultTextField placeholder="آدرس" value={form.address} onChange={(v) => setField("address", v)} disabled={disabled} multiline rows={2} />
|
<DefaultTextField placeholder="آدرس" value={form.address} onChange={(v) => setField("address", v)} disabled={disabled} multiline rows={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PermissionAccordions permissions={form.permission} onChange={setPermission} disabled={disabled} isClinic={isClinic} />
|
{catalog.isLoading ? (
|
||||||
|
<p className="text-[var(--text-3)] text-[14px]">در حال بارگذاری فهرست دسترسیها...</p>
|
||||||
|
) : catalog.isError ? (
|
||||||
|
<p className="text-[var(--text-3)] text-[14px]">فهرست دسترسیها خوانده نشد. صفحه را دوباره باز کنید.</p>
|
||||||
|
) : (
|
||||||
|
<PermissionAccordions permissions={form.permission} onChange={setPermission} disabled={disabled} isClinic={isClinic} resources={catalog.resources} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function ResourceDetailPage() {
|
|||||||
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
|
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
|
||||||
const { items: serviceOptions } = useAllServiceItems();
|
const { items: serviceOptions } = useAllServiceItems();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('resources', 'update');
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
|
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function ResourcePoolsPage() {
|
|||||||
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
|
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
|
||||||
const { types } = useResourceTypes();
|
const { types } = useResourceTypes();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('resources', 'update');
|
||||||
|
|
||||||
const [urlState, setUrlState] = useUrlState({ search: '' });
|
const [urlState, setUrlState] = useUrlState({ search: '' });
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
|||||||
export default function ResourceTypesPage() {
|
export default function ResourceTypesPage() {
|
||||||
const { types, loading, create, update, remove } = useResourceTypes();
|
const { types, loading, create, update, remove } = useResourceTypes();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('resources', 'update');
|
||||||
|
|
||||||
const [urlState, setUrlState] = useUrlState({ search: '' });
|
const [urlState, setUrlState] = useUrlState({ search: '' });
|
||||||
const [editing, setEditing] = useState<{ open: boolean; type: ResourceType | null }>({ open: false, type: null });
|
const [editing, setEditing] = useState<{ open: boolean; type: ResourceType | null }>({ open: false, type: null });
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function ResourcesPage() {
|
|||||||
const { types } = useResourceTypes();
|
const { types } = useResourceTypes();
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('resources', 'update');
|
||||||
|
|
||||||
const { resources, loading, create } = useResources({
|
const { resources, loading, create } = useResources({
|
||||||
type_uuid: urlState.type || undefined,
|
type_uuid: urlState.type || undefined,
|
||||||
|
|||||||
@@ -14,176 +14,30 @@ import Pagination from '../components/ui/Pagination';
|
|||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
import { usePermissionCatalog, alignPermissions } from '../hooks/usePermissionCatalog';
|
||||||
|
import type { CatalogResource } from '../hooks/usePermissionCatalog';
|
||||||
|
|
||||||
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
||||||
appointments: { view: true, create: false, cancel: false, update_status: false },
|
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
||||||
patients: { view: true, create: false, update: false, delete: false },
|
|
||||||
payments: { view: true, create: false, update: false, delete: false },
|
|
||||||
insurances: { view: true, create: false, update: false, delete: false },
|
|
||||||
addresses: { view: true, create: false, update: false, delete: false },
|
|
||||||
clinic_info: { view: true, update: false },
|
|
||||||
inventory: { view: false, create: false, update: false, delete: false },
|
|
||||||
tags: { view: false, create: false, update: false, delete: false },
|
|
||||||
services: { view: false, create: false, update: false, delete: false },
|
|
||||||
staff: { view: false, create: false, update: false, delete: false },
|
|
||||||
discounts: { view: false, create: false, update: false, delete: false },
|
|
||||||
sms: { view: false, create: false, update: false, delete: false },
|
|
||||||
appointment_settings: { view: false, update: false },
|
|
||||||
clinic_doctors: { view: false, create: false, update: false, delete: false },
|
|
||||||
subscription: { view: false, create: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
type PermSection = keyof SecretaryPermissions;
|
|
||||||
|
|
||||||
const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: string; label: string }[] }> = {
|
|
||||||
appointments: {
|
|
||||||
label: 'نوبتها',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'cancel', label: 'لغو' },
|
|
||||||
{ key: 'update_status', label: 'تغییر وضعیت' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
patients: {
|
|
||||||
label: 'پرونده بیماران',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
payments: {
|
|
||||||
label: 'پرداختها',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
addresses: {
|
|
||||||
label: 'آدرسها',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
clinic_info: {
|
|
||||||
label: 'اطلاعات کلینیک',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
insurances: {
|
|
||||||
label: 'بیمهها',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
inventory: {
|
|
||||||
label: 'انبار',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
label: 'تگها',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
label: 'خدمات و تعرفهها',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
staff: {
|
|
||||||
label: 'پرسنل',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
discounts: {
|
|
||||||
label: 'تخفیفها',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sms: {
|
|
||||||
label: 'پیامکها',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'شارژ/ارسال' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
appointment_settings: {
|
|
||||||
label: 'تنظیمات نوبتدهی',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
clinic_doctors: {
|
|
||||||
label: 'مدیریت پزشکان کلینیک',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'ایجاد' },
|
|
||||||
{ key: 'update', label: 'ویرایش' },
|
|
||||||
{ key: 'delete', label: 'حذف' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
subscription: {
|
|
||||||
label: 'خرید اشتراک',
|
|
||||||
actions: [
|
|
||||||
{ key: 'view', label: 'مشاهده' },
|
|
||||||
{ key: 'create', label: 'خرید' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function PermissionsMatrix({
|
function PermissionsMatrix({
|
||||||
permissions,
|
permissions,
|
||||||
onChange,
|
onChange,
|
||||||
|
resources,
|
||||||
}: {
|
}: {
|
||||||
permissions: SecretaryPermissions;
|
permissions: SecretaryPermissions;
|
||||||
onChange: (p: SecretaryPermissions) => void;
|
onChange: (p: SecretaryPermissions) => void;
|
||||||
|
resources: CatalogResource[];
|
||||||
}) {
|
}) {
|
||||||
const toggle = (section: PermSection, action: string) => {
|
const toggle = (section: string, action: string) => {
|
||||||
const current = (permissions[section] as Record<string, boolean>)[action];
|
|
||||||
onChange({
|
onChange({
|
||||||
...permissions,
|
...permissions,
|
||||||
[section]: { ...(permissions[section] as Record<string, boolean>), [action]: !current },
|
[section]: { ...permissions[section], [action]: !permissions[section]?.[action] },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const allActions = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
const allActions = ACTION_COLUMNS;
|
||||||
const actionHeaders = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
const actionHeaders = ACTION_HEADERS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
@@ -197,24 +51,22 @@ function PermissionsMatrix({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section) => {
|
{resources.map((resource) => {
|
||||||
const config = PERMISSION_LABELS[section];
|
const available = new Set(resource.actions.map((a) => a.key));
|
||||||
const sectionPerms = permissions[section] as Record<string, boolean>;
|
|
||||||
return (
|
return (
|
||||||
<tr key={section}>
|
<tr key={resource.key}>
|
||||||
<td><b>{config.label}</b></td>
|
<td><b>{resource.label}</b></td>
|
||||||
{allActions.map((action) => {
|
{allActions.map((action) => {
|
||||||
const actionConfig = config.actions.find((a) => a.key === action);
|
if (!available.has(action)) {
|
||||||
if (!actionConfig) {
|
|
||||||
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<td key={action}>
|
<td key={action}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={sectionPerms[action] ?? false}
|
checked={permissions[resource.key]?.[action] ?? false}
|
||||||
onChange={() => toggle(section, action)}
|
onChange={() => toggle(resource.key, action)}
|
||||||
ariaLabel={`${section} — ${action}`}
|
ariaLabel={`${resource.label} — ${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -238,7 +90,8 @@ export default function SecretariesPage() {
|
|||||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||||
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
||||||
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
const [editPerms, setEditPerms] = useState<SecretaryPermissions>({});
|
||||||
|
const catalog = usePermissionCatalog();
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
||||||
const limit = 15;
|
const limit = 15;
|
||||||
|
|
||||||
@@ -274,7 +127,8 @@ export default function SecretariesPage() {
|
|||||||
|
|
||||||
const openEdit = (s: Secretary) => {
|
const openEdit = (s: Secretary) => {
|
||||||
setEditTarget(s);
|
setEditTarget(s);
|
||||||
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
|
// شکل را کاتالوگ میدهد؛ منبعی که در JSONِ ذخیرهشده نیست خاموش نمایش داده میشود.
|
||||||
|
setEditPerms(alignPermissions(s.permissions, catalog.resources));
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: Column<Secretary>[] = [
|
const columns: Column<Secretary>[] = [
|
||||||
@@ -347,14 +201,20 @@ export default function SecretariesPage() {
|
|||||||
<button onClick={() => setEditTarget(null)} className="btn ghost sm">لغو</button>
|
<button onClick={() => setEditTarget(null)} className="btn ghost sm">لغو</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })}
|
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })}
|
||||||
disabled={updatePermsMutation.isPending}
|
disabled={updatePermsMutation.isPending || catalog.isLoading}
|
||||||
className="btn primary sm">
|
className="btn primary sm">
|
||||||
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} />
|
{catalog.isLoading ? (
|
||||||
|
<p className="muted">در حال بارگذاری فهرست دسترسیها...</p>
|
||||||
|
) : catalog.isError ? (
|
||||||
|
<p className="muted">فهرست دسترسیها خوانده نشد. صفحه را دوباره باز کنید.</p>
|
||||||
|
) : (
|
||||||
|
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} resources={catalog.resources} />
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
|||||||
export default function SkillsPage() {
|
export default function SkillsPage() {
|
||||||
const { skills, loading, create, update, remove } = useSkills();
|
const { skills, loading, create, update, remove } = useSkills();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('resources', 'update');
|
||||||
|
|
||||||
const [urlState, setUrlState] = useUrlState({ search: '' });
|
const [urlState, setUrlState] = useUrlState({ search: '' });
|
||||||
const [editing, setEditing] = useState<{ open: boolean; skill: Skill | null }>({ open: false, skill: null });
|
const [editing, setEditing] = useState<{ open: boolean; skill: Skill | null }>({ open: false, skill: null });
|
||||||
|
|||||||
@@ -512,92 +512,14 @@ export interface Secretary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecretaryPermissions {
|
/**
|
||||||
appointments: {
|
* مجوزهای یک رابطه — نقشهٔ باز، نه interface با فیلدهای ثابت.
|
||||||
view: boolean;
|
*
|
||||||
create: boolean;
|
* فهرستِ منابع از GET /api/v1/permission-catalog میآید و ممکن است با افزودن
|
||||||
cancel: boolean;
|
* صفحهٔ تازه رشد کند؛ تایپِ فیلد-به-فیلد یعنی هر منبع جدید یک خطای کامپایل.
|
||||||
update_status: boolean;
|
* منبع/اکشنِ ناشناخته با ?? false خوانده میشود.
|
||||||
};
|
*/
|
||||||
patients: {
|
export type SecretaryPermissions = Record<string, Record<string, boolean>>;
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
payments: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
insurances: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
addresses: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
clinic_info: {
|
|
||||||
view: boolean;
|
|
||||||
update: boolean;
|
|
||||||
};
|
|
||||||
inventory: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
tags: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
services: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
staff: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
discounts: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
sms: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
appointment_settings: {
|
|
||||||
view: boolean;
|
|
||||||
update: boolean;
|
|
||||||
};
|
|
||||||
clinic_doctors: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
subscription: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Specialty {
|
export interface Specialty {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ class HolidayController extends BaseController
|
|||||||
{
|
{
|
||||||
use ResourcePermissionTrait;
|
use ResourcePermissionTrait;
|
||||||
|
|
||||||
|
/** تعطیلات زیرمجموعهٔ تنظیمات نوبتدهی است، نه فهرست منابع. */
|
||||||
|
private function permissionResource(): string
|
||||||
|
{
|
||||||
|
return 'appointment_settings';
|
||||||
|
}
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly HolidayService $holidays,
|
private readonly HolidayService $holidays,
|
||||||
private readonly ResourceContext $context,
|
private readonly ResourceContext $context,
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ use App\Shared\Exception\AppException;
|
|||||||
use Symfony\Contracts\Service\Attribute\Required;
|
use Symfony\Contracts\Service\Attribute\Required;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* گِیتِ مشترک چهار کنترلر این دامنه.
|
* گِیتِ مشترک کنترلرهای این دامنه.
|
||||||
*
|
*
|
||||||
* مجوز `appointment_settings` بازاستفاده میشود و مجوز تازهای ساخته نمیشود: منابع
|
* منابع مجوزِ خودشان را دارند (`resources`). پیش از رجیستریِ واحد، این کنترلرها
|
||||||
* بخشی از پیکربندی نوبتدهیاند و افزودن یک کلید تازه یعنی یک ستون تازه در جدول
|
* `appointment_settings` را قرض میگرفتند چون افزودن یک کلید تازه یعنی ویرایش
|
||||||
* مجوزهای هر منشی و هر پزشکِ عضو، بدون اینکه کسی خواسته باشد آنها را جدا کند.
|
* دستیِ شش فهرست؛ حالا یک ردیف در `PermissionCatalog` کافی است.
|
||||||
|
*
|
||||||
|
* تعطیلات از این قاعده مستثناست و `appointment_settings` میماند — صفحهاش
|
||||||
|
* زیرمجموعهٔ تنظیمات نوبتدهی است، نه فهرست دستگاهها.
|
||||||
*/
|
*/
|
||||||
trait ResourcePermissionTrait
|
trait ResourcePermissionTrait
|
||||||
{
|
{
|
||||||
@@ -30,11 +33,19 @@ trait ResourcePermissionTrait
|
|||||||
$this->clinicDoctorAccess = $clinicDoctorAccess;
|
$this->clinicDoctorAccess = $clinicDoctorAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param 'view'|'update' $action */
|
/** کنترلری که منبعِ دیگری را گِیت میکند این را بازنویسی میکند. */
|
||||||
|
private function permissionResource(): string
|
||||||
|
{
|
||||||
|
return 'resources';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param 'view'|'create'|'update'|'delete' $action */
|
||||||
private function denyUnlessGranted(User $user, string $action): void
|
private function denyUnlessGranted(User $user, string $action): void
|
||||||
{
|
{
|
||||||
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action);
|
$resource = $this->permissionResource();
|
||||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action);
|
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
|
||||||
|
$this->clinicDoctorAccess->denyUnlessGranted($user, $resource, $action);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,9 +59,11 @@ trait ResourcePermissionTrait
|
|||||||
*/
|
*/
|
||||||
private function denyUnlessGrantedForBooking(User $user): void
|
private function denyUnlessGrantedForBooking(User $user): void
|
||||||
{
|
{
|
||||||
|
$resource = $this->permissionResource();
|
||||||
|
|
||||||
$allowed =
|
$allowed =
|
||||||
($this->secretaryAccess->canOrNonSecretary($user, 'appointment_settings', 'view')
|
($this->secretaryAccess->canOrNonSecretary($user, $resource, 'view')
|
||||||
&& $this->clinicDoctorAccess->canOrNonMember($user, 'appointment_settings', 'view'))
|
&& $this->clinicDoctorAccess->canOrNonMember($user, $resource, 'view'))
|
||||||
|| ($this->secretaryAccess->canOrNonSecretary($user, 'appointments', 'view')
|
|| ($this->secretaryAccess->canOrNonSecretary($user, 'appointments', 'view')
|
||||||
&& $this->clinicDoctorAccess->canOrNonMember($user, 'appointments', 'view'));
|
&& $this->clinicDoctorAccess->canOrNonMember($user, 'appointments', 'view'));
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ use Symfony\Component\HttpFoundation\JsonResponse;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||||
|
use App\Secretary\Security\SecretaryAccessChecker;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[OA\Tag(name: 'Treatment')]
|
#[OA\Tag(name: 'Treatment')]
|
||||||
@@ -40,11 +42,28 @@ class TreatmentCaseController extends BaseController
|
|||||||
private readonly TreatmentCaseEditor $editor,
|
private readonly TreatmentCaseEditor $editor,
|
||||||
private readonly TreatmentPlanProjector $planner,
|
private readonly TreatmentPlanProjector $planner,
|
||||||
private readonly \App\UserProfile\Repository\UserProfileRepository $profiles,
|
private readonly \App\UserProfile\Repository\UserProfileRepository $profiles,
|
||||||
|
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||||
|
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تا پیش از رجیستریِ واحد، این کنترلر هیچ گِیت مجوزی نداشت و صفحهاش در پنل
|
||||||
|
* روی `appointments.view` سوار بود — یعنی هر منشیای که اجازهٔ دیدن نوبت داشت
|
||||||
|
* پروندهٔ درمان را هم میدید.
|
||||||
|
*
|
||||||
|
* @param 'view'|'update' $action
|
||||||
|
*/
|
||||||
|
private function denyUnlessGranted(User $user, string $action): void
|
||||||
|
{
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'treatment', $action);
|
||||||
|
$this->clinicDoctorAccess->denyUnlessGranted($user, 'treatment', $action);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
|
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
|
||||||
public function list(#[CurrentUser] User $user, Request $request): JsonResponse
|
public function list(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->denyUnlessGranted($user, 'view');
|
||||||
|
|
||||||
[$entityType, $entityId] = $this->branches->pair($user);
|
[$entityType, $entityId] = $this->branches->pair($user);
|
||||||
|
|
||||||
$status = $request->query->get('status');
|
$status = $request->query->get('status');
|
||||||
@@ -88,6 +107,8 @@ class TreatmentCaseController extends BaseController
|
|||||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
|
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
|
||||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->denyUnlessGranted($user, 'view');
|
||||||
|
|
||||||
$case = $this->requireCase($user, $uuid);
|
$case = $this->requireCase($user, $uuid);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +132,8 @@ class TreatmentCaseController extends BaseController
|
|||||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
|
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
|
||||||
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->denyUnlessGranted($user, 'update');
|
||||||
|
|
||||||
$data = json_decode($request->getContent(), true);
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
@@ -131,6 +154,8 @@ class TreatmentCaseController extends BaseController
|
|||||||
#[Route('/api/v1/treatment-sessions/unbooked', name: 'treatment_sessions_unbooked', methods: ['GET'])]
|
#[Route('/api/v1/treatment-sessions/unbooked', name: 'treatment_sessions_unbooked', methods: ['GET'])]
|
||||||
public function unbooked(#[CurrentUser] User $user, Request $request): JsonResponse
|
public function unbooked(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->denyUnlessGranted($user, 'view');
|
||||||
|
|
||||||
[$entityType, $entityId] = $this->branches->pair($user);
|
[$entityType, $entityId] = $this->branches->pair($user);
|
||||||
|
|
||||||
$withinDays = (int) $request->query->get('within_days', 7);
|
$withinDays = (int) $request->query->get('within_days', 7);
|
||||||
@@ -157,6 +182,8 @@ class TreatmentCaseController extends BaseController
|
|||||||
#[Route('/api/v1/treatment-case/{uuid}/plan', name: 'treatment_case_plan', methods: ['GET'])]
|
#[Route('/api/v1/treatment-case/{uuid}/plan', name: 'treatment_case_plan', methods: ['GET'])]
|
||||||
public function plan(#[CurrentUser] User $user, string $uuid): JsonResponse
|
public function plan(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->denyUnlessGranted($user, 'view');
|
||||||
|
|
||||||
$case = $this->requireCase($user, $uuid);
|
$case = $this->requireCase($user, $uuid);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,6 +230,8 @@ class TreatmentCaseController extends BaseController
|
|||||||
#[Route('/api/v1/treatment-session/{uuid}', name: 'treatment_session_show', methods: ['GET'])]
|
#[Route('/api/v1/treatment-session/{uuid}', name: 'treatment_session_show', methods: ['GET'])]
|
||||||
public function showSession(#[CurrentUser] User $user, string $uuid): JsonResponse
|
public function showSession(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->denyUnlessGranted($user, 'view');
|
||||||
|
|
||||||
$session = $this->requireSession($user, $uuid);
|
$session = $this->requireSession($user, $uuid);
|
||||||
$case = $session->getTreatmentCase();
|
$case = $session->getTreatmentCase();
|
||||||
|
|
||||||
@@ -225,6 +254,8 @@ class TreatmentCaseController extends BaseController
|
|||||||
#[Route('/api/v1/treatment-session/{uuid}/slot-suggestions', name: 'treatment_session_slots', methods: ['GET'])]
|
#[Route('/api/v1/treatment-session/{uuid}/slot-suggestions', name: 'treatment_session_slots', methods: ['GET'])]
|
||||||
public function slotSuggestions(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
public function slotSuggestions(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->denyUnlessGranted($user, 'view');
|
||||||
|
|
||||||
$session = $this->requireSession($user, $uuid);
|
$session = $this->requireSession($user, $uuid);
|
||||||
|
|
||||||
$resourceUuid = $request->query->get('resource_uuid');
|
$resourceUuid = $request->query->get('resource_uuid');
|
||||||
|
|||||||
Reference in New Issue
Block a user