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` |
|-----------|-------------|-------------|
| خود دکتر | `'doctor'` | `null` |
| کلینیک | `'clinic'` | `<id کلینیک>` |
| ایجادکننده | `owner_type` | `clinic_id` |
| ---------- | ------------ | ------------- |
| خود دکتر | `'doctor'` | `null` |
| کلینیک | `'clinic'` | `<id کلینیک>` |
دسترسی‌ها، داشبورد، و فیلتر نوبت‌ها باید فقط در scope مربوطه اعمال شوند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود |
| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند |
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند |
| `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبت‌های منشی — باید scope-aware شود |
| `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود |
| `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود |
| `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد |
| `migrations/` | migration جدید برای `owner_type` + `clinic_id` |
| `docs/api/secretary.md` | مستندات API |
| فایل | نقش |
| --------------------------------------------------------- | ---------------------------------------------------------------- |
| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود |
| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند |
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند |
| `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبت‌های منشی — باید scope-aware شود |
| `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود |
| `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود |
| `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد |
| `migrations/` | migration جدید برای `owner_type` + `clinic_id` |
| `docs/api/secretary.md` | مستندات API |
## وضعیت فعلی
### Entity (بدون scope)
```php
// src/Secretary/Entity/DoctorSecretary.php
// هیچ فیلد owner_type یا clinic_id وجود ندارد
@@ -54,6 +55,7 @@ class DoctorSecretary
```
### Repository — scope-blind
```php
public function findActiveBySecretary(User $user): ?DoctorSecretary
{
@@ -63,6 +65,7 @@ public function findActiveBySecretary(User $user): ?DoctorSecretary
```
### Controller — ایجاد بدون scope
```php
// create() در SecretaryController
// هیچ owner_type یا clinic_id ست نمی‌شود
@@ -70,6 +73,7 @@ $secretary = new DoctorSecretary($doctor, $secretaryUser);
```
### Auth context builder — scope-blind
```php
// AuthController::buildContexts()
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
@@ -86,6 +90,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
```
### Appointment فیلتر — فقط یک رابطه
```php
// MyAppointmentsController — منشی فقط به نوبت‌های یک دکتر دسترسی دارد
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
@@ -128,6 +133,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
```
در `toArray()` هم اضافه شود:
```php
'owner_type' => $this->ownerType,
'clinic_uuid' => $this->clinic?->getUuid(),
@@ -136,6 +142,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
### ۲. Migration
بعد از تغییر Entity:
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
@@ -206,7 +213,8 @@ public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): arra
}
```
همچنین `findByClinic` باید فقط منشیان با `owner_type='clinic'` را برگرداند:
همچنین `findByClinic` باید فقط منشی ها با `owner_type='clinic'` را برگرداند:
```php
public function findByClinic(Clinic $clinic): array
{
@@ -225,6 +233,7 @@ public function findByClinic(Clinic $clinic): array
### ۴. SecretaryController: ایجاد با scope
در `create()`:
- اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)`
- اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null`
@@ -238,6 +247,7 @@ if ($currentUser->hasRole('ROLE_CLINIC')) {
```
همچنین `canManageDoctor()` باید scope را چک کند:
- `ROLE_DOCTOR` فقط می‌تواند روابط `owner_type='doctor'` را مدیریت کند
- `ROLE_CLINIC` فقط می‌تواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند
@@ -278,6 +288,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
### ۶. Appointment و Dashboard: scope-aware
در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`:
- اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آن‌ها متصل است
- اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'`
+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 کاملاً پیاده است:
- `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت
- `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز
- `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }`
@@ -11,6 +12,7 @@
**مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل.
**هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود:
1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند
@@ -21,12 +23,12 @@
### قابلیت‌های محدودشده توسط اشتراک
| Feature Key | صفحه/منو | نقش مرتبط |
|-------------|----------|----------|
| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary |
| `services` | `/admin/clinic-services` | doctor, clinic |
| `sms_panel` | `/admin/sms-wallet` | doctor, clinic |
| `max_secretaries` | `/admin/my-secretaries` (تعداد) | doctor, clinic |
| Feature Key | صفحه/منو | نقش مرتبط |
| ----------------- | ------------------------------- | ------------------------- |
| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary |
| `services` | `/admin/clinic-services` | doctor, clinic |
| `sms_panel` | `/admin/sms-wallet` | doctor, clinic |
| `max_secretaries` | `/admin/my-secretaries` (تعداد) | doctor, clinic |
**نکته منشی:** منشی اشتراک مستقل ندارد — اشتراک entity ای که به آن متصل است (دکتر یا کلینیک) اعمال می‌شود. endpoint `/api/v1/subscription/my` برای منشی null برمی‌گرداند (چون `resolveEntity` در `SubscriptionController` فقط ROLE_DOCTOR و ROLE_CLINIC را handle می‌کند).
@@ -34,24 +36,25 @@
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| فایل | نقش |
| -------------------------------------------------------- | ------------------------------------------------------------- |
| `src/Subscription/Controller/SubscriptionController.php` | endpoint `GET /api/v1/subscription/my` — برای doctor و clinic |
| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` |
| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد |
| `assets/admin/components/layout/Sidebar.tsx` | `buildSections()` — منوها بر اساس `primaryRole` |
| `assets/admin/App.tsx` | `RoleRoute` — گیت نقش‌ها، باید feature gate هم اضافه شود |
| `assets/admin/pages/MyPatientsPage.tsx` | نیاز به `patient_records` |
| `assets/admin/pages/ClinicServicesPage.tsx` | نیاز به `services` |
| `assets/admin/pages/SmsWalletPage.tsx` | نیاز به `sms_panel` |
| `assets/admin/pages/MySecretariesPage.tsx` | نیاز به `max_secretaries` بیش از ۱ |
| `assets/admin/pages/SubscriptionPage.tsx` | صفحه ارتقاء پنل — مقصد CTA ها |
| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` |
| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد |
| `assets/admin/components/layout/Sidebar.tsx` | `buildSections()` — منوها بر اساس `primaryRole` |
| `assets/admin/App.tsx` | `RoleRoute` — گیت نقش‌ها، باید feature gate هم اضافه شود |
| `assets/admin/pages/MyPatientsPage.tsx` | نیاز به `patient_records` |
| `assets/admin/pages/ClinicServicesPage.tsx` | نیاز به `services` |
| `assets/admin/pages/SmsWalletPage.tsx` | نیاز به `sms_panel` |
| `assets/admin/pages/MySecretariesPage.tsx` | نیاز به `max_secretaries` بیش از ۱ |
| `assets/admin/pages/SubscriptionPage.tsx` | صفحه ارتقاء پنل — مقصد CTA ها |
---
## وضعیت فعلی
### Backend — endpoint اشتراک
```php
// SubscriptionController.php — GET /api/v1/subscription/my
// فقط doctor و clinic را handle می‌کند
@@ -70,50 +73,77 @@ private function resolveEntity(User $user): array
```
پاسخ:
```json
{
"success": true,
"data": {
"subscription": {
"uuid": "...",
"plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true, "sms_panel": false }, "max_secretaries": 3 },
"is_trial": false,
"expires_at": 1750000000,
"days_remaining": 45
},
"used_trial": false
}
"success": true,
"data": {
"subscription": {
"uuid": "...",
"plan": {
"name": "basic",
"level": 1,
"features": {
"patient_records": true,
"services": true,
"sms_panel": false
},
"max_secretaries": 3
},
"is_trial": false,
"expires_at": 1750000000,
"days_remaining": 45
},
"used_trial": false
}
}
```
اگر اشتراک فعال نداشته باشد: `"subscription": null`
### Frontend — authStore
```ts
// authStore.ts — فعلاً subscription اصلاً در store نیست
interface AuthState {
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
dbUuid: string | null;
// ... subscription وجود ندارد
primaryRole: "admin" | "clinic" | "doctor" | "secretary" | "user" | null;
dbUuid: string | null;
// ... subscription وجود ندارد
}
```
### Frontend — Sidebar
```tsx
// Sidebar.tsx — همه آیتم‌ها را بدون بررسی feature نمایش می‌دهد
if (primaryRole === 'doctor') {
return [{
label: 'مدیریت',
items: [
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' },
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' },
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' },
],
}];
if (primaryRole === "doctor") {
return [
{
label: "مدیریت",
items: [
{
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
},
],
},
];
}
```
### Frontend — App.tsx
```tsx
// فقط نقش‌ها چک می‌شوند، feature gate وجود ندارد
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
@@ -148,34 +178,38 @@ if ($user->hasRole('ROLE_SECRETARY')) {
یک فایل `assets/admin/hooks/useSubscription.ts` بساز:
```ts
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import type { ApiResponse, MySubscriptionData } from '../types';
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { useAuthStore } from "../stores/authStore";
import type { ApiResponse, MySubscriptionData } from "../types";
export function useSubscription() {
const primaryRole = useAuthStore((s) => s.primaryRole);
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary';
const primaryRole = useAuthStore((s) => s.primaryRole);
const enabled =
primaryRole === "doctor" ||
primaryRole === "clinic" ||
primaryRole === "secretary";
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
queryKey: ['subscription-my'],
queryFn: () => api.get('/api/v1/subscription/my'),
enabled,
staleTime: 2 * 60 * 1000,
});
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
queryKey: ["subscription-my"],
queryFn: () => api.get("/api/v1/subscription/my"),
enabled,
staleTime: 2 * 60 * 1000,
});
const sub = data?.data?.subscription ?? null;
const features: Record<string, boolean> = sub?.plan?.features ?? {};
const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1;
const hasPlan = sub !== null;
const sub = data?.data?.subscription ?? null;
const features: Record<string, boolean> = sub?.plan?.features ?? {};
const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1;
const hasPlan = sub !== null;
return {
subscription: sub,
hasFeature: (key: string) => hasPlan && (features[key] ?? false),
maxSecretaries,
hasPlan,
isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
};
return {
subscription: sub,
hasFeature: (key: string) => hasPlan && (features[key] ?? false),
maxSecretaries,
hasPlan,
isExpiringSoon:
(sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
};
}
```
@@ -186,28 +220,30 @@ export function useSubscription() {
در `Sidebar.tsx`:
```tsx
import { useSubscription } from '../../hooks/useSubscription';
import { useSubscription } from "../../hooks/useSubscription";
// ...
export default function Sidebar() {
const primaryRole = useAuthStore((s) => s.primaryRole);
const dbUuid = useAuthStore((s) => s.dbUuid);
const { hasFeature } = useSubscription();
// ...
const primaryRole = useAuthStore((s) => s.primaryRole);
const dbUuid = useAuthStore((s) => s.dbUuid);
const { hasFeature } = useSubscription();
// ...
}
```
نوع `SectionItem` را گسترش بده:
```ts
type SectionItem = {
to: string;
icon: React.ElementType;
label: string;
feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل
to: string;
icon: React.ElementType;
label: string;
feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل
};
```
آیتم‌های محدود را با `feature` mark کن:
```tsx
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
{ to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' },
@@ -215,18 +251,19 @@ type SectionItem = {
```
در render هر آیتم:
```tsx
const isLocked = item.feature ? !hasFeature(item.feature) : false;
<NavLink
to={isLocked ? '/admin/subscription' : item.to}
style={isLocked ? { opacity: 0.5 } : {}}
title={isLocked ? 'نیاز به ارتقاء پنل' : undefined}
to={isLocked ? "/admin/subscription" : item.to}
style={isLocked ? { opacity: 0.5 } : {}}
title={isLocked ? "نیاز به ارتقاء پنل" : undefined}
>
<item.icon />
{item.label}
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: 'auto' }} />}
</NavLink>
<item.icon />
{item.label}
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: "auto" }} />}
</NavLink>;
```
import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
@@ -236,81 +273,96 @@ import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
یک component کوچک `assets/admin/components/ui/FeatureGate.tsx` بساز:
```tsx
import React from 'react';
import { Link } from 'react-router-dom';
import { LockClosedIcon } from '@heroicons/react/24/outline';
import { useSubscription } from '../../hooks/useSubscription';
import React from "react";
import { Link } from "react-router-dom";
import { LockClosedIcon } from "@heroicons/react/24/outline";
import { useSubscription } from "../../hooks/useSubscription";
interface FeatureGateProps {
feature: string;
children: React.ReactNode;
feature: string;
children: React.ReactNode;
}
export default function FeatureGate({ feature, children }: FeatureGateProps) {
const { hasFeature, hasPlan } = useSubscription();
const { hasFeature, hasPlan } = useSubscription();
if (hasFeature(feature)) return <>{children}</>;
if (hasFeature(feature)) return <>{children}</>;
return (
<div style={{
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
minHeight: 320, gap: 16, padding: 40, textAlign: 'center',
}}>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: 'oklch(0.97 0.01 256)',
display: 'grid', placeItems: 'center',
}}>
<LockClosedIcon style={{ width: 28, color: 'var(--text-3)' }} />
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
این قابلیت در پنل فعلی شما فعال نیست
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: 320,
gap: 16,
padding: 40,
textAlign: "center",
}}
>
<div
style={{
width: 64,
height: 64,
borderRadius: "50%",
background: "oklch(0.97 0.01 256)",
display: "grid",
placeItems: "center",
}}
>
<LockClosedIcon style={{ width: 28, color: "var(--text-3)" }} />
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
این قابلیت در پنل فعلی شما فعال نیست
</div>
<div
style={{
color: "var(--text-3)",
fontSize: 13.5,
maxWidth: 360,
}}
>
{hasPlan
? "برای استفاده از این قابلیت پنل خود را ارتقاء دهید"
: "برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید"}
</div>
</div>
<Link
to="/admin/subscription"
className="btn primary sm"
style={{ textDecoration: "none" }}
>
{hasPlan ? "ارتقاء پنل" : "مشاهده پنل‌های اشتراکی"}
</Link>
</div>
<div style={{ color: 'var(--text-3)', fontSize: 13.5, maxWidth: 360 }}>
{hasPlan
? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید'
: 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'}
</div>
</div>
<Link to="/admin/subscription" className="btn primary sm" style={{ textDecoration: 'none' }}>
{hasPlan ? 'ارتقاء پنل' : 'مشاهده پنل‌های اشتراکی'}
</Link>
</div>
);
);
}
```
### ۵. Frontend — استفاده از FeatureGate در صفحات
در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن:
```tsx
import FeatureGate from '../components/ui/FeatureGate';
import FeatureGate from "../components/ui/FeatureGate";
// ...
return (
<FeatureGate feature="patient_records">
{/* کد فعلی صفحه */}
</FeatureGate>
<FeatureGate feature="patient_records">{/* کد فعلی صفحه */}</FeatureGate>
);
```
در **`ClinicServicesPage.tsx`**:
```tsx
return (
<FeatureGate feature="services">
{/* کد فعلی */}
</FeatureGate>
);
return <FeatureGate feature="services">{/* کد فعلی */}</FeatureGate>;
```
در **`SmsWalletPage.tsx`**:
```tsx
return (
<FeatureGate feature="sms_panel">
{/* کد فعلی */}
</FeatureGate>
);
return <FeatureGate feature="sms_panel">{/* کد فعلی */}</FeatureGate>;
```
### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage
@@ -318,27 +370,34 @@ return (
در **`MySecretariesPage.tsx`** وقتی کاربر می‌خواهد منشی جدید اضافه کند:
```tsx
import { useSubscription } from '../hooks/useSubscription';
import { useSubscription } from "../hooks/useSubscription";
// ...
const { maxSecretaries } = useSubscription();
// تعداد فعلی منشیان از data موجود
const activeCount = secretaries.filter(s => s.is_active).length;
// تعداد فعلی منشی ها از data موجود
const activeCount = secretaries.filter((s) => s.is_active).length;
const isAtLimit = activeCount >= maxSecretaries;
// روی دکمه «افزودن منشی»:
<button
className="btn primary sm"
onClick={() => setAddOpen(true)}
disabled={isAtLimit}
title={isAtLimit ? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است` : undefined}
className="btn primary sm"
onClick={() => setAddOpen(true)}
disabled={isAtLimit}
title={
isAtLimit
? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است`
: undefined
}
>
افزودن منشی
</button>
{isAtLimit && (
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
برای افزودن منشی بیشتر <Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
</div>
)}
افزودن منشی
</button>;
{
isAtLimit && (
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 4 }}>
برای افزودن منشی بیشتر{" "}
<Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
</div>
);
}
```
---
+129 -102
View File
@@ -10,41 +10,31 @@
```css
/* متغیرها */
--primary: oklch(0.55 0.2 256)
--primary-subtle: oklch(0.97 0.02 256)
--surface: #fff
--border: oklch(0.9 0 0)
--text-1 / --text-2 / --text-3
--r (border-radius base)
--gap / --card-pad
/* کلاس‌های کاربردی */
.card /* white surface با shadow و border-radius */
.card-pad /* padding داخل card */
.stat /* عدد آماری بزرگ + label */
.stat-grid /* grid چند ستونه برای stat‌ها */
.badge green/red/yellow/blue/gray /* برچسب وضعیت */
.btn primary/sm/ghost /* دکمه */
.field /* label + input wrapper */
.seg /* segment/tab selector */
.bar /* نوار پیشرفت */
.grid-2 /* دو‌ستونه responsive */
.avatar sm /* آواتار دایره‌ای */
--primary: oklch(0.55 0.2 256) --primary-subtle: oklch(0.97 0.02 256)
--surface: #fff --border: oklch(0.9 0 0) --text-1 / --text-2 / --text-3 --r
(border-radius base) --gap / --card-pad /* کلاس‌های کاربردی */ .card
/* white surface با shadow و border-radius */ .card-pad
/* padding داخل card */ .stat /* عدد آماری بزرگ + label */ .stat-grid
/* grid چند ستونه برای stat‌ها */ .badge green/red/yellow/blue/gray
/* برچسب وضعیت */ .btn primary/sm/ghost /* دکمه */ .field
/* label + input wrapper */ .seg /* segment/tab selector */ .bar
/* نوار پیشرفت */ .grid-2 /* دو‌ستونه responsive */ .avatar sm
/* آواتار دایره‌ای */;
```
**الگوی header صفحات:** `<PageHeader title="..." description="..." action={<button>} />`
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشیان — CRUD + permissions matrix |
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویس‌های کلینیک — دو پنل: بخش‌ها + آیتم‌ها |
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
| `assets/admin/styles.css` | سیستم CSS کامل |
| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحی‌شده با gradient + progress bar |
| فایل | نقش |
| ------------------------------------------- | -------------------------------------------------------- |
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشی ها — CRUD + permissions matrix |
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویس‌های کلینیک — دو پنل: بخش‌ها + آیتم‌ها |
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
| `assets/admin/styles.css` | سیستم CSS کامل |
| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحی‌شده با gradient + progress bar |
## وضعیت فعلی هر صفحه
@@ -173,6 +163,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** لیست با identity واضح + پرونده بیمار با مینی‌داشبورد.
**الف — حالت لیست بیماران:**
- Header اختصاصی: `<PageHeader>` با description «مراجعه‌کنندگان ثبت‌شده شما»
- Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول)
- `DataTable` را با ردیف‌های بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی
@@ -180,15 +171,17 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما
**ب — حالت پرونده بیمار (selectedRecord):**
- PageHeader با نام بیمار + شماره + تاریخ ثبت
- یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`)
- SessionRow باید بهتر شود — هر مراجعه یک card مستقل با:
- سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge
- بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row
- footer: لیست خدمات (services) اگر وجود داشت
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت
- سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge
- بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row
- footer: لیست خدمات (services) اگر وجود داشت
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت
**ج — Modal ثبت مراجعه:**
- سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سه‌ستونه گذاشتن خوب است — این کافیست
- بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن
- لیست خدمات انتخاب‌شده را با badge یا chip نشان بده (نه فقط متن)
@@ -202,20 +195,23 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** صفحه‌ای با KPI bar + جدول بهتر + فرم واضح‌تر.
**الف — KPI bar:**
- بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال
- داده از همان `staff` query محاسبه شود (نه API جدید):
```ts
const total = staff.length;
const active = staff.filter((s) => s.active).length;
const inactive = total - active;
```
```ts
const total = staff.length;
const active = staff.filter((s) => s.active).length;
const inactive = total - active;
```
**ب — جدول بهتر:**
- ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage)
- ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود)
- ردیف‌های hover state با background تغییر کند
**ج — فرم StaffFormFields:**
- فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده
- فیلدهای `phone` و `national_code` را در یک ردیف دیگر
- فیلد `address` تنها بماند (textarea یا input تمام عرض)
@@ -226,7 +222,8 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصری‌تر + Empty State جذاب.
**الف — جدول منشیان:**
**الف — جدول منشی ها:**
- جدول دستی را **حذف کن** و از `DataTable` component استفاده کن
- ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد
- ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن
@@ -235,21 +232,32 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسی‌ها + غیرفعال‌سازی)
**ب — Empty State:**
- وقتی `secretaries.length === 0`:
```tsx
<div className="card card-pad" style={{ textAlign: 'center', padding: '60px 24px' }}>
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px' }} />
<div style={{ fontWeight: 600, marginBottom: 8 }}>هنوز منشی‌ای اضافه نشده</div>
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}>
منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند
```tsx
<div
className="card card-pad"
style={{ textAlign: "center", padding: "60px 24px" }}
>
<IdentificationIcon
style={{ width: 48, color: "var(--text-3)", margin: "0 auto 16px" }}
/>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
هنوز منشی‌ای اضافه نشده
</div>
<div
style={{ color: "var(--text-3)", fontSize: 13.5, marginBottom: 20 }}
>
منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند
</div>
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
</button>
</div>
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
</button>
</div>
```
```
**ج — PermissionsMatrix:**
- ردیف‌های جدول با رنگ متناوب (striped) یا hover state
- checkbox‌ها با `accent-color: var(--primary)` (قبلاً بود، نگه دار)
- header ستون‌ها را bold و با background کم‌رنگ کن
@@ -262,38 +270,49 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** پنل بخش‌ها بهتر + جدول آیتم‌ها با badge فعال/غیرفعال.
**الف — پنل بخش‌ها (ستون ۲۸۰px):**
- هر بخش یک item با:
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
- نام بخش bold
- badge «فعال» / «غیرفعال» بر اساس `s.active`
- دکمه‌های edit/delete فقط هنگام hover نشان داده شوند (opacity transition)
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
- نام بخش bold
- badge «فعال» / «غیرفعال» بر اساس `s.active`
- دکمه‌های edit/delete فقط هنگام hover نشان داده شوند (opacity transition)
- انتخاب شده: border `var(--primary)` + background `var(--primary-subtle)`
- افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون)
**ب — پنل آیتم‌ها (ستون 1fr):**
- بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتم‌ها + دکمه «سرویس جدید»
- هر row آیتم:
- badge «فعال» / «غیرفعال» برای `item.active`
- قیمت با `formatRial()` و رنگ primary
- نام پرسنل با avatar خیلی کوچک اگر وجود داشت
- badge «فعال» / «غیرفعال» برای `item.active`
- قیمت با `formatRial()` و رنگ primary
- نام پرسنل با avatar خیلی کوچک اگر وجود داشت
- Empty state وقتی `items.length === 0`:
```tsx
<div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}>
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block' }} />
سرویسی در این بخش وجود ندارد
<br />
<button className="btn primary sm" style={{ marginTop: 12 }} onClick={...}>افزودن سرویس</button>
</div>
```
```tsx
<div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}>
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block' }} />
سرویسی در این بخش وجود ندارد
<br />
<button className="btn primary sm" style={{ marginTop: 12 }} onClick={...}>افزودن سرویس</button>
</div>
```
**ج — State اولیه (هیچ بخشی انتخاب نشده):**
- جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده:
```tsx
<div style={{ border: '2px dashed var(--border)', borderRadius: 10, padding: '60px 0', textAlign: 'center', color: 'var(--text-3)' }}>
<WrenchScrewdriverIcon style={{ width: 40, margin: '0 auto 12px' }} />
<div>یک بخش انتخاب کنید تا سرویس‌های آن را ببینید</div>
</div>
```
```tsx
<div
style={{
border: "2px dashed var(--border)",
borderRadius: 10,
padding: "60px 0",
textAlign: "center",
color: "var(--text-3)",
}}
>
<WrenchScrewdriverIcon style={{ width: 40, margin: "0 auto 12px" }} />
<div>یک بخش انتخاب کنید تا سرویس‌های آن را ببینید</div>
</div>
```
---
@@ -302,6 +321,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضح‌تر + جدول تراکنش‌ها با visual type indicator.
**الف — کارت موجودی:**
- gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))`
- رنگ متن: white
- موجودی را بزرگ‌تر کن: `fontSize: 36, fontWeight: 800`
@@ -310,48 +330,55 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- دکمه شارژ: white با primary text (برعکس از theme معمول)
**ب — کارت تنظیمات:**
- عنوان با divider پایین
- هر toggle را با یک card داخلی wrapper کن:
```tsx
<div style={{ background: 'var(--primary-subtle)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" ... />
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
<div style={{ color: 'var(--text-3)', fontSize: 12 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
```tsx
<div style={{ background: 'var(--primary-subtle)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" ... />
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
<div style={{ color: 'var(--text-3)', fontSize: 12 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
</div>
</div>
</div>
```
```
- input «چند ساعت قبل» را با border و padding واضح‌تر style کن
**ج — جدول تراکنش‌ها:**
- ستون «نوع» را با یک آیکون بهبود بده:
- `credit`: `ArrowUpCircleIcon` با رنگ سبز
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز
- `credit`: `ArrowUpCircleIcon` با رنگ سبز
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز
- ستون «مبلغ» را بر اساس نوع رنگ بده: credit → سبز، debit → قرمز
- header table با background خاکستری کم‌رنگ
**د — Modal شارژ:**
- انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage):
```tsx
{(['mellat', 'sep'] as const).map((gw) => (
<div
key={gw}
onClick={() => setGateway(gw)}
style={{
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`,
borderRadius: 8,
padding: '10px 14px',
cursor: 'pointer',
background: gateway === gw ? 'var(--primary-subtle)' : '',
transition: 'all 0.15s',
flex: 1,
textAlign: 'center',
}}
>
<div style={{ fontWeight: 600 }}>{gw === 'mellat' ? 'بانک ملت' : 'سپ'}</div>
</div>
))}
```
```tsx
{
(["mellat", "sep"] as const).map((gw) => (
<div
key={gw}
onClick={() => setGateway(gw)}
style={{
border: `2px solid ${gateway === gw ? "var(--primary)" : "var(--border)"}`,
borderRadius: 8,
padding: "10px 14px",
cursor: "pointer",
background: gateway === gw ? "var(--primary-subtle)" : "",
transition: "all 0.15s",
flex: 1,
textAlign: "center",
}}
>
<div style={{ fontWeight: 600 }}>
{gw === "mellat" ? "بانک ملت" : "سپ"}
</div>
</div>
));
}
```
- پیش‌نمایش مبلغ: وقتی کاربر عدد می‌زند، زیر input بنویس «پرداخت X ریال از طریق Y»
## نکات مهم
@@ -364,5 +391,5 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است
7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد
8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است)
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشیان استفاده کن
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشی ها استفاده کن
10. **`formatDate()` و `formatDateTime()`** — همه تاریخ‌ها شمسی نمایش داده شوند
+72 -42
View File
@@ -2,6 +2,8 @@
**پنل ادمین:** https://clinic-pro.ddev.site/admin
> رمز عبور همه کاربران seed‌شده: `Test@1234`
---
## ادمین
@@ -14,65 +16,93 @@
---
## کاربران Multi-Context
### کاربر ۱ — دکتر در چند کلینیک
| فیلد | مقدار |
| -------- | -------------------------- |
| موبایل | `09100000011` |
| پسورد | `Test1234` |
| نقش | `ROLE_DOCTOR` |
| Contexts | مطب شخصی دکتر آرمان رضایی |
| | کلینیک جامع حضرت علی (عضو) |
| | بیمارستان ابن سینا (عضو) |
---
### کاربر ۲ — صاحب کلینیک
## کلینیک نمونه — تبریز
| فیلد | مقدار |
| ----------- | -------------------------------------- |
| موبایل | `09100000022` |
| پسورد | `Test1234` |
| موبایل | `09100100000` |
| پسورد | `Test@1234` |
| نقش | `ROLE_CLINIC` |
| Contexts | کلینیک تست پگاه |
| UUID کلینیک | `0730373f-7c0a-4610-8120-d58ecf4a9916` |
| نام | کلینیک تخصصی امید تبریز |
| UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` |
| شهر | تبریز (id: 101) |
---
### کاربر ۳ — منشی
| فیلد | مقدار |
| -------- | ---------------------- |
| موبایل | `09100000033` |
| پسورد | `Test1234` |
| نقش | `ROLE_SECRETARY` |
| Contexts | مطب دکتر مهناز سلیمانی |
---
### کاربر ۴ — دکتر + صاحب کلینیک
## دکتر نمونه کامل — تبریز
| فیلد | مقدار |
| ----------- | -------------------------------------- |
| موبایل | `09100000044` |
| پسورد | `Test1234` |
| نقش | `ROLE_DOCTOR` + `ROLE_CLINIC` |
| Contexts | مطب شخصی دکتر سامان علوی |
| | کلینیک تخصصی علوی |
| UUID کلینیک | `26a1ff95-32aa-4a96-b0fd-03e75e810be9` |
| موبایل | `09100100001` |
| پسورد | `Test@1234` |
| نقش | `ROLE_DOCTOR` |
| نام | دکتر آرمان رضایی |
| تخصص | قلب و عروق + داخلی |
| 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
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,
BanknotesIcon,
BuildingOffice2Icon,
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CalendarDaysIcon,
ChartBarIcon,
ChatBubbleLeftEllipsisIcon,
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CreditCardIcon,
DevicePhoneMobileIcon,
DocumentTextIcon,
FolderOpenIcon,
HeartIcon,
IdentificationIcon,
KeyIcon,
LockClosedIcon,
StarIcon,
TagIcon,
UserCircleIcon,
UserGroupIcon,
UsersIcon,
UserPlusIcon,
UsersIcon,
WrenchScrewdriverIcon,
FolderOpenIcon,
IdentificationIcon,
} from "@heroicons/react/24/outline";
import { NavLink, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { useAuthStore } from "../../stores/authStore";
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[] };
function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] {
if (primaryRole === 'admin') {
function buildSections(
primaryRole: string | null,
dbUuid: string | null,
): Section[] {
if (primaryRole === "admin") {
return [
{
label: 'عمومی',
label: "عمومی",
items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
{ to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' },
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
{ to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیک‌ها' },
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
{
to: "/admin/users",
icon: UserGroupIcon,
label: "کاربران",
},
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
{
to: "/admin/clinics",
icon: BuildingOffice2Icon,
label: "کلینیک‌ها",
},
],
},
{
label: 'مدیریت',
label: "مدیریت",
items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' },
{ to: '/admin/payments', icon: CreditCardIcon, label: 'پرداخت‌ها' },
{ to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویه‌حساب' },
{ to: '/admin/pre-registrations', icon: ClipboardDocumentCheckIcon, label: 'درخواست‌های ثبت‌نام' },
{
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
},
{
to: "/admin/payments",
icon: CreditCardIcon,
label: "پرداخت‌ها",
},
{
to: "/admin/settlements",
icon: BanknotesIcon,
label: "تسویه‌حساب",
},
{
to: "/admin/pre-registrations",
icon: ClipboardDocumentCheckIcon,
label: "درخواست‌های ثبت‌نام",
},
],
},
{
label: 'محتوا',
label: "محتوا",
items: [
{ to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' },
{ to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' },
{ to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' },
{ to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' },
{
to: "/admin/comments",
icon: ChatBubbleLeftEllipsisIcon,
label: "نظرات",
},
{ to: "/admin/ratings", icon: StarIcon, label: "امتیازها" },
{
to: "/admin/blogs",
icon: DocumentTextIcon,
label: "بلاگ",
},
{
to: "/admin/sms",
icon: DevicePhoneMobileIcon,
label: "پیامک",
},
],
},
{
label: 'سیستم',
label: "سیستم",
items: [
{ to: '/admin/categories', icon: TagIcon, 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: 'تنظیمات' },
{
to: "/admin/categories",
icon: TagIcon,
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') {
const clinicTo = dbUuid ? `/admin/clinics/${dbUuid}` : '/admin/my-clinic';
if (primaryRole === "clinic") {
const clinicTo = dbUuid
? `/admin/clinics/${dbUuid}`
: "/admin/my-clinic";
return [
{
label: 'عمومی',
label: "عمومی",
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: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' },
{ 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' },
{
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
},
{
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: [
{ to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' },
{
to: "/admin/subscription",
icon: CreditCardIcon,
label: "پنل اشتراکی",
},
],
},
];
}
if (primaryRole === 'doctor') {
if (primaryRole === "doctor") {
return [
{
label: 'عمومی',
label: "عمومی",
items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
},
{
label: 'پروفایل',
label: "پروفایل",
items: [
{ to: '/admin/profile', icon: UserCircleIcon, label: 'پروفایل من' },
{
to: "/admin/profile",
icon: UserCircleIcon,
label: "پروفایل من",
},
],
},
{
label: 'مدیریت',
label: "مدیریت",
items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌های من' },
{ 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' },
{
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌های من",
},
{
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: [
{ to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' },
{
to: "/admin/subscription",
icon: CreditCardIcon,
label: "پنل اشتراکی",
},
],
},
];
}
if (primaryRole === 'secretary') {
if (primaryRole === "secretary") {
return [
{
label: 'عمومی',
label: "عمومی",
items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
},
{
label: 'مدیریت',
label: "مدیریت",
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 [
{
label: 'عمومی',
items: [{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }],
label: "عمومی",
items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
},
];
}
const ROLE_LABELS: Record<string, string> = {
admin: 'مدیر کل',
clinic: 'مالک کلینیک',
doctor: 'پزشک',
secretary: 'منشی',
user: 'کاربر',
admin: "مدیر کل",
clinic: "مالک کلینیک",
doctor: "پزشک",
secretary: "منشی",
user: "کاربر",
};
const HUES = [256, 205, 162, 295, 272];
@@ -186,13 +345,14 @@ interface Props {
}
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore();
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
const { logout, primaryRole, userName, availableContexts, dbUuid } =
useAuthStore();
const { hasFeature } = useSubscription();
const navigate = useNavigate();
const sections = buildSections(primaryRole, dbUuid);
const initials = (userName ?? 'U').charAt(0).toUpperCase();
const initials = (userName ?? "U").charAt(0).toUpperCase();
return (
<aside className="sidebar">
@@ -212,23 +372,56 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
{sections.map((section) => (
<div className="nav-group" key={section.label}>
<span className="nav-label">{section.label}</span>
{section.items.map(({ to, icon: Icon, label, feature }) => {
const isLocked = feature ? !hasFeature(feature) : false;
const dest = isLocked ? '/admin/subscription' : to;
return (
<NavLink
key={to}
to={dest}
title={isLocked ? 'نیاز به ارتقاء پنل' : (!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>
);
})}
{section.items.map(
({ to, icon: Icon, label, feature }) => {
const isLocked = feature
? !hasFeature(feature)
: false;
const dest = isLocked
? "/admin/subscription"
: to;
return (
<NavLink
key={to}
to={dest}
title={
isLocked
? "نیاز به ارتقاء پنل"
: !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>
))}
@@ -238,10 +431,14 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
<span className="nav-label">محیط کاری</span>
<NavLink
to="/admin/select-context"
title={!sidebarOpen ? 'تغییر محیط' : undefined}
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
title={!sidebarOpen ? "تغییر محیط" : undefined}
className={({ isActive }) =>
`nav-item${isActive ? " active" : ""}`
}
>
<ArrowsRightLeftIcon style={{ width: 19, height: 19, flexShrink: 0 }} />
<ArrowsRightLeftIcon
style={{ width: 19, height: 19, flexShrink: 0 }}
/>
<span>تغییر محیط</span>
</NavLink>
</div>
@@ -252,20 +449,37 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
<div className="sidebar-foot">
<div
className="user-chip"
onClick={() => { logout(); navigate('/admin/login'); }}
onClick={() => {
logout();
navigate("/admin/login");
}}
title="خروج از سیستم"
>
<div
className="avatar sm"
style={{ background: avatarBg(userName ?? 'U'), flexShrink: 0 }}
style={{
background: avatarBg(userName ?? "U"),
flexShrink: 0,
}}
>
{initials}
</div>
<div className="user-meta">
<b>{userName ?? 'کاربر'}</b>
<span>{ROLE_LABELS[primaryRole ?? ''] ?? primaryRole ?? ''}</span>
<b>{userName ?? "کاربر"}</b>
<span>
{ROLE_LABELS[primaryRole ?? ""] ??
primaryRole ??
""}
</span>
</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>
</aside>
File diff suppressed because it is too large Load Diff
+180 -145
View File
@@ -1,4 +1,5 @@
# PRD — ClinicPro Phase 2
**تاریخ:** ۲۴ خرداد ۱۴۰۵
**نسخه:** 1.2
**وضعیت:** Draft
@@ -13,24 +14,24 @@
## وضعیت فعلی پروژه
| ماژول | Backend | API Doc |
|--------|---------|---------|
| Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` |
| Doctor profile | ✅ کامل | `docs/api/doctor.md` |
| Clinic management | ✅ کامل | `docs/api/clinic.md` |
| Appointment booking + settings | ✅ کامل | `docs/api/appointment.md`, `appointment-settings.md` |
| Secretary (`DoctorSecretary` entity) | ✅ entity + API موجود | `docs/api/secretary.md` |
| Payment gateway (Mellat, SEP) | ✅ کامل — `Payment.type='subscription'` هم موجود | `docs/api/payment.md` |
| SMS log + template | ✅ زیرساخت موجود | `docs/api/sms.md` |
| Wallet / Settlement | ✅ کامل | `docs/api/settlement.md` |
| SiteConfig (key-value store) | ✅ موجود | `docs/api/admin.md` §Settings |
| Dashboard (clinic/doctor/secretary) | ✅ کامل | `docs/api/dashboard.md` |
| Rating, Blog, Representation, Location | ✅ کامل | فایل‌های مربوطه در `docs/api/` |
| **Staff (پرسنل)** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Subscription tiers** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Clinic services** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Patient records** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **SMS wallet/settings per clinic** | ❌ entity وجود ندارد | ❌ بخش در sms.md وجود ندارد |
| ماژول | Backend | API Doc |
| -------------------------------------- | ------------------------------------------------ | ---------------------------------------------------- |
| Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` |
| Doctor profile | ✅ کامل | `docs/api/doctor.md` |
| Clinic management | ✅ کامل | `docs/api/clinic.md` |
| Appointment booking + settings | ✅ کامل | `docs/api/appointment.md`, `appointment-settings.md` |
| Secretary (`DoctorSecretary` entity) | ✅ entity + API موجود | `docs/api/secretary.md` |
| Payment gateway (Mellat, SEP) | ✅ کامل — `Payment.type='subscription'` هم موجود | `docs/api/payment.md` |
| SMS log + template | ✅ زیرساخت موجود | `docs/api/sms.md` |
| Wallet / Settlement | ✅ کامل | `docs/api/settlement.md` |
| SiteConfig (key-value store) | ✅ موجود | `docs/api/admin.md` §Settings |
| Dashboard (clinic/doctor/secretary) | ✅ کامل | `docs/api/dashboard.md` |
| Rating, Blog, Representation, Location | ✅ کامل | فایل‌های مربوطه در `docs/api/` |
| **Staff (پرسنل)** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Subscription tiers** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Clinic services** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **Patient records** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد |
| **SMS wallet/settings per clinic** | ❌ entity وجود ندارد | ❌ بخش در sms.md وجود ندارد |
---
@@ -41,19 +42,21 @@
### اپیک ۱ — پرسنل (Staff)
#### توضیح
مدیر کلینیک یا دکتر مستقل می‌تواند پرسنل کلینیک را ثبت کند. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند. در سرویس‌دهی، سشن‌ها و گزارشات به آن‌ها ارجاع می‌شود.
#### نیازمندی‌های کارکردی
| کد | نیازمندی |
|----|---------|
| F1.1 | ایجاد / ویرایش / غیرفعال‌سازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) |
| کد | نیازمندی |
| ---- | ------------------------------------------------------------------------------------ |
| F1.1 | ایجاد / ویرایش / غیرفعال‌سازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) |
| F1.2 | فیلدها: نام و نام‌خانوادگی (اجباری)، شماره تماس، عنوان شغل، آدرس، کد ملی، وضعیت فعال |
| F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) |
| F1.4 | لیست پرسنل فقط در scope کلینیک/مطب کاربر جاری نمایش داده می‌شود |
| F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب می‌شود |
| F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) |
| F1.4 | لیست پرسنل فقط در scope کلینیک/مطب کاربر جاری نمایش داده می‌شود |
| F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب می‌شود |
#### Entity جدید
```
clinic_staff:
id, uuid
@@ -69,18 +72,21 @@ clinic_staff:
```
#### API
| Method | Path | توضیح |
|--------|------|-------|
| `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری |
| `POST` | `/api/v1/staff` | ایجاد پرسنل جدید |
| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات |
| Method | Path | توضیح |
| ------- | ----------------------------- | -------------------------- |
| `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری |
| `POST` | `/api/v1/staff` | ایجاد پرسنل جدید |
| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات |
| `PATCH` | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) |
#### Frontend
- `StaffPage.tsx` — DataTable + Modal ایجاد/ویرایش
- Sidebar: زیر «تنظیمات»
#### فایل‌های جدید
- `src/Staff/Entity/ClinicStaff.php`
- `src/Staff/Controller/StaffController.php`
- `src/Staff/Repository/ClinicStaffRepository.php`
@@ -91,25 +97,26 @@ clinic_staff:
### اپیک ۲ — پنل اشتراکی (Subscription Tiers)
#### توضیح
سه سطح پنل با قابلیت‌های متفاوت. ادمین برای هر پنل دوره‌های زمانی با قیمت مشخص تعریف می‌کند. کلینیک/دکتر دوره + پنل انتخاب کرده و پرداخت می‌کند.
#### سطوح پنل
| Level | Name | منشی | قابلیت‌های اضافه |
|-------|------|------|----------------|
| 0 | Free | ۱ منشی | نوبت‌دهی — رایگان، بدون انقضا |
| 1 | Basic | ۲ منشی | پرونده بیمار + سرویس‌ها |
| 2 | Professional | ۵ منشی | پرونده بیمار + سرویس‌ها |
| Level | Name | منشی | قابلیت‌های اضافه |
| ----- | ------------ | ------ | ----------------------------- |
| 0 | Free | ۱ منشی | نوبت‌دهی — رایگان، بدون انقضا |
| 1 | Basic | ۲ منشی | پرونده بیمار + سرویس‌ها |
| 2 | Professional | ۵ منشی | پرونده بیمار + سرویس‌ها |
#### دوره‌های اشتراک (مثال — ادمین تنظیم می‌کند)
| نوع | مدت | قیمت |
|-----|-----|------|
| تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یک‌بار |
| ۱ ماهه | ۱ ماه | ادمین تعیین می‌کند |
| ۳ ماهه | ۳ ماه | ادمین تعیین می‌کند |
| ۶ ماهه | ۶ ماه | ادمین تعیین می‌کند |
| ۱۲ ماهه | ۱۲ ماه | ادمین تعیین می‌کند |
| نوع | مدت | قیمت |
| ------- | ------ | ------------------------------- |
| تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یک‌بار |
| ۱ ماهه | ۱ ماه | ادمین تعیین می‌کند |
| ۳ ماهه | ۳ ماه | ادمین تعیین می‌کند |
| ۶ ماهه | ۶ ماه | ادمین تعیین می‌کند |
| ۱۲ ماهه | ۱۲ ماه | ادمین تعیین می‌کند |
#### قوانین تریال
@@ -121,18 +128,19 @@ clinic_staff:
#### نیازمندی‌های کارکردی
| کد | نیازمندی |
|----|---------|
| F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف می‌کند |
| F2.2 | ادمین می‌تواند دوره‌های جدید اضافه یا غیرفعال کند |
| کد | نیازمندی |
| ---- | ---------------------------------------------------------------------------- |
| F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف می‌کند |
| F2.2 | ادمین می‌تواند دوره‌های جدید اضافه یا غیرفعال کند |
| F2.3 | پرداخت از طریق gateway موجود (Mellat/SEP) — `Payment.type='subscription'` ✅ |
| F2.4 | پس از پرداخت موفق: `starts_at=now`, `expires_at=now + months*30*86400` |
| F2.5 | تمدید از تاریخ انقضای قبلی محاسبه می‌شود (نه از now) |
| F2.6 | gate check: `hasFeature('patient_records')` — اشتراک فعال و منقضی نشده |
| F2.7 | هشدار ۷ روز قبل از انقضا به کلینیک/دکتر |
| F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) |
| F2.4 | پس از پرداخت موفق: `starts_at=now`, `expires_at=now + months*30*86400` |
| F2.5 | تمدید از تاریخ انقضای قبلی محاسبه می‌شود (نه از now) |
| F2.6 | gate check: `hasFeature('patient_records')` — اشتراک فعال و منقضی نشده |
| F2.7 | هشدار ۷ روز قبل از انقضا به کلینیک/دکتر |
| F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) |
#### Entities جدید
```
subscription_plans:
id, uuid
@@ -168,31 +176,34 @@ clinic_subscriptions:
```
#### API
| Method | Path | Permission | توضیح |
|--------|------|-----------|-------|
| `GET` | `/api/v1/subscription/plans` | public | لیست پنل‌ها با دوره‌ها و قیمت‌ها |
| `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا |
| `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعال‌سازی تریال (رایگان، یک‌بار) |
| `POST` | `/api/v1/subscription-payment` | clinic/doctor | شروع پرداخت — body: `{ period_uuid }` |
| `GET` | `/api/v1/admin/subscription/plans` | admin | لیست پنل‌ها |
| `POST` | `/api/v1/admin/subscription/plan` | admin | ایجاد پنل |
| `PATCH` | `/api/v1/admin/subscription/plan/{uuid}` | admin | ویرایش پنل |
| `POST` | `/api/v1/admin/subscription/period` | admin | افزودن دوره جدید |
| `PATCH` | `/api/v1/admin/subscription/period/{uuid}` | admin | ویرایش قیمت/مدت |
| `DELETE` | `/api/v1/admin/subscription/period/{uuid}` | admin | غیرفعال‌سازی دوره |
| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریال‌ها |
| Method | Path | Permission | توضیح |
| -------- | ------------------------------------------ | ------------- | ---------------------------------------- |
| `GET` | `/api/v1/subscription/plans` | public | لیست پنل‌ها با دوره‌ها و قیمت‌ها |
| `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا |
| `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعال‌سازی تریال (رایگان، یک‌بار) |
| `POST` | `/api/v1/subscription-payment` | clinic/doctor | شروع پرداخت — body: `{ period_uuid }` |
| `GET` | `/api/v1/admin/subscription/plans` | admin | لیست پنل‌ها |
| `POST` | `/api/v1/admin/subscription/plan` | admin | ایجاد پنل |
| `PATCH` | `/api/v1/admin/subscription/plan/{uuid}` | admin | ویرایش پنل |
| `POST` | `/api/v1/admin/subscription/period` | admin | افزودن دوره جدید |
| `PATCH` | `/api/v1/admin/subscription/period/{uuid}` | admin | ویرایش قیمت/مدت |
| `DELETE` | `/api/v1/admin/subscription/period/{uuid}` | admin | غیرفعال‌سازی دوره |
| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریال‌ها |
#### Frontend
- `SubscriptionPage.tsx`:
- بنر تریال اگر `used_trial=false`
- وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال»
- جدول مقایسه پنل‌ها با انتخاب دوره
- دکمه خرید → redirect به gateway
- بنر تریال اگر `used_trial=false`
- وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال»
- جدول مقایسه پنل‌ها با انتخاب دوره
- دکمه خرید → redirect به gateway
- `AdminSubscriptionPage.tsx`:
- تب «پنل‌ها»: ویرایش دوره‌ها و قیمت‌ها
- تب «گزارش»: فروش بر اساس بازه زمانی
- تب «پنل‌ها»: ویرایش دوره‌ها و قیمت‌ها
- تب «گزارش»: فروش بر اساس بازه زمانی
#### فایل‌های جدید
- `src/Subscription/Entity/SubscriptionPlan.php`
- `src/Subscription/Entity/SubscriptionPeriod.php`
- `src/Subscription/Entity/ClinicSubscription.php`
@@ -205,24 +216,28 @@ clinic_subscriptions:
### اپیک ۳ — منشی (Secretary) — تکمیل
#### توضیح
Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.md`). باید:
- محدودیت تعداد بر اساس پنل اعمال شود
- UI مدیریت permissions تکمیل شود
#### نیازمندی‌های کارکردی
| کد | نیازمندی |
|----|---------|
| F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 |
| F3.2 | UI لیست منشیان + اضافه‌کردن + ویرایش + غیرفعال‌سازی |
| کد | نیازمندی |
| ---- | -------------------------------------------------------------------------------------- |
| F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 |
| F3.2 | UI لیست منشی ها + اضافه‌کردن + ویرایش + غیرفعال‌سازی |
| F3.3 | ویرایشگر permissions: checkbox matrix (موجود در `DoctorSecretary.DEFAULT_PERMISSIONS`) |
| F3.4 | منشی هرگز حذف نمی‌شود — `active: false` |
| F3.4 | منشی هرگز حذف نمی‌شود — `active: false` |
#### تغییرات مورد نیاز
- **Backend:** `SecretaryController::create()` — بررسی سقف پنل با `SubscriptionService`
- **Frontend:** Modal با checkbox matrix برای permissions در `SecretariesPage.tsx`
#### API موجود (نیاز به تغییر ندارند)
`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)
#### توضیح
دسترسی فقط در پنل Basic+. سرویس‌ها در دو سطح: بخش (مثلاً «تزریقات») و زیربخش (مثلاً «سرم»).
#### نیازمندی‌های کارکردی
| کد | نیازمندی |
|----|---------|
| F4.1 | CRUD بخش‌ها (ServiceSection) |
| F4.2 | CRUD زیربخش‌ها (ServiceItem) با انتخاب بخش + پرسنل انجام‌دهنده |
| F4.3 | قیمت هر زیربخش قابل ویرایش |
| F4.4 | gate: `hasFeature('services')` — بدون Basic+ → 403 |
| کد | نیازمندی |
| ---- | ------------------------------------------------------------------ |
| F4.1 | CRUD بخش‌ها (ServiceSection) |
| F4.2 | CRUD زیربخش‌ها (ServiceItem) با انتخاب بخش + پرسنل انجام‌دهنده |
| F4.3 | قیمت هر زیربخش قابل ویرایش |
| F4.4 | gate: `hasFeature('services')` — بدون Basic+ → 403 |
| F4.5 | لیست ServiceItems در ثبت سشن بیمار استفاده می‌شود (وابستگی اپیک ۶) |
#### Entities جدید
```
service_sections:
id, uuid
@@ -263,21 +280,24 @@ service_items:
```
#### API
| Method | Path | توضیح |
|--------|------|-------|
| `GET` | `/api/v1/service-sections` | لیست بخش‌ها |
| `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) |
| `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش |
| `DELETE` | `/api/v1/service-section/{uuid}` | حذف |
| `GET` | `/api/v1/service-items/{sectionUuid}` | لیست زیربخش‌های یک بخش |
| `POST` | `/api/v1/service-item` | ایجاد زیربخش (Basic+) |
| `PATCH` | `/api/v1/service-item/{uuid}` | ویرایش |
| `DELETE` | `/api/v1/service-item/{uuid}` | حذف |
| Method | Path | توضیح |
| -------- | ------------------------------------- | ---------------------- |
| `GET` | `/api/v1/service-sections` | لیست بخش‌ها |
| `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) |
| `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش |
| `DELETE` | `/api/v1/service-section/{uuid}` | حذف |
| `GET` | `/api/v1/service-items/{sectionUuid}` | لیست زیربخش‌های یک بخش |
| `POST` | `/api/v1/service-item` | ایجاد زیربخش (Basic+) |
| `PATCH` | `/api/v1/service-item/{uuid}` | ویرایش |
| `DELETE` | `/api/v1/service-item/{uuid}` | حذف |
#### Frontend
- `ServicesPage.tsx` — accordion دو سطحی: بخش‌ها ← زیربخش‌ها
#### فایل‌های جدید
- `src/ClinicService/Entity/ServiceSection.php`
- `src/ClinicService/Entity/ServiceItem.php`
- `src/ClinicService/Controller/ClinicServiceController.php`
@@ -288,25 +308,28 @@ service_items:
### اپیک ۵ — پنل پیامکی (SMS Panel)
#### توضیح
زیرساخت SMS موجود است (`SmsLog`, `SmsTemplate`, `SmsProvider` در `src/Sms/`). باید:
- کیف پول پیامک اختصاصی برای هر کلینیک/دکتر
- تنظیمات ارسال پیامک (یادآوری، بعد از ویزیت)
- گزارش لاگ برای کلینیک و ادمین
#### نیازمندی‌های کارکردی
| کد | نیازمندی |
|----|---------|
| F5.1 | کیف پول پیامک جدا از کیف پول مالی |
| F5.2 | شارژ از طریق gateway موجود |
| کد | نیازمندی |
| ---- | ---------------------------------------------------------------------- |
| F5.1 | کیف پول پیامک جدا از کیف پول مالی |
| F5.2 | شارژ از طریق gateway موجود |
| F5.3 | ادمین تعرفه هر پیامک را تعیین می‌کند (`sms_price_rials` در SiteConfig) |
| F5.4 | کسر خودکار هزینه بعد از هر ارسال موفق |
| F5.5 | تنظیمات: یادآوری نوبت (فعال/غیرفعال + چند ساعت قبل) |
| F5.6 | تنظیمات: پیام بعد از ویزیت (فعال/غیرفعال + متن) |
| F5.7 | گزارش لاگ پیامک‌های کلینیک/مطب |
| F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی |
| F5.4 | کسر خودکار هزینه بعد از هر ارسال موفق |
| F5.5 | تنظیمات: یادآوری نوبت (فعال/غیرفعال + چند ساعت قبل) |
| F5.6 | تنظیمات: پیام بعد از ویزیت (فعال/غیرفعال + متن) |
| F5.7 | گزارش لاگ پیامک‌های کلینیک/مطب |
| F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی |
#### Entities جدید
```
sms_wallets:
id
@@ -327,20 +350,23 @@ sms_settings:
```
#### API
| 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/logs` | clinic/doctor | تاریخچه کسر/شارژ |
| `GET` | `/api/v1/sms/settings` | clinic/doctor | تنظیمات پیامک |
| `PATCH` | `/api/v1/sms/settings` | clinic/doctor | ذخیره تنظیمات |
| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد |
| 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/logs` | clinic/doctor | تاریخچه کسر/شارژ |
| `GET` | `/api/v1/sms/settings` | clinic/doctor | تنظیمات پیامک |
| `PATCH` | `/api/v1/sms/settings` | clinic/doctor | ذخیره تنظیمات |
| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد |
#### Frontend
- تب «پیامک» در صفحه تنظیمات کلینیک/دکتر
- نمایش موجودی + دکمه شارژ + لاگ + فرم تنظیمات
#### تأثیر بر فایل‌های موجود
- `docs/api/sms.md`**بخش جدید wallet/settings اضافه شود**
---
@@ -348,20 +374,22 @@ sms_settings:
### اپیک ۶ — پرونده بیمار (Patient Records)
#### توضیح
دسترسی فقط در پنل Basic+. پرونده به صورت خودکار پس از تأیید نوبت ایجاد می‌شود یا دستی توسط منشی/دکتر. هر مراجعه یک سشن است که شامل ویزیت + سرویس‌ها است.
#### نیازمندی‌های کارکردی
| کد | نیازمندی |
|----|---------|
| F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) |
| F6.2 | ایجاد دستی توسط منشی یا دکتر |
| کد | نیازمندی |
| ---- | ----------------------------------------------------------------------------- |
| F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) |
| F6.2 | ایجاد دستی توسط منشی یا دکتر |
| F6.3 | ثبت سشن: بیمه پایه + مکمل، مبلغ ویزیت (پیش‌فرض یا دستی)، محاسبه خودکار کسورات |
| F6.4 | ثبت سرویس‌های انجام‌شده در سشن با پرسنل انجام‌دهنده |
| F6.5 | gate: `hasFeature('patient_records')` — بدون Basic+ → 403 |
| F6.6 | تاریخچه کامل مراجعات یک بیمار |
| F6.4 | ثبت سرویس‌های انجام‌شده در سشن با پرسنل انجام‌دهنده |
| F6.5 | gate: `hasFeature('patient_records')` — بدون Basic+ → 403 |
| F6.6 | تاریخچه کامل مراجعات یک بیمار |
#### محاسبه مبلغ نهایی سشن
```
visit_price_rials ← ادمین/دکتر تعیین می‌کند
- base_insurance_discount% ← بیمه پایه
@@ -371,6 +399,7 @@ visit_price_rials ← ادمین/دکتر تعیین می‌کند
```
#### Entities جدید
```
patient_records:
id, uuid
@@ -405,21 +434,24 @@ session_services:
```
#### API
| Method | Path | Permission | توضیح |
|--------|------|-----------|-------|
| `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو |
| `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی |
| `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده |
| `GET` | `/api/v1/patient/{uuid}/sessions` | clinic/doctor | لیست مراجعات |
| `POST` | `/api/v1/patient/{uuid}/session` | clinic/doctor | ثبت مراجعه جدید |
| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه |
| Method | Path | Permission | توضیح |
| ------- | --------------------------------- | ---------------------- | --------------------- |
| `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو |
| `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی |
| `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده |
| `GET` | `/api/v1/patient/{uuid}/sessions` | clinic/doctor | لیست مراجعات |
| `POST` | `/api/v1/patient/{uuid}/session` | clinic/doctor | ثبت مراجعه جدید |
| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه |
#### Frontend
- `PatientsPage.tsx` — لیست بیماران + جستجو
- `PatientDetailPage.tsx` — پرونده + تاریخچه مراجعات
- Modal ثبت سشن جدید (بیمه + ویزیت + سرویس‌ها)
#### فایل‌های جدید
- `src/Patient/Entity/PatientRecord.php`
- `src/Patient/Entity/PatientSession.php`
- `src/Patient/Entity/SessionService.php`
@@ -431,22 +463,25 @@ session_services:
### اپیک ۷ — داشبورد هوشمند (Smart Dashboard)
#### توضیح
داشبوردهای موجود (clinic/doctor/secretary در `docs/api/dashboard.md`) باید با چارت و فیلتر بازه زمانی تکمیل شوند.
#### نیازمندی‌های کارکردی
| کد | نیازمندی |
|----|---------|
| F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبت‌ها + گزارش فروش پنل‌ها |
| کد | نیازمندی |
| ---- | ----------------------------------------------------------------------- |
| F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبت‌ها + گزارش فروش پنل‌ها |
| F7.2 | داشبورد کلینیک/مطب: درآمد ماه جاری + بیماران منحصربه‌فرد + موجودی پیامک |
| F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی |
| F7.4 | فیلتر نقش: منشی فقط نوبت‌ها را می‌بیند |
| F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی |
| F7.4 | فیلتر نقش: منشی فقط نوبت‌ها را می‌بیند |
#### API موجود (نیاز به endpoint جدید ندارد)
`GET /api/v1/admin/dashboard/stats` | `/charts` | `/recent`
`GET /api/v1/dashboard/clinic` | `/doctor` | `/secretary`
#### تغییر مورد نیاز
- backend: اضافه کردن پارامترهای `from` و `to` به endpoint های charts
- frontend: `DashboardPage.tsx` — افزودن date range selector + نمودارها (Recharts/Chart.js)
@@ -454,15 +489,15 @@ session_services:
## اولویت‌بندی پیاده‌سازی
| اولویت | اپیک | وابستگی |
|--------|------|---------|
| ۱ | اپیک ۱ — Staff | پیش‌نیاز اپیک ۴ و ۶ |
| ۲ | اپیک ۲ — Subscription | پیش‌نیاز gate در اپیک ۳، ۴، ۶ |
| ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ |
| ۴ | اپیک ۴ — Services | وابسته به اپیک ۱ و ۲ |
| ۵ | اپیک ۵ — SMS Panel | مستقل |
| ۶ | اپیک ۶ — Patient Records | وابسته به اپیک ۱، ۲، ۴ |
| ۷ | اپیک ۷ — Dashboard | آخر — نیاز به داده واقعی |
| اولویت | اپیک | وابستگی |
| ------ | ------------------------ | ----------------------------- |
| ۱ | اپیک ۱ — Staff | پیش‌نیاز اپیک ۴ و ۶ |
| ۲ | اپیک ۲ — Subscription | پیش‌نیاز gate در اپیک ۳، ۴، ۶ |
| ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ |
| ۴ | اپیک ۴ — Services | وابسته به اپیک ۱ و ۲ |
| ۵ | اپیک ۵ — SMS Panel | مستقل |
| ۶ | اپیک ۶ — Patient Records | وابسته به اپیک ۱، ۲، ۴ |
| ۷ | اپیک ۷ — Dashboard | آخر — نیاز به داده واقعی |
---
@@ -488,15 +523,15 @@ sms_wallets sms_settings
## زیرساخت موجود قابل استفاده
| زیرساخت | مسیر | استفاده در فاز ۲ |
|---------|------|-----------------|
| Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet |
| `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ |
| SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` |
| Categories (insurance) | `bundle='insurance_type'` | انتخاب بیمه در PatientSession |
| DoctorSecretary | `src/Secretary/Entity/` | اضافه کردن gate check |
| SmsLog/SmsTemplate | `src/Sms/Entity/` | ارسال پیامک‌های خودکار |
| WalletTransaction | `src/Settlement/Entity/` | الگوی مشابه برای SmsWallet |
| زیرساخت | مسیر | استفاده در فاز ۲ |
| ----------------------------- | ---------------------------------- | ------------------------------------- |
| Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet |
| `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ |
| SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` |
| Categories (insurance) | `bundle='insurance_type'` | انتخاب بیمه در PatientSession |
| DoctorSecretary | `src/Secretary/Entity/` | اضافه کردن gate check |
| SmsLog/SmsTemplate | `src/Sms/Entity/` | ارسال پیامک‌های خودکار |
| WalletTransaction | `src/Settlement/Entity/` | الگوی مشابه برای SmsWallet |
---
+146 -107
View File
@@ -6,10 +6,10 @@
هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیط‌های مختلف جلوگیری می‌کند:
| Scope | `owner_type` | تعریف‌کننده | دسترسی |
|-------|-------------|-------------|---------|
| مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی |
| کلینیک | `clinic` | مدیر کلینیک | فقط نوبت‌ها و داده‌های کلینیک |
| Scope | `owner_type` | تعریف‌کننده | دسترسی |
| -------- | ------------ | ----------- | ------------------------------- |
| مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی |
| کلینیک | `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`
### Request Body (`application/json`)
```json
{
"doctor_uuid": "550e8400-...",
"mobile_number": "09123456789",
"password": "secretaryPass123",
"permissions": {
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "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 }
"doctor_uuid": "550e8400-...",
"mobile_number": "09123456789",
"password": "secretaryPass123",
"permissions": {
"version": 1,
"resources": {
"appointments": {
"view": true,
"create": true,
"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 |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
| `mobile_number` | string | ✅ | Secretary's login mobile |
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
| `permissions` | object | ❌ | Permission set (see structure below) |
| Field | Type | Required | Description |
| --------------- | ------------- | -------- | -------------------------------------------- |
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
| `mobile_number` | string | ✅ | Secretary's login mobile |
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
| `permissions` | object | ❌ | Permission set (see structure below) |
**Permissions Structure:**
```json
{
"version": 1,
"resources": {
"appointments": {
"view": true, // Can view appointments list
"create": true, // Can book appointments
"cancel": false, // Can cancel appointments
"update_status": true // Can mark as completed/no_show
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"clinic_info": {
"view": true,
"update": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
"version": 1,
"resources": {
"appointments": {
"view": true, // Can view appointments list
"create": true, // Can book appointments
"cancel": false, // Can cancel appointments
"update_status": true // Can mark as completed/no_show
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"clinic_info": {
"view": true,
"update": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
}
}
}
}
```
### Response `201`
```json
{
"success": true,
@@ -107,13 +125,14 @@ Create a secretary for a doctor.
| `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک |
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor |
| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached |
| Code | HTTP | Description |
| ------------------- | ---- | ------------------------------------------- |
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `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`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| Param | Type | Description |
| ------ | ------------- | -------------- |
| `uuid` | string (UUID) | Secretary UUID |
### Response `200`
```json
{
"success": true,
@@ -144,11 +165,12 @@ Get secretary detail.
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
| Code | HTTP | Description |
| ------------------- | ---- | ------------------- |
| `ERR_AUTH_001` | 401 | Missing token |
| `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
### Request Body (`application/json`)
```json
{
"active": false,
"permissions": {
"version": 1,
"resources": {
"appointments": { "view": true, "create": false, "cancel": false, "update_status": false }
"active": false,
"permissions": {
"version": 1,
"resources": {
"appointments": {
"view": true,
"create": false,
"cancel": false,
"update_status": false
}
}
}
}
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `active` | boolean | ❌ | Enable/disable secretary |
| `permissions` | object | ❌ | New permissions object |
| Field | Type | Required | Description |
| ------------- | ------- | -------- | ------------------------ |
| `active` | boolean | ❌ | Enable/disable secretary |
| `permissions` | object | ❌ | New permissions object |
### Response `200`
Updated secretary object.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
| Code | HTTP | Description |
| ------------------- | ---- | --------------------- |
| `ERR_AUTH_001` | 401 | Missing token |
| `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
### Response `200`
```json
{ "success": true, "data": { "message": "منشی حذف شد" } }
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
| Code | HTTP | Description |
| ------------------- | ---- | --------------------- |
| `ERR_AUTH_001` | 401 | Missing token |
| `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`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| Param | Type | Description |
| ------------ | ------------- | ----------- |
| `doctorUuid` | string (UUID) | Doctor UUID |
### Response `200`
```json
{
"success": true,
@@ -239,11 +273,12 @@ Get all secretaries for a specific doctor.
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| Code | HTTP | Description |
| ------------------- | ---- | ---------------- |
| `ERR_AUTH_001` | 401 | Missing token |
| `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`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| Param | Type | Description |
| ------------ | ------------- | ----------- |
| `clinicUuid` | string (UUID) | Clinic UUID |
### Response `200`
```json
{
"success": true,
@@ -278,16 +315,18 @@ Get all secretaries across **all doctors** of a clinic.
```
### Notes
- این endpoint فقط منشیانی را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند
- منشیانی که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
- این endpoint فقط منشی های را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند
- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
- یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر)
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
| Code | HTTP | Description |
| ------------------- | ---- | ---------------- |
| `ERR_AUTH_001` | 401 | Missing token |
| `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 تعیین می‌شود:
| پنل | حداکثر منشی |
|-----|-------------|
| Free (بدون اشتراک) | ۱ |
| Basic | ۳ |
| Professional | ۱۰ |
| پنل | حداکثر منشی |
| ------------------ | ----------- |
| Free (بدون اشتراک) | ۱ |
| Basic | ۳ |
| Professional | ۱۰ |
اگر تعداد منشی‌های فعال به حد مجاز رسیده باشد، ایجاد منشی جدید خطای زیر را برمی‌گرداند:
```json
{
"success": false,
"errors": [
{
"code": "ERR_SECRETARY_001",
"message": "پلن فعلی اجازه منشی بیشتر را نمی‌دهد"
}
]
"success": false,
"errors": [
{
"code": "ERR_SECRETARY_001",
"message": "پلن فعلی اجازه منشی بیشتر را نمی‌دهد"
}
]
}
```
@@ -1,7 +1,9 @@
# تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions
## توضیح
Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه می‌کند:
1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی
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()` |
| `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions |
| `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions |
## API موجود (بدون تغییر)
| متد | مسیر | توضیح |
|-----|------|-------|
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشیان |
| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** |
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** |
| DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) |
| متد | مسیر | توضیح |
| ------ | ---------------------------------- | ----------------------------------------------- |
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشی ها |
| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** |
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** |
| DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) |
## پیش‌نیازها
- تسک ۱۴ (Secretary — موجود در کد)
- **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد)
## زمان تخمینی
۴ تا ۵ ساعت
## تغییر Backend — SecretaryController::create()
### وضعیت فعلی (قبل):
```php
public function create(Request $request): JsonResponse
{
@@ -44,6 +49,7 @@ public function create(Request $request): JsonResponse
```
### وضعیت جدید (بعد):
```php
public function create(Request $request): JsonResponse
{
@@ -68,6 +74,7 @@ public function create(Request $request): JsonResponse
```
### خطای جدید که باید در ErrorCodes.php اضافه شود:
```php
// src/Shared/Constant/ErrorCodes.php
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
### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS):
```php
[
'version' => 1,
@@ -90,6 +98,7 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
```
### نمونه UI Permissions checkbox matrix در Modal:
```
┌─────────────────────────────────────────────────────┐
│ دسترسی‌های منشی │
@@ -104,31 +113,45 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
```
### PATCH /api/v1/secretary/{uuid} با permissions:
```json
{
"permissions": {
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
"addresses": { "view": true, "create": false, "update": false, "delete": false }
"permissions": {
"version": 1,
"resources": {
"appointments": {
"view": true,
"create": true,
"cancel": true,
"update_status": true
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
}
}
}
}
}
```
## کدهای خطای جدید
| کد | پیام فارسی |
|----|-----------|
| کد | پیام فارسی |
| ----------------------------- | ---------------------------------------- |
| `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است |
## Response خطا (403)
```json
{
"success": false,
"errors": [{
"code": "ERR_SECRETARY_LIMIT_REACHED",
"message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید."
}]
"success": false,
"errors": [
{
"code": "ERR_SECRETARY_LIMIT_REACHED",
"message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید."
}
]
}
```
@@ -64,7 +64,7 @@ Professional → 5 → 403
## نکته: منشی غیرفعال از سقف کم نمی‌شود
```
countActive() فقط منشیان active=true را می‌شمارد
countActive() فقط منشی ها active=true را می‌شمارد
یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → می‌توانی دوباره 1 منشی جدید اضافه کنی
(مادامی که در پنل 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' => 'منشی غیرفعال شد']);
}
/** لیست منشیان مطب شخصی یک دکتر */
/** لیست منشی ها مطب شخصی یک دکتر */
#[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])]
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);
}
// فقط خود دکتر یا ادمین می‌توانند منشیان مطب شخصی را ببینند
// فقط خود دکتر یا ادمین می‌توانند منشی ها مطب شخصی را ببینند
if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -193,7 +193,7 @@ class SecretaryController extends BaseController
return $this->success($secretaries);
}
/** لیست همه منشیان کلینیک (از همه دکترها، owner_type='clinic') */
/** لیست همه منشی ها کلینیک (از همه دکترها، owner_type='clinic') */
#[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])]
public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse
{
@@ -227,7 +227,7 @@ class SecretaryController extends BaseController
}
if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) {
// فقط مالک کلینیک می‌تواند منشیان کلینیکی را مدیریت کند
// فقط مالک کلینیک می‌تواند منشی ها کلینیکی را مدیریت کند
if (!$user->hasRole('ROLE_CLINIC')) {
return false;
}
@@ -38,7 +38,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
}
/** منشیان مطب شخصی یک دکتر (owner_type='doctor') */
/** منشی ها مطب شخصی یک دکتر (owner_type='doctor') */
public function findByDoctorScope(Doctor $doctor): array
{
return $this->findBy(
@@ -108,7 +108,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
return $clinic->getDoctors()->contains($doctor);
}
/** همه منشیان کلینیک (owner_type='clinic') */
/** همه منشی ها کلینیک (owner_type='clinic') */
public function findByClinic(Clinic $clinic): array
{
return $this->createQueryBuilder('s')