feat(subscription): gate insurance pages behind "insurance" plan feature
Wrap InsurancePricingPage and ClaimsPage in <FeatureGate feature="insurance"> so direct-URL access is blocked without an active subscription that enables the feature. Add feature: "insurance" to the sidebar links (doctor/clinic/ secretary sections) so the menu items hide when the plan lacks it. Make AdminSubscriptionPage feature controls dynamic: derive the plan feature checkboxes from FEATURE_LABELS (now including "insurance") and switch the Zod schema to z.record, so adding a feature only touches the label map. No backend/migration change: plan.features is free-form JSON; existing plans default to insurance:false and admins enable it per plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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` با `<FeatureGate feature="services">` و `SmsWalletPage` با `<FeatureGate feature="sms_panel">`.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
صفحات **«قیمتگذاری بیمه» (`/admin/insurance-pricing`)** و **«مطالبات بیمه» (`/admin/claims`)** بهتازگی اضافه شدهاند ولی **هیچ محدودسازی اشتراکی ندارند** — نه در سطح صفحه و نه در منو:
|
||||
|
||||
- `InsurancePricingPage.tsx` و `ClaimsPage.tsx` در `<FeatureGate>` پیچیده **نشدهاند** → باز کردن مستقیم URL، صفحه را بدون اشتراک نمایش میدهد.
|
||||
- در `Sidebar.tsx` لینک این دو صفحه برخلاف `my-patients` **کلید `feature:` ندارند** → همیشه در منو دیده میشوند (در حالیکه `my-patients` با `feature: "patient_records"` مخفی میشود).
|
||||
|
||||
هدف: این دو صفحه فقط برای دارندگانِ پلن اشتراکی فعال که feature **`insurance`** را دارند باز باشند (کلید جدید، طبق تصمیم کاربر). دقیقاً مثل `services` / `sms_panel`.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `assets/admin/pages/InsurancePricingPage.tsx` | باید در `<FeatureGate feature="insurance">` پیچیده شود |
|
||||
| `assets/admin/pages/ClaimsPage.tsx` | باید در `<FeatureGate feature="insurance">` پیچیده شود |
|
||||
| `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 (
|
||||
<FeatureGate feature="sms_panel">
|
||||
...
|
||||
</FeatureGate>
|
||||
);
|
||||
```
|
||||
|
||||
### صفحات محافظتنشده
|
||||
|
||||
```tsx
|
||||
// assets/admin/pages/InsurancePricingPage.tsx — هیچ گاردی ندارد
|
||||
export default function InsurancePricingPage() {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader title="بیمه و قیمتگذاری" description="..." />
|
||||
<FreeVisitPrice />
|
||||
<TenantInsuranceContracts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```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<string, string> = {
|
||||
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` در `<FeatureGate feature="insurance">` بپیچ:
|
||||
|
||||
```tsx
|
||||
// InsurancePricingPage.tsx
|
||||
import FeatureGate from '../components/ui/FeatureGate';
|
||||
|
||||
export default function InsurancePricingPage() {
|
||||
return (
|
||||
<FeatureGate feature="insurance">
|
||||
<div className="fade-in">
|
||||
<PageHeader title="بیمه و قیمتگذاری" description="..." />
|
||||
<FreeVisitPrice />
|
||||
<TenantInsuranceContracts />
|
||||
</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
برای `ClaimsPage` کل JSX داخل `return ( ... )` را در `<FeatureGate feature="insurance">...</FeatureGate>` بگذار. (اگر 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<string, string> = {
|
||||
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 اشتراکی را روی دو صفحهی بیمه اضافه میکند.
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<typeof planSchema>;
|
||||
@@ -60,8 +56,14 @@ const FEATURE_LABELS: Record<string, string> = {
|
||||
patient_records: 'پرونده بیمار',
|
||||
services: 'سرویسها',
|
||||
sms_panel: 'پنل پیامک',
|
||||
insurance: 'بیمه و مطالبات',
|
||||
};
|
||||
|
||||
const FEATURE_KEYS = Object.keys(FEATURE_LABELS);
|
||||
|
||||
const emptyFeatures = (): Record<string, boolean> =>
|
||||
Object.fromEntries(FEATURE_KEYS.map((k) => [k, false]));
|
||||
|
||||
const PLAN_DISPLAY: Record<string, string> = {
|
||||
free: 'رایگان',
|
||||
basic: 'پایه',
|
||||
@@ -86,7 +88,7 @@ function PlansTab() {
|
||||
|
||||
const planForm = useForm<PlanForm>({
|
||||
resolver: zodResolver(planSchema),
|
||||
defaultValues: { features: { patient_records: false, services: false, sms_panel: false }, active: true },
|
||||
defaultValues: { features: emptyFeatures(), active: true },
|
||||
});
|
||||
|
||||
const periodForm = useForm<PeriodForm>({
|
||||
@@ -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 (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<button className="btn primary sm" onClick={() => { planForm.reset({ features: { patient_records: false, services: false, sms_panel: false }, active: true, level: 0, max_secretaries: 1 }); setPlanModal('create'); }}>
|
||||
<button className="btn primary sm" onClick={() => { planForm.reset({ features: emptyFeatures(), active: true, level: 0, max_secretaries: 1 }); setPlanModal('create'); }}>
|
||||
<PlusIcon style={{ width: 16 }} /> پنل جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<FeatureGate feature="insurance">
|
||||
<div className="fade-in">
|
||||
<PageHeader title="مطالبات بیمه" description="پیگیری مطالبات و بدهی بیمهها" />
|
||||
|
||||
@@ -189,5 +191,6 @@ export default function ClaimsPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="بیمه و قیمتگذاری"
|
||||
description="قیمت ویزیت آزاد و قراردادهای بیمه (پایه و تکمیلی)"
|
||||
/>
|
||||
<FreeVisitPrice />
|
||||
<TenantInsuranceContracts />
|
||||
</div>
|
||||
<FeatureGate feature="insurance">
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="بیمه و قیمتگذاری"
|
||||
description="قیمت ویزیت آزاد و قراردادهای بیمه (پایه و تکمیلی)"
|
||||
/>
|
||||
<FreeVisitPrice />
|
||||
<TenantInsuranceContracts />
|
||||
</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user