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:
File diff suppressed because it is too large
Load Diff
@@ -14,30 +14,31 @@
|
||||
|
||||
قوانین:
|
||||
|
||||
| ایجادکننده | `owner_type` | `clinic_id` |
|
||||
|-----------|-------------|-------------|
|
||||
| خود دکتر | `'doctor'` | `null` |
|
||||
| کلینیک | `'clinic'` | `<id کلینیک>` |
|
||||
| ایجادکننده | `owner_type` | `clinic_id` |
|
||||
| ---------- | ------------ | ------------- |
|
||||
| خود دکتر | `'doctor'` | `null` |
|
||||
| کلینیک | `'clinic'` | `<id کلینیک>` |
|
||||
|
||||
دسترسیها، داشبورد، و فیلتر نوبتها باید فقط در scope مربوطه اعمال شوند.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود |
|
||||
| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند |
|
||||
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند |
|
||||
| `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبتهای منشی — باید scope-aware شود |
|
||||
| `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود |
|
||||
| `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود |
|
||||
| `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد |
|
||||
| `migrations/` | migration جدید برای `owner_type` + `clinic_id` |
|
||||
| `docs/api/secretary.md` | مستندات API |
|
||||
| فایل | نقش |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود |
|
||||
| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند |
|
||||
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند |
|
||||
| `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبتهای منشی — باید scope-aware شود |
|
||||
| `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود |
|
||||
| `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود |
|
||||
| `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد |
|
||||
| `migrations/` | migration جدید برای `owner_type` + `clinic_id` |
|
||||
| `docs/api/secretary.md` | مستندات API |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### Entity (بدون scope)
|
||||
|
||||
```php
|
||||
// src/Secretary/Entity/DoctorSecretary.php
|
||||
// هیچ فیلد owner_type یا clinic_id وجود ندارد
|
||||
@@ -54,6 +55,7 @@ class DoctorSecretary
|
||||
```
|
||||
|
||||
### Repository — scope-blind
|
||||
|
||||
```php
|
||||
public function findActiveBySecretary(User $user): ?DoctorSecretary
|
||||
{
|
||||
@@ -63,6 +65,7 @@ public function findActiveBySecretary(User $user): ?DoctorSecretary
|
||||
```
|
||||
|
||||
### Controller — ایجاد بدون scope
|
||||
|
||||
```php
|
||||
// create() در SecretaryController
|
||||
// هیچ owner_type یا clinic_id ست نمیشود
|
||||
@@ -70,6 +73,7 @@ $secretary = new DoctorSecretary($doctor, $secretaryUser);
|
||||
```
|
||||
|
||||
### Auth context builder — scope-blind
|
||||
|
||||
```php
|
||||
// AuthController::buildContexts()
|
||||
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||
@@ -86,6 +90,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||
```
|
||||
|
||||
### Appointment فیلتر — فقط یک رابطه
|
||||
|
||||
```php
|
||||
// MyAppointmentsController — منشی فقط به نوبتهای یک دکتر دسترسی دارد
|
||||
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
@@ -128,6 +133,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
|
||||
```
|
||||
|
||||
در `toArray()` هم اضافه شود:
|
||||
|
||||
```php
|
||||
'owner_type' => $this->ownerType,
|
||||
'clinic_uuid' => $this->clinic?->getUuid(),
|
||||
@@ -136,6 +142,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
|
||||
### ۲. Migration
|
||||
|
||||
بعد از تغییر Entity:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
@@ -206,7 +213,8 @@ public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): arra
|
||||
}
|
||||
```
|
||||
|
||||
همچنین `findByClinic` باید فقط منشیان با `owner_type='clinic'` را برگرداند:
|
||||
همچنین `findByClinic` باید فقط منشی ها با `owner_type='clinic'` را برگرداند:
|
||||
|
||||
```php
|
||||
public function findByClinic(Clinic $clinic): array
|
||||
{
|
||||
@@ -225,6 +233,7 @@ public function findByClinic(Clinic $clinic): array
|
||||
### ۴. SecretaryController: ایجاد با scope
|
||||
|
||||
در `create()`:
|
||||
|
||||
- اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)`
|
||||
- اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null`
|
||||
|
||||
@@ -238,6 +247,7 @@ if ($currentUser->hasRole('ROLE_CLINIC')) {
|
||||
```
|
||||
|
||||
همچنین `canManageDoctor()` باید scope را چک کند:
|
||||
|
||||
- `ROLE_DOCTOR` فقط میتواند روابط `owner_type='doctor'` را مدیریت کند
|
||||
- `ROLE_CLINIC` فقط میتواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند
|
||||
|
||||
@@ -278,6 +288,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||
### ۶. Appointment و Dashboard: scope-aware
|
||||
|
||||
در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`:
|
||||
|
||||
- اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آنها متصل است
|
||||
- اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'`
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
# Seed واقعی دیتا — پاکسازی و ایجاد ۱۰۰ دکتر به ازای هر شهر
|
||||
|
||||
## زمینه
|
||||
|
||||
پروژه ClinicPro نیاز به دیتای واقعی و کامل برای تست و نمایش دارد. در حال حاضر دیتا ناقص یا تستنشده است. باید دیتابیس از دکتر، کلینیک، منشی و بیمار پاک شود، سپس دیتای واقعی با پروفایل کامل ایجاد شود.
|
||||
|
||||
## هدف
|
||||
|
||||
۱. پاکسازی کامل: users، doctors، clinics، patient_records، doctor_secretaries
|
||||
۲. ایجاد یک **کلینیک نمونه کامل** با همه فیلدها
|
||||
۳. ایجاد یک **دکتر نمونه کامل** با برنامه کاری و منشی
|
||||
۴. ایجاد **۱۰۰ دکتر به ازای هر شهر** در تخصصهای مختلف
|
||||
|
||||
## ساختار دیتابیس (جداول واقعی)
|
||||
|
||||
```
|
||||
users → id, uuid, mobile_number, password_hash, real_name, roles JSON, status
|
||||
doctors → id, uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id
|
||||
clinics → id, uuid, user_id, name, info, address, telephone, is_24_7, working_days, latitude, longitude, city_id, province_id, representation_id, is_active
|
||||
doctor_addresses → id, uuid, doctor_id, clinic_id, city_id, province_id, type('personal'|'clinic'), name, address, telephone
|
||||
weekly_schedules → id, uuid, doctor_id, setting (JSON — آرایه ۷ روز)
|
||||
doctor_specialties → doctor_id, specialty_id
|
||||
doctor_expertise → doctor_id, service_id
|
||||
doctor_insurances → doctor_id, insurance_id
|
||||
doctor_cities → doctor_id, city_id
|
||||
doctor_provinces → doctor_id, province_id
|
||||
clinic_doctors → clinic_id, doctor_id
|
||||
clinic_specialties → clinic_id, specialty_id
|
||||
clinic_services → clinic_id, service_id
|
||||
clinic_insurances → clinic_id, insurance_id
|
||||
doctor_secretaries → id, uuid, doctor_id, secretary_id, active, owner_type, clinic_id
|
||||
patient_records → id, uuid, user_id, entity_type, entity_id, created_by_type, created_by_id
|
||||
```
|
||||
|
||||
## IDهای واقعی lookup tables
|
||||
|
||||
### شهرها (cities.id)
|
||||
```
|
||||
101=تبریز 102=ارومیه 103=اردبیل 104=اصفهان 105=کرج
|
||||
106=ایلام 107=بوشهر 108=تهران 109=شهرکرد 110=بیرجند
|
||||
111=مشهد 112=بجنورد 113=اهواز 114=زنجان 115=سمنان
|
||||
116=زاهدان 117=شیراز 118=قزوین 119=قم 120=سنندج
|
||||
121=کرمان 122=کرمانشاه 123=یاسوج 125=گرگان 126=رشت
|
||||
127=خرمآباد 128=ساری 129=اراک 130=بندرعباس 131=همدان 132=یزد
|
||||
```
|
||||
|
||||
### استانها (provinces.id)
|
||||
```
|
||||
1=آذربایجان شرقی 2=آذربایجان غربی 3=اردبیل 4=اصفهان 5=البرز
|
||||
6=ایلام 7=بوشهر 8=تهران 9=چهارمحال 10=خراسان جنوبی
|
||||
11=خراسان رضوی 12=خراسان شمالی 13=خوزستان 14=زنجان 15=سمنان
|
||||
16=سیستان 17=فارس 18=قزوین 19=قم 20=کردستان
|
||||
21=کرمان 22=کرمانشاه 23=کهگیلویه 24=گلستان 25=گیلان
|
||||
26=لرستان 27=مازندران 28=مرکزی 29=هرمزگان 30=همدان 100=یزد
|
||||
```
|
||||
نگاشت شهر → استان:
|
||||
`101→1, 102→2, 103→3, 104→4, 105→5, 106→6, 107→7, 108→8, 109→9, 110→10, 111→11, 112→12, 113→13, 114→14, 115→15, 116→16, 117→17, 118→18, 119→19, 120→20, 121→21, 122→22, 123→23, 125→24, 126→25, 127→26, 128→27, 129→28, 130→29, 131→30, 132→100`
|
||||
|
||||
### تخصصها (specialties.id) — دستهبندی برای توزیع واقعی
|
||||
```
|
||||
داخلی: 1,2,3,4,5,6,7,8,9,10,11,12
|
||||
جراحی: 20,21,22,23,24,25,26,27,28,29,30
|
||||
اطفال: 40,41,42,43,44,45
|
||||
زنان: 60,61,62,63,64,65
|
||||
روانپزشکی: 70,71,72,73,74
|
||||
پوست: 80,81,82,83,84
|
||||
چشم: 90,91,92,93,94,95,96,97,98
|
||||
ENT: 100,101,102,103,104
|
||||
ارتوپدی: 110,111,112,113,114,115,116
|
||||
اورولوژی: 120,121,122,123,124
|
||||
رادیولوژی: 130,131,132,133,134
|
||||
انکولوژی: 150,151,152
|
||||
طب فیزیکی: 170,171,172,173
|
||||
دندانپزشکی: 180,181,182,183,184,185,186,187,188,189
|
||||
اورژانس/عمومی: 190,200
|
||||
```
|
||||
|
||||
### خدمات پزشکی (doctor_services.id) — نمونههایی برای هر تخصص
|
||||
```
|
||||
قلب: 101,102,103,104,105,110
|
||||
گوارش: 201,202,203,210
|
||||
ریه: 301,302,303,307
|
||||
کلیه: 401,402,403,406
|
||||
غدد: 501,502,503,505,506
|
||||
روماتولوژی: 601,602,603,604
|
||||
خون: 701,702,703,704
|
||||
نورولوژی: 801,802,803,804,805
|
||||
آلرژی: 901,902,903,905
|
||||
عفونی: 1001,1002,1003
|
||||
جراحی عمومی: 2101,2102,2103,2104,2105,2113
|
||||
جراحی مغز: 2201,2202,2203,2207
|
||||
```
|
||||
|
||||
### بیمهها (insurances.id)
|
||||
```
|
||||
1=تأمین اجتماعی 2=خدمات درمانی 3=نیروهای مسلح 4=کمیته امداد
|
||||
5=بهزیستی 6=بیمه روستایی 7=ایران 8=آسیا 9=البرز
|
||||
10=پارسیان 11=سامان 12=میهن 13=دانا 14=رازی 15=کوثر
|
||||
```
|
||||
|
||||
## ساختار weekly_schedules.setting (JSON)
|
||||
|
||||
آرایه ۷ عنصر (شنبه تا جمعه). هر عنصر:
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"active": true,
|
||||
"location_id": <doctor_address.id>,
|
||||
"start_time": "09:00",
|
||||
"end_time": "13:00",
|
||||
"duration_per_patient": 20,
|
||||
"has_rest": true,
|
||||
"rest_interval": 60,
|
||||
"time_to_rest": 10,
|
||||
"patient_limit": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
روزهای بدون کار: `{"sessions": []}`
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. ایجاد فایل seed_realistic_data.php
|
||||
|
||||
فایل را در ریشه پروژه (`/var/www/html/seed_realistic_data.php`) بساز. این فایل باید:
|
||||
|
||||
#### بخش A — پاکسازی (به ترتیب FK)
|
||||
|
||||
```php
|
||||
// حذف به ترتیب وابستگی FK
|
||||
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
|
||||
$conn->executeStatement('DELETE FROM weekly_schedules WHERE doctor_id IN (SELECT id FROM doctors)');
|
||||
$conn->executeStatement('DELETE FROM doctor_addresses');
|
||||
$conn->executeStatement('DELETE FROM doctor_specialties');
|
||||
$conn->executeStatement('DELETE FROM doctor_expertise');
|
||||
$conn->executeStatement('DELETE FROM doctor_insurances');
|
||||
$conn->executeStatement('DELETE FROM doctor_cities');
|
||||
$conn->executeStatement('DELETE FROM doctor_provinces');
|
||||
$conn->executeStatement('DELETE FROM doctor_secretaries');
|
||||
$conn->executeStatement('DELETE FROM clinic_doctors');
|
||||
$conn->executeStatement('DELETE FROM clinic_specialties');
|
||||
$conn->executeStatement('DELETE FROM clinic_services');
|
||||
$conn->executeStatement('DELETE FROM clinic_insurances');
|
||||
$conn->executeStatement('DELETE FROM patient_records');
|
||||
$conn->executeStatement('DELETE FROM appointments');
|
||||
$conn->executeStatement('DELETE FROM payments');
|
||||
// حذف doctors, clinics, users (به جز ادمینها)
|
||||
$conn->executeStatement("DELETE d FROM doctors d");
|
||||
$conn->executeStatement("DELETE c FROM clinics c");
|
||||
$conn->executeStatement("DELETE u FROM users u WHERE JSON_CONTAINS(u.roles, '\"ROLE_DOCTOR\"') OR JSON_CONTAINS(u.roles, '\"ROLE_CLINIC\"') OR JSON_CONTAINS(u.roles, '\"ROLE_PATIENT\"') OR JSON_CONTAINS(u.roles, '\"ROLE_SECRETARY\"')");
|
||||
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
|
||||
```
|
||||
|
||||
#### بخش B — کلینیک نمونه کامل (تبریز)
|
||||
|
||||
یک کلینیک با همه فیلدها:
|
||||
- نام: کلینیک تخصصی امید تبریز
|
||||
- city_id=101, province_id=1
|
||||
- representation_id=101 (تبریز)
|
||||
- تمام specialties داخلی + قلب
|
||||
- بیمههای 1,2,3,7,8,9
|
||||
- خدمات 201,202,203,101,102,103
|
||||
|
||||
#### بخش C — دکتر نمونه کامل (تبریز)
|
||||
|
||||
دکتر کامل با:
|
||||
- تخصص قلب (id=3) + داخلی (id=2)
|
||||
- بیمههای 1,2,3,7
|
||||
- خدمات 101,102,103,104,110
|
||||
- آدرس مطب در تبریز + آدرس کلینیک امید
|
||||
- برنامه کاری: شنبه تا چهارشنبه ۹-۱۳ و ۱۴-۱۸، پنجشنبه ۹-۱۳، جمعه تعطیل
|
||||
- یک منشی
|
||||
- representation_id=101
|
||||
|
||||
#### بخش D — ۱۰۰ دکتر برای هر شهر (bulk generation)
|
||||
|
||||
برای شهرهای: `[101,102,103,104,105,108,111,113,117,121,122,126,128,131,132]` (۱۵ شهر مهم)
|
||||
|
||||
هر دکتر باید داشته باشد:
|
||||
- user با موبایل منحصربهفرد (فرمت: `09XXXXXXXXX` — از ۰۹۱۰۰۱۰۰۰۰۰ به بالا)
|
||||
- نام واقعی فارسی (از لیست ۱۰۰ نام ایرانی)
|
||||
- پروفایل کامل: gender, degree, medical_system_code, info, rate
|
||||
- تخصص (توزیع واقعی بین تخصصها)
|
||||
- ۲-۳ خدمات متناسب با تخصص
|
||||
- بیمههای 1,2 (پایه) + یک بیمه تکمیلی تصادفی
|
||||
- یک آدرس مطب در شهرش
|
||||
- برنامه کاری (۵ روز کاری)
|
||||
- representation_id برابر با id شهر
|
||||
|
||||
**لیست نامهای واقعی ایرانی (برای استفاده در generator):**
|
||||
```php
|
||||
$firstNames = ['علی', 'محمد', 'حسین', 'رضا', 'امیر', 'مهدی', 'سعید', 'احمد', 'حسن', 'جواد',
|
||||
'فاطمه', 'زهرا', 'مریم', 'سارا', 'نگین', 'الهه', 'نیلوفر', 'شیما', 'لیلا', 'نازنین',
|
||||
'کریم', 'ابراهیم', 'اکبر', 'ناصر', 'پیمان', 'بهروز', 'شاهین', 'کاوه', 'آرش', 'سهراب',
|
||||
'مینا', 'آزاده', 'ندا', 'رویا', 'پریسا', 'گلناز', 'سپیده', 'بهاره', 'شادی', 'فریده'];
|
||||
$lastNames = ['رضایی', 'محمدی', 'حسینی', 'علوی', 'موسوی', 'احمدی', 'کریمی', 'صادقی', 'جعفری',
|
||||
'مرادی', 'نجفی', 'تبریزی', 'اصفهانی', 'مشهدی', 'شیرازی', 'کرمانی', 'رشیدی', 'نظری',
|
||||
'ابراهیمی', 'قاسمی', 'یوسفی', 'منصوری', 'زارعی', 'ملکی', 'بهرامی', 'سلیمانی', 'نوری'];
|
||||
```
|
||||
|
||||
**توزیع واقعی تخصصها در ۱۰۰ دکتر:**
|
||||
```php
|
||||
$specialtyGroups = [
|
||||
['specs' => [200], 'services' => [], 'degree' => 'general', 'count' => 15], // عمومی ۱۵
|
||||
['specs' => [2,3], 'services' => [101,102,103,110], 'degree' => 'specialist', 'count' => 10], // داخلی+قلب ۱۰
|
||||
['specs' => [4], 'services' => [201,202,203,210], 'degree' => 'specialist', 'count' => 6], // گوارش ۶
|
||||
['specs' => [10], 'services' => [801,802,803,805], 'degree' => 'specialist', 'count' => 6], // نورولوژی ۶
|
||||
['specs' => [21], 'services' => [2101,2102,2103,2113], 'degree' => 'specialist', 'count' => 8],// جراحی ۸
|
||||
['specs' => [61], 'services' => [], 'degree' => 'specialist', 'count' => 8], // زنان ۸
|
||||
['specs' => [41], 'services' => [], 'degree' => 'specialist', 'count' => 8], // اطفال ۸
|
||||
['specs' => [111], 'services' => [], 'degree' => 'specialist', 'count' => 7], // ارتوپدی ۷
|
||||
['specs' => [81,82], 'services' => [], 'degree' => 'specialist', 'count' => 6], // پوست ۶
|
||||
['specs' => [91], 'services' => [], 'degree' => 'specialist', 'count' => 6], // چشم ۶
|
||||
['specs' => [101], 'services' => [], 'degree' => 'specialist', 'count' => 5], // ENT ۵
|
||||
['specs' => [71], 'services' => [], 'degree' => 'specialist', 'count' => 5], // روانپزشکی ۵
|
||||
['specs' => [121], 'services' => [], 'degree' => 'specialist', 'count' => 4], // اورولوژی ۴
|
||||
['specs' => [181], 'services' => [], 'degree' => 'dentist', 'count' => 6], // دندانپزشکی ۶
|
||||
];
|
||||
// جمع = 100
|
||||
```
|
||||
|
||||
**فرمت برنامه کاری (۷ روز شنبه تا جمعه):**
|
||||
```php
|
||||
function makeSchedule(int $addressId): string {
|
||||
$days = [];
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
if ($i === 6) { // جمعه تعطیل
|
||||
$days[] = ['sessions' => []];
|
||||
} elseif ($i === 5) { // پنجشنبه نیمهوقت
|
||||
$days[] = ['sessions' => [
|
||||
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
|
||||
'end_time' => '13:00', 'duration_per_patient' => 20,
|
||||
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null]
|
||||
]];
|
||||
} else { // شنبه تا چهارشنبه
|
||||
$days[] = ['sessions' => [
|
||||
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
|
||||
'end_time' => '13:00', 'duration_per_patient' => 20,
|
||||
'has_rest' => true, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
|
||||
['active' => true, 'location_id' => $addressId, 'start_time' => '14:00',
|
||||
'end_time' => '18:00', 'duration_per_patient' => 20,
|
||||
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
|
||||
]];
|
||||
}
|
||||
}
|
||||
return json_encode($days, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
```
|
||||
|
||||
## نکات مهم اجرا
|
||||
|
||||
- **نام فایل PHP:** `seed_realistic_data.php` در ریشه پروژه
|
||||
- **password_hash:** از `password_hash('Test@1234', PASSWORD_BCRYPT)` برای همه کاربران
|
||||
- **roles JSON:** `'["ROLE_USER","ROLE_DOCTOR"]'` برای دکتر، `'["ROLE_USER","ROLE_CLINIC"]'` برای کلینیک
|
||||
- **uuid:** از `Symfony\Component\Uid\Uuid::v4()->toRfc4122()` یا `bin2hex(random_bytes(8))` — باید unique باشد
|
||||
- **موبایل منحصربهفرد:** از counter شمارنده استفاده کن تا تکراری نشود
|
||||
- **representation_id:** برابر با city_id شهر مربوطه (چون cities.id = representations.id)
|
||||
- **FK ها:** weekly_schedule بعد از ایجاد doctor_address insert شود (نیاز به address.id دارد)
|
||||
- **transaction:** همه insert ها در یک transaction بزرگ بپیچ تا در صورت خطا rollback شود
|
||||
- **echo پیشرفت:** بعد از هر شهر تعداد دکتر ایجادشده را چاپ کن
|
||||
|
||||
## دستور اجرا
|
||||
|
||||
```bash
|
||||
ddev exec php seed_realistic_data.php
|
||||
```
|
||||
|
||||
## خروجی مورد انتظار
|
||||
|
||||
```
|
||||
✅ پاکسازی کامل شد
|
||||
✅ کلینیک امید تبریز ایجاد شد (id: X)
|
||||
✅ دکتر نمونه تبریز ایجاد شد (id: Y)
|
||||
🏙️ تبریز: 100 دکتر ایجاد شد
|
||||
🏙️ تهران: 100 دکتر ایجاد شد
|
||||
...
|
||||
✅ پایان — مجموع X دکتر در Y شهر ایجاد شد
|
||||
```
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -10,41 +10,31 @@
|
||||
|
||||
```css
|
||||
/* متغیرها */
|
||||
--primary: oklch(0.55 0.2 256)
|
||||
--primary-subtle: oklch(0.97 0.02 256)
|
||||
--surface: #fff
|
||||
--border: oklch(0.9 0 0)
|
||||
--text-1 / --text-2 / --text-3
|
||||
--r (border-radius base)
|
||||
--gap / --card-pad
|
||||
|
||||
/* کلاسهای کاربردی */
|
||||
.card /* white surface با shadow و border-radius */
|
||||
.card-pad /* padding داخل card */
|
||||
.stat /* عدد آماری بزرگ + label */
|
||||
.stat-grid /* grid چند ستونه برای statها */
|
||||
.badge green/red/yellow/blue/gray /* برچسب وضعیت */
|
||||
.btn primary/sm/ghost /* دکمه */
|
||||
.field /* label + input wrapper */
|
||||
.seg /* segment/tab selector */
|
||||
.bar /* نوار پیشرفت */
|
||||
.grid-2 /* دوستونه responsive */
|
||||
.avatar sm /* آواتار دایرهای */
|
||||
--primary: oklch(0.55 0.2 256) --primary-subtle: oklch(0.97 0.02 256)
|
||||
--surface: #fff --border: oklch(0.9 0 0) --text-1 / --text-2 / --text-3 --r
|
||||
(border-radius base) --gap / --card-pad /* کلاسهای کاربردی */ .card
|
||||
/* white surface با shadow و border-radius */ .card-pad
|
||||
/* padding داخل card */ .stat /* عدد آماری بزرگ + label */ .stat-grid
|
||||
/* grid چند ستونه برای statها */ .badge green/red/yellow/blue/gray
|
||||
/* برچسب وضعیت */ .btn primary/sm/ghost /* دکمه */ .field
|
||||
/* label + input wrapper */ .seg /* segment/tab selector */ .bar
|
||||
/* نوار پیشرفت */ .grid-2 /* دوستونه responsive */ .avatar sm
|
||||
/* آواتار دایرهای */;
|
||||
```
|
||||
|
||||
**الگوی header صفحات:** `<PageHeader title="..." description="..." action={<button>} />`
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
|
||||
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
|
||||
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشیان — CRUD + permissions matrix |
|
||||
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویسهای کلینیک — دو پنل: بخشها + آیتمها |
|
||||
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
|
||||
| `assets/admin/styles.css` | سیستم CSS کامل |
|
||||
| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحیشده با gradient + progress bar |
|
||||
| فایل | نقش |
|
||||
| ------------------------------------------- | -------------------------------------------------------- |
|
||||
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
|
||||
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
|
||||
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشی ها — CRUD + permissions matrix |
|
||||
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویسهای کلینیک — دو پنل: بخشها + آیتمها |
|
||||
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
|
||||
| `assets/admin/styles.css` | سیستم CSS کامل |
|
||||
| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحیشده با gradient + progress bar |
|
||||
|
||||
## وضعیت فعلی هر صفحه
|
||||
|
||||
@@ -173,6 +163,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
**هدف:** لیست با identity واضح + پرونده بیمار با مینیداشبورد.
|
||||
|
||||
**الف — حالت لیست بیماران:**
|
||||
|
||||
- Header اختصاصی: `<PageHeader>` با description «مراجعهکنندگان ثبتشده شما»
|
||||
- Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول)
|
||||
- `DataTable` را با ردیفهای بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی
|
||||
@@ -180,15 +171,17 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
- وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما
|
||||
|
||||
**ب — حالت پرونده بیمار (selectedRecord):**
|
||||
|
||||
- PageHeader با نام بیمار + شماره + تاریخ ثبت
|
||||
- یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`)
|
||||
- SessionRow باید بهتر شود — هر مراجعه یک card مستقل با:
|
||||
- سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge
|
||||
- بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row
|
||||
- footer: لیست خدمات (services) اگر وجود داشت
|
||||
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت
|
||||
- سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge
|
||||
- بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row
|
||||
- footer: لیست خدمات (services) اگر وجود داشت
|
||||
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت
|
||||
|
||||
**ج — Modal ثبت مراجعه:**
|
||||
|
||||
- سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سهستونه گذاشتن خوب است — این کافیست
|
||||
- بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن
|
||||
- لیست خدمات انتخابشده را با badge یا chip نشان بده (نه فقط متن)
|
||||
@@ -202,20 +195,23 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
**هدف:** صفحهای با KPI bar + جدول بهتر + فرم واضحتر.
|
||||
|
||||
**الف — KPI bar:**
|
||||
|
||||
- بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال
|
||||
- داده از همان `staff` query محاسبه شود (نه API جدید):
|
||||
```ts
|
||||
const total = staff.length;
|
||||
const active = staff.filter((s) => s.active).length;
|
||||
const inactive = total - active;
|
||||
```
|
||||
```ts
|
||||
const total = staff.length;
|
||||
const active = staff.filter((s) => s.active).length;
|
||||
const inactive = total - active;
|
||||
```
|
||||
|
||||
**ب — جدول بهتر:**
|
||||
|
||||
- ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage)
|
||||
- ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود)
|
||||
- ردیفهای hover state با background تغییر کند
|
||||
|
||||
**ج — فرم StaffFormFields:**
|
||||
|
||||
- فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده
|
||||
- فیلدهای `phone` و `national_code` را در یک ردیف دیگر
|
||||
- فیلد `address` تنها بماند (textarea یا input تمام عرض)
|
||||
@@ -226,7 +222,8 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
|
||||
**هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصریتر + Empty State جذاب.
|
||||
|
||||
**الف — جدول منشیان:**
|
||||
**الف — جدول منشی ها:**
|
||||
|
||||
- جدول دستی را **حذف کن** و از `DataTable` component استفاده کن
|
||||
- ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد
|
||||
- ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن
|
||||
@@ -235,21 +232,32 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
- ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسیها + غیرفعالسازی)
|
||||
|
||||
**ب — Empty State:**
|
||||
|
||||
- وقتی `secretaries.length === 0`:
|
||||
```tsx
|
||||
<div className="card card-pad" style={{ textAlign: 'center', padding: '60px 24px' }}>
|
||||
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px' }} />
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>هنوز منشیای اضافه نشده</div>
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}>
|
||||
منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند
|
||||
```tsx
|
||||
<div
|
||||
className="card card-pad"
|
||||
style={{ textAlign: "center", padding: "60px 24px" }}
|
||||
>
|
||||
<IdentificationIcon
|
||||
style={{ width: 48, color: "var(--text-3)", margin: "0 auto 16px" }}
|
||||
/>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
هنوز منشیای اضافه نشده
|
||||
</div>
|
||||
<div
|
||||
style={{ color: "var(--text-3)", fontSize: 13.5, marginBottom: 20 }}
|
||||
>
|
||||
منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
```
|
||||
|
||||
**ج — PermissionsMatrix:**
|
||||
|
||||
- ردیفهای جدول با رنگ متناوب (striped) یا hover state
|
||||
- checkboxها با `accent-color: var(--primary)` (قبلاً بود، نگه دار)
|
||||
- header ستونها را bold و با background کمرنگ کن
|
||||
@@ -262,38 +270,49 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
**هدف:** پنل بخشها بهتر + جدول آیتمها با badge فعال/غیرفعال.
|
||||
|
||||
**الف — پنل بخشها (ستون ۲۸۰px):**
|
||||
|
||||
- هر بخش یک item با:
|
||||
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
|
||||
- نام بخش bold
|
||||
- badge «فعال» / «غیرفعال» بر اساس `s.active`
|
||||
- دکمههای edit/delete فقط هنگام hover نشان داده شوند (opacity transition)
|
||||
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
|
||||
- نام بخش bold
|
||||
- badge «فعال» / «غیرفعال» بر اساس `s.active`
|
||||
- دکمههای edit/delete فقط هنگام hover نشان داده شوند (opacity transition)
|
||||
- انتخاب شده: border `var(--primary)` + background `var(--primary-subtle)`
|
||||
- افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون)
|
||||
|
||||
**ب — پنل آیتمها (ستون 1fr):**
|
||||
|
||||
- بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتمها + دکمه «سرویس جدید»
|
||||
- هر row آیتم:
|
||||
- badge «فعال» / «غیرفعال» برای `item.active`
|
||||
- قیمت با `formatRial()` و رنگ primary
|
||||
- نام پرسنل با avatar خیلی کوچک اگر وجود داشت
|
||||
- badge «فعال» / «غیرفعال» برای `item.active`
|
||||
- قیمت با `formatRial()` و رنگ primary
|
||||
- نام پرسنل با avatar خیلی کوچک اگر وجود داشت
|
||||
- Empty state وقتی `items.length === 0`:
|
||||
```tsx
|
||||
<div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}>
|
||||
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block' }} />
|
||||
سرویسی در این بخش وجود ندارد
|
||||
<br />
|
||||
<button className="btn primary sm" style={{ marginTop: 12 }} onClick={...}>افزودن سرویس</button>
|
||||
</div>
|
||||
```
|
||||
```tsx
|
||||
<div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}>
|
||||
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block' }} />
|
||||
سرویسی در این بخش وجود ندارد
|
||||
<br />
|
||||
<button className="btn primary sm" style={{ marginTop: 12 }} onClick={...}>افزودن سرویس</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**ج — State اولیه (هیچ بخشی انتخاب نشده):**
|
||||
|
||||
- جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده:
|
||||
```tsx
|
||||
<div style={{ border: '2px dashed var(--border)', borderRadius: 10, padding: '60px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<WrenchScrewdriverIcon style={{ width: 40, margin: '0 auto 12px' }} />
|
||||
<div>یک بخش انتخاب کنید تا سرویسهای آن را ببینید</div>
|
||||
</div>
|
||||
```
|
||||
```tsx
|
||||
<div
|
||||
style={{
|
||||
border: "2px dashed var(--border)",
|
||||
borderRadius: 10,
|
||||
padding: "60px 0",
|
||||
textAlign: "center",
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
>
|
||||
<WrenchScrewdriverIcon style={{ width: 40, margin: "0 auto 12px" }} />
|
||||
<div>یک بخش انتخاب کنید تا سرویسهای آن را ببینید</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -302,6 +321,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
**هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضحتر + جدول تراکنشها با visual type indicator.
|
||||
|
||||
**الف — کارت موجودی:**
|
||||
|
||||
- gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))`
|
||||
- رنگ متن: white
|
||||
- موجودی را بزرگتر کن: `fontSize: 36, fontWeight: 800`
|
||||
@@ -310,48 +330,55 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
- دکمه شارژ: white با primary text (برعکس از theme معمول)
|
||||
|
||||
**ب — کارت تنظیمات:**
|
||||
|
||||
- عنوان با divider پایین
|
||||
- هر toggle را با یک card داخلی wrapper کن:
|
||||
```tsx
|
||||
<div style={{ background: 'var(--primary-subtle)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<input type="checkbox" ... />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 12 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
|
||||
```tsx
|
||||
<div style={{ background: 'var(--primary-subtle)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<input type="checkbox" ... />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 12 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
```
|
||||
- input «چند ساعت قبل» را با border و padding واضحتر style کن
|
||||
|
||||
**ج — جدول تراکنشها:**
|
||||
|
||||
- ستون «نوع» را با یک آیکون بهبود بده:
|
||||
- `credit`: `ArrowUpCircleIcon` با رنگ سبز
|
||||
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز
|
||||
- `credit`: `ArrowUpCircleIcon` با رنگ سبز
|
||||
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز
|
||||
- ستون «مبلغ» را بر اساس نوع رنگ بده: credit → سبز، debit → قرمز
|
||||
- header table با background خاکستری کمرنگ
|
||||
|
||||
**د — Modal شارژ:**
|
||||
|
||||
- انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage):
|
||||
```tsx
|
||||
{(['mellat', 'sep'] as const).map((gw) => (
|
||||
<div
|
||||
key={gw}
|
||||
onClick={() => setGateway(gw)}
|
||||
style={{
|
||||
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
background: gateway === gw ? 'var(--primary-subtle)' : '',
|
||||
transition: 'all 0.15s',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{gw === 'mellat' ? 'بانک ملت' : 'سپ'}</div>
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
```tsx
|
||||
{
|
||||
(["mellat", "sep"] as const).map((gw) => (
|
||||
<div
|
||||
key={gw}
|
||||
onClick={() => setGateway(gw)}
|
||||
style={{
|
||||
border: `2px solid ${gateway === gw ? "var(--primary)" : "var(--border)"}`,
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
cursor: "pointer",
|
||||
background: gateway === gw ? "var(--primary-subtle)" : "",
|
||||
transition: "all 0.15s",
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{gw === "mellat" ? "بانک ملت" : "سپ"}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
```
|
||||
- پیشنمایش مبلغ: وقتی کاربر عدد میزند، زیر input بنویس «پرداخت X ریال از طریق Y»
|
||||
|
||||
## نکات مهم
|
||||
@@ -364,5 +391,5 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
||||
6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است
|
||||
7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد
|
||||
8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است)
|
||||
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشیان استفاده کن
|
||||
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشی ها استفاده کن
|
||||
10. **`formatDate()` و `formatDateTime()`** — همه تاریخها شمسی نمایش داده شوند
|
||||
|
||||
Reference in New Issue
Block a user