Files
clinicpro/.claude/prompt/subscription-gate-ui.md
T
hamed df7a784701 feat: implement realistic data seeding for doctors, clinics, and secretaries
- 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.
2026-06-15 14:18:25 +03:30

429 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# اعمال محدودیت‌های اشتراک در پنل ادمین (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`