diff --git a/.claude/prompt/subscription-gate-ui.md b/.claude/prompt/subscription-gate-ui.md new file mode 100644 index 00000000..808447ad --- /dev/null +++ b/.claude/prompt/subscription-gate-ui.md @@ -0,0 +1,369 @@ +# اعمال محدودیت‌های اشتراک در پنل ادمین (Frontend Gate) + +## زمینه + +سیستم اشتراک در backend کاملاً پیاده است: +- `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت +- `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز +- `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }` +- backend روی API های `PatientController` و `ClinicServiceController` گیت دارد (`ERR_SUBSCRIPTION_REQUIRED`) + +**مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل. + +**هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود: +1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد +2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند +3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند + +--- + +## مشکل / هدف + +### قابلیت‌های محدودشده توسط اشتراک + +| Feature Key | صفحه/منو | نقش مرتبط | +|-------------|----------|----------| +| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary | +| `services` | `/admin/clinic-services` | doctor, clinic | +| `sms_panel` | `/admin/sms-wallet` | doctor, clinic | +| `max_secretaries` | `/admin/my-secretaries` (تعداد) | doctor, clinic | + +**نکته منشی:** منشی اشتراک مستقل ندارد — اشتراک entity ای که به آن متصل است (دکتر یا کلینیک) اعمال می‌شود. endpoint `/api/v1/subscription/my` برای منشی null برمی‌گرداند (چون `resolveEntity` در `SubscriptionController` فقط ROLE_DOCTOR و ROLE_CLINIC را handle می‌کند). + +--- + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Subscription/Controller/SubscriptionController.php` | endpoint `GET /api/v1/subscription/my` — برای doctor و clinic | +| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` | +| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد | +| `assets/admin/components/layout/Sidebar.tsx` | `buildSections()` — منوها بر اساس `primaryRole` | +| `assets/admin/App.tsx` | `RoleRoute` — گیت نقش‌ها، باید feature gate هم اضافه شود | +| `assets/admin/pages/MyPatientsPage.tsx` | نیاز به `patient_records` | +| `assets/admin/pages/ClinicServicesPage.tsx` | نیاز به `services` | +| `assets/admin/pages/SmsWalletPage.tsx` | نیاز به `sms_panel` | +| `assets/admin/pages/MySecretariesPage.tsx` | نیاز به `max_secretaries` بیش از ۱ | +| `assets/admin/pages/SubscriptionPage.tsx` | صفحه ارتقاء پنل — مقصد CTA ها | + +--- + +## وضعیت فعلی + +### Backend — endpoint اشتراک +```php +// SubscriptionController.php — GET /api/v1/subscription/my +// فقط doctor و clinic را handle می‌کند +private function resolveEntity(User $user): array +{ + if ($user->hasRole('ROLE_DOCTOR')) { + $doctor = $this->doctorRepo->findByUser($user); + return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null]; + } + if ($user->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($user); + return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; + } + return ['unknown', null]; // منشی → 403 +} +``` + +پاسخ: +```json +{ + "success": true, + "data": { + "subscription": { + "uuid": "...", + "plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true, "sms_panel": false }, "max_secretaries": 3 }, + "is_trial": false, + "expires_at": 1750000000, + "days_remaining": 45 + }, + "used_trial": false + } +} +``` + +اگر اشتراک فعال نداشته باشد: `"subscription": null` + +### Frontend — authStore +```ts +// authStore.ts — فعلاً subscription اصلاً در store نیست +interface AuthState { + primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null; + dbUuid: string | null; + // ... subscription وجود ندارد +} +``` + +### Frontend — Sidebar +```tsx +// Sidebar.tsx — همه آیتم‌ها را بدون بررسی feature نمایش می‌دهد +if (primaryRole === 'doctor') { + return [{ + label: 'مدیریت', + items: [ + { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' }, + { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' }, + { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' }, + ], + }]; +} +``` + +### Frontend — App.tsx +```tsx +// فقط نقش‌ها چک می‌شوند، feature gate وجود ندارد +} /> +} /> +} /> +``` + +--- + +## وظایف + +### ۱. Backend — منشی می‌تواند اشتراک entity خود را ببیند + +در `SubscriptionController::resolveEntity()` حالت `ROLE_SECRETARY` را اضافه کن: + +```php +if ($user->hasRole('ROLE_SECRETARY')) { + $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); + if ($dbUuid) { + $clinic = $this->clinicRepo->findByUuid($dbUuid); + if ($clinic) return ['clinic', $clinic->getId()]; + $doctor = $this->doctorRepo->findByUuid($dbUuid); + if ($doctor) return ['doctor', $doctor->getId()]; + } +} +``` + +نیاز به inject کردن `UserActiveContextRepository` و `ClinicRepository` و `DoctorRepository` به `SubscriptionController` دارد. + +### ۲. Frontend — hook مشترک `useSubscription` + +یک فایل `assets/admin/hooks/useSubscription.ts` بساز: + +```ts +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import type { ApiResponse, MySubscriptionData } from '../types'; + +export function useSubscription() { + const primaryRole = useAuthStore((s) => s.primaryRole); + const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary'; + + const { data } = useQuery>({ + queryKey: ['subscription-my'], + queryFn: () => api.get('/api/v1/subscription/my'), + enabled, + staleTime: 2 * 60 * 1000, + }); + + const sub = data?.data?.subscription ?? null; + const features: Record = sub?.plan?.features ?? {}; + const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1; + const hasPlan = sub !== null; + + return { + subscription: sub, + hasFeature: (key: string) => hasPlan && (features[key] ?? false), + maxSecretaries, + hasPlan, + isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, + }; +} +``` + +**نکته:** query key `['subscription-my']` همان key ی است که `SubscriptionPage` هم استفاده می‌کند — cache مشترک، یک بار fetch. + +### ۳. Frontend — Sidebar با قفل feature + +در `Sidebar.tsx`: + +```tsx +import { useSubscription } from '../../hooks/useSubscription'; +// ... + +export default function Sidebar() { + const primaryRole = useAuthStore((s) => s.primaryRole); + const dbUuid = useAuthStore((s) => s.dbUuid); + const { hasFeature } = useSubscription(); + // ... +} +``` + +نوع `SectionItem` را گسترش بده: +```ts +type SectionItem = { + to: string; + icon: React.ElementType; + label: string; + feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل +}; +``` + +آیتم‌های محدود را با `feature` mark کن: +```tsx +{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, +{ to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, +{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' }, +``` + +در render هر آیتم: +```tsx +const isLocked = item.feature ? !hasFeature(item.feature) : false; + + + + {item.label} + {isLocked && } + +``` + +import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';` + +### ۴. Frontend — FeatureGate component + +یک component کوچک `assets/admin/components/ui/FeatureGate.tsx` بساز: + +```tsx +import React from 'react'; +import { Link } from 'react-router-dom'; +import { LockClosedIcon } from '@heroicons/react/24/outline'; +import { useSubscription } from '../../hooks/useSubscription'; + +interface FeatureGateProps { + feature: string; + children: React.ReactNode; +} + +export default function FeatureGate({ feature, children }: FeatureGateProps) { + const { hasFeature, hasPlan } = useSubscription(); + + if (hasFeature(feature)) return <>{children}; + + return ( +
+
+ +
+
+
+ این قابلیت در پنل فعلی شما فعال نیست +
+
+ {hasPlan + ? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید' + : 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'} +
+
+ + {hasPlan ? 'ارتقاء پنل' : 'مشاهده پنل‌های اشتراکی'} + +
+ ); +} +``` + +### ۵. Frontend — استفاده از FeatureGate در صفحات + +در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن: +```tsx +import FeatureGate from '../components/ui/FeatureGate'; +// ... +return ( + + {/* کد فعلی صفحه */} + +); +``` + +در **`ClinicServicesPage.tsx`**: +```tsx +return ( + + {/* کد فعلی */} + +); +``` + +در **`SmsWalletPage.tsx`**: +```tsx +return ( + + {/* کد فعلی */} + +); +``` + +### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage + +در **`MySecretariesPage.tsx`** وقتی کاربر می‌خواهد منشی جدید اضافه کند: + +```tsx +import { useSubscription } from '../hooks/useSubscription'; +// ... +const { maxSecretaries } = useSubscription(); +// تعداد فعلی منشیان از data موجود +const activeCount = secretaries.filter(s => s.is_active).length; +const isAtLimit = activeCount >= maxSecretaries; + +// روی دکمه «افزودن منشی»: + +{isAtLimit && ( +
+ برای افزودن منشی بیشتر پنل خود را ارتقاء دهید +
+)} +``` + +--- + +## نکات مهم + +- **admin نقش اشتراک ندارد:** `useSubscription` فقط برای doctor/clinic/secretary فعال است. در Sidebar ادمین هیچ feature gate نداشته باشد. +- **secretary اشتراک entity خود را می‌بیند:** بعد از task 1، `/api/v1/subscription/my` برای secretary هم کار می‌کند. اگر db_uuid هنوز set نشده (قبل از انتخاب context)، `subscription: null` برمی‌گردد — `useSubscription` به‌درستی `hasPlan: false` برمی‌گرداند. +- **Cache مشترک:** SubscriptionPage هم از `queryKey: ['subscription-my']` استفاده می‌کند — تغییر ندهید تا cache reuse شود. +- **پلن free:** در DB ممکن است اصلاً subscription نداشته باشند (`subscription: null`). این حالت را با `hasPlan: false` handle کن. فرض نکن که "free" یک plan object است. +- **LockClosedIcon** را import کن از `@heroicons/react/24/outline` — باید در heroicons وجود داشته باشد. +- **migration لازم نیست** — فقط backend method و frontend تغییر می‌کند. +- **مستندات:** بعد از تغییر `SubscriptionController`، فایل `docs/api/subscription.md` باید به‌روز شود تا `GET /api/v1/subscription/my` برای secretary هم مستند شود. + +--- + +## ترتیب اجرا + +1. Backend: `SubscriptionController::resolveEntity()` — حالت secretary +2. تست route: `ddev exec php bin/console cache:clear` +3. Frontend: `assets/admin/hooks/useSubscription.ts` — hook جدید +4. Frontend: `assets/admin/components/ui/FeatureGate.tsx` — component جدید +5. Frontend: `Sidebar.tsx` — اضافه کردن `feature` به آیتم‌ها و render قفل +6. Frontend: `MyPatientsPage.tsx`، `ClinicServicesPage.tsx`، `SmsWalletPage.tsx` — wrap با FeatureGate +7. Frontend: `MySecretariesPage.tsx` — محدودیت تعداد منشی +8. Build: `ddev exec yarn dev` +9. TypeScript: `ddev exec npx tsc --noEmit` +10. مستندات: `docs/api/subscription.md` diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 3ba968ef..1a13e7f8 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -13,6 +13,7 @@ import { DocumentTextIcon, HeartIcon, KeyIcon, + LockClosedIcon, StarIcon, TagIcon, UserCircleIcon, @@ -26,8 +27,9 @@ import { import { NavLink, useNavigate } from "react-router-dom"; import { useAuthStore } from "../../stores/authStore"; import { useUiStore } from "../../stores/uiStore"; +import { useSubscription } from "../../hooks/useSubscription"; -type SectionItem = { to: string; icon: React.ElementType; label: string }; +type SectionItem = { to: string; icon: React.ElementType; label: string; feature?: string }; type Section = { label: string; items: SectionItem[] }; function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] { @@ -87,11 +89,11 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti label: 'مدیریت', items: [ { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, - { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' }, + { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, { to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' }, { to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' }, - { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' }, - { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' }, + { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, + { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' }, ], }, { @@ -121,11 +123,11 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti label: 'مدیریت', items: [ { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌های من' }, - { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' }, + { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, { to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' }, { to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' }, - { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' }, - { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' }, + { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, + { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' }, ], }, { @@ -149,6 +151,7 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti label: 'مدیریت', items: [ { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, + { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, ], }, ]; @@ -185,6 +188,7 @@ interface Props { export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) { const sidebarOpen = useUiStore((s) => s.sidebarOpen); const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore(); + const { hasFeature } = useSubscription(); const navigate = useNavigate(); const sections = buildSections(primaryRole, dbUuid); @@ -208,17 +212,23 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) { {sections.map((section) => (
{section.label} - {section.items.map(({ to, icon: Icon, label }) => ( - `nav-item${isActive ? ' active' : ''}`} - > - - {label} - - ))} + {section.items.map(({ to, icon: Icon, label, feature }) => { + const isLocked = feature ? !hasFeature(feature) : false; + const dest = isLocked ? '/admin/subscription' : to; + return ( + `nav-item${isActive && !isLocked ? ' active' : ''}${isLocked ? ' locked' : ''}`} + style={isLocked ? { opacity: 0.55 } : undefined} + > + + {label} + {isLocked && } + + ); + })}
))} diff --git a/assets/admin/components/ui/FeatureGate.tsx b/assets/admin/components/ui/FeatureGate.tsx new file mode 100644 index 00000000..7a7e1241 --- /dev/null +++ b/assets/admin/components/ui/FeatureGate.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { LockClosedIcon } from '@heroicons/react/24/outline'; +import { useSubscription } from '../../hooks/useSubscription'; + +interface FeatureGateProps { + feature: string; + children: React.ReactNode; +} + +export default function FeatureGate({ feature, children }: FeatureGateProps) { + const { hasFeature, hasPlan } = useSubscription(); + + if (hasFeature(feature)) return <>{children}; + + return ( +
+
+ +
+
+
+ این قابلیت در پنل فعلی شما فعال نیست +
+
+ {hasPlan + ? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید' + : 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'} +
+
+ + {hasPlan ? 'ارتقاء پنل' : 'مشاهده پنل‌های اشتراکی'} + +
+ ); +} diff --git a/assets/admin/hooks/useSubscription.ts b/assets/admin/hooks/useSubscription.ts new file mode 100644 index 00000000..b4f016ea --- /dev/null +++ b/assets/admin/hooks/useSubscription.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import type { MySubscriptionData } from '../types'; + +export function useSubscription() { + const primaryRole = useAuthStore((s) => s.primaryRole); + const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary'; + + const { data } = useQuery>({ + queryKey: ['subscription-my'], + queryFn: () => api.get('/api/v1/subscription/my'), + enabled, + staleTime: 2 * 60 * 1000, + }); + + const sub = data?.data?.subscription ?? null; + const features: Record = sub?.plan?.features ?? {}; + const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1; + const hasPlan = sub !== null; + + return { + subscription: sub, + hasFeature: (key: string) => hasPlan && (features[key] ?? false), + maxSecretaries, + hasPlan, + isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, + }; +} diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index c639411d..6083da06 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -14,6 +14,7 @@ import PriceInput from '../components/ui/PriceInput'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import PageHeader from '../components/ui/PageHeader'; import SearchableSelect from '../components/ui/SearchableSelect'; +import FeatureGate from '../components/ui/FeatureGate'; const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') }); const itemSchema = z.object({ @@ -27,7 +28,7 @@ type ItemForm = z.infer; const EMPTY_SECTIONS: ServiceSection[] = []; const EMPTY_ITEMS: ServiceItem[] = []; -export default function ClinicServicesPage() { +function ClinicServicesPageInner() { const qc = useQueryClient(); const [selectedSection, setSelectedSection] = useState(null); @@ -409,3 +410,11 @@ export default function ClinicServicesPage() { ); } + +export default function ClinicServicesPage() { + return ( + + + + ); +} diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index 30fb1601..a758f3b9 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -17,6 +17,7 @@ import Pagination from '../components/ui/Pagination'; import Modal from '../components/ui/Modal'; import PageHeader from '../components/ui/PageHeader'; import SearchableSelect from '../components/ui/SearchableSelect'; +import FeatureGate from '../components/ui/FeatureGate'; const sessionSchema = z.object({ visit_price_rials: z.coerce.number().min(0), @@ -44,7 +45,7 @@ function calcFinalPrice(visitPrice: number, baseDiscount: number, suppDiscount: return Math.round(afterSupp) + servicesTotal; } -export default function MyPatientsPage() { +function MyPatientsPageInner() { const qc = useQueryClient(); const [selectedRecord, setSelectedRecord] = useState(null); const [page, setPage] = useState(1); @@ -543,3 +544,11 @@ function EditSessionModal({ ); } + +export default function MyPatientsPage() { + return ( + + + + ); +} diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index 386ff3a5..8393c4b7 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -15,6 +15,8 @@ import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import PageHeader from '../components/ui/PageHeader'; import { useAuthStore } from '../stores/authStore'; +import { useSubscription } from '../hooks/useSubscription'; +import { Link } from 'react-router-dom'; // ── Default permissions & labels ────────────────────────────────────────── @@ -143,6 +145,7 @@ export default function MySecretariesPage() { const { doctorUuid, dbUuid, primaryRole } = useAuthStore(); const isClinic = primaryRole === 'clinic'; + const { maxSecretaries } = useSubscription(); // for clinic: selected doctor to add secretary for const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(''); @@ -309,10 +312,16 @@ export default function MySecretariesPage() { title="منشیان من" description="مدیریت منشیان و دسترسی‌های آن‌ها" action={ - + secretaries.length >= maxSecretaries ? ( + + ارتقاء پنل برای افزودن منشی + + ) : ( + + ) } /> diff --git a/assets/admin/pages/SmsWalletPage.tsx b/assets/admin/pages/SmsWalletPage.tsx index 8686bf4c..c6ef071f 100644 --- a/assets/admin/pages/SmsWalletPage.tsx +++ b/assets/admin/pages/SmsWalletPage.tsx @@ -14,6 +14,7 @@ import { formatRial, formatNumber, formatDateTime } from '../lib/utils'; import Modal from '../components/ui/Modal'; import Pagination from '../components/ui/Pagination'; import PageHeader from '../components/ui/PageHeader'; +import FeatureGate from '../components/ui/FeatureGate'; const chargeSchema = z.object({ amount_rials: z.coerce.number().min(10000, 'حداقل مبلغ ۱۰,۰۰۰ ریال است'), @@ -22,7 +23,7 @@ type ChargeForm = z.infer; const EMPTY_LOGS: SmsWalletLog[] = []; -export default function SmsWalletPage() { +function SmsWalletPageInner() { const qc = useQueryClient(); const [chargeOpen, setChargeOpen] = useState(false); const [gateway, setGateway] = useState<'mellat' | 'sep'>('mellat'); @@ -433,3 +434,11 @@ export default function SmsWalletPage() { ); } + +export default function SmsWalletPage() { + return ( + + + + ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 7a34d14b..b4ac27ae 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -339,7 +339,7 @@ export interface SubscriptionPeriod { export interface MySubscriptionData { subscription: { - plan: { name: string; level: number; features: Record }; + plan: { name: string; level: number; max_secretaries: number; features: Record }; period?: { label: string; duration_months: number }; is_trial: boolean; starts_at?: number; diff --git a/docs/api/subscription.md b/docs/api/subscription.md index 95b3ca5f..494e94b0 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -54,6 +54,8 @@ **Permission:** `IS_AUTHENTICATED_FULLY` +**نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار می‌کند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا می‌کند و اشتراک همان entity برگردانده می‌شود. + **Response 200:** ```json { diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index e68f76d6..cd542ec7 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -3,6 +3,7 @@ namespace App\Subscription\Controller; use App\Auth\Entity\User; +use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; use App\Shared\Constant\ErrorCodes; @@ -30,6 +31,7 @@ class SubscriptionController extends BaseController private readonly ClinicSubscriptionRepository $subscriptionRepo, private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, + private readonly UserActiveContextRepository $contextRepo, private readonly EntityManagerInterface $em, ) {} @@ -250,6 +252,20 @@ class SubscriptionController extends BaseController return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; } + if ($user->hasRole('ROLE_SECRETARY')) { + $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); + if ($dbUuid !== null) { + $clinic = $this->clinicRepo->findByUuid($dbUuid); + if ($clinic !== null) { + return ['clinic', $clinic->getId()]; + } + $doctor = $this->doctorRepo->findByUuid($dbUuid); + if ($doctor !== null) { + return ['doctor', $doctor->getId()]; + } + } + } + return ['unknown', null]; } }