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
File diff suppressed because it is too large Load Diff
+27 -16
View File
@@ -14,30 +14,31 @@
قوانین: قوانین:
| ایجادکننده | `owner_type` | `clinic_id` | | ایجادکننده | `owner_type` | `clinic_id` |
|-----------|-------------|-------------| | ---------- | ------------ | ------------- |
| خود دکتر | `'doctor'` | `null` | | خود دکتر | `'doctor'` | `null` |
| کلینیک | `'clinic'` | `<id کلینیک>` | | کلینیک | `'clinic'` | `<id کلینیک>` |
دسترسی‌ها، داشبورد، و فیلتر نوبت‌ها باید فقط در scope مربوطه اعمال شوند. دسترسی‌ها، داشبورد، و فیلتر نوبت‌ها باید فقط در scope مربوطه اعمال شوند.
## فایل‌های مرتبط ## فایل‌های مرتبط
| فایل | نقش | | فایل | نقش |
|------|-----| | --------------------------------------------------------- | ---------------------------------------------------------------- |
| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود | | `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود |
| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند | | `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند |
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند | | `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند |
| `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبت‌های منشی — باید scope-aware شود | | `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبت‌های منشی — باید scope-aware شود |
| `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود | | `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود |
| `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود | | `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود |
| `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد | | `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد |
| `migrations/` | migration جدید برای `owner_type` + `clinic_id` | | `migrations/` | migration جدید برای `owner_type` + `clinic_id` |
| `docs/api/secretary.md` | مستندات API | | `docs/api/secretary.md` | مستندات API |
## وضعیت فعلی ## وضعیت فعلی
### Entity (بدون scope) ### Entity (بدون scope)
```php ```php
// src/Secretary/Entity/DoctorSecretary.php // src/Secretary/Entity/DoctorSecretary.php
// هیچ فیلد owner_type یا clinic_id وجود ندارد // هیچ فیلد owner_type یا clinic_id وجود ندارد
@@ -54,6 +55,7 @@ class DoctorSecretary
``` ```
### Repository — scope-blind ### Repository — scope-blind
```php ```php
public function findActiveBySecretary(User $user): ?DoctorSecretary public function findActiveBySecretary(User $user): ?DoctorSecretary
{ {
@@ -63,6 +65,7 @@ public function findActiveBySecretary(User $user): ?DoctorSecretary
``` ```
### Controller — ایجاد بدون scope ### Controller — ایجاد بدون scope
```php ```php
// create() در SecretaryController // create() در SecretaryController
// هیچ owner_type یا clinic_id ست نمی‌شود // هیچ owner_type یا clinic_id ست نمی‌شود
@@ -70,6 +73,7 @@ $secretary = new DoctorSecretary($doctor, $secretaryUser);
``` ```
### Auth context builder — scope-blind ### Auth context builder — scope-blind
```php ```php
// AuthController::buildContexts() // AuthController::buildContexts()
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
@@ -86,6 +90,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
``` ```
### Appointment فیلتر — فقط یک رابطه ### Appointment فیلتر — فقط یک رابطه
```php ```php
// MyAppointmentsController — منشی فقط به نوبت‌های یک دکتر دسترسی دارد // MyAppointmentsController — منشی فقط به نوبت‌های یک دکتر دسترسی دارد
} elseif (in_array('ROLE_SECRETARY', $roles, true)) { } elseif (in_array('ROLE_SECRETARY', $roles, true)) {
@@ -128,6 +133,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
``` ```
در `toArray()` هم اضافه شود: در `toArray()` هم اضافه شود:
```php ```php
'owner_type' => $this->ownerType, 'owner_type' => $this->ownerType,
'clinic_uuid' => $this->clinic?->getUuid(), 'clinic_uuid' => $this->clinic?->getUuid(),
@@ -136,6 +142,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
### ۲. Migration ### ۲. Migration
بعد از تغییر Entity: بعد از تغییر Entity:
```bash ```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --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 ```php
public function findByClinic(Clinic $clinic): array public function findByClinic(Clinic $clinic): array
{ {
@@ -225,6 +233,7 @@ public function findByClinic(Clinic $clinic): array
### ۴. SecretaryController: ایجاد با scope ### ۴. SecretaryController: ایجاد با scope
در `create()`: در `create()`:
- اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)` - اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)`
- اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null` - اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null`
@@ -238,6 +247,7 @@ if ($currentUser->hasRole('ROLE_CLINIC')) {
``` ```
همچنین `canManageDoctor()` باید scope را چک کند: همچنین `canManageDoctor()` باید scope را چک کند:
- `ROLE_DOCTOR` فقط می‌تواند روابط `owner_type='doctor'` را مدیریت کند - `ROLE_DOCTOR` فقط می‌تواند روابط `owner_type='doctor'` را مدیریت کند
- `ROLE_CLINIC` فقط می‌تواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند - `ROLE_CLINIC` فقط می‌تواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند
@@ -278,6 +288,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
### ۶. Appointment و Dashboard: scope-aware ### ۶. Appointment و Dashboard: scope-aware
در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`: در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`:
- اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آن‌ها متصل است - اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آن‌ها متصل است
- اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'` - اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'`
+280
View File
@@ -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 شهر ایجاد شد
```
+201 -142
View File
@@ -3,6 +3,7 @@
## زمینه ## زمینه
سیستم اشتراک در backend کاملاً پیاده است: سیستم اشتراک در backend کاملاً پیاده است:
- `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت - `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت
- `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز - `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز
- `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }` - `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }`
@@ -11,6 +12,7 @@
**مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل. **مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل.
**هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود: **هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود:
1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد 1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند 2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند 3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند
@@ -21,12 +23,12 @@
### قابلیت‌های محدودشده توسط اشتراک ### قابلیت‌های محدودشده توسط اشتراک
| Feature Key | صفحه/منو | نقش مرتبط | | Feature Key | صفحه/منو | نقش مرتبط |
|-------------|----------|----------| | ----------------- | ------------------------------- | ------------------------- |
| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary | | `patient_records` | `/admin/my-patients` | doctor, clinic, secretary |
| `services` | `/admin/clinic-services` | doctor, clinic | | `services` | `/admin/clinic-services` | doctor, clinic |
| `sms_panel` | `/admin/sms-wallet` | doctor, clinic | | `sms_panel` | `/admin/sms-wallet` | doctor, clinic |
| `max_secretaries` | `/admin/my-secretaries` (تعداد) | doctor, clinic | | `max_secretaries` | `/admin/my-secretaries` (تعداد) | doctor, clinic |
**نکته منشی:** منشی اشتراک مستقل ندارد — اشتراک entity ای که به آن متصل است (دکتر یا کلینیک) اعمال می‌شود. endpoint `/api/v1/subscription/my` برای منشی null برمی‌گرداند (چون `resolveEntity` در `SubscriptionController` فقط ROLE_DOCTOR و ROLE_CLINIC را handle می‌کند). **نکته منشی:** منشی اشتراک مستقل ندارد — اشتراک 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/Controller/SubscriptionController.php` | endpoint `GET /api/v1/subscription/my` — برای doctor و clinic |
| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` | | `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` |
| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد | | `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد |
| `assets/admin/components/layout/Sidebar.tsx` | `buildSections()` — منوها بر اساس `primaryRole` | | `assets/admin/components/layout/Sidebar.tsx` | `buildSections()` — منوها بر اساس `primaryRole` |
| `assets/admin/App.tsx` | `RoleRoute` — گیت نقش‌ها، باید feature gate هم اضافه شود | | `assets/admin/App.tsx` | `RoleRoute` — گیت نقش‌ها، باید feature gate هم اضافه شود |
| `assets/admin/pages/MyPatientsPage.tsx` | نیاز به `patient_records` | | `assets/admin/pages/MyPatientsPage.tsx` | نیاز به `patient_records` |
| `assets/admin/pages/ClinicServicesPage.tsx` | نیاز به `services` | | `assets/admin/pages/ClinicServicesPage.tsx` | نیاز به `services` |
| `assets/admin/pages/SmsWalletPage.tsx` | نیاز به `sms_panel` | | `assets/admin/pages/SmsWalletPage.tsx` | نیاز به `sms_panel` |
| `assets/admin/pages/MySecretariesPage.tsx` | نیاز به `max_secretaries` بیش از ۱ | | `assets/admin/pages/MySecretariesPage.tsx` | نیاز به `max_secretaries` بیش از ۱ |
| `assets/admin/pages/SubscriptionPage.tsx` | صفحه ارتقاء پنل — مقصد CTA ها | | `assets/admin/pages/SubscriptionPage.tsx` | صفحه ارتقاء پنل — مقصد CTA ها |
--- ---
## وضعیت فعلی ## وضعیت فعلی
### Backend — endpoint اشتراک ### Backend — endpoint اشتراک
```php ```php
// SubscriptionController.php — GET /api/v1/subscription/my // SubscriptionController.php — GET /api/v1/subscription/my
// فقط doctor و clinic را handle می‌کند // فقط doctor و clinic را handle می‌کند
@@ -70,50 +73,77 @@ private function resolveEntity(User $user): array
``` ```
پاسخ: پاسخ:
```json ```json
{ {
"success": true, "success": true,
"data": { "data": {
"subscription": { "subscription": {
"uuid": "...", "uuid": "...",
"plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true, "sms_panel": false }, "max_secretaries": 3 }, "plan": {
"is_trial": false, "name": "basic",
"expires_at": 1750000000, "level": 1,
"days_remaining": 45 "features": {
}, "patient_records": true,
"used_trial": false "services": true,
} "sms_panel": false
},
"max_secretaries": 3
},
"is_trial": false,
"expires_at": 1750000000,
"days_remaining": 45
},
"used_trial": false
}
} }
``` ```
اگر اشتراک فعال نداشته باشد: `"subscription": null` اگر اشتراک فعال نداشته باشد: `"subscription": null`
### Frontend — authStore ### Frontend — authStore
```ts ```ts
// authStore.ts — فعلاً subscription اصلاً در store نیست // authStore.ts — فعلاً subscription اصلاً در store نیست
interface AuthState { interface AuthState {
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null; primaryRole: "admin" | "clinic" | "doctor" | "secretary" | "user" | null;
dbUuid: string | null; dbUuid: string | null;
// ... subscription وجود ندارد // ... subscription وجود ندارد
} }
``` ```
### Frontend — Sidebar ### Frontend — Sidebar
```tsx ```tsx
// Sidebar.tsx — همه آیتم‌ها را بدون بررسی feature نمایش می‌دهد // Sidebar.tsx — همه آیتم‌ها را بدون بررسی feature نمایش می‌دهد
if (primaryRole === 'doctor') { if (primaryRole === "doctor") {
return [{ return [
label: 'مدیریت', {
items: [ label: "مدیریت",
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' }, items: [
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' }, {
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' }, to: "/admin/my-patients",
], icon: FolderOpenIcon,
}]; label: "پرونده بیماران",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
},
],
},
];
} }
``` ```
### Frontend — App.tsx ### Frontend — App.tsx
```tsx ```tsx
// فقط نقش‌ها چک می‌شوند، feature gate وجود ندارد // فقط نقش‌ها چک می‌شوند، feature gate وجود ندارد
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} /> <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` بساز: یک فایل `assets/admin/hooks/useSubscription.ts` بساز:
```ts ```ts
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { api } from '../lib/api'; import { api } from "../lib/api";
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from "../stores/authStore";
import type { ApiResponse, MySubscriptionData } from '../types'; import type { ApiResponse, MySubscriptionData } from "../types";
export function useSubscription() { export function useSubscription() {
const primaryRole = useAuthStore((s) => s.primaryRole); const primaryRole = useAuthStore((s) => s.primaryRole);
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary'; const enabled =
primaryRole === "doctor" ||
primaryRole === "clinic" ||
primaryRole === "secretary";
const { data } = useQuery<ApiResponse<MySubscriptionData>>({ const { data } = useQuery<ApiResponse<MySubscriptionData>>({
queryKey: ['subscription-my'], queryKey: ["subscription-my"],
queryFn: () => api.get('/api/v1/subscription/my'), queryFn: () => api.get("/api/v1/subscription/my"),
enabled, enabled,
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
}); });
const sub = data?.data?.subscription ?? null; const sub = data?.data?.subscription ?? null;
const features: Record<string, boolean> = sub?.plan?.features ?? {}; const features: Record<string, boolean> = sub?.plan?.features ?? {};
const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1; const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1;
const hasPlan = sub !== null; const hasPlan = sub !== null;
return { return {
subscription: sub, subscription: sub,
hasFeature: (key: string) => hasPlan && (features[key] ?? false), hasFeature: (key: string) => hasPlan && (features[key] ?? false),
maxSecretaries, maxSecretaries,
hasPlan, hasPlan,
isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, isExpiringSoon:
}; (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
};
} }
``` ```
@@ -186,28 +220,30 @@ export function useSubscription() {
در `Sidebar.tsx`: در `Sidebar.tsx`:
```tsx ```tsx
import { useSubscription } from '../../hooks/useSubscription'; import { useSubscription } from "../../hooks/useSubscription";
// ... // ...
export default function Sidebar() { export default function Sidebar() {
const primaryRole = useAuthStore((s) => s.primaryRole); const primaryRole = useAuthStore((s) => s.primaryRole);
const dbUuid = useAuthStore((s) => s.dbUuid); const dbUuid = useAuthStore((s) => s.dbUuid);
const { hasFeature } = useSubscription(); const { hasFeature } = useSubscription();
// ... // ...
} }
``` ```
نوع `SectionItem` را گسترش بده: نوع `SectionItem` را گسترش بده:
```ts ```ts
type SectionItem = { type SectionItem = {
to: string; to: string;
icon: React.ElementType; icon: React.ElementType;
label: string; label: string;
feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل
}; };
``` ```
آیتم‌های محدود را با `feature` mark کن: آیتم‌های محدود را با `feature` mark کن:
```tsx ```tsx
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
{ to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, { to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' },
@@ -215,18 +251,19 @@ type SectionItem = {
``` ```
در render هر آیتم: در render هر آیتم:
```tsx ```tsx
const isLocked = item.feature ? !hasFeature(item.feature) : false; const isLocked = item.feature ? !hasFeature(item.feature) : false;
<NavLink <NavLink
to={isLocked ? '/admin/subscription' : item.to} to={isLocked ? "/admin/subscription" : item.to}
style={isLocked ? { opacity: 0.5 } : {}} style={isLocked ? { opacity: 0.5 } : {}}
title={isLocked ? 'نیاز به ارتقاء پنل' : undefined} title={isLocked ? "نیاز به ارتقاء پنل" : undefined}
> >
<item.icon /> <item.icon />
{item.label} {item.label}
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: 'auto' }} />} {isLocked && <LockClosedIcon style={{ width: 12, marginRight: "auto" }} />}
</NavLink> </NavLink>;
``` ```
import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';` 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` بساز: یک component کوچک `assets/admin/components/ui/FeatureGate.tsx` بساز:
```tsx ```tsx
import React from 'react'; import React from "react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { LockClosedIcon } from '@heroicons/react/24/outline'; import { LockClosedIcon } from "@heroicons/react/24/outline";
import { useSubscription } from '../../hooks/useSubscription'; import { useSubscription } from "../../hooks/useSubscription";
interface FeatureGateProps { interface FeatureGateProps {
feature: string; feature: string;
children: React.ReactNode; children: React.ReactNode;
} }
export default function FeatureGate({ feature, children }: FeatureGateProps) { 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 ( return (
<div style={{ <div
display: 'flex', flexDirection: 'column', style={{
alignItems: 'center', justifyContent: 'center', display: "flex",
minHeight: 320, gap: 16, padding: 40, textAlign: 'center', flexDirection: "column",
}}> alignItems: "center",
<div style={{ justifyContent: "center",
width: 64, height: 64, borderRadius: '50%', minHeight: 320,
background: 'oklch(0.97 0.01 256)', gap: 16,
display: 'grid', placeItems: 'center', padding: 40,
}}> textAlign: "center",
<LockClosedIcon style={{ width: 28, color: 'var(--text-3)' }} /> }}
</div> >
<div> <div
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}> 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>
<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 در صفحات ### ۵. Frontend — استفاده از FeatureGate در صفحات
در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن: در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن:
```tsx ```tsx
import FeatureGate from '../components/ui/FeatureGate'; import FeatureGate from "../components/ui/FeatureGate";
// ... // ...
return ( return (
<FeatureGate feature="patient_records"> <FeatureGate feature="patient_records">{/* کد فعلی صفحه */}</FeatureGate>
{/* کد فعلی صفحه */}
</FeatureGate>
); );
``` ```
در **`ClinicServicesPage.tsx`**: در **`ClinicServicesPage.tsx`**:
```tsx ```tsx
return ( return <FeatureGate feature="services">{/* کد فعلی */}</FeatureGate>;
<FeatureGate feature="services">
{/* کد فعلی */}
</FeatureGate>
);
``` ```
در **`SmsWalletPage.tsx`**: در **`SmsWalletPage.tsx`**:
```tsx ```tsx
return ( return <FeatureGate feature="sms_panel">{/* کد فعلی */}</FeatureGate>;
<FeatureGate feature="sms_panel">
{/* کد فعلی */}
</FeatureGate>
);
``` ```
### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage ### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage
@@ -318,27 +370,34 @@ return (
در **`MySecretariesPage.tsx`** وقتی کاربر می‌خواهد منشی جدید اضافه کند: در **`MySecretariesPage.tsx`** وقتی کاربر می‌خواهد منشی جدید اضافه کند:
```tsx ```tsx
import { useSubscription } from '../hooks/useSubscription'; import { useSubscription } from "../hooks/useSubscription";
// ... // ...
const { maxSecretaries } = useSubscription(); const { maxSecretaries } = useSubscription();
// تعداد فعلی منشیان از data موجود // تعداد فعلی منشی ها از data موجود
const activeCount = secretaries.filter(s => s.is_active).length; const activeCount = secretaries.filter((s) => s.is_active).length;
const isAtLimit = activeCount >= maxSecretaries; const isAtLimit = activeCount >= maxSecretaries;
// روی دکمه «افزودن منشی»: // روی دکمه «افزودن منشی»:
<button <button
className="btn primary sm" className="btn primary sm"
onClick={() => setAddOpen(true)} onClick={() => setAddOpen(true)}
disabled={isAtLimit} disabled={isAtLimit}
title={isAtLimit ? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است` : undefined} title={
isAtLimit
? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است`
: undefined
}
> >
افزودن منشی افزودن منشی
</button> </button>;
{isAtLimit && ( {
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}> isAtLimit && (
برای افزودن منشی بیشتر <Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link> <div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 4 }}>
</div> برای افزودن منشی بیشتر{" "}
)} <Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
</div>
);
}
``` ```
--- ---
+129 -102
View File
@@ -10,41 +10,31 @@
```css ```css
/* متغیرها */ /* متغیرها */
--primary: oklch(0.55 0.2 256) --primary: oklch(0.55 0.2 256) --primary-subtle: oklch(0.97 0.02 256)
--primary-subtle: oklch(0.97 0.02 256) --surface: #fff --border: oklch(0.9 0 0) --text-1 / --text-2 / --text-3 --r
--surface: #fff (border-radius base) --gap / --card-pad /* کلاس‌های کاربردی */ .card
--border: oklch(0.9 0 0) /* white surface با shadow و border-radius */ .card-pad
--text-1 / --text-2 / --text-3 /* padding داخل card */ .stat /* عدد آماری بزرگ + label */ .stat-grid
--r (border-radius base) /* grid چند ستونه برای stat‌ها */ .badge green/red/yellow/blue/gray
--gap / --card-pad /* برچسب وضعیت */ .btn primary/sm/ghost /* دکمه */ .field
/* label + input wrapper */ .seg /* segment/tab selector */ .bar
/* کلاس‌های کاربردی */ /* نوار پیشرفت */ .grid-2 /* دو‌ستونه responsive */ .avatar sm
.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>} />` **الگوی header صفحات:** `<PageHeader title="..." description="..." action={<button>} />`
## فایل‌های مرتبط ## فایل‌های مرتبط
| فایل | نقش | | فایل | نقش |
|------|-----| | ------------------------------------------- | -------------------------------------------------------- |
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار | | `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD | | `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشیان — CRUD + permissions matrix | | `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشی ها — CRUD + permissions matrix |
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویس‌های کلینیک — دو پنل: بخش‌ها + آیتم‌ها | | `assets/admin/pages/ClinicServicesPage.tsx` | سرویس‌های کلینیک — دو پنل: بخش‌ها + آیتم‌ها |
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs | | `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
| `assets/admin/styles.css` | سیستم CSS کامل | | `assets/admin/styles.css` | سیستم CSS کامل |
| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحی‌شده با gradient + progress bar | | `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحی‌شده با gradient + progress bar |
## وضعیت فعلی هر صفحه ## وضعیت فعلی هر صفحه
@@ -173,6 +163,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** لیست با identity واضح + پرونده بیمار با مینی‌داشبورد. **هدف:** لیست با identity واضح + پرونده بیمار با مینی‌داشبورد.
**الف — حالت لیست بیماران:** **الف — حالت لیست بیماران:**
- Header اختصاصی: `<PageHeader>` با description «مراجعه‌کنندگان ثبت‌شده شما» - Header اختصاصی: `<PageHeader>` با description «مراجعه‌کنندگان ثبت‌شده شما»
- Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول) - Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول)
- `DataTable` را با ردیف‌های بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی - `DataTable` را با ردیف‌های بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی
@@ -180,15 +171,17 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما - وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما
**ب — حالت پرونده بیمار (selectedRecord):** **ب — حالت پرونده بیمار (selectedRecord):**
- PageHeader با نام بیمار + شماره + تاریخ ثبت - PageHeader با نام بیمار + شماره + تاریخ ثبت
- یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`) - یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`)
- SessionRow باید بهتر شود — هر مراجعه یک card مستقل با: - SessionRow باید بهتر شود — هر مراجعه یک card مستقل با:
- سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge - سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge
- بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row - بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row
- footer: لیست خدمات (services) اگر وجود داشت - footer: لیست خدمات (services) اگر وجود داشت
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت - دکمه «ویرایش» در گوشه بالا سمت چپ کارت
**ج — Modal ثبت مراجعه:** **ج — Modal ثبت مراجعه:**
- سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سه‌ستونه گذاشتن خوب است — این کافیست - سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سه‌ستونه گذاشتن خوب است — این کافیست
- بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن - بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن
- لیست خدمات انتخاب‌شده را با badge یا chip نشان بده (نه فقط متن) - لیست خدمات انتخاب‌شده را با badge یا chip نشان بده (نه فقط متن)
@@ -202,20 +195,23 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** صفحه‌ای با KPI bar + جدول بهتر + فرم واضح‌تر. **هدف:** صفحه‌ای با KPI bar + جدول بهتر + فرم واضح‌تر.
**الف — KPI bar:** **الف — KPI bar:**
- بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال - بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال
- داده از همان `staff` query محاسبه شود (نه API جدید): - داده از همان `staff` query محاسبه شود (نه API جدید):
```ts ```ts
const total = staff.length; const total = staff.length;
const active = staff.filter((s) => s.active).length; const active = staff.filter((s) => s.active).length;
const inactive = total - active; const inactive = total - active;
``` ```
**ب — جدول بهتر:** **ب — جدول بهتر:**
- ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage) - ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage)
- ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود) - ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود)
- ردیف‌های hover state با background تغییر کند - ردیف‌های hover state با background تغییر کند
**ج — فرم StaffFormFields:** **ج — فرم StaffFormFields:**
- فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده - فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده
- فیلدهای `phone` و `national_code` را در یک ردیف دیگر - فیلدهای `phone` و `national_code` را در یک ردیف دیگر
- فیلد `address` تنها بماند (textarea یا input تمام عرض) - فیلد `address` تنها بماند (textarea یا input تمام عرض)
@@ -226,7 +222,8 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصری‌تر + Empty State جذاب. **هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصری‌تر + Empty State جذاب.
**الف — جدول منشیان:** **الف — جدول منشی ها:**
- جدول دستی را **حذف کن** و از `DataTable` component استفاده کن - جدول دستی را **حذف کن** و از `DataTable` component استفاده کن
- ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد - ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد
- ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن - ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن
@@ -235,21 +232,32 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسی‌ها + غیرفعال‌سازی) - ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسی‌ها + غیرفعال‌سازی)
**ب — Empty State:** **ب — Empty State:**
- وقتی `secretaries.length === 0`: - وقتی `secretaries.length === 0`:
```tsx ```tsx
<div className="card card-pad" style={{ textAlign: 'center', padding: '60px 24px' }}> <div
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px' }} /> className="card card-pad"
<div style={{ fontWeight: 600, marginBottom: 8 }}>هنوز منشی‌ای اضافه نشده</div> style={{ textAlign: "center", padding: "60px 24px" }}
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}> >
منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند <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> </div>
<button className="btn primary sm" onClick={() => setCreateOpen(true)}> ```
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
</button>
</div>
```
**ج — PermissionsMatrix:** **ج — PermissionsMatrix:**
- ردیف‌های جدول با رنگ متناوب (striped) یا hover state - ردیف‌های جدول با رنگ متناوب (striped) یا hover state
- checkbox‌ها با `accent-color: var(--primary)` (قبلاً بود، نگه دار) - checkbox‌ها با `accent-color: var(--primary)` (قبلاً بود، نگه دار)
- header ستون‌ها را bold و با background کم‌رنگ کن - header ستون‌ها را bold و با background کم‌رنگ کن
@@ -262,38 +270,49 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** پنل بخش‌ها بهتر + جدول آیتم‌ها با badge فعال/غیرفعال. **هدف:** پنل بخش‌ها بهتر + جدول آیتم‌ها با badge فعال/غیرفعال.
**الف — پنل بخش‌ها (ستون ۲۸۰px):** **الف — پنل بخش‌ها (ستون ۲۸۰px):**
- هر بخش یک item با: - هر بخش یک item با:
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme) - آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
- نام بخش bold - نام بخش bold
- badge «فعال» / «غیرفعال» بر اساس `s.active` - badge «فعال» / «غیرفعال» بر اساس `s.active`
- دکمه‌های edit/delete فقط هنگام hover نشان داده شوند (opacity transition) - دکمه‌های edit/delete فقط هنگام hover نشان داده شوند (opacity transition)
- انتخاب شده: border `var(--primary)` + background `var(--primary-subtle)` - انتخاب شده: border `var(--primary)` + background `var(--primary-subtle)`
- افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون) - افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون)
**ب — پنل آیتم‌ها (ستون 1fr):** **ب — پنل آیتم‌ها (ستون 1fr):**
- بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتم‌ها + دکمه «سرویس جدید» - بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتم‌ها + دکمه «سرویس جدید»
- هر row آیتم: - هر row آیتم:
- badge «فعال» / «غیرفعال» برای `item.active` - badge «فعال» / «غیرفعال» برای `item.active`
- قیمت با `formatRial()` و رنگ primary - قیمت با `formatRial()` و رنگ primary
- نام پرسنل با avatar خیلی کوچک اگر وجود داشت - نام پرسنل با avatar خیلی کوچک اگر وجود داشت
- Empty state وقتی `items.length === 0`: - Empty state وقتی `items.length === 0`:
```tsx ```tsx
<div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}> <div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}>
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block' }} /> <PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block' }} />
سرویسی در این بخش وجود ندارد سرویسی در این بخش وجود ندارد
<br /> <br />
<button className="btn primary sm" style={{ marginTop: 12 }} onClick={...}>افزودن سرویس</button> <button className="btn primary sm" style={{ marginTop: 12 }} onClick={...}>افزودن سرویس</button>
</div> </div>
``` ```
**ج — State اولیه (هیچ بخشی انتخاب نشده):** **ج — State اولیه (هیچ بخشی انتخاب نشده):**
- جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده: - جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده:
```tsx ```tsx
<div style={{ border: '2px dashed var(--border)', borderRadius: 10, padding: '60px 0', textAlign: 'center', color: 'var(--text-3)' }}> <div
<WrenchScrewdriverIcon style={{ width: 40, margin: '0 auto 12px' }} /> style={{
<div>یک بخش انتخاب کنید تا سرویس‌های آن را ببینید</div> border: "2px dashed var(--border)",
</div> 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. **هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضح‌تر + جدول تراکنش‌ها با visual type indicator.
**الف — کارت موجودی:** **الف — کارت موجودی:**
- gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))` - gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))`
- رنگ متن: white - رنگ متن: white
- موجودی را بزرگ‌تر کن: `fontSize: 36, fontWeight: 800` - موجودی را بزرگ‌تر کن: `fontSize: 36, fontWeight: 800`
@@ -310,48 +330,55 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- دکمه شارژ: white با primary text (برعکس از theme معمول) - دکمه شارژ: white با primary text (برعکس از theme معمول)
**ب — کارت تنظیمات:** **ب — کارت تنظیمات:**
- عنوان با divider پایین - عنوان با divider پایین
- هر toggle را با یک card داخلی wrapper کن: - هر toggle را با یک card داخلی wrapper کن:
```tsx ```tsx
<div style={{ background: 'var(--primary-subtle)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ background: 'var(--primary-subtle)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" ... /> <input type="checkbox" ... />
<div> <div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div> <div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
<div style={{ color: 'var(--text-3)', fontSize: 12 }}>ارسال خودکار چند ساعت قبل از نوبت</div> <div style={{ color: 'var(--text-3)', fontSize: 12 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
</div>
</div> </div>
</div> ```
```
- input «چند ساعت قبل» را با border و padding واضح‌تر style کن - input «چند ساعت قبل» را با border و padding واضح‌تر style کن
**ج — جدول تراکنش‌ها:** **ج — جدول تراکنش‌ها:**
- ستون «نوع» را با یک آیکون بهبود بده: - ستون «نوع» را با یک آیکون بهبود بده:
- `credit`: `ArrowUpCircleIcon` با رنگ سبز - `credit`: `ArrowUpCircleIcon` با رنگ سبز
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز - `debit`: `ArrowDownCircleIcon` با رنگ قرمز
- ستون «مبلغ» را بر اساس نوع رنگ بده: credit → سبز، debit → قرمز - ستون «مبلغ» را بر اساس نوع رنگ بده: credit → سبز، debit → قرمز
- header table با background خاکستری کم‌رنگ - header table با background خاکستری کم‌رنگ
**د — Modal شارژ:** **د — Modal شارژ:**
- انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage): - انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage):
```tsx ```tsx
{(['mellat', 'sep'] as const).map((gw) => ( {
<div (["mellat", "sep"] as const).map((gw) => (
key={gw} <div
onClick={() => setGateway(gw)} key={gw}
style={{ onClick={() => setGateway(gw)}
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`, style={{
borderRadius: 8, border: `2px solid ${gateway === gw ? "var(--primary)" : "var(--border)"}`,
padding: '10px 14px', borderRadius: 8,
cursor: 'pointer', padding: "10px 14px",
background: gateway === gw ? 'var(--primary-subtle)' : '', cursor: "pointer",
transition: 'all 0.15s', background: gateway === gw ? "var(--primary-subtle)" : "",
flex: 1, transition: "all 0.15s",
textAlign: 'center', flex: 1,
}} textAlign: "center",
> }}
<div style={{ fontWeight: 600 }}>{gw === 'mellat' ? 'بانک ملت' : 'سپ'}</div> >
</div> <div style={{ fontWeight: 600 }}>
))} {gw === "mellat" ? "بانک ملت" : "سپ"}
``` </div>
</div>
));
}
```
- پیش‌نمایش مبلغ: وقتی کاربر عدد می‌زند، زیر input بنویس «پرداخت X ریال از طریق Y» - پیش‌نمایش مبلغ: وقتی کاربر عدد می‌زند، زیر input بنویس «پرداخت X ریال از طریق Y»
## نکات مهم ## نکات مهم
@@ -364,5 +391,5 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است 6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است
7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد 7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد
8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است) 8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است)
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشیان استفاده کن 9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشی ها استفاده کن
10. **`formatDate()` و `formatDateTime()`** — همه تاریخ‌ها شمسی نمایش داده شوند 10. **`formatDate()` و `formatDateTime()`** — همه تاریخ‌ها شمسی نمایش داده شوند
+72 -42
View File
@@ -2,6 +2,8 @@
**پنل ادمین:** https://clinic-pro.ddev.site/admin **پنل ادمین:** https://clinic-pro.ddev.site/admin
> رمز عبور همه کاربران seed‌شده: `Test@1234`
--- ---
## ادمین ## ادمین
@@ -14,65 +16,93 @@
--- ---
## کاربران Multi-Context ## کلینیک نمونه — تبریز
### کاربر ۱ — دکتر در چند کلینیک
| فیلد | مقدار |
| -------- | -------------------------- |
| موبایل | `09100000011` |
| پسورد | `Test1234` |
| نقش | `ROLE_DOCTOR` |
| Contexts | مطب شخصی دکتر آرمان رضایی |
| | کلینیک جامع حضرت علی (عضو) |
| | بیمارستان ابن سینا (عضو) |
---
### کاربر ۲ — صاحب کلینیک
| فیلد | مقدار | | فیلد | مقدار |
| ----------- | -------------------------------------- | | ----------- | -------------------------------------- |
| موبایل | `09100000022` | | موبایل | `09100100000` |
| پسورد | `Test1234` | | پسورد | `Test@1234` |
| نقش | `ROLE_CLINIC` | | نقش | `ROLE_CLINIC` |
| Contexts | کلینیک تست پگاه | | نام | کلینیک تخصصی امید تبریز |
| UUID کلینیک | `0730373f-7c0a-4610-8120-d58ecf4a9916` | | UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` |
| شهر | تبریز (id: 101) |
--- ---
### کاربر ۳ — منشی ## دکتر نمونه کامل — تبریز
| فیلد | مقدار |
| -------- | ---------------------- |
| موبایل | `09100000033` |
| پسورد | `Test1234` |
| نقش | `ROLE_SECRETARY` |
| Contexts | مطب دکتر مهناز سلیمانی |
---
### کاربر ۴ — دکتر + صاحب کلینیک
| فیلد | مقدار | | فیلد | مقدار |
| ----------- | -------------------------------------- | | ----------- | -------------------------------------- |
| موبایل | `09100000044` | | موبایل | `09100100001` |
| پسورد | `Test1234` | | پسورد | `Test@1234` |
| نقش | `ROLE_DOCTOR` + `ROLE_CLINIC` | | نقش | `ROLE_DOCTOR` |
| Contexts | مطب شخصی دکتر سامان علوی | | نام | دکتر آرمان رضایی |
| | کلینیک تخصصی علوی | | تخصص | قلب و عروق + داخلی |
| UUID کلینیک | `26a1ff95-32aa-4a96-b0fd-03e75e810be9` | | UUID دکتر | `2a3a7ab9-8d34-4118-862f-b458bcd6d77f` |
| کلینیک | کلینیک تخصصی امید تبریز (عضو) |
| آدرس مطب | تبریز، خیابان آزادی |
| برنامه کاری | شنبه–چهارشنبه ۹–۱۳ و ۱۴–۱۸، پنجشنبه ۹–۱۳ |
| بیمه | تأمین اجتماعی، خدمات درمانی، نیروهای مسلح، ایران |
---
## منشی دکتر نمونه
| فیلد | مقدار |
| ------ | ------------------- |
| موبایل | `09100100002` |
| پسورد | `Test@1234` |
| نقش | `ROLE_SECRETARY` |
| نام | خانم نرگس صادقی |
| مرتبط | دکتر آرمان رضایی |
---
## دکتران bulk (۱۵۰۰ دکتر در ۱۵ شهر)
هر شهر ۱۰۰ دکتر با توزیع واقعی تخصص:
| شهر | شروع موبایل | تعداد |
| ---------- | ------------- | ----- |
| تبریز | `09100100003` | ۱۰۰ |
| ارومیه | `09100100103` | ۱۰۰ |
| اردبیل | `09100100203` | ۱۰۰ |
| اصفهان | `09100100303` | ۱۰۰ |
| کرج | `09100100403` | ۱۰۰ |
| تهران | `09100100503` | ۱۰۰ |
| مشهد | `09100100603` | ۱۰۰ |
| اهواز | `09100100703` | ۱۰۰ |
| شیراز | `09100100803` | ۱۰۰ |
| کرمان | `09100100903` | ۱۰۰ |
| کرمانشاه | `09100101003` | ۱۰۰ |
| رشت | `09100101103` | ۱۰۰ |
| ساری | `09100101203` | ۱۰۰ |
| همدان | `09100101303` | ۱۰۰ |
| یزد | `09100101403` | ۱۰۰ |
توزیع تخصص در هر شهر:
- ۱۵ پزشک عمومی
- ۱۰ داخلی + قلب
- ۸ جراحی عمومی
- ۸ زنان و زایمان
- ۸ اطفال
- ۷ ارتوپدی
- ۶ گوارش، نورولوژی، پوست، چشم‌پزشکی، دندانپزشکی (هر کدام ۶)
- ۵ ENT، روانپزشکی (هر کدام ۵)
- ۴ اورولوژی
--- ---
## ساخت مجدد ## ساخت مجدد
اگر دیتابیس ریست شد، اجرا کن: اگر دیتابیس ریست شد، ادمین را بساز:
```bash ```bash
ddev exec php create_test_users.php ddev exec php bin/console app:create-admin
``` ```
09212908096 برای seed دیتای واقعی دکتران و کلینیک:
09212908096 ```bash
ddev exec php seed_realistic_data.php
```
+313 -99
View File
@@ -3,155 +3,308 @@ import {
ArrowsRightLeftIcon, ArrowsRightLeftIcon,
BanknotesIcon, BanknotesIcon,
BuildingOffice2Icon, BuildingOffice2Icon,
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CalendarDaysIcon, CalendarDaysIcon,
ChartBarIcon, ChartBarIcon,
ChatBubbleLeftEllipsisIcon, ChatBubbleLeftEllipsisIcon,
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CreditCardIcon, CreditCardIcon,
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
DocumentTextIcon, DocumentTextIcon,
FolderOpenIcon,
HeartIcon, HeartIcon,
IdentificationIcon,
KeyIcon, KeyIcon,
LockClosedIcon, LockClosedIcon,
StarIcon, StarIcon,
TagIcon, TagIcon,
UserCircleIcon, UserCircleIcon,
UserGroupIcon, UserGroupIcon,
UsersIcon,
UserPlusIcon, UserPlusIcon,
UsersIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
FolderOpenIcon,
IdentificationIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { NavLink, useNavigate } from "react-router-dom"; import { NavLink, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
import { useSubscription } from "../../hooks/useSubscription";
type SectionItem = { to: string; icon: React.ElementType; label: string; feature?: string }; type SectionItem = {
to: string;
icon: React.ElementType;
label: string;
feature?: string;
};
type Section = { label: string; items: SectionItem[] }; type Section = { label: string; items: SectionItem[] };
function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] { function buildSections(
if (primaryRole === 'admin') { primaryRole: string | null,
dbUuid: string | null,
): Section[] {
if (primaryRole === "admin") {
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [ items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, {
{ to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' }, to: "/admin/dashboard",
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' }, icon: ChartBarIcon,
{ to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیک‌ها' }, label: "داشبورد",
},
{
to: "/admin/users",
icon: UserGroupIcon,
label: "کاربران",
},
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
{
to: "/admin/clinics",
icon: BuildingOffice2Icon,
label: "کلینیک‌ها",
},
], ],
}, },
{ {
label: 'مدیریت', label: "مدیریت",
items: [ items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, {
{ to: '/admin/payments', icon: CreditCardIcon, label: 'پرداخت‌ها' }, to: "/admin/appointments",
{ to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویه‌حساب' }, icon: CalendarDaysIcon,
{ to: '/admin/pre-registrations', icon: ClipboardDocumentCheckIcon, label: 'درخواست‌های ثبت‌نام' }, label: "نوبت‌ها",
},
{
to: "/admin/payments",
icon: CreditCardIcon,
label: "پرداخت‌ها",
},
{
to: "/admin/settlements",
icon: BanknotesIcon,
label: "تسویه‌حساب",
},
{
to: "/admin/pre-registrations",
icon: ClipboardDocumentCheckIcon,
label: "درخواست‌های ثبت‌نام",
},
], ],
}, },
{ {
label: 'محتوا', label: "محتوا",
items: [ items: [
{ to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' }, {
{ to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' }, to: "/admin/comments",
{ to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' }, icon: ChatBubbleLeftEllipsisIcon,
{ to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' }, label: "نظرات",
},
{ to: "/admin/ratings", icon: StarIcon, label: "امتیازها" },
{
to: "/admin/blogs",
icon: DocumentTextIcon,
label: "بلاگ",
},
{
to: "/admin/sms",
icon: DevicePhoneMobileIcon,
label: "پیامک",
},
], ],
}, },
{ {
label: 'سیستم', label: "سیستم",
items: [ items: [
{ to: '/admin/categories', icon: TagIcon, label: 'دسته‌بندی‌ها' }, {
{ to: '/admin/representations', icon: UsersIcon, label: 'نمایندگان' }, to: "/admin/categories",
{ to: '/admin/secretaries', icon: KeyIcon, label: 'منشی‌ها' }, icon: TagIcon,
{ to: '/admin/admin-subscription', icon: CreditCardIcon, label: 'اشتراک‌ها' }, label: "دسته‌بندی‌ها",
{ to: '/admin/settings', icon: Cog6ToothIcon, label: 'تنظیمات' }, },
{
to: "/admin/representations",
icon: UsersIcon,
label: "نمایندگان",
},
{
to: "/admin/secretaries",
icon: KeyIcon,
label: "منشی‌ها",
},
{
to: "/admin/admin-subscription",
icon: CreditCardIcon,
label: "اشتراک‌ها",
},
{
to: "/admin/settings",
icon: Cog6ToothIcon,
label: "تنظیمات",
},
], ],
}, },
]; ];
} }
if (primaryRole === 'clinic') { if (primaryRole === "clinic") {
const clinicTo = dbUuid ? `/admin/clinics/${dbUuid}` : '/admin/my-clinic'; const clinicTo = dbUuid
? `/admin/clinics/${dbUuid}`
: "/admin/my-clinic";
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [ items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, {
{ to: clinicTo, icon: BuildingOffice2Icon, label: 'کلینیک من' }, to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
{
to: clinicTo,
icon: BuildingOffice2Icon,
label: "کلینیک من",
},
], ],
}, },
{ {
label: 'مدیریت', label: "مدیریت",
items: [ items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, {
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, to: "/admin/appointments",
{ to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' }, icon: CalendarDaysIcon,
{ to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' }, label: "نوبت‌ها",
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, },
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' }, {
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
{
to: "/admin/my-secretaries",
icon: IdentificationIcon,
label: "منشی ها",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
feature: "services",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
feature: "sms_panel",
},
], ],
}, },
{ {
label: 'اشتراک', label: "اشتراک",
items: [ items: [
{ to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' }, {
to: "/admin/subscription",
icon: CreditCardIcon,
label: "پنل اشتراکی",
},
], ],
}, },
]; ];
} }
if (primaryRole === 'doctor') { if (primaryRole === "doctor") {
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [ items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, {
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
], ],
}, },
{ {
label: 'پروفایل', label: "پروفایل",
items: [ items: [
{ to: '/admin/profile', icon: UserCircleIcon, label: 'پروفایل من' }, {
to: "/admin/profile",
icon: UserCircleIcon,
label: "پروفایل من",
},
], ],
}, },
{ {
label: 'مدیریت', label: "مدیریت",
items: [ items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌های من' }, {
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, to: "/admin/appointments",
{ to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' }, icon: CalendarDaysIcon,
{ to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' }, label: "نوبت‌های من",
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, },
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' }, {
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
{
to: "/admin/my-secretaries",
icon: IdentificationIcon,
label: "منشی ها",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
feature: "services",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
feature: "sms_panel",
},
], ],
}, },
{ {
label: 'اشتراک', label: "اشتراک",
items: [ items: [
{ to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' }, {
to: "/admin/subscription",
icon: CreditCardIcon,
label: "پنل اشتراکی",
},
], ],
}, },
]; ];
} }
if (primaryRole === 'secretary') { if (primaryRole === "secretary") {
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [ items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, {
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
], ],
}, },
{ {
label: 'مدیریت', label: "مدیریت",
items: [ items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, {
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
},
{
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
], ],
}, },
]; ];
@@ -159,18 +312,24 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }], items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
}, },
]; ];
} }
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
admin: 'مدیر کل', admin: "مدیر کل",
clinic: 'مالک کلینیک', clinic: "مالک کلینیک",
doctor: 'پزشک', doctor: "پزشک",
secretary: 'منشی', secretary: "منشی",
user: 'کاربر', user: "کاربر",
}; };
const HUES = [256, 205, 162, 295, 272]; const HUES = [256, 205, 162, 295, 272];
@@ -186,13 +345,14 @@ interface Props {
} }
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) { export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
const sidebarOpen = useUiStore((s) => s.sidebarOpen); const sidebarOpen = useUiStore((s) => s.sidebarOpen);
const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore(); const { logout, primaryRole, userName, availableContexts, dbUuid } =
useAuthStore();
const { hasFeature } = useSubscription(); const { hasFeature } = useSubscription();
const navigate = useNavigate(); const navigate = useNavigate();
const sections = buildSections(primaryRole, dbUuid); const sections = buildSections(primaryRole, dbUuid);
const initials = (userName ?? 'U').charAt(0).toUpperCase(); const initials = (userName ?? "U").charAt(0).toUpperCase();
return ( return (
<aside className="sidebar"> <aside className="sidebar">
@@ -212,23 +372,56 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
{sections.map((section) => ( {sections.map((section) => (
<div className="nav-group" key={section.label}> <div className="nav-group" key={section.label}>
<span className="nav-label">{section.label}</span> <span className="nav-label">{section.label}</span>
{section.items.map(({ to, icon: Icon, label, feature }) => { {section.items.map(
const isLocked = feature ? !hasFeature(feature) : false; ({ to, icon: Icon, label, feature }) => {
const dest = isLocked ? '/admin/subscription' : to; const isLocked = feature
return ( ? !hasFeature(feature)
<NavLink : false;
key={to} const dest = isLocked
to={dest} ? "/admin/subscription"
title={isLocked ? 'نیاز به ارتقاء پنل' : (!sidebarOpen ? label : undefined)} : to;
className={({ isActive }) => `nav-item${isActive && !isLocked ? ' active' : ''}${isLocked ? ' locked' : ''}`} return (
style={isLocked ? { opacity: 0.55 } : undefined} <NavLink
> key={to}
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} /> to={dest}
<span>{label}</span> title={
{isLocked && <LockClosedIcon style={{ width: 12, height: 12, marginRight: 'auto', flexShrink: 0 }} />} isLocked
</NavLink> ? "نیاز به ارتقاء پنل"
); : !sidebarOpen
})} ? label
: undefined
}
className={({ isActive }) =>
`nav-item${isActive && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}`
}
style={
isLocked
? { opacity: 0.55 }
: undefined
}
>
<Icon
style={{
width: 19,
height: 19,
flexShrink: 0,
}}
/>
<span>{label}</span>
{isLocked && (
<LockClosedIcon
style={{
width: 12,
height: 12,
marginRight: "auto",
flexShrink: 0,
}}
/>
)}
</NavLink>
);
},
)}
</div> </div>
))} ))}
@@ -238,10 +431,14 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
<span className="nav-label">محیط کاری</span> <span className="nav-label">محیط کاری</span>
<NavLink <NavLink
to="/admin/select-context" to="/admin/select-context"
title={!sidebarOpen ? 'تغییر محیط' : undefined} title={!sidebarOpen ? "تغییر محیط" : undefined}
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} className={({ isActive }) =>
`nav-item${isActive ? " active" : ""}`
}
> >
<ArrowsRightLeftIcon style={{ width: 19, height: 19, flexShrink: 0 }} /> <ArrowsRightLeftIcon
style={{ width: 19, height: 19, flexShrink: 0 }}
/>
<span>تغییر محیط</span> <span>تغییر محیط</span>
</NavLink> </NavLink>
</div> </div>
@@ -252,20 +449,37 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
<div className="sidebar-foot"> <div className="sidebar-foot">
<div <div
className="user-chip" className="user-chip"
onClick={() => { logout(); navigate('/admin/login'); }} onClick={() => {
logout();
navigate("/admin/login");
}}
title="خروج از سیستم" title="خروج از سیستم"
> >
<div <div
className="avatar sm" className="avatar sm"
style={{ background: avatarBg(userName ?? 'U'), flexShrink: 0 }} style={{
background: avatarBg(userName ?? "U"),
flexShrink: 0,
}}
> >
{initials} {initials}
</div> </div>
<div className="user-meta"> <div className="user-meta">
<b>{userName ?? 'کاربر'}</b> <b>{userName ?? "کاربر"}</b>
<span>{ROLE_LABELS[primaryRole ?? ''] ?? primaryRole ?? ''}</span> <span>
{ROLE_LABELS[primaryRole ?? ""] ??
primaryRole ??
""}
</span>
</div> </div>
<ArrowLeftOnRectangleIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} /> <ArrowLeftOnRectangleIcon
style={{
width: 16,
height: 16,
flexShrink: 0,
color: "var(--text-3)",
}}
/>
</div> </div>
</div> </div>
</aside> </aside>
File diff suppressed because it is too large Load Diff
+180 -145
View File
@@ -1,4 +1,5 @@
# PRD — ClinicPro Phase 2 # PRD — ClinicPro Phase 2
**تاریخ:** ۲۴ خرداد ۱۴۰۵ **تاریخ:** ۲۴ خرداد ۱۴۰۵
**نسخه:** 1.2 **نسخه:** 1.2
**وضعیت:** Draft **وضعیت:** Draft
@@ -13,24 +14,24 @@
## وضعیت فعلی پروژه ## وضعیت فعلی پروژه
| ماژول | Backend | API Doc | | ماژول | Backend | API Doc |
|--------|---------|---------| | -------------------------------------- | ------------------------------------------------ | ---------------------------------------------------- |
| Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` | | Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` |
| Doctor profile | ✅ کامل | `docs/api/doctor.md` | | Doctor profile | ✅ کامل | `docs/api/doctor.md` |
| Clinic management | ✅ کامل | `docs/api/clinic.md` | | Clinic management | ✅ کامل | `docs/api/clinic.md` |
| Appointment booking + settings | ✅ کامل | `docs/api/appointment.md`, `appointment-settings.md` | | Appointment booking + settings | ✅ کامل | `docs/api/appointment.md`, `appointment-settings.md` |
| Secretary (`DoctorSecretary` entity) | ✅ entity + API موجود | `docs/api/secretary.md` | | Secretary (`DoctorSecretary` entity) | ✅ entity + API موجود | `docs/api/secretary.md` |
| Payment gateway (Mellat, SEP) | ✅ کامل — `Payment.type='subscription'` هم موجود | `docs/api/payment.md` | | Payment gateway (Mellat, SEP) | ✅ کامل — `Payment.type='subscription'` هم موجود | `docs/api/payment.md` |
| SMS log + template | ✅ زیرساخت موجود | `docs/api/sms.md` | | SMS log + template | ✅ زیرساخت موجود | `docs/api/sms.md` |
| Wallet / Settlement | ✅ کامل | `docs/api/settlement.md` | | Wallet / Settlement | ✅ کامل | `docs/api/settlement.md` |
| SiteConfig (key-value store) | ✅ موجود | `docs/api/admin.md` §Settings | | SiteConfig (key-value store) | ✅ موجود | `docs/api/admin.md` §Settings |
| Dashboard (clinic/doctor/secretary) | ✅ کامل | `docs/api/dashboard.md` | | Dashboard (clinic/doctor/secretary) | ✅ کامل | `docs/api/dashboard.md` |
| Rating, Blog, Representation, Location | ✅ کامل | فایل‌های مربوطه در `docs/api/` | | Rating, Blog, Representation, Location | ✅ کامل | فایل‌های مربوطه در `docs/api/` |
| **Staff (پرسنل)** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | | **Staff (پرسنل)** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Subscription tiers** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | | **Subscription tiers** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Clinic services** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | | **Clinic services** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Patient records** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | | **Patient records** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **SMS wallet/settings per clinic** | ❌ entity وجود ندارد | ❌ بخش در sms.md وجود ندارد | | **SMS wallet/settings per clinic** | ❌ entity وجود ندارد | ❌ بخش در sms.md وجود ندارد |
--- ---
@@ -41,19 +42,21 @@
### اپیک ۱ — پرسنل (Staff) ### اپیک ۱ — پرسنل (Staff)
#### توضیح #### توضیح
مدیر کلینیک یا دکتر مستقل می‌تواند پرسنل کلینیک را ثبت کند. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند. در سرویس‌دهی، سشن‌ها و گزارشات به آن‌ها ارجاع می‌شود. مدیر کلینیک یا دکتر مستقل می‌تواند پرسنل کلینیک را ثبت کند. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند. در سرویس‌دهی، سشن‌ها و گزارشات به آن‌ها ارجاع می‌شود.
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ------------------------------------------------------------------------------------ |
| F1.1 | ایجاد / ویرایش / غیرفعال‌سازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) | | F1.1 | ایجاد / ویرایش / غیرفعال‌سازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) |
| F1.2 | فیلدها: نام و نام‌خانوادگی (اجباری)، شماره تماس، عنوان شغل، آدرس، کد ملی، وضعیت فعال | | F1.2 | فیلدها: نام و نام‌خانوادگی (اجباری)، شماره تماس، عنوان شغل، آدرس، کد ملی، وضعیت فعال |
| F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) | | F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) |
| F1.4 | لیست پرسنل فقط در scope کلینیک/مطب کاربر جاری نمایش داده می‌شود | | F1.4 | لیست پرسنل فقط در scope کلینیک/مطب کاربر جاری نمایش داده می‌شود |
| F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب می‌شود | | F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب می‌شود |
#### Entity جدید #### Entity جدید
``` ```
clinic_staff: clinic_staff:
id, uuid id, uuid
@@ -69,18 +72,21 @@ clinic_staff:
``` ```
#### API #### API
| Method | Path | توضیح |
|--------|------|-------| | Method | Path | توضیح |
| `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری | | ------- | ----------------------------- | -------------------------- |
| `POST` | `/api/v1/staff` | ایجاد پرسنل جدید | | `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری |
| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات | | `POST` | `/api/v1/staff` | ایجاد پرسنل جدید |
| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات |
| `PATCH` | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) | | `PATCH` | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) |
#### Frontend #### Frontend
- `StaffPage.tsx` — DataTable + Modal ایجاد/ویرایش - `StaffPage.tsx` — DataTable + Modal ایجاد/ویرایش
- Sidebar: زیر «تنظیمات» - Sidebar: زیر «تنظیمات»
#### فایل‌های جدید #### فایل‌های جدید
- `src/Staff/Entity/ClinicStaff.php` - `src/Staff/Entity/ClinicStaff.php`
- `src/Staff/Controller/StaffController.php` - `src/Staff/Controller/StaffController.php`
- `src/Staff/Repository/ClinicStaffRepository.php` - `src/Staff/Repository/ClinicStaffRepository.php`
@@ -91,25 +97,26 @@ clinic_staff:
### اپیک ۲ — پنل اشتراکی (Subscription Tiers) ### اپیک ۲ — پنل اشتراکی (Subscription Tiers)
#### توضیح #### توضیح
سه سطح پنل با قابلیت‌های متفاوت. ادمین برای هر پنل دوره‌های زمانی با قیمت مشخص تعریف می‌کند. کلینیک/دکتر دوره + پنل انتخاب کرده و پرداخت می‌کند. سه سطح پنل با قابلیت‌های متفاوت. ادمین برای هر پنل دوره‌های زمانی با قیمت مشخص تعریف می‌کند. کلینیک/دکتر دوره + پنل انتخاب کرده و پرداخت می‌کند.
#### سطوح پنل #### سطوح پنل
| Level | Name | منشی | قابلیت‌های اضافه | | Level | Name | منشی | قابلیت‌های اضافه |
|-------|------|------|----------------| | ----- | ------------ | ------ | ----------------------------- |
| 0 | Free | ۱ منشی | نوبت‌دهی — رایگان، بدون انقضا | | 0 | Free | ۱ منشی | نوبت‌دهی — رایگان، بدون انقضا |
| 1 | Basic | ۲ منشی | پرونده بیمار + سرویس‌ها | | 1 | Basic | ۲ منشی | پرونده بیمار + سرویس‌ها |
| 2 | Professional | ۵ منشی | پرونده بیمار + سرویس‌ها | | 2 | Professional | ۵ منشی | پرونده بیمار + سرویس‌ها |
#### دوره‌های اشتراک (مثال — ادمین تنظیم می‌کند) #### دوره‌های اشتراک (مثال — ادمین تنظیم می‌کند)
| نوع | مدت | قیمت | | نوع | مدت | قیمت |
|-----|-----|------| | ------- | ------ | ------------------------------- |
| تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یک‌بار | | تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یک‌بار |
| ۱ ماهه | ۱ ماه | ادمین تعیین می‌کند | | ۱ ماهه | ۱ ماه | ادمین تعیین می‌کند |
| ۳ ماهه | ۳ ماه | ادمین تعیین می‌کند | | ۳ ماهه | ۳ ماه | ادمین تعیین می‌کند |
| ۶ ماهه | ۶ ماه | ادمین تعیین می‌کند | | ۶ ماهه | ۶ ماه | ادمین تعیین می‌کند |
| ۱۲ ماهه | ۱۲ ماه | ادمین تعیین می‌کند | | ۱۲ ماهه | ۱۲ ماه | ادمین تعیین می‌کند |
#### قوانین تریال #### قوانین تریال
@@ -121,18 +128,19 @@ clinic_staff:
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ---------------------------------------------------------------------------- |
| F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف می‌کند | | F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف می‌کند |
| F2.2 | ادمین می‌تواند دوره‌های جدید اضافه یا غیرفعال کند | | F2.2 | ادمین می‌تواند دوره‌های جدید اضافه یا غیرفعال کند |
| F2.3 | پرداخت از طریق gateway موجود (Mellat/SEP) — `Payment.type='subscription'` ✅ | | F2.3 | پرداخت از طریق gateway موجود (Mellat/SEP) — `Payment.type='subscription'` ✅ |
| F2.4 | پس از پرداخت موفق: `starts_at=now`, `expires_at=now + months*30*86400` | | F2.4 | پس از پرداخت موفق: `starts_at=now`, `expires_at=now + months*30*86400` |
| F2.5 | تمدید از تاریخ انقضای قبلی محاسبه می‌شود (نه از now) | | F2.5 | تمدید از تاریخ انقضای قبلی محاسبه می‌شود (نه از now) |
| F2.6 | gate check: `hasFeature('patient_records')` — اشتراک فعال و منقضی نشده | | F2.6 | gate check: `hasFeature('patient_records')` — اشتراک فعال و منقضی نشده |
| F2.7 | هشدار ۷ روز قبل از انقضا به کلینیک/دکتر | | F2.7 | هشدار ۷ روز قبل از انقضا به کلینیک/دکتر |
| F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) | | F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) |
#### Entities جدید #### Entities جدید
``` ```
subscription_plans: subscription_plans:
id, uuid id, uuid
@@ -168,31 +176,34 @@ clinic_subscriptions:
``` ```
#### API #### API
| Method | Path | Permission | توضیح |
|--------|------|-----------|-------| | Method | Path | Permission | توضیح |
| `GET` | `/api/v1/subscription/plans` | public | لیست پنل‌ها با دوره‌ها و قیمت‌ها | | -------- | ------------------------------------------ | ------------- | ---------------------------------------- |
| `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا | | `GET` | `/api/v1/subscription/plans` | public | لیست پنل‌ها با دوره‌ها و قیمت‌ها |
| `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعال‌سازی تریال (رایگان، یک‌بار) | | `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا |
| `POST` | `/api/v1/subscription-payment` | clinic/doctor | شروع پرداخت — body: `{ period_uuid }` | | `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعال‌سازی تریال (رایگان، یک‌بار) |
| `GET` | `/api/v1/admin/subscription/plans` | admin | لیست پنل‌ها | | `POST` | `/api/v1/subscription-payment` | clinic/doctor | شروع پرداخت — body: `{ period_uuid }` |
| `POST` | `/api/v1/admin/subscription/plan` | admin | ایجاد پنل | | `GET` | `/api/v1/admin/subscription/plans` | admin | لیست پنل‌ها |
| `PATCH` | `/api/v1/admin/subscription/plan/{uuid}` | admin | ویرایش پنل | | `POST` | `/api/v1/admin/subscription/plan` | admin | ایجاد پنل |
| `POST` | `/api/v1/admin/subscription/period` | admin | افزودن دوره جدید | | `PATCH` | `/api/v1/admin/subscription/plan/{uuid}` | admin | ویرایش پنل |
| `PATCH` | `/api/v1/admin/subscription/period/{uuid}` | admin | ویرایش قیمت/مدت | | `POST` | `/api/v1/admin/subscription/period` | admin | افزودن دوره جدید |
| `DELETE` | `/api/v1/admin/subscription/period/{uuid}` | admin | غیرفعال‌سازی دوره | | `PATCH` | `/api/v1/admin/subscription/period/{uuid}` | admin | ویرایش قیمت/مدت |
| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریال‌ها | | `DELETE` | `/api/v1/admin/subscription/period/{uuid}` | admin | غیرفعال‌سازی دوره |
| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریال‌ها |
#### Frontend #### Frontend
- `SubscriptionPage.tsx`: - `SubscriptionPage.tsx`:
- بنر تریال اگر `used_trial=false` - بنر تریال اگر `used_trial=false`
- وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال» - وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال»
- جدول مقایسه پنل‌ها با انتخاب دوره - جدول مقایسه پنل‌ها با انتخاب دوره
- دکمه خرید → redirect به gateway - دکمه خرید → redirect به gateway
- `AdminSubscriptionPage.tsx`: - `AdminSubscriptionPage.tsx`:
- تب «پنل‌ها»: ویرایش دوره‌ها و قیمت‌ها - تب «پنل‌ها»: ویرایش دوره‌ها و قیمت‌ها
- تب «گزارش»: فروش بر اساس بازه زمانی - تب «گزارش»: فروش بر اساس بازه زمانی
#### فایل‌های جدید #### فایل‌های جدید
- `src/Subscription/Entity/SubscriptionPlan.php` - `src/Subscription/Entity/SubscriptionPlan.php`
- `src/Subscription/Entity/SubscriptionPeriod.php` - `src/Subscription/Entity/SubscriptionPeriod.php`
- `src/Subscription/Entity/ClinicSubscription.php` - `src/Subscription/Entity/ClinicSubscription.php`
@@ -205,24 +216,28 @@ clinic_subscriptions:
### اپیک ۳ — منشی (Secretary) — تکمیل ### اپیک ۳ — منشی (Secretary) — تکمیل
#### توضیح #### توضیح
Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.md`). باید: Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.md`). باید:
- محدودیت تعداد بر اساس پنل اعمال شود - محدودیت تعداد بر اساس پنل اعمال شود
- UI مدیریت permissions تکمیل شود - UI مدیریت permissions تکمیل شود
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | -------------------------------------------------------------------------------------- |
| F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 | | F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 |
| F3.2 | UI لیست منشیان + اضافه‌کردن + ویرایش + غیرفعال‌سازی | | F3.2 | UI لیست منشی ها + اضافه‌کردن + ویرایش + غیرفعال‌سازی |
| F3.3 | ویرایشگر permissions: checkbox matrix (موجود در `DoctorSecretary.DEFAULT_PERMISSIONS`) | | F3.3 | ویرایشگر permissions: checkbox matrix (موجود در `DoctorSecretary.DEFAULT_PERMISSIONS`) |
| F3.4 | منشی هرگز حذف نمی‌شود — `active: false` | | F3.4 | منشی هرگز حذف نمی‌شود — `active: false` |
#### تغییرات مورد نیاز #### تغییرات مورد نیاز
- **Backend:** `SecretaryController::create()` — بررسی سقف پنل با `SubscriptionService` - **Backend:** `SecretaryController::create()` — بررسی سقف پنل با `SubscriptionService`
- **Frontend:** Modal با checkbox matrix برای permissions در `SecretariesPage.tsx` - **Frontend:** Modal با checkbox matrix برای permissions در `SecretariesPage.tsx`
#### API موجود (نیاز به تغییر ندارند) #### API موجود (نیاز به تغییر ندارند)
`GET /api/v1/secretaries/{doctorUuid}` | `POST /api/v1/secretary` | `PATCH /api/v1/secretary/{uuid}` | `DELETE /api/v1/secretary/{uuid}` `GET /api/v1/secretaries/{doctorUuid}` | `POST /api/v1/secretary` | `PATCH /api/v1/secretary/{uuid}` | `DELETE /api/v1/secretary/{uuid}`
--- ---
@@ -230,19 +245,21 @@ Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.
### اپیک ۴ — سرویس‌های کلینیک (Clinic Services) ### اپیک ۴ — سرویس‌های کلینیک (Clinic Services)
#### توضیح #### توضیح
دسترسی فقط در پنل Basic+. سرویس‌ها در دو سطح: بخش (مثلاً «تزریقات») و زیربخش (مثلاً «سرم»). دسترسی فقط در پنل Basic+. سرویس‌ها در دو سطح: بخش (مثلاً «تزریقات») و زیربخش (مثلاً «سرم»).
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ------------------------------------------------------------------ |
| F4.1 | CRUD بخش‌ها (ServiceSection) | | F4.1 | CRUD بخش‌ها (ServiceSection) |
| F4.2 | CRUD زیربخش‌ها (ServiceItem) با انتخاب بخش + پرسنل انجام‌دهنده | | F4.2 | CRUD زیربخش‌ها (ServiceItem) با انتخاب بخش + پرسنل انجام‌دهنده |
| F4.3 | قیمت هر زیربخش قابل ویرایش | | F4.3 | قیمت هر زیربخش قابل ویرایش |
| F4.4 | gate: `hasFeature('services')` — بدون Basic+ → 403 | | F4.4 | gate: `hasFeature('services')` — بدون Basic+ → 403 |
| F4.5 | لیست ServiceItems در ثبت سشن بیمار استفاده می‌شود (وابستگی اپیک ۶) | | F4.5 | لیست ServiceItems در ثبت سشن بیمار استفاده می‌شود (وابستگی اپیک ۶) |
#### Entities جدید #### Entities جدید
``` ```
service_sections: service_sections:
id, uuid id, uuid
@@ -263,21 +280,24 @@ service_items:
``` ```
#### API #### API
| Method | Path | توضیح |
|--------|------|-------| | Method | Path | توضیح |
| `GET` | `/api/v1/service-sections` | لیست بخش‌ها | | -------- | ------------------------------------- | ---------------------- |
| `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) | | `GET` | `/api/v1/service-sections` | لیست بخش‌ها |
| `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش | | `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) |
| `DELETE` | `/api/v1/service-section/{uuid}` | حذف | | `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش |
| `GET` | `/api/v1/service-items/{sectionUuid}` | لیست زیربخش‌های یک بخش | | `DELETE` | `/api/v1/service-section/{uuid}` | حذف |
| `POST` | `/api/v1/service-item` | ایجاد زیربخش (Basic+) | | `GET` | `/api/v1/service-items/{sectionUuid}` | لیست زیربخش‌های یک بخش |
| `PATCH` | `/api/v1/service-item/{uuid}` | ویرایش | | `POST` | `/api/v1/service-item` | ایجاد زیربخش (Basic+) |
| `DELETE` | `/api/v1/service-item/{uuid}` | حذف | | `PATCH` | `/api/v1/service-item/{uuid}` | ویرایش |
| `DELETE` | `/api/v1/service-item/{uuid}` | حذف |
#### Frontend #### Frontend
- `ServicesPage.tsx` — accordion دو سطحی: بخش‌ها ← زیربخش‌ها - `ServicesPage.tsx` — accordion دو سطحی: بخش‌ها ← زیربخش‌ها
#### فایل‌های جدید #### فایل‌های جدید
- `src/ClinicService/Entity/ServiceSection.php` - `src/ClinicService/Entity/ServiceSection.php`
- `src/ClinicService/Entity/ServiceItem.php` - `src/ClinicService/Entity/ServiceItem.php`
- `src/ClinicService/Controller/ClinicServiceController.php` - `src/ClinicService/Controller/ClinicServiceController.php`
@@ -288,25 +308,28 @@ service_items:
### اپیک ۵ — پنل پیامکی (SMS Panel) ### اپیک ۵ — پنل پیامکی (SMS Panel)
#### توضیح #### توضیح
زیرساخت SMS موجود است (`SmsLog`, `SmsTemplate`, `SmsProvider` در `src/Sms/`). باید: زیرساخت SMS موجود است (`SmsLog`, `SmsTemplate`, `SmsProvider` در `src/Sms/`). باید:
- کیف پول پیامک اختصاصی برای هر کلینیک/دکتر - کیف پول پیامک اختصاصی برای هر کلینیک/دکتر
- تنظیمات ارسال پیامک (یادآوری، بعد از ویزیت) - تنظیمات ارسال پیامک (یادآوری، بعد از ویزیت)
- گزارش لاگ برای کلینیک و ادمین - گزارش لاگ برای کلینیک و ادمین
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ---------------------------------------------------------------------- |
| F5.1 | کیف پول پیامک جدا از کیف پول مالی | | F5.1 | کیف پول پیامک جدا از کیف پول مالی |
| F5.2 | شارژ از طریق gateway موجود | | F5.2 | شارژ از طریق gateway موجود |
| F5.3 | ادمین تعرفه هر پیامک را تعیین می‌کند (`sms_price_rials` در SiteConfig) | | F5.3 | ادمین تعرفه هر پیامک را تعیین می‌کند (`sms_price_rials` در SiteConfig) |
| F5.4 | کسر خودکار هزینه بعد از هر ارسال موفق | | F5.4 | کسر خودکار هزینه بعد از هر ارسال موفق |
| F5.5 | تنظیمات: یادآوری نوبت (فعال/غیرفعال + چند ساعت قبل) | | F5.5 | تنظیمات: یادآوری نوبت (فعال/غیرفعال + چند ساعت قبل) |
| F5.6 | تنظیمات: پیام بعد از ویزیت (فعال/غیرفعال + متن) | | F5.6 | تنظیمات: پیام بعد از ویزیت (فعال/غیرفعال + متن) |
| F5.7 | گزارش لاگ پیامک‌های کلینیک/مطب | | F5.7 | گزارش لاگ پیامک‌های کلینیک/مطب |
| F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی | | F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی |
#### Entities جدید #### Entities جدید
``` ```
sms_wallets: sms_wallets:
id id
@@ -327,20 +350,23 @@ sms_settings:
``` ```
#### API #### API
| Method | Path | Permission | توضیح |
|--------|------|-----------|-------| | Method | Path | Permission | توضیح |
| `GET` | `/api/v1/sms/wallet/balance` | clinic/doctor | موجودی کیف پول پیامک | | ------- | --------------------------------- | ------------- | ---------------------------------------- |
| `POST` | `/api/v1/sms/wallet/charge` | clinic/doctor | شارژ — body: `{ gateway, amount_rials }` | | `GET` | `/api/v1/sms/wallet/balance` | clinic/doctor | موجودی کیف پول پیامک |
| `GET` | `/api/v1/sms/wallet/logs` | clinic/doctor | تاریخچه کسر/شارژ | | `POST` | `/api/v1/sms/wallet/charge` | clinic/doctor | شارژ — body: `{ gateway, amount_rials }` |
| `GET` | `/api/v1/sms/settings` | clinic/doctor | تنظیمات پیامک | | `GET` | `/api/v1/sms/wallet/logs` | clinic/doctor | تاریخچه کسر/شارژ |
| `PATCH` | `/api/v1/sms/settings` | clinic/doctor | ذخیره تنظیمات | | `GET` | `/api/v1/sms/settings` | clinic/doctor | تنظیمات پیامک |
| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد | | `PATCH` | `/api/v1/sms/settings` | clinic/doctor | ذخیره تنظیمات |
| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد |
#### Frontend #### Frontend
- تب «پیامک» در صفحه تنظیمات کلینیک/دکتر - تب «پیامک» در صفحه تنظیمات کلینیک/دکتر
- نمایش موجودی + دکمه شارژ + لاگ + فرم تنظیمات - نمایش موجودی + دکمه شارژ + لاگ + فرم تنظیمات
#### تأثیر بر فایل‌های موجود #### تأثیر بر فایل‌های موجود
- `docs/api/sms.md`**بخش جدید wallet/settings اضافه شود** - `docs/api/sms.md`**بخش جدید wallet/settings اضافه شود**
--- ---
@@ -348,20 +374,22 @@ sms_settings:
### اپیک ۶ — پرونده بیمار (Patient Records) ### اپیک ۶ — پرونده بیمار (Patient Records)
#### توضیح #### توضیح
دسترسی فقط در پنل Basic+. پرونده به صورت خودکار پس از تأیید نوبت ایجاد می‌شود یا دستی توسط منشی/دکتر. هر مراجعه یک سشن است که شامل ویزیت + سرویس‌ها است. دسترسی فقط در پنل Basic+. پرونده به صورت خودکار پس از تأیید نوبت ایجاد می‌شود یا دستی توسط منشی/دکتر. هر مراجعه یک سشن است که شامل ویزیت + سرویس‌ها است.
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ----------------------------------------------------------------------------- |
| F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) | | F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) |
| F6.2 | ایجاد دستی توسط منشی یا دکتر | | F6.2 | ایجاد دستی توسط منشی یا دکتر |
| F6.3 | ثبت سشن: بیمه پایه + مکمل، مبلغ ویزیت (پیش‌فرض یا دستی)، محاسبه خودکار کسورات | | F6.3 | ثبت سشن: بیمه پایه + مکمل، مبلغ ویزیت (پیش‌فرض یا دستی)، محاسبه خودکار کسورات |
| F6.4 | ثبت سرویس‌های انجام‌شده در سشن با پرسنل انجام‌دهنده | | F6.4 | ثبت سرویس‌های انجام‌شده در سشن با پرسنل انجام‌دهنده |
| F6.5 | gate: `hasFeature('patient_records')` — بدون Basic+ → 403 | | F6.5 | gate: `hasFeature('patient_records')` — بدون Basic+ → 403 |
| F6.6 | تاریخچه کامل مراجعات یک بیمار | | F6.6 | تاریخچه کامل مراجعات یک بیمار |
#### محاسبه مبلغ نهایی سشن #### محاسبه مبلغ نهایی سشن
``` ```
visit_price_rials ← ادمین/دکتر تعیین می‌کند visit_price_rials ← ادمین/دکتر تعیین می‌کند
- base_insurance_discount% ← بیمه پایه - base_insurance_discount% ← بیمه پایه
@@ -371,6 +399,7 @@ visit_price_rials ← ادمین/دکتر تعیین می‌کند
``` ```
#### Entities جدید #### Entities جدید
``` ```
patient_records: patient_records:
id, uuid id, uuid
@@ -405,21 +434,24 @@ session_services:
``` ```
#### API #### API
| Method | Path | Permission | توضیح |
|--------|------|-----------|-------| | Method | Path | Permission | توضیح |
| `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو | | ------- | --------------------------------- | ---------------------- | --------------------- |
| `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی | | `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو |
| `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده | | `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی |
| `GET` | `/api/v1/patient/{uuid}/sessions` | clinic/doctor | لیست مراجعات | | `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده |
| `POST` | `/api/v1/patient/{uuid}/session` | clinic/doctor | ثبت مراجعه جدید | | `GET` | `/api/v1/patient/{uuid}/sessions` | clinic/doctor | لیست مراجعات |
| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه | | `POST` | `/api/v1/patient/{uuid}/session` | clinic/doctor | ثبت مراجعه جدید |
| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه |
#### Frontend #### Frontend
- `PatientsPage.tsx` — لیست بیماران + جستجو - `PatientsPage.tsx` — لیست بیماران + جستجو
- `PatientDetailPage.tsx` — پرونده + تاریخچه مراجعات - `PatientDetailPage.tsx` — پرونده + تاریخچه مراجعات
- Modal ثبت سشن جدید (بیمه + ویزیت + سرویس‌ها) - Modal ثبت سشن جدید (بیمه + ویزیت + سرویس‌ها)
#### فایل‌های جدید #### فایل‌های جدید
- `src/Patient/Entity/PatientRecord.php` - `src/Patient/Entity/PatientRecord.php`
- `src/Patient/Entity/PatientSession.php` - `src/Patient/Entity/PatientSession.php`
- `src/Patient/Entity/SessionService.php` - `src/Patient/Entity/SessionService.php`
@@ -431,22 +463,25 @@ session_services:
### اپیک ۷ — داشبورد هوشمند (Smart Dashboard) ### اپیک ۷ — داشبورد هوشمند (Smart Dashboard)
#### توضیح #### توضیح
داشبوردهای موجود (clinic/doctor/secretary در `docs/api/dashboard.md`) باید با چارت و فیلتر بازه زمانی تکمیل شوند. داشبوردهای موجود (clinic/doctor/secretary در `docs/api/dashboard.md`) باید با چارت و فیلتر بازه زمانی تکمیل شوند.
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ----------------------------------------------------------------------- |
| F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبت‌ها + گزارش فروش پنل‌ها | | F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبت‌ها + گزارش فروش پنل‌ها |
| F7.2 | داشبورد کلینیک/مطب: درآمد ماه جاری + بیماران منحصربه‌فرد + موجودی پیامک | | F7.2 | داشبورد کلینیک/مطب: درآمد ماه جاری + بیماران منحصربه‌فرد + موجودی پیامک |
| F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی | | F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی |
| F7.4 | فیلتر نقش: منشی فقط نوبت‌ها را می‌بیند | | F7.4 | فیلتر نقش: منشی فقط نوبت‌ها را می‌بیند |
#### API موجود (نیاز به endpoint جدید ندارد) #### API موجود (نیاز به endpoint جدید ندارد)
`GET /api/v1/admin/dashboard/stats` | `/charts` | `/recent` `GET /api/v1/admin/dashboard/stats` | `/charts` | `/recent`
`GET /api/v1/dashboard/clinic` | `/doctor` | `/secretary` `GET /api/v1/dashboard/clinic` | `/doctor` | `/secretary`
#### تغییر مورد نیاز #### تغییر مورد نیاز
- backend: اضافه کردن پارامترهای `from` و `to` به endpoint های charts - backend: اضافه کردن پارامترهای `from` و `to` به endpoint های charts
- frontend: `DashboardPage.tsx` — افزودن date range selector + نمودارها (Recharts/Chart.js) - frontend: `DashboardPage.tsx` — افزودن date range selector + نمودارها (Recharts/Chart.js)
@@ -454,15 +489,15 @@ session_services:
## اولویت‌بندی پیاده‌سازی ## اولویت‌بندی پیاده‌سازی
| اولویت | اپیک | وابستگی | | اولویت | اپیک | وابستگی |
|--------|------|---------| | ------ | ------------------------ | ----------------------------- |
| ۱ | اپیک ۱ — Staff | پیش‌نیاز اپیک ۴ و ۶ | | ۱ | اپیک ۱ — Staff | پیش‌نیاز اپیک ۴ و ۶ |
| ۲ | اپیک ۲ — Subscription | پیش‌نیاز gate در اپیک ۳، ۴، ۶ | | ۲ | اپیک ۲ — Subscription | پیش‌نیاز gate در اپیک ۳، ۴، ۶ |
| ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ | | ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ |
| ۴ | اپیک ۴ — Services | وابسته به اپیک ۱ و ۲ | | ۴ | اپیک ۴ — Services | وابسته به اپیک ۱ و ۲ |
| ۵ | اپیک ۵ — SMS Panel | مستقل | | ۵ | اپیک ۵ — SMS Panel | مستقل |
| ۶ | اپیک ۶ — Patient Records | وابسته به اپیک ۱، ۲، ۴ | | ۶ | اپیک ۶ — Patient Records | وابسته به اپیک ۱، ۲، ۴ |
| ۷ | اپیک ۷ — Dashboard | آخر — نیاز به داده واقعی | | ۷ | اپیک ۷ — Dashboard | آخر — نیاز به داده واقعی |
--- ---
@@ -488,15 +523,15 @@ sms_wallets sms_settings
## زیرساخت موجود قابل استفاده ## زیرساخت موجود قابل استفاده
| زیرساخت | مسیر | استفاده در فاز ۲ | | زیرساخت | مسیر | استفاده در فاز ۲ |
|---------|------|-----------------| | ----------------------------- | ---------------------------------- | ------------------------------------- |
| Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet | | Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet |
| `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ | | `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ |
| SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` | | SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` |
| Categories (insurance) | `bundle='insurance_type'` | انتخاب بیمه در PatientSession | | Categories (insurance) | `bundle='insurance_type'` | انتخاب بیمه در PatientSession |
| DoctorSecretary | `src/Secretary/Entity/` | اضافه کردن gate check | | DoctorSecretary | `src/Secretary/Entity/` | اضافه کردن gate check |
| SmsLog/SmsTemplate | `src/Sms/Entity/` | ارسال پیامک‌های خودکار | | SmsLog/SmsTemplate | `src/Sms/Entity/` | ارسال پیامک‌های خودکار |
| WalletTransaction | `src/Settlement/Entity/` | الگوی مشابه برای SmsWallet | | WalletTransaction | `src/Settlement/Entity/` | الگوی مشابه برای SmsWallet |
--- ---
+146 -107
View File
@@ -6,10 +6,10 @@
هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیط‌های مختلف جلوگیری می‌کند: هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیط‌های مختلف جلوگیری می‌کند:
| Scope | `owner_type` | تعریف‌کننده | دسترسی | | Scope | `owner_type` | تعریف‌کننده | دسترسی |
|-------|-------------|-------------|---------| | -------- | ------------ | ----------- | ------------------------------- |
| مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی | | مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی |
| کلینیک | `clinic` | مدیر کلینیک | فقط نوبت‌ها و داده‌های کلینیک | | کلینیک | `clinic` | مدیر کلینیک | فقط نوبت‌ها و داده‌های کلینیک |
- یک منشی می‌تواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا) - یک منشی می‌تواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا)
- منشی کلینیک می‌تواند به چند دکتر در همان کلینیک متصل باشد - منشی کلینیک می‌تواند به چند دکتر در همان کلینیک متصل باشد
@@ -26,62 +26,80 @@ Create a secretary for a doctor.
**Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN` **Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN`
### Request Body (`application/json`) ### Request Body (`application/json`)
```json ```json
{ {
"doctor_uuid": "550e8400-...", "doctor_uuid": "550e8400-...",
"mobile_number": "09123456789", "mobile_number": "09123456789",
"password": "secretaryPass123", "password": "secretaryPass123",
"permissions": { "permissions": {
"version": 1, "version": 1,
"resources": { "resources": {
"appointments": { "view": true, "create": true, "cancel": false, "update_status": true }, "appointments": {
"addresses": { "view": true, "create": false, "update": false, "delete": false }, "view": true,
"clinic_info": { "view": true, "update": false }, "create": true,
"insurances": { "view": true, "create": false, "update": false, "delete": false } "cancel": false,
"update_status": true
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"clinic_info": { "view": true, "update": false },
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
}
}
} }
}
} }
``` ```
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| | --------------- | ------------- | -------- | -------------------------------------------- |
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to | | `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
| `mobile_number` | string | ✅ | Secretary's login mobile | | `mobile_number` | string | ✅ | Secretary's login mobile |
| `password` | string | ❌ | Initial password (auto-generated if omitted) | | `password` | string | ❌ | Initial password (auto-generated if omitted) |
| `permissions` | object | ❌ | Permission set (see structure below) | | `permissions` | object | ❌ | Permission set (see structure below) |
**Permissions Structure:** **Permissions Structure:**
```json ```json
{ {
"version": 1, "version": 1,
"resources": { "resources": {
"appointments": { "appointments": {
"view": true, // Can view appointments list "view": true, // Can view appointments list
"create": true, // Can book appointments "create": true, // Can book appointments
"cancel": false, // Can cancel appointments "cancel": false, // Can cancel appointments
"update_status": true // Can mark as completed/no_show "update_status": true // Can mark as completed/no_show
}, },
"addresses": { "addresses": {
"view": true, "view": true,
"create": false, "create": false,
"update": false, "update": false,
"delete": false "delete": false
}, },
"clinic_info": { "clinic_info": {
"view": true, "view": true,
"update": false "update": false
}, },
"insurances": { "insurances": {
"view": true, "view": true,
"create": false, "create": false,
"update": false, "update": false,
"delete": false "delete": false
}
} }
}
} }
``` ```
### Response `201` ### Response `201`
```json ```json
{ {
"success": true, "success": true,
@@ -107,13 +125,14 @@ Create a secretary for a doctor.
| `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک | | `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک |
### Errors ### Errors
| Code | HTTP | Description |
|------|------|-------------| | Code | HTTP | Description |
| `ERR_AUTH_001` | 401 | Missing token | | ------------------- | ---- | ------------------------------------------- |
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found | | `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor | | `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached | | `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor |
| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached |
--- ---
@@ -124,11 +143,13 @@ Get secretary detail.
**Permission:** `AUTH` — must be the linked doctor or `ROLE_ADMIN` **Permission:** `AUTH` — must be the linked doctor or `ROLE_ADMIN`
### Path Parameters ### Path Parameters
| Param | Type | Description |
|-------|------|-------------| | Param | Type | Description |
| ------ | ------------- | -------------- |
| `uuid` | string (UUID) | Secretary UUID | | `uuid` | string (UUID) | Secretary UUID |
### Response `200` ### Response `200`
```json ```json
{ {
"success": true, "success": true,
@@ -144,11 +165,12 @@ Get secretary detail.
``` ```
### Errors ### Errors
| Code | HTTP | Description |
|------|------|-------------| | Code | HTTP | Description |
| `ERR_AUTH_001` | 401 | Missing token | | ------------------- | ---- | ------------------- |
| `ERR_FORBIDDEN_001` | 403 | Not authorized | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found | | `ERR_FORBIDDEN_001` | 403 | Not authorized |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
--- ---
@@ -159,32 +181,40 @@ Update secretary active status or permissions.
**Permission:** `ROLE_DOCTOR` — must be the linked doctor **Permission:** `ROLE_DOCTOR` — must be the linked doctor
### Request Body (`application/json`) ### Request Body (`application/json`)
```json ```json
{ {
"active": false, "active": false,
"permissions": { "permissions": {
"version": 1, "version": 1,
"resources": { "resources": {
"appointments": { "view": true, "create": false, "cancel": false, "update_status": false } "appointments": {
"view": true,
"create": false,
"cancel": false,
"update_status": false
}
}
} }
}
} }
``` ```
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| | ------------- | ------- | -------- | ------------------------ |
| `active` | boolean | ❌ | Enable/disable secretary | | `active` | boolean | ❌ | Enable/disable secretary |
| `permissions` | object | ❌ | New permissions object | | `permissions` | object | ❌ | New permissions object |
### Response `200` ### Response `200`
Updated secretary object. Updated secretary object.
### Errors ### Errors
| Code | HTTP | Description |
|------|------|-------------| | Code | HTTP | Description |
| `ERR_AUTH_001` | 401 | Missing token | | ------------------- | ---- | --------------------- |
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found | | `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
--- ---
@@ -195,16 +225,18 @@ Delete a secretary.
**Permission:** `ROLE_DOCTOR` — must be the linked doctor **Permission:** `ROLE_DOCTOR` — must be the linked doctor
### Response `200` ### Response `200`
```json ```json
{ "success": true, "data": { "message": "منشی حذف شد" } } { "success": true, "data": { "message": "منشی حذف شد" } }
``` ```
### Errors ### Errors
| Code | HTTP | Description |
|------|------|-------------| | Code | HTTP | Description |
| `ERR_AUTH_001` | 401 | Missing token | | ------------------- | ---- | --------------------- |
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found | | `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
--- ---
@@ -215,11 +247,13 @@ Get all secretaries for a specific doctor.
**Permission:** `ROLE_DOCTOR` (must own doctor) | `ROLE_CLINIC` (must have doctor in clinic) | `ROLE_ADMIN` **Permission:** `ROLE_DOCTOR` (must own doctor) | `ROLE_CLINIC` (must have doctor in clinic) | `ROLE_ADMIN`
### Path Parameters ### Path Parameters
| Param | Type | Description |
|-------|------|-------------| | Param | Type | Description |
| ------------ | ------------- | ----------- |
| `doctorUuid` | string (UUID) | Doctor UUID | | `doctorUuid` | string (UUID) | Doctor UUID |
### Response `200` ### Response `200`
```json ```json
{ {
"success": true, "success": true,
@@ -239,11 +273,12 @@ Get all secretaries for a specific doctor.
``` ```
### Errors ### Errors
| Code | HTTP | Description |
|------|------|-------------| | Code | HTTP | Description |
| `ERR_AUTH_001` | 401 | Missing token | | ------------------- | ---- | ---------------- |
| `ERR_FORBIDDEN_001` | 403 | Not authorized | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found | | `ERR_FORBIDDEN_001` | 403 | Not authorized |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
--- ---
@@ -254,11 +289,13 @@ Get all secretaries across **all doctors** of a clinic.
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN` **Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
### Path Parameters ### Path Parameters
| Param | Type | Description |
|-------|------|-------------| | Param | Type | Description |
| ------------ | ------------- | ----------- |
| `clinicUuid` | string (UUID) | Clinic UUID | | `clinicUuid` | string (UUID) | Clinic UUID |
### Response `200` ### Response `200`
```json ```json
{ {
"success": true, "success": true,
@@ -278,16 +315,18 @@ Get all secretaries across **all doctors** of a clinic.
``` ```
### Notes ### Notes
- این endpoint فقط منشیانی را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند
- منشیانی که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند - این endpoint فقط منشی های را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند
- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
- یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر) - یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر)
### Errors ### Errors
| Code | HTTP | Description |
|------|------|-------------| | Code | HTTP | Description |
| `ERR_AUTH_001` | 401 | Missing token | | ------------------- | ---- | ---------------- |
| `ERR_FORBIDDEN_001` | 403 | Not clinic owner | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found | | `ERR_FORBIDDEN_001` | 403 | Not clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
--- ---
@@ -295,23 +334,23 @@ Get all secretaries across **all doctors** of a clinic.
تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود: تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود:
| پنل | حداکثر منشی | | پنل | حداکثر منشی |
|-----|-------------| | ------------------ | ----------- |
| Free (بدون اشتراک) | ۱ | | Free (بدون اشتراک) | ۱ |
| Basic | ۳ | | Basic | ۳ |
| Professional | ۱۰ | | Professional | ۱۰ |
اگر تعداد منشی‌های فعال به حد مجاز رسیده باشد، ایجاد منشی جدید خطای زیر را برمی‌گرداند: اگر تعداد منشی‌های فعال به حد مجاز رسیده باشد، ایجاد منشی جدید خطای زیر را برمی‌گرداند:
```json ```json
{ {
"success": false, "success": false,
"errors": [ "errors": [
{ {
"code": "ERR_SECRETARY_001", "code": "ERR_SECRETARY_001",
"message": "پلن فعلی اجازه منشی بیشتر را نمی‌دهد" "message": "پلن فعلی اجازه منشی بیشتر را نمی‌دهد"
} }
] ]
} }
``` ```
@@ -1,7 +1,9 @@
# تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions # تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions
## توضیح ## توضیح
Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه می‌کند: Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه می‌کند:
1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی 1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی
2. Frontend: Modal با checkbox matrix برای ویرایش permissions 2. Frontend: Modal با checkbox matrix برای ویرایش permissions
@@ -9,30 +11,33 @@ Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`).
## فایل‌های موجود که تغییر می‌کنند ## فایل‌های موجود که تغییر می‌کنند
| فایل | تغییر | | فایل | تغییر |
|------|-------| | -------------------------------------------------- | ------------------------------------------------------------------ |
| `src/Secretary/Controller/SecretaryController.php` | inject `SubscriptionService`، اضافه کردن limit check در `create()` | | `src/Secretary/Controller/SecretaryController.php` | inject `SubscriptionService`، اضافه کردن limit check در `create()` |
| `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions | | `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions |
## API موجود (بدون تغییر) ## API موجود (بدون تغییر)
| متد | مسیر | توضیح | | متد | مسیر | توضیح |
|-----|------|-------| | ------ | ---------------------------------- | ----------------------------------------------- |
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشیان | | GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشی ها |
| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** | | POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** |
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** | | PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** |
| DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) | | DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) |
## پیش‌نیازها ## پیش‌نیازها
- تسک ۱۴ (Secretary — موجود در کد) - تسک ۱۴ (Secretary — موجود در کد)
- **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد) - **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد)
## زمان تخمینی ## زمان تخمینی
۴ تا ۵ ساعت ۴ تا ۵ ساعت
## تغییر Backend — SecretaryController::create() ## تغییر Backend — SecretaryController::create()
### وضعیت فعلی (قبل): ### وضعیت فعلی (قبل):
```php ```php
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -44,6 +49,7 @@ public function create(Request $request): JsonResponse
``` ```
### وضعیت جدید (بعد): ### وضعیت جدید (بعد):
```php ```php
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -68,6 +74,7 @@ public function create(Request $request): JsonResponse
``` ```
### خطای جدید که باید در ErrorCodes.php اضافه شود: ### خطای جدید که باید در ErrorCodes.php اضافه شود:
```php ```php
// src/Shared/Constant/ErrorCodes.php // src/Shared/Constant/ErrorCodes.php
const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED'; const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
@@ -77,6 +84,7 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
## تغییر Frontend — SecretariesPage.tsx ## تغییر Frontend — SecretariesPage.tsx
### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS): ### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS):
```php ```php
[ [
'version' => 1, 'version' => 1,
@@ -90,6 +98,7 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
``` ```
### نمونه UI Permissions checkbox matrix در Modal: ### نمونه UI Permissions checkbox matrix در Modal:
``` ```
┌─────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────┐
│ دسترسی‌های منشی │ │ دسترسی‌های منشی │
@@ -104,31 +113,45 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
``` ```
### PATCH /api/v1/secretary/{uuid} با permissions: ### PATCH /api/v1/secretary/{uuid} با permissions:
```json ```json
{ {
"permissions": { "permissions": {
"version": 1, "version": 1,
"resources": { "resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true }, "appointments": {
"addresses": { "view": true, "create": false, "update": false, "delete": false } "view": true,
"create": true,
"cancel": true,
"update_status": true
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
}
}
} }
}
} }
``` ```
## کدهای خطای جدید ## کدهای خطای جدید
| کد | پیام فارسی | | کد | پیام فارسی |
|----|-----------| | ----------------------------- | ---------------------------------------- |
| `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است | | `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است |
## Response خطا (403) ## Response خطا (403)
```json ```json
{ {
"success": false, "success": false,
"errors": [{ "errors": [
"code": "ERR_SECRETARY_LIMIT_REACHED", {
"message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید." "code": "ERR_SECRETARY_LIMIT_REACHED",
}] "message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید."
}
]
} }
``` ```
@@ -64,7 +64,7 @@ Professional → 5 → 403
## نکته: منشی غیرفعال از سقف کم نمی‌شود ## نکته: منشی غیرفعال از سقف کم نمی‌شود
``` ```
countActive() فقط منشیان active=true را می‌شمارد countActive() فقط منشی ها active=true را می‌شمارد
یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → می‌توانی دوباره 1 منشی جدید اضافه کنی یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → می‌توانی دوباره 1 منشی جدید اضافه کنی
(مادامی که در پنل Basic هستی و سقف 2 داری) (مادامی که در پنل Basic هستی و سقف 2 داری)
``` ```
+439
View File
@@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
(new Symfony\Component\Dotenv\Dotenv())->bootEnv(__DIR__ . '/.env');
$kernel = new App\Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();
$conn = $kernel->getContainer()->get('doctrine')->getManager()->getConnection();
$now = time();
$passwordHash = password_hash('Test@1234', PASSWORD_BCRYPT);
$mobileCounter = 9100100000; // شروع از 09100100000
// ── Helpers ──────────────────────────────────────────────────────────────────
function newUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
function nextMobile(): string
{
global $mobileCounter;
return '0' . $mobileCounter++;
}
function makeSchedule(int $addressId, bool $fullWeek = true): 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],
]];
} elseif ($fullWeek) {
$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],
]];
} else {
$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],
]];
}
}
return json_encode($days, JSON_UNESCAPED_UNICODE);
}
// نگاشت شهر → استان
$cityProvince = [
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,
];
$cityNames = [
101 => 'تبریز', 102 => 'ارومیه', 103 => 'اردبیل', 104 => 'اصفهان',
105 => 'کرج', 108 => 'تهران', 111 => 'مشهد', 113 => 'اهواز',
117 => 'شیراز', 121 => 'کرمان', 122 => 'کرمانشاه', 126 => 'رشت',
128 => 'ساری', 131 => 'همدان', 132 => 'یزد',
];
// توزیع تخصص‌ها برای ۱۰۰ دکتر
$specialtyGroups = [
['specs' => [200], 'services' => [], 'degree' => 'general', 'gender_mix' => 'both', 'count' => 15],
['specs' => [2, 3], 'services' => [101,102,103,110], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 10],
['specs' => [4], 'services' => [201,202,203,210], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
['specs' => [10], 'services' => [801,802,803,805], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
['specs' => [21], 'services' => [2101,2102,2103,2113],'degree' => 'specialist','gender_mix' => 'male', 'count' => 8],
['specs' => [61], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'female', 'count' => 8],
['specs' => [41], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 8],
['specs' => [111], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 7],
['specs' => [81, 82], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
['specs' => [91], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
['specs' => [101], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 5],
['specs' => [71], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 5],
['specs' => [121], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 4],
['specs' => [181], 'services' => [], 'degree' => 'dentist', 'gender_mix' => 'both', 'count' => 6],
];
$maleFirstNames = ['علی', 'محمد', 'حسین', 'رضا', 'امیر', 'مهدی', 'سعید', 'احمد', 'حسن', 'جواد',
'کریم', 'ابراهیم', 'اکبر', 'ناصر', 'پیمان', 'بهروز', 'شاهین', 'کاوه', 'آرش', 'سهراب',
'فرزاد', 'بهنام', 'وحید', 'مجید', 'عباس', 'صادق', 'داود', 'یوسف', 'منصور', 'اسماعیل'];
$femaleFirstNames = ['فاطمه', 'زهرا', 'مریم', 'سارا', 'نگین', 'الهه', 'نیلوفر', 'شیما', 'لیلا', 'نازنین',
'مینا', 'آزاده', 'ندا', 'رویا', 'پریسا', 'گلناز', 'سپیده', 'بهاره', 'شادی', 'فریده',
'سمانه', 'مهتاب', 'شهرزاد', 'گیتا', 'آناهیتا', 'سیمین', 'منیژه', 'ناهید', 'پگاه', 'روشنک'];
$lastNames = ['رضایی', 'محمدی', 'حسینی', 'علوی', 'موسوی', 'احمدی', 'کریمی', 'صادقی', 'جعفری', 'مرادی',
'نجفی', 'تبریزی', 'اصفهانی', 'مشهدی', 'شیرازی', 'کرمانی', 'رشیدی', 'نظری', 'ابراهیمی',
'قاسمی', 'یوسفی', 'منصوری', 'زارعی', 'ملکی', 'بهرامی', 'سلیمانی', 'نوری', 'پارسا', 'امینی', 'زاهدی'];
$degreeLabels = ['general' => 'پزشک عمومی', 'specialist' => 'متخصص', 'dentist' => 'دندانپزشک', 'subspecialist' => 'فوق‌تخصص'];
$specNames = [
2 => 'داخلی', 3 => 'قلب و عروق', 4 => 'گوارش', 10 => 'نورولوژی',
21 => 'جراحی عمومی', 41 => 'اطفال', 61 => 'زنان و زایمان', 71 => 'روانپزشکی',
81 => 'پوست', 82 => 'زیبایی پوست', 91 => 'چشم‌پزشکی', 101 => 'گوش، حلق و بینی',
111 => 'ارتوپدی', 121 => 'اورولوژی', 181 => 'دندانپزشکی', 200 => 'پزشکی عمومی',
];
$supplementaryInsurances = [7, 8, 9, 10, 11, 12, 13, 14, 15];
// ─────────────────────────────────────────────────────────────────────────────
// بخش A — پاک‌سازی
// ─────────────────────────────────────────────────────────────────────────────
echo "🧹 پاک‌سازی دیتابیس...\n";
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
$conn->executeStatement('DELETE FROM weekly_schedules');
$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');
$conn->executeStatement('DELETE FROM sms_logs');
$conn->executeStatement('DELETE FROM rates');
$conn->executeStatement('DELETE FROM doctors');
$conn->executeStatement('DELETE FROM clinics');
$conn->executeStatement("DELETE FROM users WHERE JSON_CONTAINS(roles, '\"ROLE_DOCTOR\"') OR JSON_CONTAINS(roles, '\"ROLE_CLINIC\"') OR JSON_CONTAINS(roles, '\"ROLE_PATIENT\"') OR JSON_CONTAINS(roles, '\"ROLE_SECRETARY\"')");
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
echo "✅ پاک‌سازی کامل شد\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// بخش B — کلینیک نمونه کامل (تبریز)
// ─────────────────────────────────────────────────────────────────────────────
echo "🏥 ایجاد کلینیک نمونه...\n";
$clinicMobile = nextMobile();
$clinicUserUuid = newUuid();
$conn->executeStatement(
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
[$clinicUserUuid, $clinicMobile, $passwordHash, 'کلینیک تخصصی امید', '["ROLE_USER","ROLE_CLINIC"]', $now, $now]
);
$clinicUserId = (int) $conn->lastInsertId();
$clinicUuid = newUuid();
$conn->executeStatement(
'INSERT INTO clinics (uuid, user_id, name, info, address, telephone, is_24_7, working_days, latitude, longitude, city_id, province_id, representation_id, is_active, created_at, updated_at)
VALUES (?,?,?,?,?,?,0,?,?,?,?,?,?,1,?,?)',
[
$clinicUuid, $clinicUserId,
'کلینیک تخصصی امید تبریز',
'کلینیک تخصصی امید تبریز با بیش از ۲۰ سال سابقه در ارائه خدمات درمانی تخصصی. مجهز به پیشرفته‌ترین تجهیزات پزشکی و دارای تیمی از متخصصان برجسته در رشته‌های مختلف. پذیرش بیماران با بیمه‌های تامین اجتماعی، خدمات درمانی و بیمه‌های تکمیلی.',
'تبریز، خیابان ارتش، نبش کوچه سوم، ساختمان امید، طبقه اول',
'04133445566',
'شنبه تا چهارشنبه ۸ تا ۲۰، پنجشنبه ۸ تا ۱۴',
37.0833, 35.7167,
101, 1, 101, // city, province, representation
$now, $now,
]
);
$clinicId = (int) $conn->lastInsertId();
// تخصص‌های کلینیک
foreach ([1, 2, 3, 4, 10, 40, 60, 80, 90, 100, 110] as $specId) {
$conn->executeStatement('INSERT IGNORE INTO clinic_specialties (clinic_id, specialty_id) VALUES (?,?)', [$clinicId, $specId]);
}
// بیمه‌های کلینیک
foreach ([1, 2, 3, 7, 8, 9] as $insId) {
$conn->executeStatement('INSERT IGNORE INTO clinic_insurances (clinic_id, insurance_id) VALUES (?,?)', [$clinicId, $insId]);
}
// خدمات کلینیک
foreach ([101, 102, 103, 201, 202, 203, 801, 802, 803] as $svcId) {
$conn->executeStatement('INSERT IGNORE INTO clinic_services (clinic_id, service_id) VALUES (?,?)', [$clinicId, $svcId]);
}
echo "✅ کلینیک امید تبریز ایجاد شد (id: $clinicId)\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// بخش C — دکتر نمونه کامل (تبریز)
// ─────────────────────────────────────────────────────────────────────────────
echo "👨‍⚕️ ایجاد دکتر نمونه...\n";
$sampleDocMobile = nextMobile();
$sampleDocUserUuid = newUuid();
$conn->executeStatement(
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
[$sampleDocUserUuid, $sampleDocMobile, $passwordHash, 'دکتر آرمان رضایی', '["ROLE_USER","ROLE_DOCTOR"]', $now, $now]
);
$sampleDocUserId = (int) $conn->lastInsertId();
$sampleDocUuid = newUuid();
$conn->executeStatement(
'INSERT INTO doctors (uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)',
[
$sampleDocUuid, $sampleDocUserId,
'دکتر آرمان رضایی',
'man', 'IR-10001', $sampleDocMobile,
mktime(0, 0, 0, 1, 1, 2008),
'specialist',
'متخصص قلب و عروق با بیش از ۱۶ سال سابقه بالینی. فارغ‌التحصیل دانشگاه علوم پزشکی تبریز با فلوشیپ قلب از دانشگاه تهران. عضو انجمن قلب ایران و مولف بیش از ۳۰ مقاله علمی در مجلات معتبر بین‌المللی. دارای تخصص ویژه در آنژیوگرافی تشخیصی و درمانی، اکوکاردیوگرافی و درمان نارسایی قلبی.',
4.8, 96.0,
101,
$now, $now,
]
);
$sampleDocId = (int) $conn->lastInsertId();
// تخصص‌ها
foreach ([2, 3] as $specId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_specialties (doctor_id, specialty_id) VALUES (?,?)', [$sampleDocId, $specId]);
}
// خدمات
foreach ([101, 102, 103, 104, 110] as $svcId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?,?)', [$sampleDocId, $svcId]);
}
// بیمه‌ها
foreach ([1, 2, 3, 7] as $insId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_insurances (doctor_id, insurance_id) VALUES (?,?)', [$sampleDocId, $insId]);
}
// شهر و استان
$conn->executeStatement('INSERT IGNORE INTO doctor_cities (doctor_id, city_id) VALUES (?,?)', [$sampleDocId, 101]);
$conn->executeStatement('INSERT IGNORE INTO doctor_provinces (doctor_id, province_id) VALUES (?,?)', [$sampleDocId, 1]);
// آدرس مطب شخصی
$addrUuid1 = newUuid();
$conn->executeStatement(
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
VALUES (?,?,NULL,?,?,?,?,?,?,?,?)',
[
$addrUuid1, $sampleDocId, 101, 1, 'personal',
'مطب دکتر رضایی',
'تبریز، خیابان آزادی، کوچه پانزدهم، ساختمان پارسیان، واحد ۱۲',
'04132001122',
$now, $now,
]
);
$addrId1 = (int) $conn->lastInsertId();
// آدرس کلینیک
$addrUuid2 = newUuid();
$conn->executeStatement(
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)',
[
$addrUuid2, $sampleDocId, $clinicId, 101, 1, 'clinic',
'کلینیک تخصصی امید تبریز',
'تبریز، خیابان ارتش، نبش کوچه سوم، ساختمان امید، طبقه اول',
'04133445566',
$now, $now,
]
);
$addrId2 = (int) $conn->lastInsertId();
// clinic_doctors
$conn->executeStatement('INSERT IGNORE INTO clinic_doctors (clinic_id, doctor_id) VALUES (?,?)', [$clinicId, $sampleDocId]);
// برنامه کاری (هر دو مطب — مطب اصلی)
$scheduleJson = makeSchedule($addrId1, true);
$scheduleUuid = newUuid();
$conn->executeStatement(
'INSERT INTO weekly_schedules (uuid, doctor_id, setting, created_at, updated_at) VALUES (?,?,?,?,?)',
[$scheduleUuid, $sampleDocId, $scheduleJson, $now, $now]
);
// منشی دکتر نمونه
$secMobile = nextMobile();
$secUserUuid = newUuid();
$conn->executeStatement(
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
[$secUserUuid, $secMobile, $passwordHash, 'خانم نرگس صادقی', '["ROLE_USER","ROLE_SECRETARY"]', $now, $now]
);
$secUserId = (int) $conn->lastInsertId();
$secUuid = newUuid();
$conn->executeStatement(
'INSERT INTO doctor_secretaries (uuid, doctor_id, secretary_id, active, owner_type, clinic_id, created_at, updated_at) VALUES (?,?,?,1,?,NULL,?,?)',
[$secUuid, $sampleDocId, $secUserId, 'doctor', $now, $now]
);
echo "✅ دکتر نمونه (آرمان رضایی) ایجاد شد (id: $sampleDocId)\n";
echo " 📍 مطب: id=$addrId1 | کلینیک امید: id=$addrId2\n";
echo " 👩‍💼 منشی: خانم نرگس صادقی (موبایل: $secMobile)\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// بخش D — ۱۰۰ دکتر به ازای هر شهر (bulk)
// ─────────────────────────────────────────────────────────────────────────────
$targetCities = [101, 102, 103, 104, 105, 108, 111, 113, 117, 121, 122, 126, 128, 131, 132];
$totalCreated = 0;
foreach ($targetCities as $cityId) {
$provincId = $cityProvince[$cityId];
$cityName = $cityNames[$cityId];
$docCount = 0;
echo "🏙️ $cityName: ";
// flat list of 100 doctors from specialty groups
$doctorQueue = [];
foreach ($specialtyGroups as $group) {
for ($n = 0; $n < $group['count']; $n++) {
$doctorQueue[] = $group;
}
}
$nameIdx = 0;
foreach ($doctorQueue as $idx => $group) {
// تعیین جنسیت
if ($group['gender_mix'] === 'female') {
$gender = 'woman';
$firstName = $femaleFirstNames[$nameIdx % count($femaleFirstNames)];
} elseif ($group['gender_mix'] === 'male') {
$gender = 'man';
$firstName = $maleFirstNames[$nameIdx % count($maleFirstNames)];
} else {
$gender = ($idx % 3 === 0) ? 'woman' : 'man';
$firstName = ($gender === 'woman')
? $femaleFirstNames[$nameIdx % count($femaleFirstNames)]
: $maleFirstNames[$nameIdx % count($maleFirstNames)];
}
$lastName = $lastNames[$nameIdx % count($lastNames)];
$nameIdx++;
$primarySpec = $group['specs'][0];
$specLabel = $specNames[$primarySpec] ?? 'پزشک';
$degreeLabel = $degreeLabels[$group['degree']] ?? 'متخصص';
$title = ($gender === 'woman') ? 'دکتر' : 'دکتر';
$fullName = "$title $firstName $lastName";
$mobile = nextMobile();
$userUuid = newUuid();
$conn->executeStatement(
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
[$userUuid, $mobile, $passwordHash, $fullName, '["ROLE_USER","ROLE_DOCTOR"]', $now, $now]
);
$userId = (int) $conn->lastInsertId();
$rate = round(3.5 + mt_rand(0, 15) / 10, 1);
$ratePercentage = $rate * 20.0;
$startYear = 1995 + mt_rand(0, 20);
$medCode = 'IR-' . str_pad((string)(10001 + $totalCreated + $docCount), 6, '0', STR_PAD_LEFT);
$info = "$degreeLabel $specLabel. فارغ‌التحصیل دانشگاه علوم پزشکی $cityName. "
. "با " . (date('Y') - $startYear) . " سال سابقه بالینی در حوزه $specLabel. "
. "پذیرش بیمه‌های تامین اجتماعی و خدمات درمانی.";
$docUuid = newUuid();
$conn->executeStatement(
'INSERT INTO doctors (uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)',
[
$docUuid, $userId, $fullName, $gender, $medCode, $mobile,
mktime(0, 0, 0, 1, 1, $startYear),
$group['degree'], $info, $rate, $ratePercentage,
$cityId,
$now, $now,
]
);
$docId = (int) $conn->lastInsertId();
// تخصص‌ها
foreach ($group['specs'] as $specId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_specialties (doctor_id, specialty_id) VALUES (?,?)', [$docId, $specId]);
}
// خدمات (حداکثر ۳ خدمت)
$services = array_slice($group['services'], 0, 3);
foreach ($services as $svcId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?,?)', [$docId, $svcId]);
}
// بیمه‌ها: پایه + یک تکمیلی تصادفی
$suppInsId = $supplementaryInsurances[mt_rand(0, count($supplementaryInsurances) - 1)];
foreach (array_unique([1, 2, $suppInsId]) as $insId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_insurances (doctor_id, insurance_id) VALUES (?,?)', [$docId, $insId]);
}
// شهر و استان
$conn->executeStatement('INSERT IGNORE INTO doctor_cities (doctor_id, city_id) VALUES (?,?)', [$docId, $cityId]);
$conn->executeStatement('INSERT IGNORE INTO doctor_provinces (doctor_id, province_id) VALUES (?,?)', [$docId, $provincId]);
// آدرس مطب
$streetNum = mt_rand(1, 50);
$buildingNo = mt_rand(1, 30);
$addrUuid = newUuid();
$conn->executeStatement(
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
VALUES (?,?,NULL,?,?,?,?,?,?,?,?)',
[
$addrUuid, $docId, $cityId, $provincId, 'personal',
"مطب $fullName",
$cityName . '، خیابان شهید بهشتی، کوچه ' . $streetNum . '، پلاک ' . $buildingNo,
'0' . (4100000000 + mt_rand(10000000, 99999999)),
$now, $now,
]
);
$addrId = (int) $conn->lastInsertId();
// برنامه کاری
$schedJson = makeSchedule($addrId, $idx % 4 !== 0);
$schedUuid = newUuid();
$conn->executeStatement(
'INSERT INTO weekly_schedules (uuid, doctor_id, setting, created_at, updated_at) VALUES (?,?,?,?,?)',
[$schedUuid, $docId, $schedJson, $now, $now]
);
$docCount++;
}
$totalCreated += $docCount;
echo "$docCount دکتر ایجاد شد ✅\n";
}
echo "\n✅ پایان — مجموع $totalCreated دکتر در " . count($targetCities) . " شهر ایجاد شد\n";
echo "📱 رمز عبور همه کاربران: Test@1234\n";
echo "📱 موبایل اول دکتر نمونه: $sampleDocMobile\n";
@@ -171,7 +171,7 @@ class SecretaryController extends BaseController
return $this->success(['message' => 'منشی غیرفعال شد']); return $this->success(['message' => 'منشی غیرفعال شد']);
} }
/** لیست منشیان مطب شخصی یک دکتر */ /** لیست منشی ها مطب شخصی یک دکتر */
#[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])] #[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])]
public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse
{ {
@@ -180,7 +180,7 @@ class SecretaryController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
} }
// فقط خود دکتر یا ادمین می‌توانند منشیان مطب شخصی را ببینند // فقط خود دکتر یا ادمین می‌توانند منشی ها مطب شخصی را ببینند
if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) { if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
} }
@@ -193,7 +193,7 @@ class SecretaryController extends BaseController
return $this->success($secretaries); return $this->success($secretaries);
} }
/** لیست همه منشیان کلینیک (از همه دکترها، owner_type='clinic') */ /** لیست همه منشی ها کلینیک (از همه دکترها، owner_type='clinic') */
#[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])] #[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])]
public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse
{ {
@@ -227,7 +227,7 @@ class SecretaryController extends BaseController
} }
if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) { if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) {
// فقط مالک کلینیک می‌تواند منشیان کلینیکی را مدیریت کند // فقط مالک کلینیک می‌تواند منشی ها کلینیکی را مدیریت کند
if (!$user->hasRole('ROLE_CLINIC')) { if (!$user->hasRole('ROLE_CLINIC')) {
return false; return false;
} }
@@ -38,7 +38,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']); return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
} }
/** منشیان مطب شخصی یک دکتر (owner_type='doctor') */ /** منشی ها مطب شخصی یک دکتر (owner_type='doctor') */
public function findByDoctorScope(Doctor $doctor): array public function findByDoctorScope(Doctor $doctor): array
{ {
return $this->findBy( return $this->findBy(
@@ -108,7 +108,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
return $clinic->getDoctors()->contains($doctor); return $clinic->getDoctors()->contains($doctor);
} }
/** همه منشیان کلینیک (owner_type='clinic') */ /** همه منشی ها کلینیک (owner_type='clinic') */
public function findByClinic(Clinic $clinic): array public function findByClinic(Clinic $clinic): array
{ {
return $this->createQueryBuilder('s') return $this->createQueryBuilder('s')