diff --git a/assets/admin/components/layout/SettingsLayout.test.tsx b/assets/admin/components/layout/SettingsLayout.test.tsx index 41a06c4e..a8b70ba7 100644 --- a/assets/admin/components/layout/SettingsLayout.test.tsx +++ b/assets/admin/components/layout/SettingsLayout.test.tsx @@ -36,15 +36,22 @@ describe('SettingsLayout', () => { /** * سایدبار و فهرست موبایل یک منبع دارند؛ آیتمی که به منو اضافه می‌شود باید در هر دو - * دیده شود. قبلاً دو کپی بود و «منابع»/«دسته‌بندی‌ها» فقط در موبایل ظاهر شدند. + * دیده شود. قبلاً دو کپی بود و «دسته‌بندی‌ها» فقط در موبایل ظاهر شد. */ it('shows the resource-first entries in the desktop sidebar too', () => { useAuthStore.setState({ primaryRole: 'clinic' }); - renderWithProviders(
); + renderWithProviders(
); - expect(screen.getByText('منابع').closest('a')).toHaveAttribute('href', '/admin/resources'); expect(screen.getByText('دسته‌بندی‌ها').closest('a')).toHaveAttribute('href', '/admin/service-categories'); - expect(screen.getByText('منابع').closest('a')).toHaveAttribute('aria-current', 'page'); + expect(screen.getByText('دسته‌بندی‌ها').closest('a')).toHaveAttribute('aria-current', 'page'); + }); + + /** «منابع» کارِ روزمره است و به سایدبار اصلی رفت؛ نباید در منوی تنظیمات تکرار شود. */ + it('does not list منابع in the settings menu any more', () => { + useAuthStore.setState({ primaryRole: 'clinic' }); + renderWithProviders(
); + + expect(screen.queryByText('منابع')).not.toBeInTheDocument(); }); it('role-gates the desktop sidebar: a doctor does not see the clinic-doctors tab', () => { diff --git a/assets/admin/components/layout/Sidebar.test.tsx b/assets/admin/components/layout/Sidebar.test.tsx index 682df639..db51d3fb 100644 --- a/assets/admin/components/layout/Sidebar.test.tsx +++ b/assets/admin/components/layout/Sidebar.test.tsx @@ -141,6 +141,22 @@ describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () => expect(screen.queryByText("تنظیمات نوبت‌دهی")).not.toBeInTheDocument(); expect(screen.queryByText("پزشکان کلینیک")).not.toBeInTheDocument(); }); + + /** + * «منابع» از منوی تنظیمات به سایدبار اصلی منتقل شد. گِیتش عوض نشده — همان + * `appointment_settings.view` که در منوی تنظیمات داشت. + */ + it("«منابع» در سایدبار اصلی می‌آید و به مجوز appointment_settings گِیت است", () => { + setSecretary({ appointment_settings: { view: true } }); + renderWithProviders(, { route: "/admin/dashboard" }); + expect(screen.getByText("منابع").closest("a")).toHaveAttribute("href", "/admin/resources"); + }); + + it("منشیِ بدون مجوز، «منابع» را نمی‌بیند", () => { + setSecretary({ appointments: { view: true } }); + renderWithProviders(, { route: "/admin/dashboard" }); + expect(screen.queryByText("منابع")).not.toBeInTheDocument(); + }); }); describe("Sidebar — گِیت منوی پزشکِ عضوِ کلینیک بر اساس مجوز", () => { diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 19feb147..c7d7cb92 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -11,6 +11,7 @@ import { ClipboardDocumentCheckIcon, Cog6ToothIcon, CreditCardIcon, + CubeIcon, CurrencyDollarIcon, DevicePhoneMobileIcon, DocumentTextIcon, @@ -102,6 +103,9 @@ function buildSections( if (can("services", "view")) { items.push({ to: "/admin/clinic-services", icon: WrenchScrewdriverIcon, label: "سرویس ها" }); } + if (can("appointment_settings", "view")) { + items.push({ to: "/admin/resources", icon: CubeIcon, label: "منابع" }); + } if (can("inventory", "view")) { items.push({ to: "/admin/inventory", icon: ArchiveBoxIcon, label: "انبارداری" }); } @@ -306,6 +310,11 @@ function buildSections( icon: WrenchScrewdriverIcon, label: "سرویس ها", }, + { + to: "/admin/resources", + icon: CubeIcon, + label: "منابع", + }, ], }, { @@ -381,6 +390,11 @@ function buildSections( icon: WrenchScrewdriverIcon, label: "سرویس ها", }, + { + to: "/admin/resources", + icon: CubeIcon, + label: "منابع", + }, ], }, { @@ -448,6 +462,13 @@ function buildSections( label: "سرویس ها", }); } + if (can("appointment_settings", "view")) { + items.push({ + to: "/admin/resources", + icon: CubeIcon, + label: "منابع", + }); + } if (can("inventory", "view")) { items.push({ to: "/admin/inventory", diff --git a/assets/admin/components/layout/settingsMenu.ts b/assets/admin/components/layout/settingsMenu.ts index 403f54a5..41219002 100644 --- a/assets/admin/components/layout/settingsMenu.ts +++ b/assets/admin/components/layout/settingsMenu.ts @@ -3,7 +3,7 @@ import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, BanknotesIcon, UsersIcon, ShieldCheckIcon, TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, - CubeIcon, RectangleStackIcon, + RectangleStackIcon, } from '@heroicons/react/24/outline'; /** @@ -34,7 +34,8 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] }, { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, - { key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + // «منابع» به سایدبار اصلی («مدیریت») منتقل شد — کارِ روزمره است، نه تنظیمات. + // گِیتش آنجا همین است: `appointment_settings.view` در هر چهار نقشی که اینجا می‌دیدندش. { key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, diff --git a/assets/admin/pages/ResourceDetailPage.tsx b/assets/admin/pages/ResourceDetailPage.tsx index e11e87a8..561deeb9 100644 --- a/assets/admin/pages/ResourceDetailPage.tsx +++ b/assets/admin/pages/ResourceDetailPage.tsx @@ -9,7 +9,6 @@ import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsP import ResourceServicesPanel from '../components/resources/ResourceServicesPanel'; import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel'; import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel'; -import SettingsLayout from '../components/layout/SettingsLayout'; import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; import { useAddresses } from '../hooks/useAddresses'; @@ -61,16 +60,13 @@ export default function ResourceDetailPage() { // یک لحظه می‌پرد و دوباره برمی‌گردد. if (loading || !resource) { return ( -
{loading ? 'در حال بارگذاری...' : 'منبع یافت نشد.'}
-
); } return ( -
-
); } diff --git a/assets/admin/pages/ResourcePoolsPage.tsx b/assets/admin/pages/ResourcePoolsPage.tsx index 8eb2be7c..626daf17 100644 --- a/assets/admin/pages/ResourcePoolsPage.tsx +++ b/assets/admin/pages/ResourcePoolsPage.tsx @@ -11,7 +11,6 @@ import { usePermissions } from '../hooks/usePermissions'; import { useAddresses } from '../hooks/useAddresses'; import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources'; import type { ResourcePool } from '../types'; -import SettingsLayout from '../components/layout/SettingsLayout'; import ResourcesSubNav from '../components/resources/ResourcesSubNav'; /** @@ -67,7 +66,6 @@ export default function ResourcePoolsPage() { ]; return ( -
setToDelete(null)} />
-
); } diff --git a/assets/admin/pages/ResourceTypesPage.tsx b/assets/admin/pages/ResourceTypesPage.tsx index bc281868..9261946b 100644 --- a/assets/admin/pages/ResourceTypesPage.tsx +++ b/assets/admin/pages/ResourceTypesPage.tsx @@ -9,7 +9,6 @@ import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; import { useResourceTypes } from '../hooks/useResources'; import type { ResourceType } from '../types'; -import SettingsLayout from '../components/layout/SettingsLayout'; import ResourcesSubNav from '../components/resources/ResourcesSubNav'; /** نوع منبع — کلینیک خودش تعریفش می‌کند؛ سه نوع سیستمی را backfill می‌سازد. */ @@ -44,7 +43,6 @@ export default function ResourceTypesPage() { ]; return ( -
setToDelete(null)} />
-
); } diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index c9a21cfc..e6ddbef1 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -16,7 +16,6 @@ import ResourceSkillsModal from '../components/resources/ResourceSkillsModal'; import ResourceServicesModal from '../components/resources/ResourceServicesModal'; import { useAllServiceItems } from '../hooks/useServiceCatalog'; import type { ClinicResource } from '../types'; -import SettingsLayout from '../components/layout/SettingsLayout'; import ResourcesSubNav from '../components/resources/ResourcesSubNav'; /** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */ @@ -114,164 +113,161 @@ export default function ResourcesPage() { ]; return ( - -
- setEditing({ open: true, resource: null })}> - افزودن منبع - - ) : undefined - } - /> +
+ setEditing({ open: true, resource: null })}> + افزودن منبع + + ) : undefined + } + /> - + - setUrlState({ search: v })} - searchPlaceholder="جستجو در منابع..." - emptyMessage="هیچ منبعی با این فیلترها یافت نشد" - headerExtra={ -
-
- ({ value: b.uuid, label: b.name || 'بدون نام' }))} - value={urlState.address || null} - onChange={(v) => setUrlState({ address: v ? String(v) : '' })} - placeholder="همهٔ شعبه‌ها" - isClearable - height={36} - /> -
-
- ({ value: t.uuid, label: t.name }))} - value={urlState.type || null} - onChange={(v) => setUrlState({ type: v ? String(v) : '' })} - placeholder="همهٔ نوع‌ها" - isClearable - height={36} - /> -
-
- ({ value: s.uuid, label: s.name }))} - value={urlState.skill || null} - onChange={(v) => setUrlState({ skill: v ? String(v) : '' })} - placeholder="همهٔ مهارت‌ها" - isClearable - height={36} - /> -
-
- setUrlState({ status: v ? String(v) : '' })} - placeholder="همهٔ وضعیت‌ها" - isClearable - height={36} - /> -
+ setUrlState({ search: v })} + searchPlaceholder="جستجو در منابع..." + emptyMessage="هیچ منبعی با این فیلترها یافت نشد" + headerExtra={ +
+
+ ({ value: b.uuid, label: b.name || 'بدون نام' }))} + value={urlState.address || null} + onChange={(v) => setUrlState({ address: v ? String(v) : '' })} + placeholder="همهٔ شعبه‌ها" + isClearable + height={36} + />
- } - actions={ - canUpdate - ? (r) => ( -
- - - - - تنظیمات - - {/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض می‌کند، - این یک بازهٔ مشخص را می‌بندد. */} - - -
- ) - : undefined - } - /> +
+ ({ value: t.uuid, label: t.name }))} + value={urlState.type || null} + onChange={(v) => setUrlState({ type: v ? String(v) : '' })} + placeholder="همهٔ نوع‌ها" + isClearable + height={36} + /> +
+
+ ({ value: s.uuid, label: s.name }))} + value={urlState.skill || null} + onChange={(v) => setUrlState({ skill: v ? String(v) : '' })} + placeholder="همهٔ مهارت‌ها" + isClearable + height={36} + /> +
+
+ setUrlState({ status: v ? String(v) : '' })} + placeholder="همهٔ وضعیت‌ها" + isClearable + height={36} + /> +
+
+ } + actions={ + canUpdate + ? (r) => ( +
+ + + + + تنظیمات + + {/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض می‌کند، + این یک بازهٔ مشخص را می‌بندد. */} + + +
+ ) + : undefined + } + /> - setBlocksFor(null)} - /> + setBlocksFor(null)} + /> - setEditing({ open: false, resource: null })} - onSave={(payload) => { - const opts = { onSuccess: () => setEditing({ open: false, resource: null }) }; - if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts); - else create.mutate(payload, opts); - }} - /> + setEditing({ open: false, resource: null })} + onSave={(payload) => { + const opts = { onSuccess: () => setEditing({ open: false, resource: null }) }; + if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts); + else create.mutate(payload, opts); + }} + /> - setSkillsFor(null)} - onSave={(lines) => - skillsFor && - setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) }) - } - /> + setSkillsFor(null)} + onSave={(lines) => + skillsFor && + setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) }) + } + /> - setServicesFor(null)} - onSave={(lines) => - servicesFor && - saveServices.mutate( - { uuid: servicesFor.uuid, services: lines }, - { onSuccess: () => setServicesFor(null) }, - ) - } - /> + setServicesFor(null)} + onSave={(lines) => + servicesFor && + saveServices.mutate( + { uuid: servicesFor.uuid, services: lines }, + { onSuccess: () => setServicesFor(null) }, + ) + } + /> - toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} - onCancel={() => setToDelete(null)} - /> -
- + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
); } diff --git a/assets/admin/pages/SkillsPage.tsx b/assets/admin/pages/SkillsPage.tsx index afa7b7a8..f06ffadc 100644 --- a/assets/admin/pages/SkillsPage.tsx +++ b/assets/admin/pages/SkillsPage.tsx @@ -9,7 +9,6 @@ import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; import { useSkills } from '../hooks/useResources'; import type { Skill } from '../types'; -import SettingsLayout from '../components/layout/SettingsLayout'; import ResourcesSubNav from '../components/resources/ResourcesSubNav'; /** @@ -41,7 +40,6 @@ export default function SkillsPage() { ]; return ( -
setToDelete(null)} />
-
); }