diff --git a/.claude/prompt/insurance-pages-subscription-gating.md b/.claude/prompt/insurance-pages-subscription-gating.md new file mode 100644 index 00000000..3260690d --- /dev/null +++ b/.claude/prompt/insurance-pages-subscription-gating.md @@ -0,0 +1,151 @@ +# محدودسازی صفحات بیمه (قیمت‌گذاری و مطالبات) به خرید پلن اشتراکی + +## پروژه + +`clinicpro` (admin frontend + UI ادمینِ پلن) + +## زمینه + +پنل ادمین یک سیستم feature-gating اشتراکی **از قبل دارد** که کار می‌کند: +- `assets/admin/hooks/useSubscription.ts` → `GET /api/v1/subscription/my` را می‌خواند و `hasFeature(key)` می‌دهد (از `subscription.plan.features`). +- `assets/admin/components/ui/FeatureGate.tsx` → اگر feature فعال نباشد، به‌جای محتوا یک قفل + دکمه‌ی «مشاهده/ارتقاء پنل اشتراکی» نشان می‌دهد. +- `Sidebar.tsx` → آیتم‌های منو با `feature: "..."` در صورت نبودِ feature مخفی می‌شوند (`!hasFeature(feature)`). + +دو صفحه با همین الگو **درست محدود شده‌اند**: `ClinicServicesPage` با `` و `SmsWalletPage` با ``. + +## مشکل / هدف + +صفحات **«قیمت‌گذاری بیمه» (`/admin/insurance-pricing`)** و **«مطالبات بیمه» (`/admin/claims`)** به‌تازگی اضافه شده‌اند ولی **هیچ محدودسازی اشتراکی ندارند** — نه در سطح صفحه و نه در منو: + +- `InsurancePricingPage.tsx` و `ClaimsPage.tsx` در `` پیچیده **نشده‌اند** → باز کردن مستقیم URL، صفحه را بدون اشتراک نمایش می‌دهد. +- در `Sidebar.tsx` لینک این دو صفحه برخلاف `my-patients` **کلید `feature:` ندارند** → همیشه در منو دیده می‌شوند (در حالی‌که `my-patients` با `feature: "patient_records"` مخفی می‌شود). + +هدف: این دو صفحه فقط برای دارندگانِ پلن اشتراکی فعال که feature **`insurance`** را دارند باز باشند (کلید جدید، طبق تصمیم کاربر). دقیقاً مثل `services` / `sms_panel`. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `assets/admin/pages/InsurancePricingPage.tsx` | باید در `` پیچیده شود | +| `assets/admin/pages/ClaimsPage.tsx` | باید در `` پیچیده شود | +| `assets/admin/components/layout/Sidebar.tsx` | افزودن `feature: "insurance"` به لینک هر دو صفحه (دو نقش: doctor و clinic) | +| `assets/admin/pages/AdminSubscriptionPage.tsx` | افزودن `insurance` به `FEATURE_LABELS` + `planSchema.features` تا ادمین بتواند این feature را روی پلن‌ها فعال کند | +| `assets/admin/components/ui/FeatureGate.tsx` / `hooks/useSubscription.ts` | فقط مصرف؛ تغییر نمی‌کنند | + +## وضعیت فعلی + +### الگوی درستِ موجود (برای تقلید) + +```tsx +// assets/admin/pages/SmsWalletPage.tsx (انتها) +return ( + + ... + +); +``` + +### صفحات محافظت‌نشده + +```tsx +// assets/admin/pages/InsurancePricingPage.tsx — هیچ گاردی ندارد +export default function InsurancePricingPage() { + return ( +
+ + + +
+ ); +} +``` + +```tsx +// assets/admin/pages/ClaimsPage.tsx — return اصلی بدون FeatureGate +export default function ClaimsPage() { + // ... useQuery / useMutation ... + return ( /* جدول مطالبات + بدهی بیمه */ ); +} +``` + +### Sidebar — لینک‌ها بدون feature + +```tsx +// assets/admin/components/layout/Sidebar.tsx (نقش doctor و مشابهش در clinic) +{ to: "/admin/my-patients", icon: FolderOpenIcon, label: "پرونده بیماران", feature: "patient_records" }, +{ to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمت‌گذاری بیمه" }, // ← feature ندارد +{ to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه" }, // ← feature ندارد +``` + +### AdminSubscriptionPage — featureهای هاردکد (فقط ۳ تا) + +```tsx +// assets/admin/pages/AdminSubscriptionPage.tsx +const FEATURE_LABELS: Record = { + patient_records: 'پرونده بیمار', + services: 'سرویس‌ها', + sms_panel: 'پنل پیامک', +}; + +const planSchema = z.object({ + ... + features: z.object({ + patient_records: z.boolean(), + services: z.boolean(), + sms_panel: z.boolean(), + }), + ... +}); +``` + +## وظایف + +### ۱. پیچیدن دو صفحه در FeatureGate + +`InsurancePricingPage` و `ClaimsPage` را عیناً مثل `SmsWalletPage`/`ClinicServicesPage` در `` بپیچ: + +```tsx +// InsurancePricingPage.tsx +import FeatureGate from '../components/ui/FeatureGate'; + +export default function InsurancePricingPage() { + return ( + +
+ + + +
+
+ ); +} +``` + +برای `ClaimsPage` کل JSX داخل `return ( ... )` را در `...` بگذار. (اگر pageدارای چند `return` شرطی است، فقط return نهاییِ محتوا را بپیچ؛ مراقب باش hookها بالای FeatureGate صدا زده شوند تا ترتیب hookها نشکند.) + +### ۲. افزودن feature به لینک‌های Sidebar + +به لینک‌های `insurance-pricing` و `claims` در **هر دو** بخش نقش (doctor و clinic) کلید `feature: "insurance"` اضافه کن تا وقتی پلن این feature را ندارد، از منو مخفی شوند (همان رفتار `my-patients`). + +### ۳. افزودن `insurance` به UI ادمینِ پلن + +تا ادمین بتواند feature `insurance` را روی پلن‌ها روشن/خاموش کند: + +```tsx +// AdminSubscriptionPage.tsx +const FEATURE_LABELS: Record = { + patient_records: 'پرونده بیمار', + services: 'سرویس‌ها', + sms_panel: 'پنل پیامک', + insurance: 'بیمه و مطالبات', +}; +``` +و `planSchema.features` و `defaultValues` و `openEditPlan` را طوری اصلاح کن که `insurance` را هم شامل شود. (بهترین کار: featureها را از روی کلیدهای `FEATURE_LABELS` بساز تا دیگر هاردکد نباشد و افزودن feature بعدی فقط map را تغییر دهد.) + +## نکات مهم + +- backend نیازی به تغییر ندارد: `SubscriptionPlan.features` یک json آزاد است و `hasFeature($key)` هر کلیدی را می‌خواند؛ پلن‌های موجود به‌صورت پیش‌فرض `insurance` ندارند (یعنی `false`) و ادمین آن را روی پلن دلخواه فعال می‌کند. پس **migration لازم نیست** و قرارداد API تغییر نمی‌کند → docs نیاز به‌روزرسانی ندارد. +- این محدودسازی فقط برای نقش‌هایی که `useSubscription` فعال دارد عمل می‌کند (`doctor` / `clinic` / `secretary`). برای `admin` و `representation` صفحات بیمه در منوی آن‌ها نیست؛ کاری لازم نیست. +- ترتیب hookها: FeatureGate نباید باعث شود hookهای صفحه به‌صورت شرطی صدا زده شوند. در `ClaimsPage` همه‌ی `useQuery`/`useMutation`/`useState` بالای `return` می‌مانند و فقط JSX خروجی wrap می‌شود. +- بعد از تغییرات: `ddev exec yarn dev` و `ddev exec npx tsc --noEmit --project tsconfig.json` برای چک تایپ. +- گاردهای نقش (`RoleRoute`) را دست نزن — درست‌اند؛ این پرامپت فقط لایه‌ی feature اشتراکی را روی دو صفحه‌ی بیمه اضافه می‌کند. diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index e0da4231..16ff60b5 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -197,11 +197,13 @@ function buildSections( to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمت‌گذاری بیمه", + feature: "insurance", }, { to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه", + feature: "insurance", }, { to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" }, { @@ -276,11 +278,13 @@ function buildSections( to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمت‌گذاری بیمه", + feature: "insurance", }, { to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه", + feature: "insurance", }, { to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" }, { @@ -345,11 +349,13 @@ function buildSections( to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمت‌گذاری بیمه", + feature: "insurance", }, { to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه", + feature: "insurance", }, ], }, diff --git a/assets/admin/pages/AdminSubscriptionPage.tsx b/assets/admin/pages/AdminSubscriptionPage.tsx index 89d72415..936e0bb9 100644 --- a/assets/admin/pages/AdminSubscriptionPage.tsx +++ b/assets/admin/pages/AdminSubscriptionPage.tsx @@ -34,11 +34,7 @@ const planSchema = z.object({ name: z.string().min(1, 'نام الزامی است'), level: z.coerce.number().min(0), max_secretaries: z.coerce.number().min(1), - features: z.object({ - patient_records: z.boolean(), - services: z.boolean(), - sms_panel: z.boolean(), - }), + features: z.record(z.string(), z.boolean()), active: z.boolean(), }); type PlanForm = z.infer; @@ -60,8 +56,14 @@ const FEATURE_LABELS: Record = { patient_records: 'پرونده بیمار', services: 'سرویس‌ها', sms_panel: 'پنل پیامک', + insurance: 'بیمه و مطالبات', }; +const FEATURE_KEYS = Object.keys(FEATURE_LABELS); + +const emptyFeatures = (): Record => + Object.fromEntries(FEATURE_KEYS.map((k) => [k, false])); + const PLAN_DISPLAY: Record = { free: 'رایگان', basic: 'پایه', @@ -86,7 +88,7 @@ function PlansTab() { const planForm = useForm({ resolver: zodResolver(planSchema), - defaultValues: { features: { patient_records: false, services: false, sms_panel: false }, active: true }, + defaultValues: { features: emptyFeatures(), active: true }, }); const periodForm = useForm({ @@ -129,7 +131,7 @@ function PlansTab() { name: plan.name, level: plan.level, max_secretaries: plan.max_secretaries, - features: { patient_records: plan.features.patient_records ?? false, services: plan.features.services ?? false, sms_panel: plan.features.sms_panel ?? false }, + features: { ...emptyFeatures(), ...plan.features }, active: (plan as any).active ?? true, }); setPlanModal(plan); @@ -157,7 +159,7 @@ function PlansTab() { return ( <>
-
diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx index 58b383b3..66d85d1d 100644 --- a/assets/admin/pages/ClaimsPage.tsx +++ b/assets/admin/pages/ClaimsPage.tsx @@ -7,6 +7,7 @@ import { formatRial, formatNumber } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; import Modal from '../components/ui/Modal'; import SearchableSelect from '../components/ui/SearchableSelect'; +import FeatureGate from '../components/ui/FeatureGate'; interface ClaimItem { invoice_item_id: number; @@ -80,6 +81,7 @@ export default function ClaimsPage() { }); return ( +
@@ -189,5 +191,6 @@ export default function ClaimsPage() {
+
); } diff --git a/assets/admin/pages/InsurancePricingPage.tsx b/assets/admin/pages/InsurancePricingPage.tsx index 2a97e665..35aed88a 100644 --- a/assets/admin/pages/InsurancePricingPage.tsx +++ b/assets/admin/pages/InsurancePricingPage.tsx @@ -1,16 +1,19 @@ import PageHeader from '../components/ui/PageHeader'; import TenantInsuranceContracts from '../components/TenantInsuranceContracts'; import FreeVisitPrice from '../components/FreeVisitPrice'; +import FeatureGate from '../components/ui/FeatureGate'; export default function InsurancePricingPage() { return ( -
- - - -
+ +
+ + + +
+
); }