From b58aacc37f97cb328e93fd447601925cc1bdb2b9 Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Sun, 14 Jun 2026 21:15:40 +0330
Subject: [PATCH] Implement SMS panel user flow and patient records system; add
wallet charging, automatic reminders, and patient session management with
detailed database schema and user flows.
---
.../task-10-staff/architecture.md | 172 +++++++++++++++
docs/phase2_taskes/task-10-staff/database.md | 51 +++++
docs/phase2_taskes/task-10-staff/task.md | 107 +++++++++
docs/phase2_taskes/task-10-staff/user_flow.md | 86 ++++++++
.../task-11-subscription/architecture.md | 207 ++++++++++++++++++
.../task-11-subscription/database.md | 92 ++++++++
.../task-11-subscription/task.md | 160 ++++++++++++++
.../task-11-subscription/user_flow.md | 126 +++++++++++
.../architecture.md | 71 ++++++
.../task-12-secretary-completion/database.md | 59 +++++
.../task-12-secretary-completion/task.md | 134 ++++++++++++
.../task-12-secretary-completion/user_flow.md | 70 ++++++
.../task-13-clinic-services/architecture.md | 130 +++++++++++
.../task-13-clinic-services/database.md | 69 ++++++
.../task-13-clinic-services/task.md | 102 +++++++++
.../task-13-clinic-services/user_flow.md | 99 +++++++++
.../task-14-sms-panel/architecture.md | 171 +++++++++++++++
.../task-14-sms-panel/database.md | 97 ++++++++
docs/phase2_taskes/task-14-sms-panel/task.md | 127 +++++++++++
.../task-14-sms-panel/user_flow.md | 102 +++++++++
.../task-15-patient-records/architecture.md | 204 +++++++++++++++++
.../task-15-patient-records/database.md | 116 ++++++++++
.../task-15-patient-records/task.md | 155 +++++++++++++
.../task-15-patient-records/user_flow.md | 123 +++++++++++
.../task-16-smart-dashboard/architecture.md | 100 +++++++++
.../task-16-smart-dashboard/database.md | 63 ++++++
.../task-16-smart-dashboard/task.md | 78 +++++++
.../task-16-smart-dashboard/user_flow.md | 98 +++++++++
28 files changed, 3169 insertions(+)
create mode 100644 docs/phase2_taskes/task-10-staff/architecture.md
create mode 100644 docs/phase2_taskes/task-10-staff/database.md
create mode 100644 docs/phase2_taskes/task-10-staff/task.md
create mode 100644 docs/phase2_taskes/task-10-staff/user_flow.md
create mode 100644 docs/phase2_taskes/task-11-subscription/architecture.md
create mode 100644 docs/phase2_taskes/task-11-subscription/database.md
create mode 100644 docs/phase2_taskes/task-11-subscription/task.md
create mode 100644 docs/phase2_taskes/task-11-subscription/user_flow.md
create mode 100644 docs/phase2_taskes/task-12-secretary-completion/architecture.md
create mode 100644 docs/phase2_taskes/task-12-secretary-completion/database.md
create mode 100644 docs/phase2_taskes/task-12-secretary-completion/task.md
create mode 100644 docs/phase2_taskes/task-12-secretary-completion/user_flow.md
create mode 100644 docs/phase2_taskes/task-13-clinic-services/architecture.md
create mode 100644 docs/phase2_taskes/task-13-clinic-services/database.md
create mode 100644 docs/phase2_taskes/task-13-clinic-services/task.md
create mode 100644 docs/phase2_taskes/task-13-clinic-services/user_flow.md
create mode 100644 docs/phase2_taskes/task-14-sms-panel/architecture.md
create mode 100644 docs/phase2_taskes/task-14-sms-panel/database.md
create mode 100644 docs/phase2_taskes/task-14-sms-panel/task.md
create mode 100644 docs/phase2_taskes/task-14-sms-panel/user_flow.md
create mode 100644 docs/phase2_taskes/task-15-patient-records/architecture.md
create mode 100644 docs/phase2_taskes/task-15-patient-records/database.md
create mode 100644 docs/phase2_taskes/task-15-patient-records/task.md
create mode 100644 docs/phase2_taskes/task-15-patient-records/user_flow.md
create mode 100644 docs/phase2_taskes/task-16-smart-dashboard/architecture.md
create mode 100644 docs/phase2_taskes/task-16-smart-dashboard/database.md
create mode 100644 docs/phase2_taskes/task-16-smart-dashboard/task.md
create mode 100644 docs/phase2_taskes/task-16-smart-dashboard/user_flow.md
diff --git a/docs/phase2_taskes/task-10-staff/architecture.md b/docs/phase2_taskes/task-10-staff/architecture.md
new file mode 100644
index 00000000..812d37c9
--- /dev/null
+++ b/docs/phase2_taskes/task-10-staff/architecture.md
@@ -0,0 +1,172 @@
+# معماری — تسک ۱۰: مدیریت پرسنل (Staff)
+
+## ساختار فایلها
+
+```
+src/Staff/
+├── Controller/
+│ └── StaffController.php
+├── Entity/
+│ └── ClinicStaff.php
+├── Repository/
+│ └── ClinicStaffRepository.php
+└── Service/
+ └── StaffService.php
+```
+
+## Entity: ClinicStaff
+
+```php
+uuid = (string) Uuid::v4();
+ $this->createdAt = time();
+ $this->updatedAt = time();
+ }
+
+ // getters/setters ...
+}
+```
+
+## Controller: StaffController
+
+```php
+success($items);
+ }
+
+ #[Route('/staff', methods: ['POST'])]
+ public function create(Request $request): JsonResponse
+ {
+ // 1. validate: full_name اجباری
+ // 2. ClinicStaff جدید با entityType/entityId از JWT بساز
+ // 3. persist و flush
+ // 4. return $this->success($staff->toArray());
+ }
+
+ #[Route('/staff/{uuid}', methods: ['PATCH'])]
+ public function update(string $uuid, Request $request): JsonResponse
+ {
+ // 1. find by uuid → 404 اگر نبود
+ // 2. بررسی مالکیت: staff->entityType/entityId == JWT claim → 403 اگر نبود
+ // 3. ویرایش فیلدهای موجود در body
+ // 4. return $this->success($staff->toArray());
+ }
+
+ #[Route('/staff/{uuid}/toggle', methods: ['PATCH'])]
+ public function toggle(string $uuid): JsonResponse
+ {
+ // 1. find → ownership check
+ // 2. active = !active
+ // 3. return $this->success(['active' => $staff->isActive()]);
+ }
+}
+```
+
+## Service: StaffService
+
+```php
+ $staff->getUuid(),
+ 'full_name' => $staff->getFullName(),
+ 'phone' => $staff->getPhone(),
+ 'job_title' => $staff->getJobTitle(),
+ 'address' => $staff->getAddress(),
+ 'national_code' => $staff->getNationalCode(),
+ 'active' => $staff->isActive(),
+ 'created_at' => $staff->getCreatedAt(),
+ ];
+ }
+}
+```
+
+## نحوه خواندن entity_type / entity_id از JWT
+
+```php
+// در BaseController یا trait:
+// JWT payload شامل: db_key ('doctor'/'clinic') و db_uuid (uuid entity)
+// باید uuid را به id تبدیل کنیم:
+$entityType = $this->getUser()->getDbKey(); // 'doctor' یا 'clinic'
+$entityUuid = $this->getUser()->getDbUuid();
+// سپس از Repository مربوطه id واقعی را بگیریم
+```
+
+## وابستگیهای آینده
+
+- `src/ClinicService/Entity/ServiceItem.php` → `staff_id FK → clinic_staff.id`
+- `src/Patient/Entity/SessionService.php` → `staff_id FK → clinic_staff.id`
diff --git a/docs/phase2_taskes/task-10-staff/database.md b/docs/phase2_taskes/task-10-staff/database.md
new file mode 100644
index 00000000..d7ebd307
--- /dev/null
+++ b/docs/phase2_taskes/task-10-staff/database.md
@@ -0,0 +1,51 @@
+# پایگاه داده — تسک ۱۰: مدیریت پرسنل (Staff)
+
+## جدول: clinic_staff
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| entity_type | VARCHAR(10) NOT NULL | `'doctor'` یا `'clinic'` |
+| entity_id | INT NOT NULL | FK به doctors.id یا clinics.id (bسته به entity_type) |
+| full_name | VARCHAR(200) NOT NULL | نام و نامخانوادگی — اجباری |
+| phone | VARCHAR(20) NULL | |
+| job_title | VARCHAR(100) NULL | عنوان شغل |
+| address | TEXT NULL | |
+| national_code | CHAR(10) NULL | |
+| active | TINYINT(1) NOT NULL DEFAULT 1 | غیرفعال = soft delete |
+| created_at | INT NOT NULL | Unix timestamp |
+| updated_at | INT NOT NULL | Unix timestamp |
+
+## ایندکسها
+
+```sql
+CREATE INDEX idx_clinic_staff_entity
+ ON clinic_staff(entity_type, entity_id, active);
+```
+
+## نکات مهم
+
+- **هیچ FK خارجی** برای entity_id تعریف نمیشود — چون entity_type polymorphic است و میتواند doctor یا clinic باشد. مسئولیت یکپارچگی با لایه application است.
+- `active = 0` به معنای حذف منطقی است — ردیف هرگز از جدول پاک نمیشود
+- وقتی staff غیرفعال میشود، service_items و session_services قبلی دستنخورده میمانند
+
+## Migration نمونه
+
+```sql
+CREATE TABLE clinic_staff (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ uuid CHAR(36) NOT NULL UNIQUE,
+ entity_type VARCHAR(10) NOT NULL,
+ entity_id INT NOT NULL,
+ full_name VARCHAR(200) NOT NULL,
+ phone VARCHAR(20) NULL,
+ job_title VARCHAR(100) NULL,
+ address TEXT NULL,
+ national_code CHAR(10) NULL,
+ active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at INT NOT NULL,
+ updated_at INT NOT NULL,
+ INDEX idx_clinic_staff_entity (entity_type, entity_id, active)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
diff --git a/docs/phase2_taskes/task-10-staff/task.md b/docs/phase2_taskes/task-10-staff/task.md
new file mode 100644
index 00000000..68592cbe
--- /dev/null
+++ b/docs/phase2_taskes/task-10-staff/task.md
@@ -0,0 +1,107 @@
+# تسک ۱۰: مدیریت پرسنل (Staff)
+
+## توضیح
+پیادهسازی سیستم مدیریت پرسنل کلینیک/مطب. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند.
+این تسک **پیشنیاز مستقیم** تسک ۱۳ (سرویسها) و تسک ۱۵ (پرونده بیمار) است.
+
+## Endpoint ها
+
+| متد | مسیر | توضیح | نیاز به Auth |
+|-----|------|-------|-------------|
+| GET | `/api/v1/staff` | لیست پرسنل context جاری | بله (Doctor/Clinic) |
+| POST | `/api/v1/staff` | ایجاد پرسنل جدید | بله |
+| PATCH | `/api/v1/staff/{uuid}` | ویرایش اطلاعات | بله (مالک) |
+| PATCH | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) | بله (مالک) |
+
+**مهم:** حذف سخت (`DELETE`) ممنوع است — تاریخچه سرویسها و سشنهای بیمار به پرسنل ارجاع دارند.
+
+## پیشنیازها
+- تسک ۰۲ (Auth — JWT + switch-context)
+- تسک ۰۵ (Doctor entity)
+- تسک ۰۶ (Clinic entity)
+
+## زمان تخمینی
+۶ تا ۸ ساعت
+
+## نمونه Request
+
+### POST /api/v1/staff
+```json
+{
+ "full_name": "علی رضایی",
+ "phone": "09121234567",
+ "job_title": "پرستار",
+ "address": "تهران، خیابان ولیعصر",
+ "national_code": "0012345678"
+}
+```
+
+### PATCH /api/v1/staff/{uuid}
+```json
+{
+ "full_name": "علی رضایی",
+ "job_title": "سرپرستار",
+ "phone": "09129999999"
+}
+```
+
+### PATCH /api/v1/staff/{uuid}/toggle
+```json
+{}
+```
+(body خالی — فقط وضعیت active را toggle میکند)
+
+## نمونه Response
+
+### GET /api/v1/staff
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
+ "full_name": "علی رضایی",
+ "phone": "09121234567",
+ "job_title": "پرستار",
+ "address": "تهران، خیابان ولیعصر",
+ "national_code": "0012345678",
+ "active": true,
+ "created_at": 1718000000
+ }
+ ]
+}
+```
+
+### POST /api/v1/staff (موفق)
+```json
+{
+ "success": true,
+ "data": {
+ "uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
+ "full_name": "علی رضایی",
+ "phone": "09121234567",
+ "job_title": "پرستار",
+ "address": null,
+ "national_code": "0012345678",
+ "active": true,
+ "created_at": 1718000000
+ }
+}
+```
+
+### PATCH /api/v1/staff/{uuid}/toggle
+```json
+{
+ "success": true,
+ "data": { "active": false }
+}
+```
+
+## کدهای خطا
+
+| کد HTTP | توضیح |
+|---------|-------|
+| 401 | JWT معتبر نیست |
+| 403 | کاربر مالک این پرسنل نیست |
+| 404 | uuid پرسنل پیدا نشد |
+| 422 | `full_name` خالی است |
diff --git a/docs/phase2_taskes/task-10-staff/user_flow.md b/docs/phase2_taskes/task-10-staff/user_flow.md
new file mode 100644
index 00000000..7f55ca97
--- /dev/null
+++ b/docs/phase2_taskes/task-10-staff/user_flow.md
@@ -0,0 +1,86 @@
+# جریان کاربری — تسک ۱۰: مدیریت پرسنل (Staff)
+
+## جریان ایجاد پرسنل جدید
+
+```
+مدیر کلینیک/دکتر وارد پنل میشود
+ │
+ ▼
+GET /api/v1/staff
+ → لیست پرسنل فعلی context جاری نمایش داده میشود
+ │
+ ▼
+کاربر روی «افزودن پرسنل» کلیک میکند → Modal باز میشود
+ │
+ ▼
+POST /api/v1/staff
+ { full_name, phone?, job_title?, address?, national_code? }
+ │
+ ├─► 422: full_name خالی است → نمایش خطا در فرم
+ │
+ └─► 201: پرسنل ایجاد شد → لیست refresh میشود
+```
+
+## جریان ویرایش پرسنل
+
+```
+کاربر روی آیکن ویرایش در جدول کلیک میکند
+ │
+ ▼
+Modal با اطلاعات فعلی پر میشود
+ │
+ ▼
+PATCH /api/v1/staff/{uuid}
+ { full_name, phone, job_title, ... }
+ │
+ ├─► 403: این پرسنل متعلق به شما نیست
+ └─► 200: ویرایش موفق → Modal بسته میشود
+```
+
+## جریان غیرفعالسازی (Soft Delete)
+
+```
+کاربر روی toggle در جدول کلیک میکند
+ │
+ ▼
+PATCH /api/v1/staff/{uuid}/toggle
+ │
+ ▼
+active: true → false یا false → true
+ │
+ ▼
+در لیست: badge وضعیت تغییر میکند (فعال/غیرفعال)
+```
+
+## چرا حذف سخت ممنوع است
+
+```
+clinic_staff (active=false)
+ │
+ ├── service_items.staff_id → هنوز به پرسنل ارجاع دارد
+ │ (تاریخچه سرویسها حفظ میشود)
+ │
+ └── session_services.staff_id → سشنهای قبلی بیمار
+ نام انجامدهنده را نشان میدهند
+```
+
+اگر پرسنل حذف سخت میشد:
+- `service_items.staff_id` → NULL (انجامدهنده سرویس گم میشد)
+- `session_services.staff_id` → NULL (تاریخچه ویزیت ناقص میشد)
+
+## نمایش در Frontend (StaffPage.tsx)
+
+```
+┌─────────────────────────────────────────────────┐
+│ پرسنل کلینیک [+ افزودن] │
+├──────────┬──────────┬──────────┬────────────────┤
+│ نام │ سمت │ تلفن │ وضعیت عملیات│
+├──────────┼──────────┼──────────┼────────────────┤
+│ علی رضایی│ پرستار │ 0912... │ ✅فعال ✏️ 🔘 │
+│ مریم نوری│ منشی │ 0913... │ ❌غیرفعال✏️ 🔘│
+└──────────┴──────────┴──────────┴────────────────┘
+```
+
+- ستون آخر: آیکن ویرایش (Modal) + toggle وضعیت
+- غیرفعالها با رنگ کمتر نمایش داده میشوند اما از لیست حذف نمیشوند
+- فیلتر: «فقط فعالها» / «همه» — query param `?active=1`
diff --git a/docs/phase2_taskes/task-11-subscription/architecture.md b/docs/phase2_taskes/task-11-subscription/architecture.md
new file mode 100644
index 00000000..1e6c60fa
--- /dev/null
+++ b/docs/phase2_taskes/task-11-subscription/architecture.md
@@ -0,0 +1,207 @@
+# معماری — تسک ۱۱: پنل اشتراکی (Subscription Tiers)
+
+## ساختار فایلها
+
+```
+src/Subscription/
+├── Controller/
+│ └── SubscriptionController.php
+├── Entity/
+│ ├── SubscriptionPlan.php
+│ ├── SubscriptionPeriod.php
+│ └── ClinicSubscription.php
+├── Repository/
+│ ├── SubscriptionPlanRepository.php
+│ ├── SubscriptionPeriodRepository.php
+│ └── ClinicSubscriptionRepository.php
+└── Service/
+ └── SubscriptionService.php
+```
+
+**فایلهایی که تغییر میکنند:**
+- `src/Payment/Controller/PaymentController.php` — متد `subscriptionCallback()` باید پس از تأیید پرداخت، `ClinicSubscription` بسازد
+
+## Entity: SubscriptionPlan
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'subscription_plans')]
+class SubscriptionPlan
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\Column(type: 'string', length: 30)]
+ private string $name; // 'free' | 'basic' | 'professional'
+
+ #[ORM\Column(type: 'smallint')]
+ private int $level; // 0 | 1 | 2
+
+ #[ORM\Column(type: 'smallint')]
+ private int $maxSecretaries; // 1 | 2 | 5
+
+ #[ORM\Column(type: 'json')]
+ private array $features; // {"patient_records": bool, "services": bool, "sms_panel": bool}
+
+ #[ORM\Column(type: 'boolean')]
+ private bool $active = true;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+
+ #[ORM\Column(type: 'integer')]
+ private int $updatedAt;
+
+ // OneToMany → SubscriptionPeriod
+}
+```
+
+## Entity: SubscriptionPeriod
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'subscription_periods')]
+class SubscriptionPeriod
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\ManyToOne(targetEntity: SubscriptionPlan::class)]
+ #[ORM\JoinColumn(name: 'plan_id', nullable: false, onDelete: 'CASCADE')]
+ private SubscriptionPlan $plan;
+
+ #[ORM\Column(type: 'string', length: 50)]
+ private string $label; // '۶ ماهه', 'تریال ۱ ماهه'
+
+ #[ORM\Column(type: 'smallint')]
+ private int $durationMonths;
+
+ #[ORM\Column(type: 'integer')]
+ private int $priceRials; // 0 برای تریال
+
+ #[ORM\Column(type: 'boolean')]
+ private bool $isTrial = false;
+
+ #[ORM\Column(type: 'boolean')]
+ private bool $active = true;
+
+ #[ORM\Column(type: 'smallint')]
+ private int $sortOrder = 0;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+
+ #[ORM\Column(type: 'integer')]
+ private int $updatedAt;
+}
+```
+
+## Entity: ClinicSubscription
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'clinic_subscriptions')]
+#[ORM\Index(columns: ['entity_type', 'entity_id', 'expires_at'], name: 'idx_clinic_sub_entity')]
+class ClinicSubscription
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\Column(type: 'string', length: 10)]
+ private string $entityType; // 'doctor' | 'clinic'
+
+ #[ORM\Column(type: 'integer')]
+ private int $entityId;
+
+ #[ORM\ManyToOne(targetEntity: SubscriptionPlan::class)]
+ #[ORM\JoinColumn(nullable: false)]
+ private SubscriptionPlan $plan;
+
+ #[ORM\ManyToOne(targetEntity: SubscriptionPeriod::class)]
+ #[ORM\JoinColumn(nullable: false)]
+ private SubscriptionPeriod $period;
+
+ #[ORM\ManyToOne(targetEntity: \App\Payment\Entity\Payment::class)]
+ #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
+ private ?\App\Payment\Entity\Payment $payment = null; // null برای تریال
+
+ #[ORM\Column(type: 'boolean')]
+ private bool $isTrial = false;
+
+ #[ORM\Column(type: 'integer')]
+ private int $startsAt;
+
+ #[ORM\Column(type: 'integer', nullable: true)]
+ private ?int $expiresAt = null; // null = بینهایت (Free)
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+}
+```
+
+## Service: SubscriptionService
+
+```php
+class SubscriptionService
+{
+ public function getActiveSubscription(string $entityType, int $entityId): ?ClinicSubscription
+ {
+ // آخرین رکورد که expires_at > time() OR expires_at IS NULL
+ }
+
+ public function hasFeature(string $entityType, int $entityId, string $feature): bool
+ {
+ $sub = $this->getActiveSubscription($entityType, $entityId);
+ if (!$sub) return false;
+ return $sub->getPlan()->getFeatures()[$feature] ?? false;
+ }
+
+ public function getSecretaryLimit(string $entityType, int $entityId): int
+ {
+ $sub = $this->getActiveSubscription($entityType, $entityId);
+ return $sub ? $sub->getPlan()->getMaxSecretaries() : 1; // Free = 1
+ }
+
+ public function hasUsedTrial(string $entityType, int $entityId): bool
+ {
+ return $this->subscriptionRepo->existsTrial($entityType, $entityId);
+ }
+
+ public function activateTrial(string $entityType, int $entityId): ClinicSubscription
+ {
+ // بررسی: آیا قبلاً تریال استفاده شده؟ → throw AppException
+ // بررسی: آیا trial_enabled در SiteConfig فعال است؟
+ // ساخت ClinicSubscription با is_trial=true, payment=null
+ }
+
+ public function calculateExpiresAt(?int $currentExpiresAt, int $durationMonths): int
+ {
+ $base = max($currentExpiresAt ?? 0, time());
+ return $base + ($durationMonths * 30 * 86400);
+ }
+}
+```
+
+## وابستگی PaymentController
+
+```php
+// src/Payment/Controller/PaymentController.php
+// متد subscriptionCallback() — بعد از تأیید پرداخت:
+
+$subscriptionService->createFromPayment($payment, $periodUuid, $entityType, $entityId);
+
+// این متد:
+// 1. Period را پیدا میکند
+// 2. expires_at را محاسبه میکند (در صورت تمدید از expires_at قبلی)
+// 3. ClinicSubscription جدید میسازد
+// 4. persist/flush
+```
diff --git a/docs/phase2_taskes/task-11-subscription/database.md b/docs/phase2_taskes/task-11-subscription/database.md
new file mode 100644
index 00000000..c50bc701
--- /dev/null
+++ b/docs/phase2_taskes/task-11-subscription/database.md
@@ -0,0 +1,92 @@
+# پایگاه داده — تسک ۱۱: پنل اشتراکی (Subscription Tiers)
+
+## جدول: subscription_plans
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| name | VARCHAR(30) NOT NULL | `'free'` \| `'basic'` \| `'professional'` |
+| level | TINYINT NOT NULL | 0, 1, یا 2 |
+| max_secretaries | TINYINT NOT NULL | 1, 2، یا 5 |
+| features | JSON NOT NULL | `{"patient_records": bool, "services": bool, "sms_panel": bool}` |
+| active | TINYINT(1) NOT NULL DEFAULT 1 | |
+| created_at | INT NOT NULL | Unix timestamp |
+| updated_at | INT NOT NULL | Unix timestamp |
+
+## جدول: subscription_periods
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| plan_id | INT NOT NULL FK→subscription_plans.id ON DELETE CASCADE | |
+| label | VARCHAR(50) NOT NULL | مثال: `'۶ ماهه'`, `'تریال ۱ ماهه'` |
+| duration_months | TINYINT NOT NULL | مدت به ماه |
+| price_rials | INT NOT NULL | `0` برای تریال |
+| is_trial | TINYINT(1) NOT NULL DEFAULT 0 | |
+| active | TINYINT(1) NOT NULL DEFAULT 1 | |
+| sort_order | TINYINT NOT NULL DEFAULT 0 | ترتیب نمایش |
+| created_at | INT NOT NULL | |
+| updated_at | INT NOT NULL | |
+
+## جدول: clinic_subscriptions
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| entity_type | VARCHAR(10) NOT NULL | `'doctor'` \| `'clinic'` |
+| entity_id | INT NOT NULL | id دکتر یا کلینیک |
+| plan_id | INT NOT NULL FK→subscription_plans.id | |
+| period_id | INT NOT NULL FK→subscription_periods.id | |
+| payment_id | INT NULL FK→payments.id ON DELETE SET NULL | `NULL` برای تریال |
+| is_trial | TINYINT(1) NOT NULL DEFAULT 0 | |
+| starts_at | INT NOT NULL | Unix timestamp |
+| expires_at | INT NULL | `NULL` = بینهایت (Free) |
+| created_at | INT NOT NULL | |
+
+## ایندکسها
+
+```sql
+-- جستجوی سریع اشتراک فعال
+CREATE INDEX idx_clinic_subscriptions_entity
+ ON clinic_subscriptions(entity_type, entity_id, expires_at);
+
+-- جلوگیری از استفاده مکرر از تریال
+-- توجه: این constraint روی is_trial=1 کار میکند چون is_trial=0 میتواند تکراری باشد
+-- بنابراین در application check میکنیم نه UNIQUE index
+CREATE INDEX idx_clinic_subscriptions_trial
+ ON clinic_subscriptions(entity_type, entity_id, is_trial);
+```
+
+## Seed Data (Migration اولیه)
+
+```sql
+-- سه پنل پایه — باید در migration ایجاد شوند
+INSERT INTO subscription_plans (uuid, name, level, max_secretaries, features, active, created_at, updated_at) VALUES
+ (UUID(), 'free', 0, 1, '{"patient_records":false,"services":false,"sms_panel":false}', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
+ (UUID(), 'basic', 1, 2, '{"patient_records":true,"services":true,"sms_panel":false}', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
+ (UUID(), 'professional', 2, 5, '{"patient_records":true,"services":true,"sms_panel":true}', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
+
+-- دورههای نمونه برای basic (ادمین بعداً قیمتها را ویرایش میکند)
+-- plan_id=2 = basic
+INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at) VALUES
+ (UUID(), 2, 'تریال ۱ ماهه', 1, 0, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
+ (UUID(), 2, '۱ ماهه', 1, 250000, 0, 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
+ (UUID(), 2, '۳ ماهه', 3, 690000, 0, 1, 2, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
+ (UUID(), 2, '۶ ماهه', 6, 1200000, 0, 1, 3, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
+ (UUID(), 2, '۱۲ ماهه', 12, 2000000, 0, 1, 4, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
+```
+
+## SiteConfig key های جدید
+
+| کلید | نوع | مقدار پیشفرض | توضیح |
+|------|-----|--------------|-------|
+| `trial_enabled` | string `'1'`\|`'0'` | `'1'` | ادمین میتواند تریال را غیرفعال کند |
+
+## نکات مهم
+
+- `expires_at = NULL` فقط برای رکوردهای Free plan است — اینها بینهایت معتبرند
+- query اشتراک فعال: `WHERE entity_type=? AND entity_id=? AND (expires_at IS NULL OR expires_at > UNIX_TIMESTAMP()) ORDER BY id DESC LIMIT 1`
+- تریال check در application: `SELECT COUNT(*) FROM clinic_subscriptions WHERE entity_type=? AND entity_id=? AND is_trial=1`
diff --git a/docs/phase2_taskes/task-11-subscription/task.md b/docs/phase2_taskes/task-11-subscription/task.md
new file mode 100644
index 00000000..49ab07b8
--- /dev/null
+++ b/docs/phase2_taskes/task-11-subscription/task.md
@@ -0,0 +1,160 @@
+# تسک ۱۱: پنل اشتراکی (Subscription Tiers)
+
+## توضیح
+پیادهسازی سیستم پنلهای اشتراکی سهسطحی. ادمین دورهها و قیمتها را تعریف میکند.
+این تسک **gate check** برای تسکهای ۱۲، ۱۳، ۱۵ فراهم میکند.
+
+**نکته مهم:** `/api/v1/subscription-payment` و callback آن از قبل در `PaymentController` موجود است.
+در این تسک فقط باید بعد از callback موفق، `ClinicSubscription` ساخته شود + endpoint های Subscription خودش.
+
+## سطوح پنل
+
+| Level | Name | منشی | patient_records | services |
+|-------|------|------|----------------|---------|
+| 0 | free | ۱ | ❌ | ❌ |
+| 1 | basic | ۲ | ✅ | ✅ |
+| 2 | professional | ۵ | ✅ | ✅ |
+
+## Endpoint ها
+
+| متد | مسیر | Permission | توضیح |
+|-----|------|-----------|-------|
+| GET | `/api/v1/subscription/plans` | public | لیست پنلها + دورهها + قیمت |
+| GET | `/api/v1/subscription/my` | doctor/clinic | اشتراک فعال + `used_trial` |
+| POST | `/api/v1/subscription/trial` | doctor/clinic | فعالسازی تریال Basic (یکبار) |
+| GET | `/api/v1/admin/subscription/plans` | ROLE_ADMIN | لیست مدیریت پنلها |
+| POST | `/api/v1/admin/subscription/plan` | ROLE_ADMIN | ایجاد پنل |
+| PATCH | `/api/v1/admin/subscription/plan/{uuid}` | ROLE_ADMIN | ویرایش پنل |
+| POST | `/api/v1/admin/subscription/period` | ROLE_ADMIN | افزودن دوره |
+| PATCH | `/api/v1/admin/subscription/period/{uuid}` | ROLE_ADMIN | ویرایش دوره/قیمت |
+| DELETE | `/api/v1/admin/subscription/period/{uuid}` | ROLE_ADMIN | غیرفعالسازی دوره |
+| GET | `/api/v1/admin/subscription/report` | ROLE_ADMIN | گزارش فروش + تریالها |
+
+**موجود (تغییر نمیکند):**
+- `POST /api/v1/subscription-payment` — شروع پرداخت (body: `{ period_uuid }`)
+- `GET|POST /api/v1/subscription-payment/callback/{gateway}` — callback gateway
+
+## پیشنیازها
+- تسک ۰۲ (Auth/JWT)
+- تسک ۱۵-payment (PaymentController — موجود)
+
+## زمان تخمینی
+۱۴ تا ۱۶ ساعت
+
+## نمونه Request
+
+### POST /api/v1/subscription/trial
+```json
+{}
+```
+(body خالی — entity از JWT گرفته میشود)
+
+### POST /api/v1/admin/subscription/period
+```json
+{
+ "plan_uuid": "uuid-of-basic-plan",
+ "label": "۶ ماهه",
+ "duration_months": 6,
+ "price_rials": 1200000,
+ "is_trial": false,
+ "sort_order": 3
+}
+```
+
+### POST /api/v1/subscription-payment (موجود)
+```json
+{
+ "period_uuid": "uuid-of-selected-period",
+ "gateway": "mellat"
+}
+```
+
+## نمونه Response
+
+### GET /api/v1/subscription/plans
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "uuid": "...",
+ "name": "basic",
+ "level": 1,
+ "max_secretaries": 2,
+ "features": { "patient_records": true, "services": true, "sms_panel": false },
+ "periods": [
+ { "uuid": "...", "label": "تریال ۱ ماهه", "duration_months": 1, "price_rials": 0, "is_trial": true },
+ { "uuid": "...", "label": "۱ ماهه", "duration_months": 1, "price_rials": 250000, "is_trial": false },
+ { "uuid": "...", "label": "۶ ماهه", "duration_months": 6, "price_rials": 1200000, "is_trial": false }
+ ]
+ }
+ ]
+}
+```
+
+### GET /api/v1/subscription/my
+```json
+{
+ "success": true,
+ "data": {
+ "plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true } },
+ "period": { "label": "۶ ماهه", "duration_months": 6 },
+ "is_trial": false,
+ "starts_at": 1718000000,
+ "expires_at": 1733360000,
+ "used_trial": true,
+ "days_remaining": 42
+ }
+}
+```
+
+وقتی اشتراک فعال ندارد:
+```json
+{
+ "success": true,
+ "data": {
+ "plan": { "name": "free", "level": 0 },
+ "expires_at": null,
+ "used_trial": false
+ }
+}
+```
+
+### POST /api/v1/subscription/trial (موفق)
+```json
+{
+ "success": true,
+ "data": {
+ "plan": "basic",
+ "starts_at": 1718000000,
+ "expires_at": 1720678400
+ }
+}
+```
+
+### POST /api/v1/subscription/trial (خطا — قبلاً استفاده شده)
+```json
+{
+ "success": false,
+ "errors": [{ "code": "ERR_TRIAL_ALREADY_USED", "message": "قبلاً از تریال استفاده کردهاید" }]
+}
+```
+
+## قوانین تریال
+- فقط برای پنل **basic** (level=1)
+- هر entity یکبار — constraint `UNIQUE(entity_type, entity_id, is_trial)` + مقدار `is_trial=1` در DB
+- بدون پرداخت — `payment_id = null`
+- بعد از انقضا → برگشت به Free (دادهها حفظ میشوند)
+- ادمین میتواند تریال را کلاً غیرفعال کند: `SiteConfig.trial_enabled = false`
+
+## قانون تمدید
+```
+expires_at جدید = max(expires_at فعلی, time()) + duration_months × 30 × 86400
+```
+یعنی اگر اشتراک هنوز منقضی نشده، تمدید از تاریخ انقضا محاسبه میشود (نه از now).
+
+## gate check در تسکهای بعدی
+```php
+// SubscriptionService::hasFeature('patient_records') → bool
+// false → $this->error(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, '...', 403)
+```
diff --git a/docs/phase2_taskes/task-11-subscription/user_flow.md b/docs/phase2_taskes/task-11-subscription/user_flow.md
new file mode 100644
index 00000000..8d7b1f5a
--- /dev/null
+++ b/docs/phase2_taskes/task-11-subscription/user_flow.md
@@ -0,0 +1,126 @@
+# جریان کاربری — تسک ۱۱: پنل اشتراکی
+
+## جریان مشاهده پنلها و خرید
+
+```
+کاربر وارد صفحه اشتراک میشود
+ │
+ ▼
+GET /api/v1/subscription/my
+ → نمایش پنل فعلی + تاریخ انقضا (شمسی) + days_remaining
+ → اگر used_trial=false و trial_enabled=true → بنر تریال نمایش داده میشود
+ │
+ ▼
+GET /api/v1/subscription/plans
+ → جدول مقایسه پنلها (free | basic | professional)
+ → هر پنل: لیست دورهها با قیمت — دروه تریال جدا نمایش داده میشود
+ │
+ ├─► کاربر «تریال» انتخاب میکند:
+ │ │
+ │ ▼
+ │ POST /api/v1/subscription/trial
+ │ ├─► 200: فعال شد → صفحه refresh
+ │ └─► 400: قبلاً استفاده شده → toast خطا
+ │
+ └─► کاربر یک دوره پولی انتخاب میکند:
+ │
+ ▼
+ POST /api/v1/subscription-payment (موجود)
+ { period_uuid, gateway: 'mellat' }
+ │
+ ▼
+ redirect به gateway بانک
+ │
+ ▼
+ GET|POST /api/v1/subscription-payment/callback/{gateway}
+ │
+ ▼
+ SubscriptionService::createFromPayment()
+ → ClinicSubscription ساخته میشود
+ │
+ ▼
+ redirect به /admin/subscription?success=1
+```
+
+## جریان تمدید اشتراک
+
+```
+اشتراک ۶ ماهه فعال است (expires_at = آینده)
+ │
+ ▼
+کاربر دوره جدید ۶ ماهه انتخاب میکند و پرداخت میکند
+ │
+ ▼
+calculateExpiresAt():
+ base = max(expires_at_فعلی, now) ← از تاریخ انقضا (نه الان)
+ new_expires = base + 6 × 30 × 86400
+ │
+ ▼
+اشتراک ۶ ماه دیگر تمدید میشود بدون اتلاف زمان باقیمانده
+```
+
+## جریان gate check در تسکهای بعدی
+
+```
+کاربر Free تلاش میکند پرونده بیمار بسازد
+ │
+ ▼
+POST /api/v1/patient
+ │
+ ▼
+PatientController::create():
+ $hasFeature = $subscriptionService->hasFeature($entityType, $entityId, 'patient_records')
+ │
+ ├─► false → 403 ERR_SUBSCRIPTION_REQUIRED
+ │ { "errors": [{ "code": "ERR_SUBSCRIPTION_REQUIRED",
+ │ "message": "این قابلیت نیاز به پنل Basic یا بالاتر دارد" }] }
+ │
+ └─► true → ادامه پردازش
+```
+
+## جریان مدیریت پنلها توسط ادمین
+
+```
+ادمین وارد صفحه مدیریت اشتراک میشود
+ │
+ ├─► تب «پنلها»:
+ │ GET /api/v1/admin/subscription/plans
+ │ → جدول پنلها با دورهها و قیمتها
+ │ → PATCH /api/v1/admin/subscription/period/{uuid} ← ویرایش inline قیمت
+ │ → POST /api/v1/admin/subscription/period ← افزودن دوره جدید
+ │
+ └─► تب «گزارش»:
+ GET /api/v1/admin/subscription/report?from=UNIX&to=UNIX
+ → تعداد خریدها + مجموع درآمد + تعداد تریالها
+ → breakdown بر اساس plan و period
+```
+
+## هشدار انقضا (frontend)
+
+```
+در هر بار لود صفحه:
+ اگر days_remaining <= 7 و expires_at != null:
+ → نمایش banner هشدار: «اشتراک شما X روز دیگر منقضی میشود — تمدید کنید»
+```
+
+## نمایش در Frontend (SubscriptionPage.tsx)
+
+```
+┌─────────────────────────────────────────────────────┐
+│ 🎁 یک ماه تریال رایگان — فعالسازی │ ← فقط اگر used_trial=false
+├─────────────────────────────────────────────────────┤
+│ پنل فعلی: Basic | انقضا: ۱۴۰۵/۰۶/۲۳ | ۴۲ روز │
+├───────────┬──────────────────┬─────────────────────┤
+│ Free │ Basic ★ │ Professional │
+│ رایگان │ پرونده بیمار ✅ │ پرونده بیمار ✅ │
+│ ۱ منشی │ سرویسها ✅ │ سرویسها ✅ │
+│ │ ۲ منشی │ ۵ منشی │
+│ │ ┌──────────┐ │ │
+│ │ │ ۱ ماهه │ │ [انتخاب دوره ▼] │
+│ │ │ ۲۵۰,۰۰۰ │ │ │
+│ │ │ ۶ ماهه │ │ │
+│ │ │ ۱,۲۰۰,۰۰۰│ │ │
+│ │ └──────────┘ │ │
+│ │ [خرید اشتراک] │ [خرید اشتراک] │
+└───────────┴──────────────────┴─────────────────────┘
+```
diff --git a/docs/phase2_taskes/task-12-secretary-completion/architecture.md b/docs/phase2_taskes/task-12-secretary-completion/architecture.md
new file mode 100644
index 00000000..afeb0818
--- /dev/null
+++ b/docs/phase2_taskes/task-12-secretary-completion/architecture.md
@@ -0,0 +1,71 @@
+# معماری — تسک ۱۲: تکمیل منشی
+
+## فایلهای موجود که تغییر میکنند
+
+```
+src/Secretary/Controller/SecretaryController.php ← inject SubscriptionService + limit check
+src/Shared/Constant/ErrorCodes.php ← ثابت جدید ERR_SECRETARY_LIMIT_REACHED
+assets/admin/pages/SecretariesPage.tsx ← Modal permissions checkbox matrix
+```
+
+## تغییرات Backend
+
+### SecretaryController — constructor injection
+```php
+public function __construct(
+ private readonly EntityManagerInterface $em,
+ private readonly DoctorSecretaryRepository $secretaryRepo,
+ private readonly DoctorRepository $doctorRepo,
+ private readonly SubscriptionService $subscriptionService, // ← جدید
+) {}
+```
+
+### SecretaryRepository — متد جدید countActive
+```php
+// src/Secretary/Repository/DoctorSecretaryRepository.php
+public function countActive(int $doctorId): int
+{
+ return (int) $this->createQueryBuilder('s')
+ ->select('COUNT(s.id)')
+ ->where('s.doctor = :doctor')
+ ->andWhere('s.active = true')
+ ->setParameter('doctor', $doctorId)
+ ->getQuery()
+ ->getSingleScalarResult();
+}
+```
+
+## تغییرات Frontend
+
+### SecretariesPage.tsx — ساختار Modal permissions
+
+```tsx
+// types جدید
+interface PermissionsMatrix {
+ version: number;
+ resources: {
+ appointments?: { view: boolean; create: boolean; cancel: boolean; update_status: boolean };
+ addresses?: { view: boolean; create: boolean; update: boolean; delete: boolean };
+ clinic_info?: { view: boolean; update: boolean };
+ insurances?: { view: boolean; create: boolean; update: boolean; delete: boolean };
+ };
+}
+
+// component
+function PermissionsEditor({ value, onChange }: {
+ value: PermissionsMatrix;
+ onChange: (v: PermissionsMatrix) => void;
+}) {
+ // رندر جدول checkbox
+ // هر ردیف: نام بخش + چکباکسهای action
+ // onChange ساختار را update میکند
+}
+```
+
+### نکته: مدیریت خطای 403 در فرم ایجاد منشی
+
+```tsx
+// وقتی POST /api/v1/secretary با 403 برمیگردد:
+// نمایش toast: "سقف منشی پنل رسیده است. برای افزایش به صفحه اشتراک بروید"
+// دکمه "ارتقاء پنل" → navigate('/admin/subscription')
+```
diff --git a/docs/phase2_taskes/task-12-secretary-completion/database.md b/docs/phase2_taskes/task-12-secretary-completion/database.md
new file mode 100644
index 00000000..4d52a93c
--- /dev/null
+++ b/docs/phase2_taskes/task-12-secretary-completion/database.md
@@ -0,0 +1,59 @@
+# پایگاه داده — تسک ۱۲: تکمیل منشی
+
+## هیچ migration لازم نیست
+
+جدول `doctor_secretaries` کامل است. فقط باید مطمئن شویم ستون `permissions` درست ذخیره میشود.
+
+## ستون موجود: permissions در doctor_secretaries
+
+| ستون | نوع | مقدار پیشفرض |
+|------|-----|--------------|
+| permissions | JSON | `DEFAULT_PERMISSIONS` از DoctorSecretary entity |
+
+### ساختار JSON ذخیرهشده:
+```json
+{
+ "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
+ }
+ }
+}
+```
+
+### نکته `version`:
+- `version: 1` — اگر در آینده ساختار permissions تغییر کرد، migration به version بعدی انجام میشود
+- همیشه هنگام خواندن permissions، `version` بررسی شود و اگر قدیمی بود با DEFAULT_PERMISSIONS merge شود
+
+## جدول doctor_secretaries (مرجع)
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT PK | |
+| uuid | CHAR(36) UNIQUE | |
+| doctor_id | INT FK→doctors.id | |
+| secretary_id | INT FK→users.id | |
+| permissions | JSON | ساختار بالا |
+| active | TINYINT(1) DEFAULT 1 | |
+| created_at | INT | |
+| updated_at | INT | |
diff --git a/docs/phase2_taskes/task-12-secretary-completion/task.md b/docs/phase2_taskes/task-12-secretary-completion/task.md
new file mode 100644
index 00000000..fb5142c9
--- /dev/null
+++ b/docs/phase2_taskes/task-12-secretary-completion/task.md
@@ -0,0 +1,134 @@
+# تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions
+
+## توضیح
+Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه میکند:
+1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی
+2. Frontend: Modal با checkbox matrix برای ویرایش permissions
+
+**هیچ endpoint جدیدی ایجاد نمیشود.**
+
+## فایلهای موجود که تغییر میکنند
+
+| فایل | تغییر |
+|------|-------|
+| `src/Secretary/Controller/SecretaryController.php` | inject `SubscriptionService`، اضافه کردن limit check در `create()` |
+| `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) |
+
+## پیشنیازها
+- تسک ۱۴ (Secretary — موجود در کد)
+- **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد)
+
+## زمان تخمینی
+۴ تا ۵ ساعت
+
+## تغییر Backend — SecretaryController::create()
+
+### وضعیت فعلی (قبل):
+```php
+public function create(Request $request): JsonResponse
+{
+ // validate fields
+ // create DoctorSecretary
+ // persist
+ return $this->success($secretary->toArray());
+}
+```
+
+### وضعیت جدید (بعد):
+```php
+public function create(Request $request): JsonResponse
+{
+ // 1. پیدا کردن دکتر از doctorUuid در body
+ $doctor = $this->doctorRepo->findByUuid($request->get('doctor_uuid'));
+
+ // 2. بررسی سقف پنل
+ $entityType = 'doctor';
+ $entityId = $doctor->getId();
+ $limit = $this->subscriptionService->getSecretaryLimit($entityType, $entityId);
+ $current = $this->secretaryRepo->countActive($doctor->getId());
+ if ($current >= $limit) {
+ return $this->error(
+ ErrorCodes::ERR_SECRETARY_LIMIT_REACHED,
+ 'سقف تعداد منشی پنل اشتراکی شما رسیده است',
+ Response::HTTP_FORBIDDEN
+ );
+ }
+
+ // 3. ادامه ایجاد منشی ...
+}
+```
+
+### خطای جدید که باید در ErrorCodes.php اضافه شود:
+```php
+// src/Shared/Constant/ErrorCodes.php
+const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
+// messages: 'سقف تعداد منشی پنل اشتراکی شما رسیده است'
+```
+
+## تغییر Frontend — SecretariesPage.tsx
+
+### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS):
+```php
+[
+ '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],
+ ],
+]
+```
+
+### نمونه UI Permissions checkbox matrix در Modal:
+```
+┌─────────────────────────────────────────────────────┐
+│ دسترسیهای منشی │
+├──────────────┬───────┬────────┬────────┬────────────┤
+│ بخش │ مشاهده│ ایجاد │ ویرایش │ حذف/لغو │
+├──────────────┼───────┼────────┼────────┼────────────┤
+│ نوبتها │ ☑ │ ☑ │ ☑ │ ☐ │
+│ آدرسها │ ☑ │ ☐ │ ☐ │ ☐ │
+│ اطلاعات کلینیک│ ☑ │ - │ ☐ │ - │
+│ بیمهها │ ☑ │ ☐ │ ☐ │ ☐ │
+└──────────────┴───────┴────────┴────────┴────────────┘
+```
+
+### 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 }
+ }
+ }
+}
+```
+
+## کدهای خطای جدید
+
+| کد | پیام فارسی |
+|----|-----------|
+| `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است |
+
+## Response خطا (403)
+```json
+{
+ "success": false,
+ "errors": [{
+ "code": "ERR_SECRETARY_LIMIT_REACHED",
+ "message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید."
+ }]
+}
+```
diff --git a/docs/phase2_taskes/task-12-secretary-completion/user_flow.md b/docs/phase2_taskes/task-12-secretary-completion/user_flow.md
new file mode 100644
index 00000000..b4b829c9
--- /dev/null
+++ b/docs/phase2_taskes/task-12-secretary-completion/user_flow.md
@@ -0,0 +1,70 @@
+# جریان کاربری — تسک ۱۲: تکمیل منشی
+
+## جریان افزودن منشی جدید با بررسی سقف پنل
+
+```
+دکتر روی «افزودن منشی» کلیک میکند → Modal باز میشود
+ │
+ ▼
+POST /api/v1/secretary { phone, ... }
+ │
+ ├─► 403 ERR_SECRETARY_LIMIT_REACHED:
+ │ ┌─────────────────────────────────────────┐
+ │ │ ⚠️ سقف منشی پنل شما تکمیل شده است │
+ │ │ پنل Free: حداکثر ۱ منشی │
+ │ │ │
+ │ │ [ارتقاء به Basic] [بستن] │
+ │ └─────────────────────────────────────────┘
+ │
+ └─► 201: منشی ایجاد شد → لیست refresh
+```
+
+## جریان ویرایش permissions منشی
+
+```
+دکتر روی آیکن تنظیمات کنار منشی کلیک میکند
+ │
+ ▼
+GET /api/v1/secretary/{uuid}
+ → بارگذاری permissions فعلی در Modal
+ │
+ ▼
+┌─────────────────────────────────────────────────────────┐
+│ دسترسیهای علی رضایی │
+├──────────────────┬──────────┬────────┬────────┬─────────┤
+│ بخش │ مشاهده │ ایجاد │ ویرایش │ لغو/حذف│
+├──────────────────┼──────────┼────────┼────────┼─────────┤
+│ نوبتها │ ✅ │ ✅ │ ✅ │ ☐ │
+│ آدرسها │ ✅ │ ☐ │ ☐ │ ☐ │
+│ اطلاعات کلینیک │ ✅ │ - │ ☐ │ - │
+│ بیمهها │ ✅ │ ☐ │ ☐ │ ☐ │
+└──────────────────┴──────────┴────────┴────────┴─────────┘
+│ │
+│ [لغو] [ذخیره دسترسیها] │
+└─────────────────────────────────────────────────────────┘
+ │
+ ▼
+PATCH /api/v1/secretary/{uuid}
+ { permissions: { version: 1, resources: { ... } } }
+ │
+ ▼
+200: «دسترسیهای منشی بروزرسانی شد» → Modal بسته میشود
+```
+
+## ترتیب سطوح سقف منشی
+
+```
+اشتراک → سقف → اگر افزودن فراتر رود
+─────────────────────────────────────────
+Free → 1 → 403
+Basic → 2 → 403
+Professional → 5 → 403
+```
+
+## نکته: منشی غیرفعال از سقف کم نمیشود
+
+```
+countActive() فقط منشیان active=true را میشمارد
+یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → میتوانی دوباره 1 منشی جدید اضافه کنی
+(مادامی که در پنل Basic هستی و سقف 2 داری)
+```
diff --git a/docs/phase2_taskes/task-13-clinic-services/architecture.md b/docs/phase2_taskes/task-13-clinic-services/architecture.md
new file mode 100644
index 00000000..19204fa9
--- /dev/null
+++ b/docs/phase2_taskes/task-13-clinic-services/architecture.md
@@ -0,0 +1,130 @@
+# معماری — تسک ۱۳: سرویسهای کلینیک
+
+## ساختار فایلها
+
+```
+src/ClinicService/
+├── Controller/
+│ └── ClinicServiceController.php
+├── Entity/
+│ ├── ServiceSection.php
+│ └── ServiceItem.php
+└── Repository/
+ ├── ServiceSectionRepository.php
+ └── ServiceItemRepository.php
+```
+
+## Entity: ServiceSection
+
+```php
+#[ORM\Entity(repositoryClass: ServiceSectionRepository::class)]
+#[ORM\Table(name: 'service_sections')]
+#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_sections_entity')]
+class ServiceSection
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\Column(type: 'string', length: 10)]
+ private string $entityType; // 'doctor' | 'clinic'
+
+ #[ORM\Column(type: 'integer')]
+ private int $entityId;
+
+ #[ORM\Column(type: 'string', length: 200)]
+ private string $name;
+
+ #[ORM\Column(type: 'boolean')]
+ private bool $active = true;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+
+ #[ORM\Column(type: 'integer')]
+ private int $updatedAt;
+}
+```
+
+## Entity: ServiceItem
+
+```php
+#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
+#[ORM\Table(name: 'service_items')]
+class ServiceItem
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\ManyToOne(targetEntity: ServiceSection::class)]
+ #[ORM\JoinColumn(name: 'section_id', nullable: false, onDelete: 'CASCADE')]
+ private ServiceSection $section;
+
+ // staff nullable — پرسنل انجامدهنده
+ #[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)]
+ #[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')]
+ private ?\App\Staff\Entity\ClinicStaff $staff = null;
+
+ #[ORM\Column(type: 'string', length: 200)]
+ private string $name;
+
+ #[ORM\Column(type: 'integer')]
+ private int $priceRials = 0;
+
+ #[ORM\Column(type: 'boolean')]
+ private bool $active = true;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+
+ #[ORM\Column(type: 'integer')]
+ private int $updatedAt;
+}
+```
+
+## Controller: ClinicServiceController
+
+```php
+#[Route('/api/v1')]
+class ClinicServiceController extends BaseController
+{
+ // برای همه write actions → gate check اول:
+ private function assertServicesFeature(): void
+ {
+ [$entityType, $entityId] = $this->resolveEntityContext();
+ if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'services')) {
+ throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
+ }
+ }
+
+ #[Route('/service-sections', methods: ['GET'])]
+ public function listSections(): JsonResponse { ... }
+
+ #[Route('/service-section', methods: ['POST'])]
+ public function createSection(Request $request): JsonResponse
+ {
+ $this->assertServicesFeature();
+ // ...
+ }
+
+ // سایر endpoint ها ...
+}
+```
+
+## نکته DELETE سرویس با استفاده در پرونده
+
+```php
+// اگر session_services.service_item_id → این item باشد:
+// FK ON DELETE RESTRICT → Doctrine exception → catch و return 409
+try {
+ $this->em->remove($item);
+ $this->em->flush();
+} catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException $e) {
+ return $this->error(ErrorCodes::ERR_SERVICE_ITEM_IN_USE, 'این سرویس در پرونده بیمار استفاده شده است', 409);
+}
+```
diff --git a/docs/phase2_taskes/task-13-clinic-services/database.md b/docs/phase2_taskes/task-13-clinic-services/database.md
new file mode 100644
index 00000000..171e302b
--- /dev/null
+++ b/docs/phase2_taskes/task-13-clinic-services/database.md
@@ -0,0 +1,69 @@
+# پایگاه داده — تسک ۱۳: سرویسهای کلینیک
+
+## جدول: service_sections
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| entity_type | VARCHAR(10) NOT NULL | `'doctor'` \| `'clinic'` |
+| entity_id | INT NOT NULL | |
+| name | VARCHAR(200) NOT NULL | نام بخش |
+| active | TINYINT(1) NOT NULL DEFAULT 1 | |
+| created_at | INT NOT NULL | Unix timestamp |
+| updated_at | INT NOT NULL | Unix timestamp |
+
+ایندکس:
+```sql
+INDEX idx_service_sections_entity ON service_sections(entity_type, entity_id)
+```
+
+## جدول: service_items
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| section_id | INT NOT NULL FK→service_sections.id ON DELETE CASCADE | |
+| staff_id | INT NULL FK→clinic_staff.id ON DELETE SET NULL | پرسنل انجامدهنده (اختیاری) |
+| name | VARCHAR(200) NOT NULL | نام سرویس |
+| price_rials | INT NOT NULL DEFAULT 0 | |
+| active | TINYINT(1) NOT NULL DEFAULT 1 | |
+| created_at | INT NOT NULL | |
+| updated_at | INT NOT NULL | |
+
+## Migration نمونه
+
+```sql
+CREATE TABLE service_sections (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ uuid CHAR(36) NOT NULL UNIQUE,
+ entity_type VARCHAR(10) NOT NULL,
+ entity_id INT NOT NULL,
+ name VARCHAR(200) NOT NULL,
+ active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at INT NOT NULL,
+ updated_at INT NOT NULL,
+ INDEX idx_service_sections_entity (entity_type, entity_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE service_items (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ uuid CHAR(36) NOT NULL UNIQUE,
+ section_id INT NOT NULL,
+ staff_id INT NULL,
+ name VARCHAR(200) NOT NULL,
+ price_rials INT NOT NULL DEFAULT 0,
+ active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at INT NOT NULL,
+ updated_at INT NOT NULL,
+ FOREIGN KEY (section_id) REFERENCES service_sections(id) ON DELETE CASCADE,
+ FOREIGN KEY (staff_id) REFERENCES clinic_staff(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+## نکات مهم
+
+- `section_id ON DELETE CASCADE` — حذف بخش، همه زیربخشها را هم حذف میکند
+- `staff_id ON DELETE SET NULL` — غیرفعالسازی پرسنل، سرویس را حذف نمیکند فقط staff_id=NULL میشود
+- `session_services.service_item_id` در تسک ۱۵ با `ON DELETE RESTRICT` → اگر item در سشنی باشد، حذف block میشود
diff --git a/docs/phase2_taskes/task-13-clinic-services/task.md b/docs/phase2_taskes/task-13-clinic-services/task.md
new file mode 100644
index 00000000..180a2e21
--- /dev/null
+++ b/docs/phase2_taskes/task-13-clinic-services/task.md
@@ -0,0 +1,102 @@
+# تسک ۱۳: سرویسهای کلینیک (Clinic Services)
+
+## توضیح
+پیادهسازی مدیریت سرویسهای کلینیک در دو سطح: بخش (ServiceSection) و زیربخش (ServiceItem).
+دسترسی فقط در پنل **Basic+** — gate: `hasFeature('services')`.
+در تسک ۱۵ (پرونده بیمار)، ServiceItems در سشنهای بیمار استفاده میشوند.
+
+## Endpoint ها
+
+| متد | مسیر | Gate | توضیح |
+|-----|------|------|-------|
+| GET | `/api/v1/service-sections` | Basic+ | لیست بخشها |
+| POST | `/api/v1/service-section` | Basic+ | ایجاد بخش |
+| PATCH | `/api/v1/service-section/{uuid}` | Basic+ | ویرایش بخش |
+| DELETE | `/api/v1/service-section/{uuid}` | Basic+ | حذف بخش |
+| GET | `/api/v1/service-items/{sectionUuid}` | Basic+ | لیست زیربخشهای یک بخش |
+| POST | `/api/v1/service-item` | Basic+ | ایجاد زیربخش |
+| PATCH | `/api/v1/service-item/{uuid}` | Basic+ | ویرایش زیربخش |
+| DELETE | `/api/v1/service-item/{uuid}` | Basic+ | حذف زیربخش |
+
+**نکته DELETE:** حذف سخت مجاز است — اما اگر ServiceItem در session_services استفاده شده باشد، باید با خطا متوقف شود (FK ON DELETE RESTRICT).
+
+## پیشنیازها
+- **تسک ۱۰** (ClinicStaff — برای staff_id در ServiceItem)
+- **تسک ۱۱** (Subscription — hasFeature gate)
+
+## زمان تخمینی
+۸ تا ۱۰ ساعت
+
+## نمونه Request
+
+### POST /api/v1/service-section
+```json
+{
+ "name": "تزریقات"
+}
+```
+
+### POST /api/v1/service-item
+```json
+{
+ "section_uuid": "uuid-of-section",
+ "name": "سرم ۵۰۰cc",
+ "price_rials": 85000,
+ "staff_uuid": "uuid-of-staff-optional"
+}
+```
+
+### PATCH /api/v1/service-item/{uuid}
+```json
+{
+ "price_rials": 95000,
+ "staff_uuid": null
+}
+```
+
+## نمونه Response
+
+### GET /api/v1/service-sections
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "uuid": "...",
+ "name": "تزریقات",
+ "active": true,
+ "items_count": 3
+ },
+ {
+ "uuid": "...",
+ "name": "فیزیوتراپی",
+ "active": true,
+ "items_count": 7
+ }
+ ]
+}
+```
+
+### GET /api/v1/service-items/{sectionUuid}
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "uuid": "...",
+ "name": "سرم ۵۰۰cc",
+ "price_rials": 85000,
+ "staff": { "uuid": "...", "full_name": "علی رضایی" },
+ "active": true
+ }
+ ]
+}
+```
+
+## کدهای خطا
+
+| کد | HTTP | توضیح |
+|----|------|-------|
+| `ERR_SUBSCRIPTION_REQUIRED` | 403 | پنل Basic یا بالاتر لازم است |
+| `ERR_SERVICE_SECTION_HAS_ITEMS` | 409 | بخش دارای زیربخش است — ابتدا آنها را حذف کنید |
+| `ERR_SERVICE_ITEM_IN_USE` | 409 | سرویس در پرونده بیمار استفاده شده — قابل حذف نیست |
diff --git a/docs/phase2_taskes/task-13-clinic-services/user_flow.md b/docs/phase2_taskes/task-13-clinic-services/user_flow.md
new file mode 100644
index 00000000..f75f7a9b
--- /dev/null
+++ b/docs/phase2_taskes/task-13-clinic-services/user_flow.md
@@ -0,0 +1,99 @@
+# جریان کاربری — تسک ۱۳: سرویسهای کلینیک
+
+## جریان مشاهده و ایجاد سرویسها
+
+```
+کاربر وارد صفحه «سرویسها» میشود
+ │
+ ▼
+GET /api/v1/service-sections
+ → accordion دو سطحی نمایش داده میشود
+ │
+بر روی هر بخش کلیک میکند:
+ ▼
+GET /api/v1/service-items/{sectionUuid}
+ → زیربخشها نمایش داده میشوند
+```
+
+## ساختار نمایش (ServicesPage.tsx)
+
+```
+┌─────────────────────────────────────────────┐
+│ سرویسهای کلینیک [+ بخش جدید] │
+├─────────────────────────────────────────────┤
+│ ▼ تزریقات ✏️ 🗑️ │
+│ ├─ سرم ۵۰۰cc ۸۵,۰۰۰ ریال ✏️ 🗑️ │
+│ ├─ آمپول B12 ۴۵,۰۰۰ ریال ✏️ 🗑️ │
+│ └─ [+ سرویس جدید] │
+├─────────────────────────────────────────────┤
+│ ► فیزیوتراپی ✏️ 🗑️ │
+└─────────────────────────────────────────────┘
+```
+
+## جریان ایجاد بخش جدید
+
+```
+کاربر «+ بخش جدید» کلیک میکند → Modal باز میشود
+ │
+ ▼
+POST /api/v1/service-section { name: 'تزریقات' }
+ │
+ ├─► 403 ERR_SUBSCRIPTION_REQUIRED → «پنل Basic لازم است»
+ └─► 201 → accordion بهروز میشود
+```
+
+## جریان حذف بخش
+
+```
+کاربر روی 🗑️ بخش کلیک میکند
+ │
+ ▼
+ConfirmDialog: «آیا از حذف بخش "تزریقات" مطمئنید؟
+ همه زیربخشهای آن هم حذف میشوند.»
+ │
+ ▼
+DELETE /api/v1/service-section/{uuid}
+ │
+ ├─► اگر هیچ زیربخشی ندارد → 200 حذف شد
+ └─► اگر زیربخش دارد → 409 ERR_SERVICE_SECTION_HAS_ITEMS
+ (یا اگر DELETE CASCADE تعریف شده: همه حذف میشوند)
+```
+
+## جریان حذف زیربخش
+
+```
+DELETE /api/v1/service-item/{uuid}
+ │
+ ├─► اگر در هیچ session_services نیست → 200
+ └─► اگر در session_services استفاده شده → 409 ERR_SERVICE_ITEM_IN_USE
+ پیام: «این سرویس در پرونده بیمار ثبت شده و قابل حذف نیست.»
+```
+
+## جریان انتخاب پرسنل انجامدهنده
+
+```
+هنگام ایجاد/ویرایش ServiceItem:
+ │
+ ▼
+GET /api/v1/staff → SearchableSelect از پرسنل فعال
+ → «علی رضایی (پرستار)»
+ → «مریم نوری (فیزیوتراپیست)»
+ → [بدون پرسنل مشخص]
+```
+
+## gate check — کاربر Free
+
+```
+کاربر Free روی «سرویسها» در sidebar کلیک میکند
+ │
+ ▼
+ServicesPage.tsx → GET /api/v1/service-sections → 403
+ │
+ ▼
+نمایش banner:
+┌─────────────────────────────────────────────┐
+│ 🔒 این قابلیت نیاز به پنل Basic یا بالاتر │
+│ دارد. │
+│ [مشاهده پنلها] │
+└─────────────────────────────────────────────┘
+```
diff --git a/docs/phase2_taskes/task-14-sms-panel/architecture.md b/docs/phase2_taskes/task-14-sms-panel/architecture.md
new file mode 100644
index 00000000..ab6ac695
--- /dev/null
+++ b/docs/phase2_taskes/task-14-sms-panel/architecture.md
@@ -0,0 +1,171 @@
+# معماری — تسک ۱۴: پنل پیامکی
+
+## ساختار فایلها
+
+```
+src/Sms/
+├── Controller/
+│ ├── SmsController.php ← موجود (تغییر نمیکند)
+│ └── SmsWalletController.php ← جدید
+├── Entity/
+│ ├── SmsLog.php ← موجود
+│ ├── SmsTemplate.php ← موجود
+│ ├── SmsWallet.php ← جدید
+│ └── SmsWalletTransaction.php ← جدید
+│ └── SmsSettings.php ← جدید
+├── Repository/
+│ └── SmsWalletRepository.php ← جدید
+└── Service/
+ ├── SmsService.php ← موجود — باید کسر wallet اضافه شود
+ └── SmsWalletService.php ← جدید
+```
+
+**فایلهایی که تغییر میکنند:**
+- `src/Payment/Entity/Payment.php` — اضافه کردن `const TYPE_SMS_WALLET = 'sms_wallet'`
+- `src/Payment/Controller/PaymentController.php` — callback برای `sms_wallet` type، شارژ wallet
+- `src/Sms/Service/SmsService.php` — قبل از ارسال، balance بررسی و کسر شود
+
+## Entity: SmsWallet
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'sms_wallets')]
+#[ORM\UniqueConstraint(name: 'uniq_sms_wallet_entity', columns: ['entity_type', 'entity_id'])]
+class SmsWallet
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 10)]
+ private string $entityType;
+
+ #[ORM\Column(type: 'integer')]
+ private int $entityId;
+
+ #[ORM\Column(type: 'integer')]
+ private int $balanceRials = 0;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+
+ #[ORM\Column(type: 'integer')]
+ private int $updatedAt;
+}
+```
+
+## Entity: SmsWalletTransaction
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'sms_wallet_transactions')]
+#[ORM\Index(columns: ['sms_wallet_id', 'created_at'], name: 'idx_sms_wallet_tx')]
+class SmsWalletTransaction
+{
+ public const TYPE_CREDIT = 'credit';
+ public const TYPE_DEBIT = 'debit';
+
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\ManyToOne(targetEntity: SmsWallet::class)]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
+ private SmsWallet $wallet;
+
+ #[ORM\Column(type: 'string', length: 10)]
+ private string $type; // 'credit' | 'debit'
+
+ #[ORM\Column(type: 'integer')]
+ private int $amountRials;
+
+ #[ORM\Column(type: 'string', length: 255, nullable: true)]
+ private ?string $description = null;
+
+ #[ORM\ManyToOne(targetEntity: \App\Payment\Entity\Payment::class)]
+ #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
+ private ?\App\Payment\Entity\Payment $payment = null;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+}
+```
+
+## Entity: SmsSettings
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'sms_settings')]
+#[ORM\UniqueConstraint(name: 'uniq_sms_settings_entity', columns: ['entity_type', 'entity_id'])]
+class SmsSettings
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 10)]
+ private string $entityType;
+
+ #[ORM\Column(type: 'integer')]
+ private int $entityId;
+
+ #[ORM\Column(type: 'boolean')]
+ private bool $reminderEnabled = false;
+
+ #[ORM\Column(type: 'smallint')]
+ private int $reminderHoursBefore = 2;
+
+ #[ORM\Column(type: 'boolean')]
+ private bool $postVisitEnabled = false;
+
+ #[ORM\Column(type: 'text', nullable: true)]
+ private ?string $postVisitText = null;
+
+ #[ORM\Column(type: 'integer')]
+ private int $updatedAt;
+}
+```
+
+## SmsWalletService
+
+```php
+class SmsWalletService
+{
+ public function getOrCreate(string $entityType, int $entityId): SmsWallet
+ {
+ // findOneBy([entityType, entityId]) یا ایجاد جدید با balance=0
+ }
+
+ public function charge(SmsWallet $wallet, int $amountRials, Payment $payment): void
+ {
+ $wallet->setBalanceRials($wallet->getBalanceRials() + $amountRials);
+ // ثبت SmsWalletTransaction با type=credit
+ }
+
+ public function deduct(SmsWallet $wallet, int $amountRials, string $description): bool
+ {
+ if ($wallet->getBalanceRials() < $amountRials) return false;
+ $wallet->setBalanceRials($wallet->getBalanceRials() - $amountRials);
+ // ثبت SmsWalletTransaction با type=debit
+ return true;
+ }
+}
+```
+
+## تغییر SmsService::send()
+
+```php
+public function send(string $phone, string $message, string $entityType, int $entityId): bool
+{
+ $priceRials = (int) $this->siteConfigRepo->getValue('sms_price_rials', '0');
+ $wallet = $this->walletService->getOrCreate($entityType, $entityId);
+
+ if (!$this->walletService->deduct($wallet, $priceRials, 'ارسال پیامک')) {
+ // لاگ: ارسال نشد — موجودی ناکافی
+ return false;
+ }
+
+ // ارسال از طریق Provider موجود ...
+ return true;
+}
+```
diff --git a/docs/phase2_taskes/task-14-sms-panel/database.md b/docs/phase2_taskes/task-14-sms-panel/database.md
new file mode 100644
index 00000000..8e64a83a
--- /dev/null
+++ b/docs/phase2_taskes/task-14-sms-panel/database.md
@@ -0,0 +1,97 @@
+# پایگاه داده — تسک ۱۴: پنل پیامکی
+
+## جدول: sms_wallets
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| entity_type | VARCHAR(10) NOT NULL | `'doctor'` \| `'clinic'` |
+| entity_id | INT NOT NULL | |
+| balance_rials | INT NOT NULL DEFAULT 0 | موجودی فعلی |
+| created_at | INT NOT NULL | |
+| updated_at | INT NOT NULL | |
+| UNIQUE | (entity_type, entity_id) | یک wallet به ازای هر entity |
+
+## جدول: sms_wallet_transactions
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| sms_wallet_id | INT NOT NULL FK→sms_wallets.id ON DELETE CASCADE | |
+| type | VARCHAR(10) NOT NULL | `'credit'` \| `'debit'` |
+| amount_rials | INT NOT NULL | مبلغ (همیشه مثبت) |
+| description | VARCHAR(255) NULL | توضیح |
+| payment_id | INT NULL FK→payments.id ON DELETE SET NULL | برای شارژ |
+| created_at | INT NOT NULL | |
+
+ایندکس:
+```sql
+INDEX idx_sms_wallet_tx ON sms_wallet_transactions(sms_wallet_id, created_at)
+```
+
+## جدول: sms_settings
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| entity_type | VARCHAR(10) NOT NULL | |
+| entity_id | INT NOT NULL | |
+| reminder_enabled | TINYINT(1) NOT NULL DEFAULT 0 | |
+| reminder_hours_before | TINYINT NOT NULL DEFAULT 2 | چند ساعت قبل از نوبت |
+| post_visit_enabled | TINYINT(1) NOT NULL DEFAULT 0 | |
+| post_visit_text | TEXT NULL | متن پیامک بعد از ویزیت |
+| updated_at | INT NOT NULL | |
+| UNIQUE | (entity_type, entity_id) | |
+
+## SiteConfig key جدید
+
+| کلید | نوع | مقدار پیشفرض | توضیح |
+|------|-----|--------------|-------|
+| `sms_price_rials` | string | `'250'` | قیمت هر پیامک — ادمین تنظیم میکند |
+
+## Migration نمونه
+
+```sql
+CREATE TABLE sms_wallets (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ entity_type VARCHAR(10) NOT NULL,
+ entity_id INT NOT NULL,
+ balance_rials INT NOT NULL DEFAULT 0,
+ created_at INT NOT NULL,
+ updated_at INT NOT NULL,
+ UNIQUE KEY uniq_sms_wallet_entity (entity_type, entity_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE sms_wallet_transactions (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ uuid CHAR(36) NOT NULL UNIQUE,
+ sms_wallet_id INT NOT NULL,
+ type VARCHAR(10) NOT NULL,
+ amount_rials INT NOT NULL,
+ description VARCHAR(255) NULL,
+ payment_id INT NULL,
+ created_at INT NOT NULL,
+ FOREIGN KEY (sms_wallet_id) REFERENCES sms_wallets(id) ON DELETE CASCADE,
+ FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL,
+ INDEX idx_sms_wallet_tx (sms_wallet_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE sms_settings (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ entity_type VARCHAR(10) NOT NULL,
+ entity_id INT NOT NULL,
+ reminder_enabled TINYINT(1) NOT NULL DEFAULT 0,
+ reminder_hours_before TINYINT NOT NULL DEFAULT 2,
+ post_visit_enabled TINYINT(1) NOT NULL DEFAULT 0,
+ post_visit_text TEXT NULL,
+ updated_at INT NOT NULL,
+ UNIQUE KEY uniq_sms_settings_entity (entity_type, entity_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+## نکات مهم
+
+- `balance_rials` هرگز منفی نمیشود — در `SmsWalletService::deduct()` چک میشود
+- `sms_wallet_transactions` لاگ کامل تمام تراکنشها است — حذف نمیشود
+- `sms_settings` با UPSERT ذخیره میشود (اگر وجود نداشت INSERT، وگرنه UPDATE)
diff --git a/docs/phase2_taskes/task-14-sms-panel/task.md b/docs/phase2_taskes/task-14-sms-panel/task.md
new file mode 100644
index 00000000..7cbb9464
--- /dev/null
+++ b/docs/phase2_taskes/task-14-sms-panel/task.md
@@ -0,0 +1,127 @@
+# تسک ۱۴: پنل پیامکی — کیف پول + تنظیمات (SMS Panel)
+
+## توضیح
+زیرساخت SMS موجود است (`src/Sms/` — SmsLog, SmsTemplate, SmsProvider).
+این تسک **کیف پول پیامک** اختصاصی و **تنظیمات** ارسال خودکار را اضافه میکند.
+کیف پول پیامک مستقل از کیف پول مالی (`src/Settlement/`) است.
+
+## Endpoint ها (همه جدید)
+
+| متد | مسیر | Permission | توضیح |
+|-----|------|-----------|-------|
+| GET | `/api/v1/sms/wallet/balance` | doctor/clinic | موجودی کیف پول پیامک |
+| POST | `/api/v1/sms/wallet/charge` | doctor/clinic | شارژ کیف پول |
+| GET | `/api/v1/sms/wallet/logs` | doctor/clinic | تاریخچه کسر/شارژ |
+| GET | `/api/v1/sms/settings` | doctor/clinic | دریافت تنظیمات |
+| PATCH | `/api/v1/sms/settings` | doctor/clinic | ذخیره تنظیمات |
+| GET | `/api/v1/admin/sms/wallet-report` | ROLE_ADMIN | گزارش مصرف و درآمد |
+
+## پیشنیازها
+- تسک ۱۷ (SMS infrastructure — موجود)
+- تسک ۱۵-payment (Payment gateway — موجود)
+
+## زمان تخمینی
+۱۰ تا ۱۲ ساعت
+
+## نمونه Request
+
+### POST /api/v1/sms/wallet/charge
+```json
+{
+ "gateway": "mellat",
+ "amount_rials": 500000
+}
+```
+→ redirect به gateway (مثل payment نوبت، اما `Payment.type = 'sms_wallet'`)
+
+### PATCH /api/v1/sms/settings
+```json
+{
+ "reminder_enabled": true,
+ "reminder_hours_before": 3,
+ "post_visit_enabled": false,
+ "post_visit_text": null
+}
+```
+
+## نمونه Response
+
+### GET /api/v1/sms/wallet/balance
+```json
+{
+ "success": true,
+ "data": {
+ "balance_rials": 150000,
+ "sms_price_rials": 250,
+ "estimated_sms_count": 600
+ }
+}
+```
+
+### GET /api/v1/sms/wallet/logs
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "uuid": "...",
+ "type": "credit",
+ "amount_rials": 500000,
+ "description": "شارژ کیف پول پیامک",
+ "created_at": 1718000000
+ },
+ {
+ "uuid": "...",
+ "type": "debit",
+ "amount_rials": 250,
+ "description": "ارسال پیامک یادآوری — نوبت ۱۴۰۵/۰۳/۱۵",
+ "created_at": 1718001000
+ }
+ ],
+ "meta": { "totalRecords": 45, "totalPages": 5, "currentPage": 1 }
+}
+```
+
+### GET /api/v1/sms/settings
+```json
+{
+ "success": true,
+ "data": {
+ "reminder_enabled": true,
+ "reminder_hours_before": 3,
+ "post_visit_enabled": false,
+ "post_visit_text": null
+ }
+}
+```
+
+### GET /api/v1/admin/sms/wallet-report
+```json
+{
+ "success": true,
+ "data": {
+ "total_charged_rials": 12500000,
+ "total_deducted_rials": 8750000,
+ "total_sms_sent": 35000,
+ "revenue_rials": 8750000,
+ "by_entity": [
+ { "entity_type": "clinic", "entity_id": 5, "name": "کلینیک سلامت", "spent_rials": 1500000 }
+ ]
+ }
+}
+```
+
+## SiteConfig key های مرتبط
+
+| کلید | توضیح |
+|------|-------|
+| `sms_price_rials` | قیمت هر پیامک — ادمین تنظیم میکند (مثلاً ۲۵۰ ریال) |
+
+## کسر خودکار هنگام ارسال پیامک
+
+```
+هر بار که SmsService::send() صدا زده میشود:
+ 1. SmsWallet پیدا شود
+ 2. اگر موجودی کافی نبود → پیامک ارسال نشود + لاگ خطا
+ 3. اگر کافی بود → ارسال + کسر balance_rials + ثبت sms_wallet_transactions
+```
diff --git a/docs/phase2_taskes/task-14-sms-panel/user_flow.md b/docs/phase2_taskes/task-14-sms-panel/user_flow.md
new file mode 100644
index 00000000..aa3c8a83
--- /dev/null
+++ b/docs/phase2_taskes/task-14-sms-panel/user_flow.md
@@ -0,0 +1,102 @@
+# جریان کاربری — تسک ۱۴: پنل پیامکی
+
+## جریان شارژ کیف پول پیامک
+
+```
+کاربر وارد صفحه تنظیمات → تب «پیامک» میشود
+ │
+ ▼
+GET /api/v1/sms/wallet/balance
+ → موجودی: ۱۵۰,۰۰۰ ریال (≈ ۶۰۰ پیامک)
+ │
+ ▼
+کاربر روی «شارژ کیف پول» کلیک میکند
+فرم: مبلغ شارژ + انتخاب gateway
+ │
+ ▼
+POST /api/v1/sms/wallet/charge { gateway: 'mellat', amount_rials: 500000 }
+ │
+ ▼
+PaymentController → ایجاد Payment با type='sms_wallet'
+ │
+ ▼
+redirect به gateway بانک
+ │
+ ▼
+callback → SmsWalletService::charge()
+ → balance_rials += 500000
+ → ثبت credit transaction
+ │
+ ▼
+redirect به /admin/sms-settings?charged=1
+```
+
+## جریان تنظیمات یادآوری خودکار
+
+```
+GET /api/v1/sms/settings → فرم پر میشود
+ │
+ ▼
+┌─────────────────────────────────────────────┐
+│ تنظیمات پیامک │
+├─────────────────────────────────────────────┤
+│ یادآوری نوبت │
+│ [✅] فعال │
+│ چند ساعت قبل: [3 ساعت ▼] │
+├─────────────────────────────────────────────┤
+│ پیامک بعد از ویزیت │
+│ [☐] فعال │
+│ متن پیامک: [______________________________]│
+└─────────────────────────────────────────────┘
+│ [ذخیره تنظیمات] │
+ │
+ ▼
+PATCH /api/v1/sms/settings
+ { reminder_enabled: true, reminder_hours_before: 3,
+ post_visit_enabled: false, post_visit_text: null }
+```
+
+## جریان کسر خودکار هنگام ارسال یادآوری
+
+```
+Scheduler/Cronjob اجرا میشود (هر ساعت)
+ │
+ ▼
+نوبتهایی که در X ساعت آینده هستند پیدا میشوند
+ │
+ ▼
+برای هر نوبت:
+ entity_type/entity_id پیدا میشود
+ │
+ ▼
+SmsWallet پیدا میشود
+ │
+ ├─► balance < sms_price_rials:
+ │ لاگ خطا: «موجودی ناکافی» — پیامک ارسال نشد
+ │
+ └─► balance کافی:
+ SmsService::send(phone, message, entityType, entityId)
+ → ارسال پیامک
+ → deduct balance
+ → ثبت debit transaction با description='یادآوری نوبت'
+```
+
+## نمایش در Frontend
+
+```
+┌─────────────────────────────────────────────────┐
+│ تنظیمات پیامک │
+├──────────────────────┬──────────────────────────┤
+│ کیف پول پیامک │ تنظیمات ارسال │
+│ │ │
+│ موجودی: │ یادآوری: ✅ ۳ ساعت قبل │
+│ ۱۵۰,۰۰۰ ریال │ │
+│ ≈ ۶۰۰ پیامک │ پس از ویزیت: ☐ غیرفعال │
+│ │ │
+│ [شارژ کیف پول] │ [ذخیره تنظیمات] │
+├──────────────────────┴──────────────────────────┤
+│ تاریخچه تراکنشها │
+│ ✅ +۵۰۰,۰۰۰ ریال — شارژ — ۱۴۰۵/۰۳/۱۰ │
+│ ⬇️ -۲۵۰ ریال — یادآوری نوبت — ۱۴۰۵/۰۳/۱۱ │
+└─────────────────────────────────────────────────┘
+```
diff --git a/docs/phase2_taskes/task-15-patient-records/architecture.md b/docs/phase2_taskes/task-15-patient-records/architecture.md
new file mode 100644
index 00000000..4cee4005
--- /dev/null
+++ b/docs/phase2_taskes/task-15-patient-records/architecture.md
@@ -0,0 +1,204 @@
+# معماری — تسک ۱۵: پرونده بیمار
+
+## ساختار فایلها
+
+```
+src/Patient/
+├── Controller/
+│ └── PatientController.php
+├── Entity/
+│ ├── PatientRecord.php
+│ ├── PatientSession.php
+│ └── SessionService.php ← entity (سرویسهای انجامشده در سشن)
+├── Repository/
+│ ├── PatientRecordRepository.php
+│ └── PatientSessionRepository.php
+└── Service/
+ └── PatientService.php
+```
+
+**فایلهایی که تغییر میکنند:**
+- `src/Appointment/Controller/AppointmentController.php` — متد `updateStatus()` باید `PatientService::autoCreateOnAppointmentConfirm()` صدا بزند
+
+## Entity: PatientRecord
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'patient_records')]
+#[ORM\UniqueConstraint(name: 'uniq_patient_record', columns: ['entity_type', 'entity_id', 'user_id'])]
+#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_records_entity')]
+class PatientRecord
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\Column(type: 'string', length: 10)]
+ private string $entityType; // 'doctor' | 'clinic'
+
+ #[ORM\Column(type: 'integer')]
+ private int $entityId;
+
+ #[ORM\ManyToOne(targetEntity: \App\Auth\Entity\User::class)]
+ #[ORM\JoinColumn(name: 'user_id', nullable: false, onDelete: 'RESTRICT')]
+ private \App\Auth\Entity\User $user;
+
+ // چه کسی پرونده را باز کرد
+ #[ORM\Column(type: 'string', length: 15)]
+ private string $createdByType; // 'doctor' | 'secretary' | 'system'
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdById;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+}
+```
+
+## Entity: PatientSession
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'patient_sessions')]
+#[ORM\Index(columns: ['record_id', 'created_at'], name: 'idx_patient_sessions_record')]
+class PatientSession
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\ManyToOne(targetEntity: PatientRecord::class)]
+ #[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
+ private PatientRecord $record;
+
+ #[ORM\ManyToOne(targetEntity: \App\Appointment\Entity\Appointment::class)]
+ #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
+ private ?\App\Appointment\Entity\Appointment $appointment = null;
+
+ // بیمه پایه — FK به categories bundle='insurance_type'
+ #[ORM\Column(type: 'integer', nullable: true)]
+ private ?int $insuranceBaseId = null;
+
+ #[ORM\Column(type: 'integer', nullable: true)]
+ private ?int $insuranceSupplementaryId = null;
+
+ #[ORM\Column(type: 'integer')]
+ private int $visitPriceRials = 0;
+
+ #[ORM\Column(type: 'decimal', precision: 5, scale: 2)]
+ private float $baseInsuranceDiscountPercent = 0;
+
+ #[ORM\Column(type: 'decimal', precision: 5, scale: 2)]
+ private float $supplementaryDiscountPercent = 0;
+
+ #[ORM\Column(type: 'integer')]
+ private int $servicesTotalRials = 0;
+
+ #[ORM\Column(type: 'integer')]
+ private int $finalPriceRials = 0;
+
+ #[ORM\Column(type: 'string', length: 15)]
+ private string $paymentMethod = 'pending'; // 'cash'|'card'|'insurance'|'pending'
+
+ #[ORM\Column(type: 'text', nullable: true)]
+ private ?string $notes = null;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+
+ #[ORM\Column(type: 'integer')]
+ private int $updatedAt;
+}
+```
+
+## Entity: SessionService (سرویسهای انجامشده در سشن)
+
+```php
+#[ORM\Entity]
+#[ORM\Table(name: 'session_services')]
+class SessionService
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'string', length: 36, unique: true)]
+ private string $uuid;
+
+ #[ORM\ManyToOne(targetEntity: PatientSession::class)]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
+ private PatientSession $session;
+
+ #[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
+ #[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
+ private \App\ClinicService\Entity\ServiceItem $serviceItem;
+
+ #[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)]
+ #[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')]
+ private ?\App\Staff\Entity\ClinicStaff $staff = null;
+
+ // کپی قیمت در زمان ثبت — تغییر قیمت سرویس بعداً اثر ندارد
+ #[ORM\Column(type: 'integer')]
+ private int $priceRials;
+
+ #[ORM\Column(type: 'integer')]
+ private int $createdAt;
+}
+```
+
+## PatientService
+
+```php
+class PatientService
+{
+ public function calculateFinalPrice(
+ int $visitPrice,
+ float $baseDiscount,
+ float $suppDiscount,
+ array $serviceItems // [{price_rials}]
+ ): array {
+ $afterBase = $visitPrice * (1 - $baseDiscount / 100);
+ $afterSupp = $afterBase * (1 - $suppDiscount / 100);
+ $servicesTotal = array_sum(array_column($serviceItems, 'price_rials'));
+ $finalPrice = (int) round($afterSupp) + $servicesTotal;
+
+ return [
+ 'services_total_rials' => $servicesTotal,
+ 'final_price_rials' => $finalPrice,
+ ];
+ }
+
+ public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
+ {
+ [$entityType, $entityId] = $this->resolveEntityFromAppointment($appointment);
+
+ if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
+ return; // پنل Free — ایجاد نشود
+ }
+
+ $userId = $appointment->getUser()->getId();
+ $record = $this->recordRepo->findOneBy([
+ 'entityType' => $entityType,
+ 'entityId' => $entityId,
+ 'user' => $userId,
+ ]) ?? $this->createRecord($entityType, $entityId, $userId, 'system');
+
+ // سشن با appointment_id، visit_price=0 (تکمیل بعداً)
+ $this->createSession($record, $appointment);
+ }
+}
+```
+
+## تغییر در AppointmentController
+
+```php
+// src/Appointment/Controller/AppointmentController.php
+// متد updateStatus() — بعد از ذخیره status جدید:
+
+if ($newStatus === 'confirmed') {
+ $this->patientService->autoCreateOnAppointmentConfirm($appointment);
+}
+```
diff --git a/docs/phase2_taskes/task-15-patient-records/database.md b/docs/phase2_taskes/task-15-patient-records/database.md
new file mode 100644
index 00000000..5e731801
--- /dev/null
+++ b/docs/phase2_taskes/task-15-patient-records/database.md
@@ -0,0 +1,116 @@
+# پایگاه داده — تسک ۱۵: پرونده بیمار
+
+## جدول: patient_records
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| entity_type | VARCHAR(10) NOT NULL | `'doctor'` \| `'clinic'` |
+| entity_id | INT NOT NULL | |
+| user_id | INT NOT NULL FK→users.id ON DELETE RESTRICT | بیمار |
+| created_by_type | VARCHAR(15) NOT NULL | `'doctor'` \| `'secretary'` \| `'system'` |
+| created_by_id | INT NOT NULL | id ایجادکننده |
+| created_at | INT NOT NULL | |
+
+```sql
+UNIQUE KEY uniq_patient_record (entity_type, entity_id, user_id)
+INDEX idx_patient_records_entity (entity_type, entity_id)
+```
+
+## جدول: patient_sessions
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| record_id | INT NOT NULL FK→patient_records.id ON DELETE CASCADE | |
+| appointment_id | INT NULL FK→appointments.id ON DELETE SET NULL | |
+| insurance_base_id | INT NULL FK→categories.id ON DELETE SET NULL | بیمه پایه |
+| insurance_supplementary_id | INT NULL FK→categories.id ON DELETE SET NULL | بیمه مکمل |
+| visit_price_rials | INT NOT NULL DEFAULT 0 | |
+| base_insurance_discount_percent | DECIMAL(5,2) NOT NULL DEFAULT 0 | |
+| supplementary_discount_percent | DECIMAL(5,2) NOT NULL DEFAULT 0 | |
+| services_total_rials | INT NOT NULL DEFAULT 0 | کپی محاسبهشده |
+| final_price_rials | INT NOT NULL DEFAULT 0 | |
+| payment_method | VARCHAR(15) NOT NULL DEFAULT 'pending' | `'cash'`\|`'card'`\|`'insurance'`\|`'pending'` |
+| notes | TEXT NULL | |
+| created_at | INT NOT NULL | |
+| updated_at | INT NOT NULL | |
+
+```sql
+INDEX idx_patient_sessions_record (record_id, created_at)
+```
+
+## جدول: session_services
+
+| ستون | نوع | توضیح |
+|------|-----|-------|
+| id | INT UNSIGNED AUTO_INCREMENT PK | |
+| uuid | CHAR(36) UNIQUE NOT NULL | |
+| session_id | INT NOT NULL FK→patient_sessions.id ON DELETE CASCADE | |
+| service_item_id | INT NOT NULL FK→service_items.id ON DELETE RESTRICT | |
+| staff_id | INT NULL FK→clinic_staff.id ON DELETE SET NULL | |
+| price_rials | INT NOT NULL | **کپی** قیمت در زمان ثبت |
+| created_at | INT NOT NULL | |
+
+## Migration نمونه
+
+```sql
+CREATE TABLE patient_records (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ uuid CHAR(36) NOT NULL UNIQUE,
+ entity_type VARCHAR(10) NOT NULL,
+ entity_id INT NOT NULL,
+ user_id INT NOT NULL,
+ created_by_type VARCHAR(15) NOT NULL,
+ created_by_id INT NOT NULL,
+ created_at INT NOT NULL,
+ UNIQUE KEY uniq_patient_record (entity_type, entity_id, user_id),
+ INDEX idx_patient_records_entity (entity_type, entity_id),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE patient_sessions (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ uuid CHAR(36) NOT NULL UNIQUE,
+ record_id INT NOT NULL,
+ appointment_id INT NULL,
+ insurance_base_id INT NULL,
+ insurance_supplementary_id INT NULL,
+ visit_price_rials INT NOT NULL DEFAULT 0,
+ base_insurance_discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
+ supplementary_discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
+ services_total_rials INT NOT NULL DEFAULT 0,
+ final_price_rials INT NOT NULL DEFAULT 0,
+ payment_method VARCHAR(15) NOT NULL DEFAULT 'pending',
+ notes TEXT NULL,
+ created_at INT NOT NULL,
+ updated_at INT NOT NULL,
+ FOREIGN KEY (record_id) REFERENCES patient_records(id) ON DELETE CASCADE,
+ FOREIGN KEY (appointment_id) REFERENCES appointments(id) ON DELETE SET NULL,
+ FOREIGN KEY (insurance_base_id) REFERENCES categories(id) ON DELETE SET NULL,
+ FOREIGN KEY (insurance_supplementary_id) REFERENCES categories(id) ON DELETE SET NULL,
+ INDEX idx_patient_sessions_record (record_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE session_services (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ uuid CHAR(36) NOT NULL UNIQUE,
+ session_id INT NOT NULL,
+ service_item_id INT NOT NULL,
+ staff_id INT NULL,
+ price_rials INT NOT NULL,
+ created_at INT NOT NULL,
+ FOREIGN KEY (session_id) REFERENCES patient_sessions(id) ON DELETE CASCADE,
+ FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE RESTRICT,
+ FOREIGN KEY (staff_id) REFERENCES clinic_staff(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+## نکات مهم
+
+- **UNIQUE(entity_type, entity_id, user_id)** — یک بیمار یک پرونده در هر کلینیک/مطب دارد
+- **price_rials در session_services کپی میشود** — تغییر قیمت سرویس در آینده روی سشنهای قبلی اثر ندارد
+- **services_total_rials و final_price_rials** در PatientSession کپی محاسبهشده هستند — برای گزارشگیری سریع
+- **user_id ON DELETE RESTRICT** — نمیتوان کاربری را که پرونده دارد حذف کرد
diff --git a/docs/phase2_taskes/task-15-patient-records/task.md b/docs/phase2_taskes/task-15-patient-records/task.md
new file mode 100644
index 00000000..19d9f882
--- /dev/null
+++ b/docs/phase2_taskes/task-15-patient-records/task.md
@@ -0,0 +1,155 @@
+# تسک ۱۵: پرونده بیمار (Patient Records)
+
+## توضیح
+پیادهسازی سیستم پرونده الکترونیک بیمار. هر بیمار یک پرونده در هر کلینیک/مطب دارد.
+هر مراجعه یک سشن است که شامل اطلاعات بیمه، مبلغ ویزیت و سرویسهای انجامشده است.
+دسترسی فقط در پنل **Basic+** — gate: `hasFeature('patient_records')`.
+
+## Endpoint ها
+
+| متد | مسیر | Gate | توضیح |
+|-----|------|------|-------|
+| GET | `/api/v1/patients` | Basic+ | لیست بیماران — `?search=name/phone` |
+| POST | `/api/v1/patient` | Basic+ | ایجاد پرونده دستی |
+| GET | `/api/v1/patient/{uuid}` | Basic+ | جزئیات پرونده |
+| GET | `/api/v1/patient/{uuid}/sessions` | Basic+ | لیست مراجعات |
+| POST | `/api/v1/patient/{uuid}/session` | Basic+ | ثبت مراجعه جدید |
+| PATCH | `/api/v1/session/{uuid}` | Basic+ | ویرایش مراجعه |
+
+## پیشنیازها
+- **تسک ۱۰** (ClinicStaff — برای staff_id در SessionService)
+- **تسک ۱۱** (Subscription — gate check)
+- **تسک ۱۳** (ClinicService — ServiceItem در session)
+
+## زمان تخمینی
+۱۶ تا ۲۰ ساعت
+
+## نمونه Request
+
+### POST /api/v1/patient (ایجاد دستی)
+```json
+{
+ "user_uuid": "uuid-of-user"
+}
+```
+
+### POST /api/v1/patient/{uuid}/session
+```json
+{
+ "appointment_uuid": null,
+ "insurance_base_uuid": "category-uuid-insurance-type",
+ "insurance_supplementary_uuid": null,
+ "visit_price_rials": 500000,
+ "base_insurance_discount_percent": 30,
+ "supplementary_discount_percent": 0,
+ "payment_method": "cash",
+ "notes": "بیمار شکایت از کمردرد داشت",
+ "services": [
+ { "service_item_uuid": "uuid-of-service-item", "staff_uuid": "uuid-of-staff" },
+ { "service_item_uuid": "uuid-of-another-item", "staff_uuid": null }
+ ]
+}
+```
+
+### PATCH /api/v1/session/{uuid}
+```json
+{
+ "notes": "ویرایش یادداشت",
+ "payment_method": "card"
+}
+```
+
+## فرمول محاسبه final_price
+
+```
+after_base = visit_price_rials × (1 - base_insurance_discount_percent / 100)
+after_supp = after_base × (1 - supplementary_discount_percent / 100)
+services_total = Σ service_item.price_rials (قیمت در زمان ثبت — نه قیمت فعلی)
+final_price = round(after_supp) + services_total
+```
+
+مثال عددی:
+```
+visit_price = 500,000
+base_disc = 30% → after_base = 350,000
+supp_disc = 10% → after_supp = 315,000
+services = [85,000 + 45,000] = 130,000
+final = 315,000 + 130,000 = 445,000
+```
+
+## نمونه Response
+
+### GET /api/v1/patients?search=علی
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "uuid": "...",
+ "user": { "uuid": "...", "name": "علی محمدی", "phone": "09121234567" },
+ "sessions_count": 5,
+ "last_session_at": 1718000000,
+ "created_at": 1710000000
+ }
+ ],
+ "meta": { "totalRecords": 12, "totalPages": 2, "currentPage": 1 }
+}
+```
+
+### GET /api/v1/patient/{uuid}
+```json
+{
+ "success": true,
+ "data": {
+ "uuid": "...",
+ "user": { "uuid": "...", "name": "علی محمدی", "phone": "09121234567" },
+ "sessions_count": 5,
+ "total_paid_rials": 2250000,
+ "created_at": 1710000000
+ }
+}
+```
+
+### GET /api/v1/patient/{uuid}/sessions
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "uuid": "...",
+ "appointment_uuid": null,
+ "visit_price_rials": 500000,
+ "base_insurance_discount_percent": 30,
+ "services_total_rials": 130000,
+ "final_price_rials": 445000,
+ "payment_method": "cash",
+ "notes": "...",
+ "services": [
+ { "name": "سرم ۵۰۰cc", "price_rials": 85000, "staff_name": "علی رضایی" }
+ ],
+ "created_at": 1718000000
+ }
+ ],
+ "meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 }
+}
+```
+
+## ایجاد خودکار پرونده هنگام تأیید نوبت
+
+```
+AppointmentController::updateStatus(uuid, 'confirmed')
+ → اگر entityType/entityId پنل Basic+ دارد:
+ → PatientService::autoCreateOnAppointmentConfirm(appointment)
+ → PatientRecord.findOrCreate(entityType, entityId, userId)
+ → PatientSession جدید با appointment_id و visit_price پیشفرض (0)
+ → کاربر بعداً در پرونده میتواند اطلاعات بیمه و قیمت را تکمیل کند
+```
+
+## کدهای خطا
+
+| کد | HTTP | توضیح |
+|----|------|-------|
+| `ERR_SUBSCRIPTION_REQUIRED` | 403 | Basic+ لازم است |
+| `ERR_PATIENT_NOT_FOUND` | 404 | پرونده پیدا نشد |
+| `ERR_SESSION_NOT_FOUND` | 404 | مراجعه پیدا نشد |
+| `ERR_SERVICE_ITEM_NOT_FOUND` | 404 | زیربخش سرویس پیدا نشد |
diff --git a/docs/phase2_taskes/task-15-patient-records/user_flow.md b/docs/phase2_taskes/task-15-patient-records/user_flow.md
new file mode 100644
index 00000000..0716dc2e
--- /dev/null
+++ b/docs/phase2_taskes/task-15-patient-records/user_flow.md
@@ -0,0 +1,123 @@
+# جریان کاربری — تسک ۱۵: پرونده بیمار
+
+## جریان خودکار — تأیید نوبت → ایجاد پرونده
+
+```
+دکتر/منشی نوبت را تأیید میکند
+ │
+ ▼
+PATCH /api/v1/appointment/{uuid}/status { status: 'confirmed' }
+ │
+ ▼
+AppointmentController::updateStatus()
+ │
+ ▼
+آیا entity دارای پنل Basic+ است؟
+ خیر → پرونده ایجاد نمیشود (Free plan)
+ بله ↓
+ ▼
+PatientService::autoCreateOnAppointmentConfirm()
+ │
+ ▼
+آیا PatientRecord برای این (entity, user) وجود دارد؟
+ خیر → ایجاد PatientRecord جدید (created_by='system')
+ بله → همان پرونده استفاده میشود
+ │
+ ▼
+ایجاد PatientSession با:
+ appointment_id = appointment.id
+ visit_price_rials = 0 ← کاربر بعداً تکمیل میکند
+ payment_method = 'pending'
+```
+
+## جریان دستی — ایجاد پرونده توسط منشی
+
+```
+منشی در صفحه «بیماران» کلیک میکند → [+ بیمار جدید]
+ │
+ ▼
+Modal: جستجو بیمار با نام یا شماره تلفن
+ GET /api/v1/admin/users?search=09121234567
+ │
+ ▼
+بیمار انتخاب میشود
+ │
+ ▼
+POST /api/v1/patient { user_uuid: '...' }
+ │
+ ├─► 409: این بیمار قبلاً پرونده دارد → نمایش پرونده موجود
+ └─► 201: پرونده ایجاد شد → redirect به PatientDetailPage
+```
+
+## جریان ثبت مراجعه (سشن)
+
+```
+منشی/دکتر در صفحه پرونده بیمار، روی «+ مراجعه جدید» کلیک میکند
+ │
+ ▼
+Modal ثبت سشن:
+
+┌──────────────────────────────────────────────────────┐
+│ ثبت مراجعه جدید │
+├──────────────────────────────────────────────────────┤
+│ بیمه پایه: [تأمین اجتماعی ▼] │
+│ درصد کسر: [۳۰%____________] │
+│ بیمه مکمل: [---بدون مکمل--- ▼] │
+├──────────────────────────────────────────────────────┤
+│ مبلغ ویزیت: [500,000 ریال____] │
+├──────────────────────────────────────────────────────┤
+│ سرویسهای انجامشده: │
+│ [+ سرم ۵۰۰cc × علی رضایی] [+ سرویس دیگر] │
+├──────────────────────────────────────────────────────┤
+│ 📊 محاسبه: │
+│ ویزیت: 500,000 → بعد از بیمه: 315,000 │
+│ سرویسها: 130,000 │
+│ مجموع: 445,000 ریال │
+├──────────────────────────────────────────────────────┤
+│ روش پرداخت: [نقدی ▼] │
+│ یادداشت: [____________________________] │
+└──────────────────────────────────────────────────────┘
+│ [لغو] [ثبت مراجعه] │
+ │
+ ▼
+POST /api/v1/patient/{uuid}/session { ... }
+ │
+ ▼
+سشن ثبت شد → لیست مراجعات refresh میشود
+```
+
+## محاسبه real-time در Frontend
+
+```
+هر بار که visit_price یا discount درصد تغییر میکند:
+ after_base = visit_price × (1 - base_discount / 100)
+ after_supp = after_base × (1 - supp_discount / 100)
+ final = round(after_supp) + services_total
+
+نمایش: «مبلغ قابل پرداخت: ۴۴۵,۰۰۰ ریال»
+```
+
+## جریان جستجو بیمار
+
+```
+GET /api/v1/patients?search=علی&page=1
+ → جستجو در نام و شماره تلفن user
+ → نمایش paginated در DataTable
+ → کلیک روی هر ردیف → PatientDetailPage
+```
+
+## نمایش صفحه بیمار (PatientDetailPage.tsx)
+
+```
+┌─────────────────────────────────────────────────────┐
+│ علی محمدی — 09121234567 │
+│ ۵ مراجعه | آخرین: ۱۴۰۵/۰۳/۱۵ | مجموع: ۲,۲۵۰,۰۰۰ │
+├─────────────────────────────────────────────────────┤
+│ [+ مراجعه جدید] │
+├──────────────┬──────────────┬──────────────────────┤
+│ تاریخ │ مبلغ نهایی │ روش پرداخت عملیات │
+├──────────────┼──────────────┼──────────────────────┤
+│ ۱۴۰۵/۰۳/۱۵ │ ۴۴۵,۰۰۰ ریال│ نقدی ✏️ 👁️ │
+│ ۱۴۰۵/۰۲/۰۸ │ ۳۲۰,۰۰۰ ریال│ کارت ✏️ 👁️ │
+└──────────────┴──────────────┴──────────────────────┘
+```
diff --git a/docs/phase2_taskes/task-16-smart-dashboard/architecture.md b/docs/phase2_taskes/task-16-smart-dashboard/architecture.md
new file mode 100644
index 00000000..fc6a07ab
--- /dev/null
+++ b/docs/phase2_taskes/task-16-smart-dashboard/architecture.md
@@ -0,0 +1,100 @@
+# معماری — تسک ۱۶: داشبورد هوشمند
+
+## فایلهایی که تغییر میکنند
+
+```
+src/Dashboard/Controller/DashboardController.php ← اضافه کردن from/to به query + فیلدهای جدید
+src/Admin/Controller/AdminApiController.php ← متد dashboardCharts() → محدود به بازه زمانی
+assets/admin/pages/DashboardPage.tsx ← date range selector + نمودارها
+```
+
+## تغییر Backend
+
+### DashboardController — اضافه کردن from/to
+
+```php
+#[Route('/api/v1/dashboard/clinic', methods: ['GET'])]
+public function clinic(Request $request): JsonResponse
+{
+ $from = (int) $request->query->get('from', strtotime('-30 days'));
+ $to = (int) $request->query->get('to', time());
+
+ // query موجود را محدود به بازه میکنیم:
+ // WHERE created_at BETWEEN :from AND :to
+
+ // فیلدهای جدید:
+ $smsBalance = $this->smsWalletRepo->getBalance($entityType, $entityId);
+ $uniquePatients = $this->patientRecordRepo->countUnique($entityType, $entityId, $from, $to);
+ $revenue = $this->patientSessionRepo->sumRevenue($entityType, $entityId, $from, $to);
+
+ return $this->success([
+ // ... فیلدهای موجود ...
+ 'sms_wallet_balance' => $smsBalance,
+ 'unique_patients_count' => $uniquePatients,
+ 'revenue_period_rials' => $revenue,
+ ]);
+}
+```
+
+### AdminApiController — dashboardCharts با بازه زمانی
+
+```php
+#[Route('/api/v1/admin/dashboard/charts', methods: ['GET'])]
+public function dashboardCharts(Request $request): JsonResponse
+{
+ $from = (int) $request->query->get('from', strtotime('-30 days'));
+ $to = (int) $request->query->get('to', time());
+
+ // appointments_by_day: GROUP BY DATE(FROM_UNIXTIME(created_at))
+ // revenue_by_day: از patient_sessions در بازه
+ // subscription_sales_by_plan: از clinic_subscriptions در بازه
+}
+```
+
+## تغییر Frontend — DashboardPage.tsx
+
+### date range selector component:
+
+```tsx
+type DateRangePreset = 'week' | 'month' | '3months' | 'custom';
+
+interface DateRange {
+ from: number; // Unix timestamp
+ to: number;
+}
+
+function DateRangeSelector({ value, onChange }: {
+ value: DateRange;
+ onChange: (r: DateRange) => void;
+}) {
+ // preset buttons + PersianCalendar برای custom range
+}
+```
+
+### نمودارها با Recharts:
+
+```tsx
+import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
+
+// chart نوبتها
+
+
+ formatDate(d)} />
+
+
+
+
+
+
+// chart درآمد
+
+
+
+```
+
+### نصب dependency:
+
+```bash
+ddev exec yarn add recharts
+ddev exec yarn add @types/recharts # اگر نیاز بود
+```
diff --git a/docs/phase2_taskes/task-16-smart-dashboard/database.md b/docs/phase2_taskes/task-16-smart-dashboard/database.md
new file mode 100644
index 00000000..bcedfc0a
--- /dev/null
+++ b/docs/phase2_taskes/task-16-smart-dashboard/database.md
@@ -0,0 +1,63 @@
+# پایگاه داده — تسک ۱۶: داشبورد هوشمند
+
+## هیچ migration لازم نیست
+
+همه دادهها از جداول موجود و جداول ساختهشده در تسکهای قبل خوانده میشوند.
+
+## Query های جدید
+
+### نوبتها بر اساس روز (admin chart)
+```sql
+SELECT
+ FLOOR(created_at / 86400) * 86400 AS date_unix,
+ COUNT(*) AS count
+FROM appointments
+WHERE created_at BETWEEN :from AND :to
+GROUP BY date_unix
+ORDER BY date_unix ASC
+```
+
+### درآمد بر اساس روز (از patient_sessions)
+```sql
+SELECT
+ FLOOR(created_at / 86400) * 86400 AS date_unix,
+ SUM(final_price_rials) AS amount_rials
+FROM patient_sessions
+WHERE entity_type = :entityType
+ AND record_id IN (
+ SELECT id FROM patient_records WHERE entity_type = :entityType AND entity_id = :entityId
+ )
+ AND created_at BETWEEN :from AND :to
+GROUP BY date_unix
+ORDER BY date_unix ASC
+```
+
+### فروش اشتراک بر اساس پنل (admin)
+```sql
+SELECT
+ sp.name AS plan,
+ COUNT(cs.id) AS count,
+ SUM(p.amount) AS total_rials
+FROM clinic_subscriptions cs
+JOIN subscription_plans sp ON cs.plan_id = sp.id
+LEFT JOIN payments p ON cs.payment_id = p.id
+WHERE cs.is_trial = 0
+ AND cs.created_at BETWEEN :from AND :to
+GROUP BY sp.name
+```
+
+### بیماران منحصربهفرد در بازه
+```sql
+SELECT COUNT(DISTINCT pr.user_id) AS unique_count
+FROM patient_records pr
+JOIN patient_sessions ps ON ps.record_id = pr.id
+WHERE pr.entity_type = :entityType
+ AND pr.entity_id = :entityId
+ AND ps.created_at BETWEEN :from AND :to
+```
+
+## نکات مهم
+
+- تمام `created_at` ها Unix timestamp هستند — فیلتر بازه زمانی مستقیم روی عدد اعمال میشود
+- گروهبندی روزانه: `FLOOR(created_at / 86400) * 86400` → شروع روز به Unix
+- نمایش در frontend: تبدیل Unix به تاریخ شمسی با `formatDate()`
diff --git a/docs/phase2_taskes/task-16-smart-dashboard/task.md b/docs/phase2_taskes/task-16-smart-dashboard/task.md
new file mode 100644
index 00000000..147c8f64
--- /dev/null
+++ b/docs/phase2_taskes/task-16-smart-dashboard/task.md
@@ -0,0 +1,78 @@
+# تسک ۱۶: داشبورد هوشمند — چارت + فیلتر زمانی
+
+## توضیح
+داشبوردهای موجود را با نمودار و فیلتر بازه زمانی تکمیل میکند.
+**هیچ endpoint جدیدی ایجاد نمیشود** — فقط پارامتر `from` و `to` (Unix timestamp) به endpoint های موجود اضافه میشود.
+همچنین چند فیلد جدید به response های موجود اضافه میشود.
+
+## Endpoint های موجود که تغییر میکنند
+
+| متد | مسیر | تغییر |
+|-----|------|-------|
+| GET | `/api/v1/admin/dashboard/charts` | اضافه: query params `from` و `to` |
+| GET | `/api/v1/dashboard/clinic` | اضافه: `from`، `to` + فیلدهای جدید response |
+| GET | `/api/v1/dashboard/doctor` | اضافه: `from`، `to` + فیلدهای جدید response |
+
+## فیلدهای جدید در Response
+
+### GET /api/v1/dashboard/clinic
+فیلدهای اضافهشده به data موجود:
+```json
+{
+ "sms_wallet_balance": 150000,
+ "unique_patients_count": 45,
+ "revenue_period_rials": 12500000
+}
+```
+
+### GET /api/v1/dashboard/doctor
+فیلدهای اضافهشده:
+```json
+{
+ "unique_patients_count": 28,
+ "revenue_period_rials": 7800000
+}
+```
+
+### GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX
+```json
+{
+ "success": true,
+ "data": {
+ "appointments_by_day": [
+ { "date": 1718000000, "count": 12 },
+ { "date": 1718086400, "count": 8 }
+ ],
+ "revenue_by_day": [
+ { "date": 1718000000, "amount_rials": 4500000 },
+ { "date": 1718086400, "amount_rials": 3200000 }
+ ],
+ "subscription_sales_by_plan": [
+ { "plan": "basic", "count": 15, "total_rials": 3750000 },
+ { "plan": "professional", "count": 5, "total_rials": 2500000 }
+ ]
+ }
+}
+```
+
+## پیشنیازها
+- همه تسکهای ۱۰–۱۵ (نیاز به داده واقعی)
+- `yarn add recharts` برای نمودارها
+
+## زمان تخمینی
+۸ تا ۱۰ ساعت
+
+## فیلتر بازه زمانی
+
+| preset | محاسبه |
+|--------|--------|
+| این هفته | from = شروع هفته جاری (شنبه) — to = now |
+| این ماه | from = اول ماه جاری (شمسی) — to = now |
+| ۳ ماه | from = now - 90 روز — to = now |
+| سفارشی | کاربر از PersianCalendar انتخاب میکند |
+
+**تبدیل تاریخ شمسی به Unix timestamp** برای ارسال به API:
+```ts
+// از کتابخانه موجود در پروژه استفاده میشود
+// PersianCalendar component از assets/admin/components/ui/
+```
diff --git a/docs/phase2_taskes/task-16-smart-dashboard/user_flow.md b/docs/phase2_taskes/task-16-smart-dashboard/user_flow.md
new file mode 100644
index 00000000..e9462cfc
--- /dev/null
+++ b/docs/phase2_taskes/task-16-smart-dashboard/user_flow.md
@@ -0,0 +1,98 @@
+# جریان کاربری — تسک ۱۶: داشبورد هوشمند
+
+## جریان انتخاب بازه زمانی
+
+```
+کاربر وارد صفحه داشبورد میشود
+ │
+ ▼
+بازه پیشفرض: «این ماه»
+ from = اول ماه جاری (شمسی → Unix)
+ to = now
+
+GET /api/v1/dashboard/clinic?from=UNIX&to=UNIX
+ │
+ ▼
+کاربر روی preset کلیک میکند:
+ [این هفته] [این ماه] [۳ ماه] [سفارشی]
+ │
+ ▼
+انتخاب «سفارشی»:
+ PersianCalendar range picker باز میشود
+ کاربر از/تا را انتخاب میکند
+ │
+ ▼
+URL update: ?from=UNIX&to=UNIX
+TanStack Query refetch میشود
+```
+
+## جریان نمایش داشبورد کلینیک/مطب
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ داشبورد کلینیک │
+│ [این هفته] [این ماه ★] [۳ ماه] [سفارشی] │
+├──────────┬──────────┬──────────┬──────────────────────────── │
+│ نوبتهای │ بیماران │ درآمد │ موجودی پیامک │
+│ امروز: ۸ │ منحصربه │ این ماه │ ۱۵۰,۰۰۰ ریال │
+│ │ فرد: ۴۵ │ ۱۲.۵M │ │
+├──────────┴──────────┴──────────┴──────────────────────────── │
+│ نمودار نوبتها — این ماه │
+│ ┌────────────────────────────────────────────────────────┐ │
+│ │ ▄ ▄ ▄ ▄ │ │
+│ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │
+│ │ ────────────────────────────────────── روز │ │
+│ └────────────────────────────────────────────────────────┘ │
+├──────────────────────────────────────────────────────────── │
+│ نمودار درآمد — این ماه │
+│ ┌────────────────────────────────────────────────────────┐ │
+│ │ █ █ █ █ │ │
+│ │ █ █ █ █ █ █ █ █ █ │ │
+│ └────────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────────┘
+```
+
+## جریان داشبورد ادمین
+
+```
+GET /api/v1/admin/dashboard/stats → کارتهای آمار کلی
+GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → نمودارها
+
+نمودار اضافه: «فروش اشتراک بر اساس پنل»
+ basic: ۱۵ فروش | professional: ۵ فروش
+ (Bar chart با رنگبندی متفاوت برای هر plan)
+```
+
+## فیلتر نقش — منشی
+
+```
+منشی وارد داشبورد میشود
+ → فقط آمار نوبتها نمایش داده میشود
+ → فیلد درآمد: مخفی
+ → فیلد بیماران: مخفی (اگر پنل Basic+ نباشد)
+ → موجودی پیامک: مخفی
+```
+
+## preset ها — محاسبه از/تا
+
+```typescript
+function getDateRange(preset: DateRangePreset): DateRange {
+ const now = Math.floor(Date.now() / 1000);
+
+ switch (preset) {
+ case 'week':
+ // شنبه این هفته
+ const dayOfWeek = new Date().getDay(); // 0=یکشنبه ... 6=شنبه
+ const daysToSaturday = dayOfWeek === 6 ? 0 : dayOfWeek + 1;
+ return { from: now - daysToSaturday * 86400, to: now };
+
+ case 'month':
+ // اول ماه جاری شمسی → Unix timestamp
+ // از PersianCalendar helper استفاده میشود
+ return { from: startOfCurrentPersianMonth(), to: now };
+
+ case '3months':
+ return { from: now - 90 * 86400, to: now };
+ }
+}
+```