# اعمال محدودیت‌های اشتراک در پنل ادمین (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`