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.
This commit is contained in:
hamed
2026-06-15 14:18:25 +03:30
parent 68dfe613a2
commit df7a784701
15 changed files with 3024 additions and 1417 deletions
+201 -142
View File
@@ -3,6 +3,7 @@
## زمینه
سیستم اشتراک در backend کاملاً پیاده است:
- `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت
- `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز
- `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }`
@@ -11,6 +12,7 @@
**مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل.
**هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود:
1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند
@@ -21,12 +23,12 @@
### قابلیت‌های محدودشده توسط اشتراک
| 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 |
| 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 می‌کند).
@@ -34,24 +36,25 @@
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| فایل | نقش |
| -------------------------------------------------------- | ------------------------------------------------------------- |
| `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 ها |
| `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 می‌کند
@@ -70,50 +73,77 @@ private function resolveEntity(User $user): array
```
پاسخ:
```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
}
"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 وجود ندارد
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: 'کیف پول پیامک' },
],
}];
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>} />
@@ -148,34 +178,38 @@ if ($user->hasRole('ROLE_SECRETARY')) {
یک فایل `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';
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 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 { 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;
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,
};
return {
subscription: sub,
hasFeature: (key: string) => hasPlan && (features[key] ?? false),
maxSecretaries,
hasPlan,
isExpiringSoon:
(sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
};
}
```
@@ -186,28 +220,30 @@ export function useSubscription() {
در `Sidebar.tsx`:
```tsx
import { useSubscription } from '../../hooks/useSubscription';
import { useSubscription } from "../../hooks/useSubscription";
// ...
export default function Sidebar() {
const primaryRole = useAuthStore((s) => s.primaryRole);
const dbUuid = useAuthStore((s) => s.dbUuid);
const { hasFeature } = useSubscription();
// ...
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 → آیکون قفل
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' },
@@ -215,18 +251,19 @@ type SectionItem = {
```
در 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}
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>
<item.icon />
{item.label}
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: "auto" }} />}
</NavLink>;
```
import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
@@ -236,81 +273,96 @@ import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
یک 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';
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;
feature: string;
children: React.ReactNode;
}
export default function FeatureGate({ feature, children }: FeatureGateProps) {
const { hasFeature, hasPlan } = useSubscription();
const { hasFeature, hasPlan } = useSubscription();
if (hasFeature(feature)) return <>{children}</>;
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 }}>
این قابلیت در پنل فعلی شما فعال نیست
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>
<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';
import FeatureGate from "../components/ui/FeatureGate";
// ...
return (
<FeatureGate feature="patient_records">
{/* کد فعلی صفحه */}
</FeatureGate>
<FeatureGate feature="patient_records">{/* کد فعلی صفحه */}</FeatureGate>
);
```
در **`ClinicServicesPage.tsx`**:
```tsx
return (
<FeatureGate feature="services">
{/* کد فعلی */}
</FeatureGate>
);
return <FeatureGate feature="services">{/* کد فعلی */}</FeatureGate>;
```
در **`SmsWalletPage.tsx`**:
```tsx
return (
<FeatureGate feature="sms_panel">
{/* کد فعلی */}
</FeatureGate>
);
return <FeatureGate feature="sms_panel">{/* کد فعلی */}</FeatureGate>;
```
### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage
@@ -318,27 +370,34 @@ return (
در **`MySecretariesPage.tsx`** وقتی کاربر می‌خواهد منشی جدید اضافه کند:
```tsx
import { useSubscription } from '../hooks/useSubscription';
import { useSubscription } from "../hooks/useSubscription";
// ...
const { maxSecretaries } = useSubscription();
// تعداد فعلی منشیان از data موجود
const activeCount = secretaries.filter(s => s.is_active).length;
// تعداد فعلی منشی ها از 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}
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>
)}
افزودن منشی
</button>;
{
isAtLimit && (
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 4 }}>
برای افزودن منشی بیشتر{" "}
<Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
</div>
);
}
```
---