feat(subscription): implement feature gating for subscription-based access in admin panel

This commit is contained in:
hamed
2026-06-15 12:00:36 +03:30
parent 3d33cf09b8
commit fa6656df48
11 changed files with 534 additions and 26 deletions
+369
View File
@@ -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 وجود ندارد
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']}><ClinicServicesPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']}><SmsWalletPage /></RoleRoute>} />
```
---
## وظایف
### ۱. 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<ApiResponse<MySubscriptionData>>({
queryKey: ['subscription-my'],
queryFn: () => api.get('/api/v1/subscription/my'),
enabled,
staleTime: 2 * 60 * 1000,
});
const sub = data?.data?.subscription ?? null;
const features: Record<string, boolean> = 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;
<NavLink
to={isLocked ? '/admin/subscription' : item.to}
style={isLocked ? { opacity: 0.5 } : {}}
title={isLocked ? 'نیاز به ارتقاء پنل' : undefined}
>
<item.icon />
{item.label}
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: 'auto' }} />}
</NavLink>
```
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 (
<div style={{
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
minHeight: 320, gap: 16, padding: 40, textAlign: 'center',
}}>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: 'oklch(0.97 0.01 256)',
display: 'grid', placeItems: 'center',
}}>
<LockClosedIcon style={{ width: 28, color: 'var(--text-3)' }} />
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
این قابلیت در پنل فعلی شما فعال نیست
</div>
<div style={{ color: 'var(--text-3)', fontSize: 13.5, maxWidth: 360 }}>
{hasPlan
? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید'
: 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'}
</div>
</div>
<Link to="/admin/subscription" className="btn primary sm" style={{ textDecoration: 'none' }}>
{hasPlan ? 'ارتقاء پنل' : 'مشاهده پنل‌های اشتراکی'}
</Link>
</div>
);
}
```
### ۵. Frontend — استفاده از FeatureGate در صفحات
در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن:
```tsx
import FeatureGate from '../components/ui/FeatureGate';
// ...
return (
<FeatureGate feature="patient_records">
{/* کد فعلی صفحه */}
</FeatureGate>
);
```
در **`ClinicServicesPage.tsx`**:
```tsx
return (
<FeatureGate feature="services">
{/* کد فعلی */}
</FeatureGate>
);
```
در **`SmsWalletPage.tsx`**:
```tsx
return (
<FeatureGate feature="sms_panel">
{/* کد فعلی */}
</FeatureGate>
);
```
### ۶. 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;
// روی دکمه «افزودن منشی»:
<button
className="btn primary sm"
onClick={() => setAddOpen(true)}
disabled={isAtLimit}
title={isAtLimit ? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است` : undefined}
>
افزودن منشی
</button>
{isAtLimit && (
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
برای افزودن منشی بیشتر <Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
</div>
)}
```
---
## نکات مهم
- **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`
+28 -18
View File
@@ -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) => (
<div className="nav-group" key={section.label}>
<span className="nav-label">{section.label}</span>
{section.items.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
title={!sidebarOpen ? label : undefined}
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
<span>{label}</span>
</NavLink>
))}
{section.items.map(({ to, icon: Icon, label, feature }) => {
const isLocked = feature ? !hasFeature(feature) : false;
const dest = isLocked ? '/admin/subscription' : to;
return (
<NavLink
key={to}
to={dest}
title={isLocked ? 'نیاز به ارتقاء پنل' : (!sidebarOpen ? label : undefined)}
className={({ isActive }) => `nav-item${isActive && !isLocked ? ' active' : ''}${isLocked ? ' locked' : ''}`}
style={isLocked ? { opacity: 0.55 } : undefined}
>
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
<span>{label}</span>
{isLocked && <LockClosedIcon style={{ width: 12, height: 12, marginRight: 'auto', flexShrink: 0 }} />}
</NavLink>
);
})}
</div>
))}
@@ -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 (
<div style={{
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
minHeight: 320, gap: 16, padding: 40, textAlign: 'center',
}}>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: 'oklch(0.97 0.01 256)',
display: 'grid', placeItems: 'center',
border: '1px solid var(--border)',
}}>
<LockClosedIcon style={{ width: 28, color: 'var(--text-3)' }} />
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
این قابلیت در پنل فعلی شما فعال نیست
</div>
<div style={{ color: 'var(--text-3)', fontSize: 13.5, maxWidth: 360, lineHeight: 1.6 }}>
{hasPlan
? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید'
: 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'}
</div>
</div>
<Link to="/admin/subscription" className="btn primary sm" style={{ textDecoration: 'none' }}>
{hasPlan ? 'ارتقاء پنل' : 'مشاهده پنل‌های اشتراکی'}
</Link>
</div>
);
}
+30
View File
@@ -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<ApiResponse<MySubscriptionData>>({
queryKey: ['subscription-my'],
queryFn: () => api.get('/api/v1/subscription/my'),
enabled,
staleTime: 2 * 60 * 1000,
});
const sub = data?.data?.subscription ?? null;
const features: Record<string, boolean> = 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,
};
}
+10 -1
View File
@@ -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<typeof itemSchema>;
const EMPTY_SECTIONS: ServiceSection[] = [];
const EMPTY_ITEMS: ServiceItem[] = [];
export default function ClinicServicesPage() {
function ClinicServicesPageInner() {
const qc = useQueryClient();
const [selectedSection, setSelectedSection] = useState<ServiceSection | null>(null);
@@ -409,3 +410,11 @@ export default function ClinicServicesPage() {
</>
);
}
export default function ClinicServicesPage() {
return (
<FeatureGate feature="services">
<ClinicServicesPageInner />
</FeatureGate>
);
}
+10 -1
View File
@@ -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<PatientRecord | null>(null);
const [page, setPage] = useState(1);
@@ -543,3 +544,11 @@ function EditSessionModal({
</Modal>
);
}
export default function MyPatientsPage() {
return (
<FeatureGate feature="patient_records">
<MyPatientsPageInner />
</FeatureGate>
);
}
+13 -4
View File
@@ -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<string>('');
@@ -309,10 +312,16 @@ export default function MySecretariesPage() {
title="منشیان من"
description="مدیریت منشیان و دسترسی‌های آن‌ها"
action={
<button className="btn primary sm" onClick={handleCreateOpen} disabled={isClinic && !selectedDoctorUuid}>
<PlusIcon style={{ width: 16 }} />
افزودن منشی
</button>
secretaries.length >= maxSecretaries ? (
<Link to="/admin/subscription" className="btn sm" style={{ textDecoration: 'none', opacity: 0.8 }} title={`حداکثر ${maxSecretaries} منشی مجاز است`}>
ارتقاء پنل برای افزودن منشی
</Link>
) : (
<button className="btn primary sm" onClick={handleCreateOpen} disabled={isClinic && !selectedDoctorUuid}>
<PlusIcon style={{ width: 16 }} />
افزودن منشی
</button>
)
}
/>
+10 -1
View File
@@ -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<typeof chargeSchema>;
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 (
<FeatureGate feature="sms_panel">
<SmsWalletPageInner />
</FeatureGate>
);
}
+1 -1
View File
@@ -339,7 +339,7 @@ export interface SubscriptionPeriod {
export interface MySubscriptionData {
subscription: {
plan: { name: string; level: number; features: Record<string, boolean> };
plan: { name: string; level: number; max_secretaries: number; features: Record<string, boolean> };
period?: { label: string; duration_months: number };
is_trial: boolean;
starts_at?: number;
+2
View File
@@ -54,6 +54,8 @@
**Permission:** `IS_AUTHENTICATED_FULLY`
**نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار می‌کند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا می‌کند و اشتراک همان entity برگردانده می‌شود.
**Response 200:**
```json
{
@@ -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];
}
}