- Added seed_realistic_data.php to clean existing data and populate the database with realistic entries for doctors, clinics, and secretaries. - Created a structured approach to generate 100 doctors per city with diverse specialties and services. - Implemented database cleanup routines to ensure a fresh start for data seeding. - Enhanced the DoctorSecretaryRepository with improved comments for clarity.
429 lines
17 KiB
Markdown
429 lines
17 KiB
Markdown
# اعمال محدودیتهای اشتراک در پنل ادمین (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`
|