diff --git a/.claude/prompt/multi-role-dashboard.md b/.claude/prompt/multi-role-dashboard.md index a9395755..598bda09 100644 --- a/.claude/prompt/multi-role-dashboard.md +++ b/.claude/prompt/multi-role-dashboard.md @@ -39,6 +39,7 @@ | Endpoint | فایل کنترلر | توضیح | |----------|-------------|-------| +| `POST /api/v1/auth/switch-context` | `src/Auth/Controller/AuthController.php` | تغییر context فعال (انتخاب محیط کاری) | | `GET /api/v1/dashboard/clinic` | `src/Dashboard/Controller/DashboardController.php` | داشبورد صاحب کلینیک | | `GET /api/v1/dashboard/doctor` | همان | داشبورد دکتر | | `GET /api/v1/dashboard/secretary` | همان | داشبورد منشی | @@ -48,6 +49,76 @@ --- +## مفاهیم کلیدی معماری Multi-Context (مهم — قبل از پیاده‌سازی بخوان) + +این سیستم از معماری **Multi-Tenant / Multi-Context** استفاده می‌کند. یعنی یک کاربر می‌تواند در چند محیط مختلف فعالیت کند و باید هنگام login محیط کاری خود را انتخاب کند. + +### مفهوم `db_uuid` + +`db_uuid` برابر UUID **موجودیت فعال فعلی** است — نه UUID کاربر: + +| وضعیت | مقدار `db_uuid` | +|--------|-----------------| +| دکتر مستقل (مطب شخصی) | UUID خود دکتر | +| صاحب کلینیک | UUID کلینیک | +| دکتر فعال در یک کلینیک | UUID آن کلینیک | +| منشی یک دکتر | UUID دکتر | +| منشی یک کلینیک | UUID کلینیک | + +### مفهوم `db_key` + +مقدار `db_key` یک hash امنیتی است: +``` +db_key = HMAC-SHA256(db_uuid, APP_SECRET) +``` +هر بار که context فعال تغییر کند، باید `db_key` جدید تولید شود. + +### مفهوم `available_contexts` + +آرایه‌ای از همه محیط‌های کاری که کاربر می‌تواند در آن‌ها فعالیت کند: + +```json +"available_contexts": [ + { + "type": "doctor", + "db_uuid": "doctor-uuid-...", + "name": "مطب شخصی دکتر احمدی", + "role": "doctor" + }, + { + "type": "clinic", + "db_uuid": "clinic-uuid-1", + "name": "کلینیک سلامت", + "role": "doctor" + }, + { + "type": "clinic", + "db_uuid": "clinic-uuid-2", + "name": "کلینیک آریا", + "role": "secretary", + "permissions": { ... } + } +] +``` + +### سناریوهای چند-context + +**دکتر چند-کلینیکی (Multi-Clinic Doctor):** +- دکتری که هم مطب شخصی دارد هم در چند کلینیک فعالیت می‌کند +- `available_contexts` شامل: مطب شخصی + هر کلینیکی که عضو است +- هر context مستقل: نوبت‌ها، تنظیمات و برنامه هفتگی جداگانه + +**منشی چند-کلینیکی (Multi-Clinic Secretary):** +- منشی که هم برای دکتر A و هم کلینیک B کار می‌کند +- `available_contexts` شامل همه روابط فعال DoctorSecretary آن منشی + +### قانون انتخاب context: +- اگر **یک context**: سیستم به‌صورت خودکار همان را فعال می‌کند +- اگر **بیش از یک context**: بعد از login، صفحه انتخاب محیط کاری نمایش داده می‌شود +- پس از انتخاب، `db_uuid` و `db_key` و `context` بر اساس انتخاب کاربر به‌روز می‌شوند + +--- + ## وضعیت فعلی کد (مهم — قبل از تغییر بخوان) ### بک‌اند @@ -98,7 +169,7 @@ endpoint موجود `/oauth/userinfo` را گسترش بده — همان route } ``` -پاسخ بعد از تغییر (دو فیلد جدید اضافه): +پاسخ بعد از تغییر (فیلدهای جدید اضافه): ```json { "success": true, @@ -110,14 +181,34 @@ endpoint موجود `/oauth/userinfo` را گسترش بده — همان route "status": 1, "roles": ["ROLE_USER", "ROLE_DOCTOR"], "primary_role": "doctor", + "db_uuid": "clinic-uuid-currently-active", + "db_key": "hmac-sha256-hash...", "context": { - "doctor_uuid": "...", - "doctor_name": "دکتر وحید درویشی" - } + "type": "clinic", + "db_uuid": "clinic-uuid-currently-active", + "name": "کلینیک سلامت", + "role": "doctor" + }, + "available_contexts": [ + { + "type": "doctor", + "db_uuid": "doctor-uuid-...", + "name": "مطب شخصی دکتر وحید درویشی", + "role": "doctor" + }, + { + "type": "clinic", + "db_uuid": "clinic-uuid-currently-active", + "name": "کلینیک سلامت", + "role": "doctor" + } + ] } } ``` +> اگر کاربر تنها یک context دارد، `available_contexts` آرایه‌ای با یک عنصر است و `db_uuid` همان را نشان می‌دهد. اگر **context فعال هنوز انتخاب نشده** (اولین login چند-context)، `db_uuid` و `db_key` باید `null` باشند — frontend صفحه انتخاب محیط کاری را نشان می‌دهد. + **قانون `primary_role`** (اولویت‌بندی): - `ROLE_ADMIN` → `"admin"` - `ROLE_CLINIC` → `"clinic"` @@ -125,14 +216,78 @@ endpoint موجود `/oauth/userinfo` را گسترش بده — همان route - `ROLE_SECRETARY` → `"secretary"` - بقیه → `"user"` -**پر کردن `context`**: -- `ROLE_DOCTOR`: از `DoctorRepository::findByUser($user)` → `{doctor_uuid, doctor_name}` -- `ROLE_CLINIC`: از `ClinicRepository::findByUser($user)` → `{clinic_uuid, clinic_name, clinic_logo}` -- `ROLE_SECRETARY`: از `DoctorSecretaryRepository::findActiveBySecretary($user)` (متد جدید) → `{secretary_uuid, doctor_uuid, doctor_name, permissions}` -- اگر موجودیت پیدا نشد: `context: null` +**ساختن `available_contexts` در بک‌اند**: + +برای هر کاربر، همه context های ممکن را جمع می‌کنیم: -**متد جدید در `DoctorSecretaryRepository`**: ```php +$contexts = []; + +// اگر دکتر است: مطب شخصی خودش +if ($doctor = $this->doctorRepo->findByUser($user)) { + $contexts[] = [ + 'type' => 'doctor', + 'db_uuid' => $doctor->getUuid(), + 'name' => 'مطب شخصی ' . $doctor->getName(), + 'role' => 'doctor', + ]; + // کلینیک‌هایی که عضو است + foreach ($doctor->getClinics() as $clinic) { + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName(), + 'role' => 'doctor', + ]; + } +} + +// اگر صاحب کلینیک است +if ($clinic = $this->clinicRepo->findByUser($user)) { + // اگر قبلاً از طریق عضویت دکتری اضافه نشده + $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinic->getUuid()); + if (empty($alreadyAdded)) { + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName(), + 'role' => 'clinic', + ]; + } +} + +// اگر منشی است: همه روابط فعال DoctorSecretary +if ($user->hasRole('ROLE_SECRETARY')) { + $secretaryRelations = $this->doctorSecretaryRepo->findAllActiveBySecretary($user); + foreach ($secretaryRelations as $rel) { + $contexts[] = [ + 'type' => 'doctor', + 'db_uuid' => $rel->getDoctor()->getUuid(), + 'name' => 'مطب ' . $rel->getDoctor()->getName(), + 'role' => 'secretary', + 'permissions' => $rel->getPermissions(), + ]; + } +} +``` + +**تولید `db_uuid` و `db_key` در بک‌اند**: +- `db_uuid`: از session/cookie/JWT claim ذخیره‌شده — مقدار آخرین context انتخاب‌شده توسط کاربر +- اگر context هنوز انتخاب نشده (یا یک context وجود دارد): اولین آیتم `available_contexts` را به‌صورت خودکار فعال کن +- `db_key = hash_hmac('sha256', $dbUuid, $this->getParameter('app.secret'))` + +**ذخیره context فعال**: +چون JWT بی‌حالت است، context انتخاب‌شده باید در **user session جدا** یا **درون JWT** ذخیره شود. ساده‌ترین روش: یک جدول `user_active_context` با فیلدهای `user_id`، `db_uuid`، `updated_at`. `/oauth/userinfo` از این جدول می‌خواند؛ `/api/v1/auth/switch-context` آن را به‌روز می‌کند. + +**متدهای جدید در Repository ها**: +```php +// DoctorSecretaryRepository +public function findAllActiveBySecretary(User $user): array +{ + return $this->findBy(['secretary' => $user, 'active' => true]); +} + +// متد قبلی همچنان نگه داشته شود: public function findActiveBySecretary(User $user): ?DoctorSecretary { return $this->findOneBy(['secretary' => $user, 'active' => true]); @@ -143,11 +298,87 @@ public function findActiveBySecretary(User $user): ?DoctorSecretary --- +## مرحله ۱.۵ — switch-context API (بک‌اند) — API جدید + +### فایل: `src/Auth/Controller/AuthController.php` + +#### `POST /api/v1/auth/switch-context` `[IS_AUTHENTICATED_FULLY]` + +کاربر یک `db_uuid` از لیست `available_contexts` خود انتخاب می‌کند. + +**Request body:** +```json +{ "db_uuid": "clinic-uuid-..." } +``` + +**پیاده‌سازی**: +1. `available_contexts` کاربر را محاسبه کن (همان منطق مرحله ۱) +2. بررسی کن آیا `db_uuid` ارسال‌شده در لیست `available_contexts` کاربر هست — اگر نه: خطای `403` +3. در جدول `user_active_context` مقدار `db_uuid` را ذخیره/به‌روز کن +4. `db_key` جدید را محاسبه کن: `hash_hmac('sha256', $dbUuid, $appSecret)` +5. `context` فعال را بر اساس `db_uuid` انتخاب‌شده بساز + +**Response `200`:** +```json +{ + "success": true, + "data": { + "db_uuid": "clinic-uuid-...", + "db_key": "new-hmac-hash...", + "context": { + "type": "clinic", + "db_uuid": "clinic-uuid-...", + "name": "کلینیک سلامت", + "role": "doctor" + } + } +} +``` + +**Errors:** +| Code | HTTP | توضیح | +|------|------|-------| +| `ERR_AUTH_001` | 401 | توکن وجود ندارد | +| `ERR_AUTH_006` | 403 | `db_uuid` در لیست context های این کاربر نیست | +| `ERR_VALIDATION_001` | 422 | `db_uuid` ارسال نشده | + +**موجودیت جدید مورد نیاز** — `src/Auth/Entity/UserActiveContext.php`: +```php +#[ORM\Entity] +#[ORM\Table(name: 'user_active_context')] +class UserActiveContext { + #[ORM\Id] + #[ORM\OneToOne(targetEntity: User::class)] + #[ORM\JoinColumn(name: 'user_id', onDelete: 'CASCADE')] + private User $user; + + #[ORM\Column(name: 'db_uuid', type: 'string', length: 36)] + private string $dbUuid; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; +} +``` + +> **Migration**: بعد از ساخت entity، `doctrine:migrations:diff` و `migrate` اجرا کن. + +**داکیومنت**: بعد از پیاده‌سازی، این endpoint را به `docs/api/auth.md` اضافه کن. + +--- + ## مرحله ۲ — به‌روز کردن `authStore.ts` (فرانت‌اند) ```typescript // assets/admin/stores/authStore.ts +interface ContextItem { + type: 'doctor' | 'clinic'; + db_uuid: string; + name: string; + role: 'admin' | 'clinic' | 'doctor' | 'secretary'; + permissions?: Record; +} + interface AuthState { token: string | null; refreshToken: string | null; @@ -156,13 +387,68 @@ interface AuthState { userUuid: string | null; userName: string | null; primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null; - context: Record | null; + dbUuid: string | null; // UUID موجودیت فعال + dbKey: string | null; // hash امنیتی context فعال + context: ContextItem | null; // context فعال انتخاب‌شده + availableContexts: ContextItem[]; // همه context های قابل انتخاب } ``` - متد `login(token, refreshToken)`: بعد از ذخیره token، یک `GET /oauth/userinfo` بزند و نتیجه را ذخیره کند - متد `logout()`: همه فیلدها را پاک کند - متد جدید `fetchMe()`: `GET /oauth/userinfo` و update store — در `App.tsx` هنگام mount فراخوانی شود (اگر token موجود بود اما `primaryRole` خالی بود، تا بعد از reload صفحه role بازیابی شود) +- متد جدید `switchContext(dbUuid: string)`: `POST /api/v1/auth/switch-context` و update `dbUuid`، `dbKey`، `context` در store + +--- + +## مرحله ۲.۵ — صفحه انتخاب محیط کاری (فرانت‌اند) — جدید + +### فایل جدید: `assets/admin/pages/SelectContextPage.tsx` + +این صفحه **فقط** هنگامی نمایش داده می‌شود که کاربر بیش از یک context دارد. + +**شرط نمایش** (در `App.tsx`): +```tsx +// بعد از fetchMe، قبل از route های اصلی: +if (isAuthenticated && availableContexts.length > 1 && !dbUuid) { + return ; +} +``` + +**UI صفحه**: +```tsx +export default function SelectContextPage() { + const { availableContexts, switchContext } = useAuthStore(); + const navigate = useNavigate(); + + const handleSelect = async (dbUuid: string) => { + await switchContext(dbUuid); // POST /api/v1/auth/switch-context + navigate('/admin/dashboard', { replace: true }); + }; + + return ( +
+

محیط کاری خود را انتخاب کنید

+
+ {availableContexts.map(ctx => ( + + ))} +
+
+ ); +} +``` + +- هر کارت: نام محیط کاری + نقش (مطب شخصی / کلینیک / منشی) +- بعد از انتخاب: `switchContext` را صدا می‌زند → store به‌روز می‌شود → redirect به dashboard +- دکمه تغییر محیط کاری در Sidebar هم باید موجود باشد (کلیک → `/admin/select-context`) --- @@ -472,6 +758,225 @@ const endpoint = primaryRole === 'admin' > برای ادمین از endpoint موجود استفاده می‌شود؛ برای بقیه نقش‌ها از endpoint جدید. +صفحه نوبت‌ها باید **دو نمای قابل‌تعویض** داشته باشد — یک toggle بین «جدولی» و «زمانبندی» (مطابق تصاویر طراحی). + +#### هدر صفحه (مشترک هر دو نما): +- ۴ کارت آمار: «کل نوبت‌های امروز» | «نوبت‌های انجام شده» | «مراجعین در انتظار» | «نوبت‌های لغو شده» +- دکمه «+ نوبت جدید» +- دکمه toggle نما (جدولی / زمانبندی) +- انتخابگر پرسنل/دکتر (dropdown) +- انتخابگر تاریخ با Jalali calendar (< روز > + آیکون calendar) + +#### نمای جدولی (`TableView`): +- جدول با ستون‌ها: ردیف | شماره تماس | شروع | پایان | سرویس | پرسنل | وضعیت | عملیات +- در ستون «وضعیت»: dropdown تغییر وضعیت با رنگ‌بندی (ثبت شده=آبی، قطعی شده=سبز، در حال پیگیری=نارنجی، سالن=بنفش، ویزیت شده=سبز تیره، لغو شده=قرمز) +- ستون «عملیات»: دکمه `...` با منو + +#### نمای زمانبندی (`TimelineView`): +- تب‌های افقی یک دکتر به ازای هر تب (نام دکتر) +- محور زمان عمودی در سمت راست (فارسی: `HH:MM`) +- هر اسلات زمانی یا: + - **پر**: کارت نوبت با رنگ پس‌زمینه بر اساس وضعیت + نام بیمار + شماره تماس + سرویس + دکمه عملیات + dropdown وضعیت + - **خالی**: کارت خالی با دکمه «+ نوبت جدید» (کلیک → مودال ثبت نوبت با slot از پیش پر شده) +- رنگ کارت‌ها: ویزیت شده=سبز روشن | لغو شده=قرمز روشن | در حال پیگیری=نارنجی روشن | سالن=بنفش روشن | ثبت شده / قطعی=آبی روشن | انتظار پرداخت=خاکستری + +#### وضعیت‌های نوبت (باید در entity و frontend هر دو باشند): +| مقدار DB | نمایش فارسی | رنگ | +|-----------|-------------|-----| +| `waiting_for_payment` | انتظار پرداخت | خاکستری | +| `pending` | ثبت شده | آبی | +| `following` | در حال پیگیری | نارنجی | +| `in_salon` | سالن | بنفش | +| `visited` | ویزیت شده | سبز | +| `cancelled_by_doctor` | لغو شده (دکتر) | قرمز | +| `cancelled_by_user` | لغو شده (کاربر) | قرمز | +| `expired` | منقضی شده | خاکستری تیره | +| `no_show` | غایب | خاکستری تیره | + +**Transition های مجاز (`ALLOWED_TRANSITIONS` در entity)**: +``` +waiting_for_payment → [pending, expired, cancelled_by_user] +pending → [following, in_salon, visited, cancelled_by_doctor, cancelled_by_user, expired, no_show] +following → [in_salon, visited, cancelled_by_doctor, cancelled_by_user] +in_salon → [visited, cancelled_by_doctor] +``` + +> **داکیومنت**: بعد از پیاده‌سازی، وضعیت‌های جدید و فیلدهای جدید را در `docs/api/appointment.md` به‌روز کن. + +--- + +## مرحله ۹ — سیستم کمیسیون نوبت‌دهی (بک‌اند + فرانت‌اند) — جدید + +### منطق کسب‌وکار: +- کاربر عادی نوبت می‌گیرد → باید **کمیسیون سایت** (نه قیمت نوبت) پرداخت کند +- منشی / دکتر / کلینیک نوبت می‌دهد → بدون کمیسیون +- مقدار کمیسیون در پنل ادمین توسط مدیر تنظیم می‌شود (مثلاً ۱۰,۰۰۰ تومان به ازای هر نوبت) + +### بک‌اند — موجودیت `SiteConfig` (جدید): +فایل جدید: `src/Admin/Entity/SiteConfig.php` +```php +#[ORM\Entity] +#[ORM\Table(name: 'site_config')] +class SiteConfig { + #[ORM\Id] + #[ORM\Column(type: 'string', length: 100)] + private string $configKey; + + #[ORM\Column(name: 'config_value', type: 'text', nullable: true)] + private ?string $configValue; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; +} +``` + +فایل جدید: `src/Admin/Repository/SiteConfigRepository.php` +```php +public function get(string $key, mixed $default = null): mixed +public function set(string $key, mixed $value): void +public function all(): array +``` + +### بک‌اند — کنترلر تنظیمات ادمین (جدید): +فایل جدید: `src/Admin/Controller/SiteConfigController.php` + +``` +GET /api/v1/admin/settings [ROLE_ADMIN] → { booking_commission_rials: int } +PATCH /api/v1/admin/settings [ROLE_ADMIN] → body: { booking_commission_rials: int (>=0) } +``` + +### بک‌اند — تغییرات `Appointment` entity: +فایل: `src/Appointment/Entity/Appointment.php` + +فیلدهای جدید: +```php +#[ORM\Column(name: 'booked_by', type: 'string', length: 20)] +private string $bookedBy = self::BOOKED_BY_USER; // 'user' | 'secretary' + +#[ORM\Column(name: 'commission_rials', type: 'integer', nullable: true)] +private ?int $commissionRials = null; +``` + +ثابت‌های جدید: +```php +public const BOOKED_BY_USER = 'user'; +public const BOOKED_BY_SECRETARY = 'secretary'; +``` + +> **Migration**: بعد از تغییر entity اجرا کن: `ddev exec php bin/console doctrine:migrations:diff` و سپس `migrate` + +### بک‌اند — تغییرات `AppointmentController::book()`: +فایل: `src/Appointment/Controller/AppointmentController.php` + +منطق در `POST /api/v1/appointment`: +``` +اگر caller دارای ROLE_SECRETARY یا ROLE_DOCTOR یا ROLE_CLINIC بود: + bookedBy = 'secretary' + status = 'pending' + commissionRials = null (بدون کمیسیون) +در غیر اینصورت (ROLE_USER): + bookedBy = 'user' + status = 'waiting_for_payment' + commissionRials = SiteConfigRepository::get('booking_commission_rials', 0) + → مقدار commission_rials را در پاسخ برگردان تا frontend به درگاه هدایت کند +``` + +پاسخ برای کاربر عادی (اضافه به پاسخ معمول): +```json +{ + "uuid": "...", + "status": "waiting_for_payment", + "booked_by": "user", + "commission_rials": 10000, + "payment_required": true +} +``` + +### فرانت‌اند — صفحه تنظیمات ادمین (جدید): +فایل جدید: `assets/admin/pages/SettingsPage.tsx` +- Route: `/admin/settings` — فقط `ROLE_ADMIN` +- یک فرم ساده: + - فیلد «کمیسیون نوبت (ریال)»: عدد، validation >= 0 + - دکمه «ذخیره» +- `GET /api/v1/admin/settings` برای مقدار اولیه +- `PATCH /api/v1/admin/settings` برای ذخیره +- نمایش مقدار با `formatRial()` از `lib/utils.ts` + +### فرانت‌اند — تغییرات `AppointmentsPage.tsx`: +- بعد از ثبت موفق نوبت توسط کاربر عادی (اگر `payment_required: true`)، کاربر را به صفحه درگاه پرداخت هدایت کن + +> **داکیومنت**: بعد از پیاده‌سازی به‌روز کن: +> - `docs/api/appointment.md` — اضافه: فیلدهای `booked_by`، `commission_rials`، وضعیت‌های جدید +> - `docs/api/admin.md` — اضافه: `GET/PATCH /api/v1/admin/settings` + +--- + +## مرحله ۱۰ — شماره موبایل اطلاع‌رسانی نوبت (بک‌اند + فرانت‌اند) — جدید + +### منطق کسب‌وکار: +- دکتر یا کلینیک می‌تواند یک شماره موبایل برای **دریافت پیامک هنگام ثبت نوبت جدید** تنظیم کند +- این شماره می‌تواند: موبایل خود دکتر، موبایل منشی، یا یک شماره دیگر باشد +- شماره باید با OTP تأیید شود قبل از فعال شدن +- اگر شماره تنظیم نشده باشد، پیامک ارسال نمی‌شود + +### بک‌اند — فیلد جدید روی موجودیت‌ها: +روی `Doctor` entity: +```php +#[ORM\Column(name: 'notification_mobile', type: 'string', length: 20, nullable: true)] +private ?string $notificationMobile = null; + +#[ORM\Column(name: 'notification_mobile_verified', type: 'boolean')] +private bool $notificationMobileVerified = false; +``` + +روی `Clinic` entity (اگر کلینیک بخواهد): +```php +#[ORM\Column(name: 'notification_mobile', type: 'string', length: 20, nullable: true)] +private ?string $notificationMobile = null; + +#[ORM\Column(name: 'notification_mobile_verified', type: 'boolean')] +private bool $notificationMobileVerified = false; +``` + +> **Migration**: بعد از تغییر entity اجرا کن. + +### بک‌اند — endpoint های جدید: +فایل: `src/Doctor/Controller/DoctorNotificationController.php` (یا در کنترلر موجود دکتر) + +``` +PATCH /api/v1/doctor/notification-mobile [ROLE_DOCTOR | ROLE_CLINIC | ROLE_ADMIN] + body: { mobile: "09..." } + → شماره را ذخیره کن (verified=false)، OTP ارسال کن + → response: { message: "کد تأیید ارسال شد" } + +POST /api/v1/doctor/notification-mobile/verify [ROLE_DOCTOR | ROLE_CLINIC | ROLE_ADMIN] + body: { mobile: "09...", code: "12345" } + → کد OTP را بررسی کن → notification_mobile_verified = true + → response: { message: "شماره تأیید شد" } + +GET /api/v1/doctor/notification-mobile [ROLE_DOCTOR | ROLE_CLINIC | ROLE_ADMIN] + → response: { mobile: "09...", verified: true } +``` + +**OTP**: از زیرساخت پیامک موجود (`src/Sms/`) استفاده کن — همان روشی که برای تأیید موبایل کاربر استفاده می‌شود. + +**ارسال پیامک هنگام ثبت نوبت**: +در `AppointmentController::book()` بعد از ذخیره نوبت: +- اگر `doctor.notificationMobile` پر بود و `notificationMobileVerified = true`: + - یک پیامک با متن «نوبت جدید — بیمار: {نام} | زمان: {ساعت}» ارسال کن + +### فرانت‌اند — تب/بخش تنظیمات اطلاع‌رسانی: +در صفحه اطلاعات دکتر (`DoctorDetailPage` یا پروفایل دکتر) یک بخش جدید اضافه کن: + +**«شماره اطلاع‌رسانی نوبت»**: +- نمایش شماره فعلی (اگر موجود) + وضعیت تأیید (تأیید شده / تأیید نشده) +- دکمه «تغییر شماره»: باز می‌کند یک فرم یک‌فیلدی (ورودی موبایل) + دکمه «ارسال کد» +- بعد از ارسال: فیلد کد OTP ظاهر می‌شود + دکمه «تأیید» +- پیشنهاد سریع: «استفاده از موبایل دکتر» (موبایل دکتر را از context پر می‌کند) + +> **داکیومنت**: بعد از پیاده‌سازی به‌روز کن: +> - `docs/api/doctor.md` — اضافه: سه endpoint notification-mobile + --- ## نکات مهم پیاده‌سازی @@ -496,43 +1001,70 @@ const endpoint = primaryRole === 'admin' - نوبت‌های «امروز»: بازه `strtotime('today midnight')` تا `strtotime('tomorrow midnight') - 1` ### ترتیب اجرا (پیشنهادی) -1. `DoctorSecretaryRepository::findActiveBySecretary()` — متد جدید -2. `AuthController::userInfo()` — گسترش برای `primary_role` + `context`، تست با curl -3. `authStore.ts` — اضافه کردن fetchMe + فیلدهای جدید -4. `App.tsx` — fetchMe در mount -5. `DashboardController` — هر سه endpoint -6. `DashboardPage.tsx` — sub-dashboardها -7. `Sidebar.tsx` — پویا -8. `App.tsx` — RoleRoute -9. `MyClinicPage.tsx` -10. `MyAppointmentsController` — بک‌اند -11. `AppointmentsPage.tsx` — فیلتر endpoint -12. بعد از هر مرحله بک‌اند: `ddev exec php bin/console cache:clear` -13. بعد از هر مرحله فرانت‌اند: `ddev exec yarn dev` +1. `UserActiveContext` entity جدید → migration +2. `DoctorSecretaryRepository` — اضافه: `findAllActiveBySecretary()` و `findActiveBySecretary()` +3. `AuthController::userInfo()` — گسترش: `primary_role`، `db_uuid`، `db_key`، `context`، `available_contexts` +4. `AuthController::switchContext()` — endpoint جدید `POST /api/v1/auth/switch-context` +5. `authStore.ts` — اضافه: `dbUuid`، `dbKey`، `availableContexts`، `fetchMe()`، `switchContext()` +6. `App.tsx` — fetchMe در mount + redirect به `/admin/select-context` اگر چند-context +7. `SelectContextPage.tsx` — صفحه انتخاب محیط کاری +8. `DashboardController` — هر سه endpoint +9. `DashboardPage.tsx` — sub-dashboardها +10. `Sidebar.tsx` — پویا + دکمه تغییر محیط کاری +11. `App.tsx` — RoleRoute +12. `MyClinicPage.tsx` +13. `MyAppointmentsController` — بک‌اند +14. `AppointmentsPage.tsx` — دو نما (جدولی/زمانبندی) + endpoint پویا + آمار هدر +15. `Appointment` entity — وضعیت‌های جدید + `booked_by` + `commission_rials` → migration +16. `SiteConfig` entity + repository → migration +17. `SiteConfigController` — `GET/PATCH /api/v1/admin/settings` +18. `SettingsPage.tsx` — پنل ادمین تنظیم کمیسیون +19. `AppointmentController::book()` — منطق کمیسیون + `booked_by` +20. `Doctor`/`Clinic` entity — فیلدهای `notification_mobile` → migration +21. `DoctorNotificationController` — سه endpoint OTP تأیید شماره +22. فرانت‌اند بخش اطلاع‌رسانی در صفحه پروفایل دکتر / کلینیک +23. بعد از هر مرحله بک‌اند: `ddev exec php bin/console cache:clear` +24. بعد از هر مرحله فرانت‌اند: `ddev exec yarn dev` --- ## خلاصه فایل‌های جدید/تغییریافته ### بک‌اند (تغییر) -- `src/Auth/Controller/AuthController.php` — گسترش `userInfo()`: اضافه کردن `primary_role` و `context` -- `src/Secretary/Repository/DoctorSecretaryRepository.php` — اضافه: `findActiveBySecretary()` +- `src/Auth/Controller/AuthController.php` — گسترش `userInfo()`: اضافه کردن `primary_role`، `db_uuid`، `db_key`، `context`، `available_contexts` + endpoint جدید `switch-context` +- `src/Secretary/Repository/DoctorSecretaryRepository.php` — اضافه: `findActiveBySecretary()` و `findAllActiveBySecretary()` +- `src/Appointment/Entity/Appointment.php` — وضعیت‌های جدید + فیلدهای `booked_by`، `commission_rials` +- `src/Appointment/Controller/AppointmentController.php` — منطق کمیسیون + ارسال پیامک اطلاع‌رسانی +- `src/Doctor/Entity/Doctor.php` — فیلدهای `notification_mobile`، `notification_mobile_verified` +- `src/Clinic/Entity/Clinic.php` — فیلدهای `notification_mobile`، `notification_mobile_verified` ### بک‌اند (جدید) +- `src/Auth/Entity/UserActiveContext.php` — ذخیره context فعال کاربر - `src/Dashboard/Controller/DashboardController.php` - `src/Appointment/Controller/MyAppointmentsController.php` +- `src/Admin/Entity/SiteConfig.php` — موجودیت تنظیمات سایت (key-value) +- `src/Admin/Repository/SiteConfigRepository.php` — متدهای `get()`, `set()`, `all()` +- `src/Admin/Controller/SiteConfigController.php` — `GET/PATCH /api/v1/admin/settings` +- `src/Doctor/Controller/DoctorNotificationController.php` — سه endpoint شماره اطلاع‌رسانی ### فرانت‌اند (تغییر) -- `assets/admin/stores/authStore.ts` — اضافه: primaryRole، context، fetchMe() -- `assets/admin/App.tsx` — اضافه: RoleRoute، fetchMe در mount، route های جدید -- `assets/admin/components/layout/Sidebar.tsx` — تبدیل به پویا +- `assets/admin/stores/authStore.ts` — اضافه: primaryRole، dbUuid، dbKey، context، availableContexts، fetchMe()، switchContext() +- `assets/admin/App.tsx` — اضافه: RoleRoute، fetchMe در mount، redirect به select-context اگر چند-context، route های جدید + `/admin/settings` +- `assets/admin/components/layout/Sidebar.tsx` — تبدیل به پویا + دکمه تغییر محیط کاری - `assets/admin/pages/DashboardPage.tsx` — multi-role -- `assets/admin/pages/AppointmentsPage.tsx` — endpoint پویا +- `assets/admin/pages/AppointmentsPage.tsx` — endpoint پویا + دو نما (جدولی/زمانبندی) + آمار هدر ### فرانت‌اند (جدید) +- `assets/admin/pages/SelectContextPage.tsx` — انتخاب محیط کاری برای کاربران چند-context - `assets/admin/pages/MyClinicPage.tsx` +- `assets/admin/pages/SettingsPage.tsx` — تنظیم کمیسیون نوبت + +### Migration +- `migrations/VersionXXX.php` — اضافه: ستون‌های `booked_by`، `commission_rials` به `appointments`؛ جدول `site_config`؛ ستون‌های `notification_mobile`، `notification_mobile_verified` به `doctors` و `clinics` ### داکیومنت (به‌روزرسانی/جدید) -- `docs/api/auth.md` — اضافه: `GET /api/v1/me` -- `docs/api/appointment.md` — اضافه: `GET /api/v1/my/appointments` +- `docs/api/auth.md` — به‌روز: پاسخ `/oauth/userinfo` با `primary_role`، `db_uuid`، `db_key`، `context`، `available_contexts` + اضافه: `POST /api/v1/auth/switch-context` +- `docs/api/appointment.md` — اضافه: `GET /api/v1/my/appointments`، وضعیت‌های جدید، `booked_by`، `commission_rials` +- `docs/api/admin.md` — اضافه: `GET/PATCH /api/v1/admin/settings` +- `docs/api/doctor.md` — اضافه: endpoint های `notification-mobile` - `docs/api/dashboard.md` — **فایل جدید** برای سه endpoint داشبورد diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index cabe759c..d7b74e4b 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuthStore } from './stores/authStore'; import AdminLayout from './components/layout/AdminLayout'; import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; +import SelectContextPage from './pages/SelectContextPage'; import UsersPage from './pages/UsersPage'; import UserDetailPage from './pages/UserDetailPage'; import DoctorsPage from './pages/DoctorsPage'; @@ -25,10 +26,33 @@ import CategoriesPage from './pages/CategoriesPage'; import BlogsPage from './pages/BlogsPage'; import BlogFormPage from './pages/BlogFormPage'; import SecretariesPage from './pages/SecretariesPage'; +import MyClinicPage from './pages/MyClinicPage'; +import SettingsPage from './pages/SettingsPage'; + +// ── Guards ────────────────────────────────────────────────────────────────── function PrivateRoute({ children }: { children: React.ReactNode }) { - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - return isAuthenticated ? <>{children} : ; + const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe } = useAuthStore(); + + useEffect(() => { + if (isAuthenticated && !primaryRole) { + fetchMe(); + } + }, [isAuthenticated, primaryRole, fetchMe]); + + if (!isAuthenticated) return ; + + // اگر context هنوز لود نشده — صبر کن + if (isAuthenticated && !primaryRole) { + return
در حال بارگذاری...
; + } + + // اگر چند context دارد و هنوز انتخاب نشده — به صفحه انتخاب برو + if (availableContexts.length > 1 && !dbUuid) { + return ; + } + + return <>{children}; } function PublicRoute({ children }: { children: React.ReactNode }) { @@ -36,74 +60,77 @@ function PublicRoute({ children }: { children: React.ReactNode }) { return isAuthenticated ? : <>{children}; } +function RoleRoute({ roles, children }: { roles: string[]; children: React.ReactNode }) { + const primaryRole = useAuthStore((s) => s.primaryRole); + if (!primaryRole) return
در حال بارگذاری...
; + if (!roles.includes(primaryRole)) return ; + return <>{children}; +} + +// ── App ────────────────────────────────────────────────────────────────────── + export default function App() { return ( + {/* Public */} - - - } + element={} /> + + {/* انتخاب محیط کاری — نیاز به auth دارد اما خارج از AdminLayout */} - + } + /> + + {/* Protected */} + } > } /> + + {/* داشبورد — همه نقش‌ها */} } /> - {/* Users */} - } /> - } /> - - {/* Doctors */} - } /> - } /> - } /> - - {/* Clinics */} - } /> - } /> - - {/* Appointments */} + {/* نوبت‌ها — همه نقش‌ها */} } /> } /> - {/* Payments */} - } /> - } /> + {/* فقط ادمین */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - {/* Settlements */} - } /> + {/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */} + } /> - {/* Representations */} - } /> - } /> - - {/* Comments & Ratings */} - } /> - } /> - - {/* SMS */} - } /> - - {/* Categories */} - } /> - - {/* Blogs */} - } /> - } /> - } /> - - {/* Secretaries */} - } /> + {/* ادمین + کلینیک */} + } /> + } /> + } /> + } /> + } /> ); diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 3c2021e6..3007d09b 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -1,7 +1,9 @@ import { ArrowLeftOnRectangleIcon, + ArrowsRightLeftIcon, BanknotesIcon, BuildingOffice2Icon, + Cog6ToothIcon, CalendarDaysIcon, ChartBarIcon, ChatBubbleLeftEllipsisIcon, @@ -19,76 +21,140 @@ import { NavLink, useNavigate } from "react-router-dom"; import { useAuthStore } from "../../stores/authStore"; import { useUiStore } from "../../stores/uiStore"; -const sections = [ - { - label: "عمومی", - items: [ - { to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }, - { to: "/admin/users", icon: UserGroupIcon, label: "کاربران" }, - { to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" }, +type SectionItem = { to: string; icon: React.ElementType; label: string }; +type Section = { label: string; items: SectionItem[] }; + +function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] { + if (primaryRole === 'admin') { + return [ { - to: "/admin/clinics", - icon: BuildingOffice2Icon, - label: "کلینیک‌ها", + label: 'عمومی', + items: [ + { to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, + { to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' }, + { to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' }, + { to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیک‌ها' }, + ], }, - ], - }, - { - label: "مدیریت", - items: [ { - to: "/admin/appointments", - icon: CalendarDaysIcon, - label: "نوبت‌ها", + label: 'مدیریت', + items: [ + { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, + { to: '/admin/payments', icon: CreditCardIcon, label: 'پرداخت‌ها' }, + { to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویه‌حساب' }, + ], }, - { to: "/admin/payments", icon: CreditCardIcon, label: "پرداخت‌ها" }, { - to: "/admin/settlements", - icon: BanknotesIcon, - label: "تسویه‌حساب", + label: 'محتوا', + items: [ + { to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' }, + { to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' }, + { to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' }, + { to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' }, + ], }, - ], - }, - { - label: "محتوا", - items: [ { - to: "/admin/comments", - icon: ChatBubbleLeftEllipsisIcon, - label: "نظرات", + label: 'سیستم', + items: [ + { to: '/admin/categories', icon: TagIcon, label: 'دسته‌بندی‌ها' }, + { to: '/admin/representations', icon: UsersIcon, label: 'نمایندگان' }, + { to: '/admin/secretaries', icon: KeyIcon, label: 'منشی‌ها' }, + { to: '/admin/settings', icon: Cog6ToothIcon, label: 'تنظیمات' }, + ], }, - { to: "/admin/ratings", icon: StarIcon, label: "امتیازها" }, - { to: "/admin/blogs", icon: DocumentTextIcon, label: "بلاگ" }, - { to: "/admin/sms", icon: DevicePhoneMobileIcon, label: "پیامک" }, - ], - }, - { - label: "سیستم", - items: [ - { to: "/admin/categories", icon: TagIcon, label: "دسته‌بندی‌ها" }, + ]; + } + + if (primaryRole === 'clinic') { + const clinicTo = dbUuid ? `/admin/clinics/${dbUuid}` : '/admin/my-clinic'; + return [ { - to: "/admin/representations", - icon: UsersIcon, - label: "نمایندگان", + label: 'عمومی', + items: [ + { to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, + { to: clinicTo, icon: BuildingOffice2Icon, label: 'کلینیک من' }, + { to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' }, + ], }, - { to: "/admin/secretaries", icon: KeyIcon, label: "منشی‌ها" }, - ], - }, -]; + { + label: 'مدیریت', + items: [ + { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, + ], + }, + ]; + } + + if (primaryRole === 'doctor') { + return [ + { + label: 'عمومی', + items: [ + { to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, + ], + }, + { + label: 'مدیریت', + items: [ + { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌های من' }, + ], + }, + ]; + } + + if (primaryRole === 'secretary') { + return [ + { + label: 'عمومی', + items: [ + { to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, + ], + }, + { + label: 'مدیریت', + items: [ + { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, + ], + }, + ]; + } + + return [ + { + label: 'عمومی', + items: [{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }], + }, + ]; +} + +const ROLE_LABELS: Record = { + admin: 'مدیر کل', + clinic: 'مالک کلینیک', + doctor: 'پزشک', + secretary: 'منشی', + user: 'کاربر', +}; + +const HUES = [256, 205, 162, 295, 272]; + +function avatarBg(name: string): string { + const hue = HUES[(name.charCodeAt(0) ?? 0) % HUES.length]; + return `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`; +} interface Props { mobileOpen?: boolean; onMobileClose?: () => void; } -export default function Sidebar({ - mobileOpen: _mobileOpen, - onMobileClose: _onMobileClose, -}: Props) { - const sidebarOpen = useUiStore((s) => s.sidebarOpen); - const logout = useAuthStore((s) => s.logout); +export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) { + const sidebarOpen = useUiStore((s) => s.sidebarOpen); + const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore(); const navigate = useNavigate(); + const sections = buildSections(primaryRole, dbUuid); + const initials = (userName ?? 'U').charAt(0).toUpperCase(); + return ( diff --git a/assets/admin/components/ui/NotificationMobileCard.tsx b/assets/admin/components/ui/NotificationMobileCard.tsx new file mode 100644 index 00000000..1fb19ad7 --- /dev/null +++ b/assets/admin/components/ui/NotificationMobileCard.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { DevicePhoneMobileIcon, CheckCircleIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; + +interface NotificationMobileData { + notification_mobile: string | null; +} + +interface Props { + target: 'doctor' | 'clinic'; +} + +export default function NotificationMobileCard({ target }: Props) { + const qc = useQueryClient(); + const [step, setStep] = useState<'idle' | 'enter_mobile' | 'enter_otp'>('idle'); + const [newMobile, setNewMobile] = useState(''); + const [otpCode, setOtpCode] = useState(''); + const [error, setError] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['notification-mobile', target], + queryFn: () => api.get>(`/api/v1/notification-mobile/${target}`), + staleTime: 60_000, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const current: string | null = ((data?.data as any)?.data ?? data?.data)?.notification_mobile ?? null; + + const requestOtp = useMutation({ + mutationFn: (mobile: string) => + api.post>('/api/v1/notification-mobile/request-otp', { + target, new_mobile: mobile, + }), + onSuccess: () => { + setError(null); + setStep('enter_otp'); + }, + onError: (e: unknown) => setError(String(e)), + }); + + const verify = useMutation({ + mutationFn: (code: string) => + api.post>('/api/v1/notification-mobile/verify', { + target, otp_code: code, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['notification-mobile', target] }); + setStep('idle'); + setNewMobile(''); + setOtpCode(''); + setError(null); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onError: (e: any) => setError(e?.message ?? 'خطا در تأیید کد'), + }); + + const remove = useMutation({ + mutationFn: () => api.delete>(`/api/v1/notification-mobile/${target}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-mobile', target] }), + }); + + if (isLoading) { + return
; + } + + return ( +
+
+
+
+ +
+
+

شماره اعلان نوبت

+

+ پیامک نوبت جدید به این شماره ارسال می‌شود +

+
+
+
+ + {/* نمایش شماره فعلی */} + {current && step === 'idle' && ( +
+ + {current} + +
+ )} + + {!current && step === 'idle' && ( +

+ شماره‌ای تنظیم نشده است. +

+ )} + + {/* مرحله ۱: ورود شماره جدید */} + {step === 'idle' && ( + + )} + + {step === 'enter_mobile' && ( +
+
+ + setNewMobile(e.target.value)} + placeholder="09XXXXXXXXX" + dir="ltr" + style={{ + width: '100%', maxWidth: 220, height: 38, padding: '0 12px', + borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', + background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, + boxSizing: 'border-box', + }} + /> +
+ {error &&

{error}

} +
+ + +
+
+ )} + + {/* مرحله ۲: ورود کد OTP */} + {step === 'enter_otp' && ( +
+

+ کد ۶ رقمی ارسال‌شده به {newMobile} را وارد کنید. +

+
+ setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="_ _ _ _ _ _" + dir="ltr" + maxLength={6} + style={{ + width: 140, height: 44, padding: '0 12px', textAlign: 'center', + letterSpacing: 8, fontSize: 22, fontWeight: 700, + borderRadius: 'var(--r-sm)', border: '1.5px solid var(--border)', + background: 'var(--surface)', color: 'var(--text)', boxSizing: 'border-box', + }} + /> +
+ {error &&

{error}

} +
+ + + +
+
+ )} +
+ ); +} diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 4b4dd8f6..1a78d1bf 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -2,8 +2,8 @@ import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { - MagnifyingGlassIcon, EyeIcon, - CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon, + MagnifyingGlassIcon, EyeIcon, TableCellsIcon, CalendarDaysIcon as CalendarViewIcon, + CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon, FunnelIcon, } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; import type { PaginatedResponse } from '../lib/api'; @@ -12,29 +12,153 @@ import { formatDate, formatRial, maskMobile } from '../lib/utils'; import DataTable, { Column } from '../components/ui/DataTable'; import StatusBadge from '../components/ui/StatusBadge'; import Pagination from '../components/ui/Pagination'; +import { useAuthStore } from '../stores/authStore'; + +// ── Status helpers ──────────────────────────────────────────────────────── const STATUS_FILTERS = [ { value: '', label: 'همه' }, { value: 'waiting_for_payment', label: 'در انتظار پرداخت' }, { value: 'reserved', label: 'رزرو شده' }, + { value: 'checked_in', label: 'ورود به مطب' }, + { value: 'waiting', label: 'صف انتظار' }, + { value: 'in_progress', label: 'در حال ویزیت' }, { value: 'visited', label: 'ویزیت شده' }, - { value: 'cancelled_by_user', label: 'لغو شده' }, + { value: 'completed', label: 'تکمیل شده' }, + { value: 'cancelled_by_doctor', label: 'لغو پزشک' }, + { value: 'cancelled_by_user', label: 'لغو بیمار' }, + { value: 'no_show', label: 'غیبت' }, ]; +const APPT_CLS: Record = { + waiting_for_payment: 'amber', reserved: 'blue', checked_in: 'violet', + waiting: 'amber', in_progress: 'violet', visited: 'green', completed: 'green', + cancelled_by_doctor: 'red', cancelled_by_user: 'red', auto_cancel_unpaid: 'gray', no_show: 'gray', +}; +const APPT_LABEL: Record = { + waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب', + waiting: 'صف انتظار', in_progress: 'در حال ویزیت', visited: 'ویزیت شده', + completed: 'تکمیل شده', cancelled_by_doctor: 'لغو پزشک', cancelled_by_user: 'لغو بیمار', + auto_cancel_unpaid: 'لغو خودکار', no_show: 'غیبت', +}; + +// ── Timeline View ───────────────────────────────────────────────────────── + +interface TimelineProps { + items: Appointment[]; + loading: boolean; + onView: (uuid: string) => void; +} + +function TimelineView({ items, loading, onView }: TimelineProps) { + if (loading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (!items.length) { + return

هیچ نوبتی یافت نشد

; + } + + // گروه‌بندی بر اساس تاریخ + const grouped = items.reduce>((acc, a) => { + const key = a.appointment_date; + if (!acc[key]) acc[key] = []; + acc[key].push(a); + return acc; + }, {}); + + return ( +
+ {Object.entries(grouped).map(([date, appts]) => ( +
+
+ + {new Date(date).toLocaleDateString('fa-IR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} + +
+
+ {appts.map((a) => ( +
onView(a.uuid)} + onMouseEnter={e => (e.currentTarget.style.boxShadow = 'var(--shadow-sm, 0 2px 8px rgba(0,0,0,.08))')} + onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')} + > + {/* ساعت */} +
+ {a.appointment_time} +
+ + {/* اطلاعات */} +
+
+ {a.patient_name || maskMobile(a.patient_mobile)} +
+
+ دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''} +
+
+ + {/* وضعیت */} + + {APPT_LABEL[a.status] ?? a.status} + + + +
+ ))} +
+
+ ))} +
+ ); +} + +// ── Main Component ──────────────────────────────────────────────────────── + export default function AppointmentsPage() { const navigate = useNavigate(); + const primaryRole = useAuthStore(s => s.primaryRole); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); + const [dateFilter, setDateFilter] = useState(''); + const [viewMode, setViewMode] = useState<'table' | 'timeline'>('table'); const limit = 15; + const isAdmin = primaryRole === 'admin'; + const endpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments'; + const { data, isLoading } = useQuery({ - queryKey: ['appointments', page, search, statusFilter], + queryKey: ['appointments', endpoint, page, search, statusFilter, dateFilter], queryFn: () => { const params = new URLSearchParams({ page: String(page), limit: String(limit) }); if (search) params.set('search', search); if (statusFilter) params.set('status', statusFilter); - return api.get>(`/api/v1/admin/appointments?${params}`); + if (dateFilter) params.set('date', dateFilter); + return api.get>(`${endpoint}?${params}`); }, }); @@ -84,11 +208,13 @@ export default function AppointmentsPage() { const items = data?.data ?? []; const total = data?.meta?.totalRecords ?? 0; + const pageTitle = isAdmin ? 'نوبت‌ها' : (primaryRole === 'doctor' ? 'نوبت‌های من' : primaryRole === 'secretary' ? 'نوبت‌های پزشک' : 'نوبت‌های کلینیک'); + const statCards = [ - { label: 'کل نوبت‌ها', value: total > 0 ? String(total) : null, bg: 'var(--info-bg)', color: 'var(--info)', Icon: CalendarIcon }, - { label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon }, - { label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon }, - { label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon }, + { label: 'کل نوبت‌ها', value: total > 0 ? String(total) : null, bg: 'var(--info-bg)', color: 'var(--info)', Icon: CalendarIcon }, + { label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon }, + { label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon }, + { label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon }, ]; return ( @@ -96,7 +222,7 @@ export default function AppointmentsPage() { {/* Header */}
-

نوبت‌ها

+

{pageTitle}

مدیریت و پیگیری نوبت‌های درمانی
+ + {/* فیلتر تاریخ */} +
+ + { setDateFilter(e.target.value); setPage(1); }} + style={{ direction: 'ltr' }} + /> +
+ + {/* فیلتر وضعیت */} + + +
+ + {/* تغییر نما */} +
+ +
- - columns={columns} - data={items} - loading={isLoading} - emptyMessage="هیچ نوبتی یافت نشد" - actions={(appt) => ( - - )} - /> - + {viewMode === 'table' ? ( + <> + + columns={columns} + data={items} + loading={isLoading} + emptyMessage="هیچ نوبتی یافت نشد" + actions={(appt) => ( + + )} + /> + + + ) : ( + <> + navigate(`/admin/appointments/${uuid}`)} + /> + + + )}
); diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 1eef876a..2bdbfdb6 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -20,6 +20,8 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { ClinicDetail } from '../types'; import { formatNumber } from '../lib/utils'; import ConfirmDialog from '../components/ui/ConfirmDialog'; +import NotificationMobileCard from '../components/ui/NotificationMobileCard'; +import { useAuthStore } from '../stores/authStore'; // Fix leaflet icons delete (L.Icon.Default.prototype as any)._getIconUrl; @@ -568,6 +570,7 @@ export default function ClinicDetailPage() { const { uuid } = useParams<{ uuid: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); + const primaryRole = useAuthStore(s => s.primaryRole); const [editOpen, setEditOpen] = useState(false); const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic'); const [deleteOpen, setDeleteOpen] = useState(false); @@ -1031,6 +1034,10 @@ export default function ClinicDetailPage() {
+ {primaryRole === 'clinic' && ( + + )} + diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index edd9890f..c77faedd 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -4,54 +4,14 @@ import { Link } from 'react-router-dom'; import { UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon, CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon, + ClockIcon, StarIcon, UserIcon, } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { formatNumber, formatRial, formatDateTime } from '../lib/utils'; +import { useAuthStore } from '../stores/authStore'; -// ── Types ───────────────────────────────────────────────────────────────── - -interface DashboardStats { - total_users: number; - active_doctors: number; - total_doctors: number; - total_clinics: number; - today_appointments: number; - total_appointments: number; - today_payments_count: number; - today_payments_amount: number; - total_payments_amount: number; - pending_comments: number; - pending_settlements: number; - this_month_revenue: number; - this_month_appointments: number; -} - -interface ChartData { - appointments_30d: { date: string; count: number }[]; - revenue_30d: { date: string; amount: number }[]; - appointment_status: { status: string; count: number }[]; - top_specialties: { name: string; count: number }[]; -} - -interface RecentAppointment { - uuid: string; slot_start: string; status: string; - doctor_name: string; user_mobile: string; user_name: string | null; created_at: string; -} -interface RecentPayment { - uuid: string; amount: number; status: string; gateway: string; - user_mobile: string; user_name: string | null; created_at: string; -} -interface RecentUser { - uuid: string; mobile: string; name: string | null; email: string | null; created_at: string; -} -interface RecentData { - appointments: RecentAppointment[]; - payments: RecentPayment[]; - users: RecentUser[]; -} - -// ── Status maps ─────────────────────────────────────────────────────────── +// ── Shared Status Maps ──────────────────────────────────────────────────── const APPT_LABEL: Record = { waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب', @@ -80,7 +40,7 @@ const PAY_CLS: Record = { pending: 'amber', received: 'green', canceled: 'red', refund: 'blue', }; -// ── SVG Chart Components ────────────────────────────────────────────────── +// ── Shared SVG Charts ───────────────────────────────────────────────────── function SvgLineChart({ data, color, h = 220 }: { data: number[]; color: string; h?: number }) { if (data.length < 2) return null; @@ -165,7 +125,7 @@ function SvgHBars({ data }: { data: { label: string; value: number }[] }) { ); } -// ── Colored Avatar ──────────────────────────────────────────────────────── +// ── Shared UI Pieces ────────────────────────────────────────────────────── function AvatarEl({ initials, hue = 222, size }: { initials: string; hue?: number; size?: 'sm' | 'lg' }) { const cls = 'avatar' + (size === 'sm' ? ' sm' : size === 'lg' ? ' lg' : ''); @@ -176,16 +136,9 @@ function AvatarEl({ initials, hue = 222, size }: { initials: string; hue?: numbe ); } -// ── MiniList ────────────────────────────────────────────────────────────── - interface MiniRow { - title: string; - sub: string; - meta: string; - badgeLabel: string; - badgeCls: string; - initials: string; - hue: number; + title: string; sub: string; meta: string; + badgeLabel: string; badgeCls: string; initials: string; hue: number; } function MiniList({ title, to, rows, loading }: { title: string; to: string; rows: MiniRow[]; loading?: boolean }) { @@ -230,8 +183,6 @@ function MiniList({ title, to, rows, loading }: { title: string; to: string; row ); } -// ── Skeleton ────────────────────────────────────────────────────────────── - function KpiSkeleton() { return (
@@ -243,62 +194,116 @@ function KpiSkeleton() { ); } -// ── Main Component ──────────────────────────────────────────────────────── +function LoadingSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => )} +
+
+
+ ); +} -export default function DashboardPage() { +// ── Appointments Table (shared) ──────────────────────────────────────────── + +interface ApptRow { + uuid: string; + patient_name: string | null; + patient_mobile?: string; + slot_start: number; + status: string; +} + +function TodayAppointmentsTable({ appts, loading }: { appts: ApptRow[]; loading: boolean }) { + if (loading) return
; + if (!appts.length) return

نوبتی برای امروز ثبت نشده

; + return ( +
+ + + + + + + + + + {appts.map((a, i) => ( + + + + + + ))} + +
بیمارساعتوضعیت
{a.patient_name || a.patient_mobile || '—'} + {new Date(a.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })} + + + {APPT_LABEL[a.status] ?? a.status} + +
+
+ ); +} + +// ── Admin Dashboard ─────────────────────────────────────────────────────── + +interface AdminStats { + total_users: number; active_doctors: number; total_doctors: number; total_clinics: number; + today_appointments: number; total_appointments: number; today_payments_count: number; + today_payments_amount: number; total_payments_amount: number; pending_comments: number; + pending_settlements: number; this_month_revenue: number; this_month_appointments: number; +} +interface AdminCharts { + appointments_30d: { date: string; count: number }[]; + revenue_30d: { date: string; amount: number }[]; + appointment_status: { status: string; count: number }[]; + top_specialties: { name: string; count: number }[]; +} +interface AdminRecent { + appointments: { uuid: string; slot_start: string; status: string; doctor_name: string; user_mobile: string; user_name: string | null; created_at: string }[]; + payments: { uuid: string; amount: number; status: string; gateway: string; user_mobile: string; user_name: string | null; created_at: string }[]; + users: { uuid: string; mobile: string; name: string | null; email: string | null; created_at: string }[]; +} + +function AdminDashboard() { const [chartMode, setChartMode] = useState<'appts' | 'rev'>('appts'); - const statsQ = useQuery({ - queryKey: ['dashboard-stats'], - queryFn: () => api.get>('/api/v1/admin/dashboard/stats'), - staleTime: 60_000, - }); - const chartsQ = useQuery({ - queryKey: ['dashboard-charts'], - queryFn: () => api.get>('/api/v1/admin/dashboard/charts'), - staleTime: 120_000, - }); - const recentQ = useQuery({ - queryKey: ['dashboard-recent'], - queryFn: () => api.get>('/api/v1/admin/dashboard/recent'), - staleTime: 30_000, - }); + const statsQ = useQuery({ queryKey: ['dashboard-stats'], queryFn: () => api.get>('/api/v1/admin/dashboard/stats'), staleTime: 60_000 }); + const chartsQ = useQuery({ queryKey: ['dashboard-charts'], queryFn: () => api.get>('/api/v1/admin/dashboard/charts'), staleTime: 120_000 }); + const recentQ = useQuery({ queryKey: ['dashboard-recent'], queryFn: () => api.get>('/api/v1/admin/dashboard/recent'), staleTime: 30_000 }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stats = useMemo(() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]); + const stats = useMemo(() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const charts = useMemo( () => (chartsQ.data?.data as any)?.data ?? chartsQ.data?.data, [chartsQ.data]); + const charts = useMemo(() => (chartsQ.data?.data as any)?.data ?? chartsQ.data?.data, [chartsQ.data]); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const recent = useMemo( () => (recentQ.data?.data as any)?.data ?? recentQ.data?.data, [recentQ.data]); + const recent = useMemo(() => (recentQ.data?.data as any)?.data ?? recentQ.data?.data, [recentQ.data]); const fn = (n?: number) => n !== undefined ? formatNumber(n) : '—'; const fr = (n?: number) => n !== undefined ? formatRial(n) : '—'; const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' }); const isFetching = statsQ.isFetching || chartsQ.isFetching || recentQ.isFetching; - // Chart data transformations const apptSeries = useMemo(() => charts?.appointments_30d?.map(d => d.count) ?? [], [charts]); const revSeries = useMemo(() => charts?.revenue_30d?.map(d => d.amount) ?? [], [charts]); const donutData = useMemo(() => (charts?.appointment_status ?? []).slice(0, 7).map(s => ({ - label: APPT_LABEL[s.status] ?? s.status, - value: s.count, - color: APPT_COLOR[s.status] ?? '#94a3b8', + label: APPT_LABEL[s.status] ?? s.status, value: s.count, color: APPT_COLOR[s.status] ?? '#94a3b8', })), [charts]); - const hbarsData = useMemo(() => - (charts?.top_specialties ?? []).map(s => ({ label: s.name, value: s.count })), [charts]); + const hbarsData = useMemo(() => (charts?.top_specialties ?? []).map(s => ({ label: s.name, value: s.count })), [charts]); - // KPI cards const kpiCards = [ - { label: 'کل کاربران', value: fn(stats?.total_users), hint: 'رشد نسبت به ماه قبل', icon: UserGroupIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' }, - { label: 'پزشکان فعال', value: fn(stats?.active_doctors), hint: stats ? `از ${fn(stats.total_doctors)} پزشک` : '', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' }, - { label: 'کلینیک‌ها', value: fn(stats?.total_clinics), hint: '', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' }, - { label: 'نوبت‌های امروز', value: fn(stats?.today_appointments), hint: stats ? `ماه جاری: ${fn(stats.this_month_appointments)}` : '', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, - { label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: 'تومان', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }, + { label: 'کل کاربران', value: fn(stats?.total_users), hint: '', icon: UserGroupIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' }, + { label: 'پزشکان فعال', value: fn(stats?.active_doctors), hint: stats ? `از ${fn(stats.total_doctors)} پزشک` : '', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' }, + { label: 'کلینیک‌ها', value: fn(stats?.total_clinics), hint: '', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' }, + { label: 'نوبت‌های امروز', value: fn(stats?.today_appointments), hint: stats ? `ماه جاری: ${fn(stats.this_month_appointments)}` : '', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, + { label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: 'تومان', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }, { label: 'در انتظار بررسی', value: fn(stats ? stats.pending_comments + stats.pending_settlements : undefined), hint: stats ? `${fn(stats.pending_comments)} نظر · ${fn(stats.pending_settlements)} تسویه` : '', icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' }, ]; - // Quick actions const quickActions = [ { label: 'پزشکان', to: '/admin/doctors', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' }, { label: 'کلینیک‌ها', to: '/admin/clinics', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' }, @@ -308,7 +313,6 @@ export default function DashboardPage() { { label: 'کاربران', to: '/admin/users', icon: UserGroupIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }, ]; - // Timeline events const timelineEvents = useMemo(() => { if (!recent) return []; const evs: { title: string; sub: string; time: string; color: string }[] = []; @@ -324,47 +328,32 @@ export default function DashboardPage() { return evs.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()).slice(0, 8); }, [recent]); - // MiniList rows const apptRows = useMemo(() => (recent?.appointments ?? []).slice(0, 5).map(a => ({ - title: a.user_name || a.user_mobile, - sub: `دکتر ${a.doctor_name}`, - meta: formatDateTime(a.slot_start), - badgeLabel: APPT_LABEL[a.status] ?? a.status, - badgeCls: APPT_CLS[a.status] ?? 'gray', - initials: (a.user_name || a.user_mobile).slice(0, 2), - hue: 222, + title: a.user_name || a.user_mobile, sub: `دکتر ${a.doctor_name}`, meta: formatDateTime(a.slot_start), + badgeLabel: APPT_LABEL[a.status] ?? a.status, badgeCls: APPT_CLS[a.status] ?? 'gray', + initials: (a.user_name || a.user_mobile).slice(0, 2), hue: 222, })), [recent]); const payRows = useMemo(() => (recent?.payments ?? []).slice(0, 5).map(p => ({ - title: p.user_name || p.user_mobile, - sub: formatRial(p.amount), - meta: formatDateTime(p.created_at), - badgeLabel: PAY_LABEL[p.status] ?? p.status, - badgeCls: PAY_CLS[p.status] ?? 'gray', - initials: (p.user_name || p.user_mobile).slice(0, 2), - hue: 162, + title: p.user_name || p.user_mobile, sub: formatRial(p.amount), meta: formatDateTime(p.created_at), + badgeLabel: PAY_LABEL[p.status] ?? p.status, badgeCls: PAY_CLS[p.status] ?? 'gray', + initials: (p.user_name || p.user_mobile).slice(0, 2), hue: 162, })), [recent]); const userRows = useMemo(() => (recent?.users ?? []).slice(0, 5).map(u => ({ - title: u.name || u.mobile, - sub: u.name ? u.mobile : '', - meta: formatDateTime(u.created_at), - badgeLabel: 'فعال', - badgeCls: 'green', - initials: (u.name || u.mobile).slice(0, 2), - hue: 256, + title: u.name || u.mobile, sub: u.name ? u.mobile : '', meta: formatDateTime(u.created_at), + badgeLabel: 'فعال', badgeCls: 'green', + initials: (u.name || u.mobile).slice(0, 2), hue: 256, })), [recent]); return (
- - {/* Page header */}
-

داشبورد

+

داشبورد مدیریت

{today} · نمای کلی عملکرد مجموعه
@@ -377,7 +366,6 @@ export default function DashboardPage() {
- {/* Stat cards (6-col grid) */}
{statsQ.isLoading ? Array.from({ length: 6 }).map((_, i) => ) @@ -396,7 +384,6 @@ export default function DashboardPage() { }
- {/* dash-main: Donut (360px) + LineChart (1fr) */}

وضعیت نوبت‌ها

@@ -428,7 +415,6 @@ export default function DashboardPage() { )}
-

{chartMode === 'appts' ? 'نوبت‌ها' : 'درآمد'} — ۳۰ روز اخیر

@@ -448,7 +434,6 @@ export default function DashboardPage() {
- {/* Top specialties (HBars) */}

پرتکرارترین تخصص‌ها

@@ -463,7 +448,6 @@ export default function DashboardPage() { )}
- {/* grid-2: Quick access + Timeline */}

دسترسی سریع

@@ -478,11 +462,8 @@ export default function DashboardPage() { ))}
-
-
-

آخرین رویدادها

-
+

آخرین رویدادها

{recentQ.isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( @@ -514,13 +495,299 @@ export default function DashboardPage() {
- {/* dash-3: Three MiniLists */}
-
); } + +// ── Clinic Dashboard ────────────────────────────────────────────────────── + +interface ClinicDashboardData { + clinic: { uuid: string; name: string; is_active: boolean; logo: string | null }; + stats: { total_doctors: number; today_appointments: number; this_month_appointments: number; pending_invitations: number }; + today_appointments: ApptRow[]; + doctors: { uuid: string; name: string; today_count: number }[]; +} + +function ClinicDashboard() { + const { context } = useAuthStore(); + const q = useQuery({ + queryKey: ['dashboard-clinic'], + queryFn: () => api.get>('/api/v1/dashboard/clinic'), + staleTime: 60_000, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = useMemo(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]); + const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' }); + + if (q.isLoading) return ; + + const kpiCards = [ + { label: 'پزشکان', value: formatNumber(d?.stats.total_doctors ?? 0), icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' }, + { label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, + { label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' }, + { label: 'دعوتنامه در انتظار', value: formatNumber(d?.stats.pending_invitations ?? 0), icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' }, + ]; + + return ( +
+
+
+

داشبورد کلینیک

+
{today} · {d?.clinic.name ?? context?.name ?? ''}
+
+ +
+ +
+ {kpiCards.map(c => ( +
+
+ +
+
{c.label}
+
{c.value}
+
+ ))} +
+ +
+
+
+

نوبت‌های امروز

+ همه نوبت‌ها +
+ +
+ +
+
+

پزشکان کلینیک

+ همه +
+ {!d?.doctors.length ? ( +

پزشکی ثبت نشده

+ ) : ( +
+ {d.doctors.map((doc, i) => ( + + +
+ دکتر {doc.name} +
+ {formatNumber(doc.today_count)} امروز + + ))} +
+ )} +
+
+
+ ); +} + +// ── Doctor Dashboard ────────────────────────────────────────────────────── + +interface DoctorDashboardData { + doctor: { uuid: string; name: string; degree: string | null }; + stats: { today_appointments: number; tomorrow_appointments: number; this_month_appointments: number; avg_rating: number | null; total_ratings: number }; + today_appointments: ApptRow[]; + clinics: { uuid: string; name: string; logo: string | null }[]; +} + +function DoctorDashboard() { + const { context } = useAuthStore(); + const q = useQuery({ + queryKey: ['dashboard-doctor'], + queryFn: () => api.get>('/api/v1/dashboard/doctor'), + staleTime: 60_000, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = useMemo(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]); + const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' }); + + if (q.isLoading) return ; + + const kpiCards = [ + { label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, + { label: 'نوبت‌های فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' }, + { label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--success)', bg: 'var(--success-bg)' }, + { label: 'میانگین امتیاز', value: d?.stats.avg_rating != null ? String(d.stats.avg_rating) : '—', icon: StarIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }, + ]; + + return ( +
+
+
+

داشبورد پزشک

+
{today} · دکتر {d?.doctor.name ?? context?.name ?? ''}
+
+ +
+ +
+ {kpiCards.map(c => ( +
+
+ +
+
{c.label}
+
{c.value}
+ {c.label === 'میانگین امتیاز' && d?.stats.total_ratings ? ( +
{formatNumber(d.stats.total_ratings)} نظر
+ ) : null} +
+ ))} +
+ +
+
+
+

نوبت‌های امروز

+ همه نوبت‌ها +
+ +
+ +
+
+

کلینیک‌های من

+
+ {!d?.clinics.length ? ( +

عضو کلینیکی نیستید

+ ) : ( +
+ {d.clinics.map((c, i) => ( +
+ +
+ {c.name} +
+ فعال +
+ ))} +
+ )} +
+
+
+ ); +} + +// ── Secretary Dashboard ─────────────────────────────────────────────────── + +interface SecretaryDashboardData { + doctor: { uuid: string; name: string; degree: string | null }; + permissions: Record; + stats: { today_appointments: number; tomorrow_appointments: number }; + today_appointments: ApptRow[]; +} + +function SecretaryDashboard() { + const q = useQuery({ + queryKey: ['dashboard-secretary'], + queryFn: () => api.get>('/api/v1/dashboard/secretary'), + staleTime: 60_000, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = useMemo(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]); + const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' }); + + if (q.isLoading) return ; + + const kpiCards = [ + { label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, + { label: 'نوبت‌های فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' }, + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const canViewAppts = (d?.permissions as any)?.resources?.appointments?.view ?? false; + + return ( +
+
+
+

داشبورد منشی

+
{today} · منشی دکتر {d?.doctor.name ?? ''}
+
+ +
+ +
+ +
+
دکتر {d?.doctor.name ?? '—'}
+ {d?.doctor.degree &&
{d.doctor.degree}
} +
+
+ + {canViewAppts ? 'دسترسی نوبت‌ها: فعال' : 'دسترسی نوبت‌ها: غیرفعال'} + +
+
+ +
+ {kpiCards.map(c => ( +
+
+ +
+
{c.label}
+
{c.value}
+
+ ))} +
+ + {canViewAppts && ( +
+
+

نوبت‌های امروز

+ همه نوبت‌ها +
+ +
+ )} + + {!canViewAppts && ( +
+ +

دسترسی مشاهده نوبت‌ها برای این منشی فعال نیست.

+
+ )} +
+ ); +} + +// ── Main Dispatcher ─────────────────────────────────────────────────────── + +export default function DashboardPage() { + const primaryRole = useAuthStore(s => s.primaryRole); + + if (!primaryRole) return ; + if (primaryRole === 'admin') return ; + if (primaryRole === 'clinic') return ; + if (primaryRole === 'doctor') return ; + if (primaryRole === 'secretary') return ; + + return ; +} diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index c475e1b6..a989f024 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -24,6 +24,7 @@ import type { ApiResponse } from '../lib/api'; import { formatNumber } from '../lib/utils'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; +import NotificationMobileCard from '../components/ui/NotificationMobileCard'; // Fix leaflet default marker icons delete (L.Icon.Default.prototype as any)._getIconUrl; @@ -1780,6 +1781,7 @@ export default function DoctorDetailPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const qc = useQueryClient(); + const primaryRole = useAuthStore(s => s.primaryRole); const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1'); const [deleteOpen, setDeleteOpen] = useState(false); @@ -2204,6 +2206,10 @@ export default function DoctorDetailPage() {
+ + {primaryRole === 'doctor' && ( + + )}
diff --git a/assets/admin/pages/MyClinicPage.tsx b/assets/admin/pages/MyClinicPage.tsx new file mode 100644 index 00000000..ca4ba2b6 --- /dev/null +++ b/assets/admin/pages/MyClinicPage.tsx @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; + +export default function MyClinicPage() { + const { dbUuid, fetchMe } = useAuthStore(); + const navigate = useNavigate(); + + useEffect(() => { + if (dbUuid) { + navigate(`/admin/clinics/${dbUuid}`, { replace: true }); + } else { + fetchMe().then(() => { + const uuid = useAuthStore.getState().dbUuid; + if (uuid) navigate(`/admin/clinics/${uuid}`, { replace: true }); + }); + } + }, [dbUuid, fetchMe, navigate]); + + return ( +
+

در حال بارگذاری اطلاعات کلینیک...

+
+ ); +} diff --git a/assets/admin/pages/SelectContextPage.tsx b/assets/admin/pages/SelectContextPage.tsx new file mode 100644 index 00000000..a14a6df4 --- /dev/null +++ b/assets/admin/pages/SelectContextPage.tsx @@ -0,0 +1,92 @@ +import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import { useAuthStore, ContextItem } from '../stores/authStore'; + +const ROLE_LABELS: Record = { + admin: 'مدیر کل', + clinic: 'مالک کلینیک', + doctor: 'پزشک', + secretary: 'منشی', + user: 'کاربر', +}; + +const TYPE_ICONS: Record = { + doctor: '🏥', + clinic: '🏢', +}; + +export default function SelectContextPage() { + const { availableContexts, switchContext } = useAuthStore(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(null); + + const handleSelect = async (ctx: ContextItem) => { + setLoading(ctx.db_uuid); + await switchContext(ctx.db_uuid); + navigate('/admin/dashboard', { replace: true }); + }; + + return ( +
+
+
+

+ انتخاب محیط کاری +

+

+ لطفاً محیط کاری مورد نظر خود را انتخاب کنید +

+ +
+ {availableContexts.map((ctx) => ( + + ))} +
+
+
+
+ ); +} diff --git a/assets/admin/pages/SettingsPage.tsx b/assets/admin/pages/SettingsPage.tsx new file mode 100644 index 00000000..5e6510a8 --- /dev/null +++ b/assets/admin/pages/SettingsPage.tsx @@ -0,0 +1,314 @@ +import React, { useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import { Cog6ToothIcon, CheckCircleIcon } from '@heroicons/react/24/outline'; + +// ── Schema ──────────────────────────────────────────────────────────────── + +const schema = z.object({ + site_name: z.string().min(1, 'نام سایت الزامی است'), + support_phone: z.string(), + commission_enabled: z.string(), + commission_percent: z.string().refine(v => { + const n = Number(v); + return !isNaN(n) && n >= 0 && n <= 100; + }, 'درصد باید بین ۰ تا ۱۰۰ باشد'), + max_cancel_hours_before: z.string(), + appointment_reminder_hours: z.string(), +}); + +type FormValues = z.infer; + +interface Settings { + site_name: string; + support_phone: string; + commission_enabled: string; + commission_percent: string; + max_cancel_hours_before: string; + appointment_reminder_hours: string; +} + +// ── Component ───────────────────────────────────────────────────────────── + +export default function SettingsPage() { + const qc = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['admin-settings'], + queryFn: () => api.get>('/api/v1/admin/settings'), + staleTime: 30_000, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const settings: Settings | undefined = (data?.data as any)?.data ?? data?.data; + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors, isDirty }, + } = useForm({ resolver: zodResolver(schema) }); + + useEffect(() => { + if (settings) { + reset({ + site_name: settings.site_name ?? 'ClinicPro', + support_phone: settings.support_phone ?? '', + commission_enabled: settings.commission_enabled ?? '0', + commission_percent: settings.commission_percent ?? '0', + max_cancel_hours_before: settings.max_cancel_hours_before ?? '24', + appointment_reminder_hours: settings.appointment_reminder_hours ?? '2', + }); + } + }, [settings, reset]); + + const mutation = useMutation({ + mutationFn: (values: FormValues) => + api.patch>('/api/v1/admin/settings', values), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin-settings'] }); + }, + }); + + const commissionEnabled = watch('commission_enabled') === '1'; + + const onSubmit = (values: FormValues) => { + mutation.mutate(values); + }; + + if (isLoading) { + return ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+ ))} +
+
+ ); + } + + return ( +
+
+
+

تنظیمات سایت

+
پیکربندی کلی پلتفرم
+
+ {mutation.isSuccess && ( +
+ + تنظیمات ذخیره شد +
+ )} +
+ +
+
+ + {/* اطلاعات پایه */} +
+
+
+
+ +
+

اطلاعات پایه

+
+
+ +
+
+ + + {errors.site_name &&

{errors.site_name.message}

} +
+ +
+ + +
+
+
+ + {/* تنظیمات کمیسیون */} +
+
+
+ ٪ +
+

کمیسیون سایت

+
+ +
+ {/* toggle فعال/غیرفعال */} +
+ + {/* تنظیمات نوبت‌دهی */} +
+
+
+ +
+

تنظیمات نوبت‌دهی

+
+ +
+
+ +
+ + ساعت قبل از نوبت +
+
+ +
+ +
+ + ساعت قبل از نوبت +
+
+
+
+ + {/* دکمه ذخیره */} +
+ + +
+ +
+ +
+ ); +} diff --git a/assets/admin/stores/authStore.ts b/assets/admin/stores/authStore.ts index 4ac94f90..f72aa931 100644 --- a/assets/admin/stores/authStore.ts +++ b/assets/admin/stores/authStore.ts @@ -1,25 +1,135 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +export interface ContextItem { + type: 'doctor' | 'clinic'; + db_uuid: string; + name: string; + role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user'; + permissions?: Record; +} + interface AuthState { token: string | null; refreshToken: string | null; isAuthenticated: boolean; + userUuid: string | null; + userName: string | null; + primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null; + dbUuid: string | null; + dbKey: string | null; + context: ContextItem | null; + availableContexts: ContextItem[]; + login: (token: string, refreshToken: string) => void; logout: () => void; + fetchMe: () => Promise; + switchContext: (dbUuid: string) => Promise; +} + +function getToken(): string | null { + try { + const raw = localStorage.getItem('clinicpro-auth'); + if (!raw) return null; + return JSON.parse(raw)?.state?.token ?? null; + } catch { + return null; + } +} + +async function apiFetch(path: string, options: RequestInit = {}) { + const token = getToken(); + const res = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + return res.json(); } export const useAuthStore = create()( persist( - (set) => ({ + (set, get) => ({ token: null, refreshToken: null, isAuthenticated: false, - login: (token, refreshToken) => - set({ token, refreshToken, isAuthenticated: true }), + userUuid: null, + userName: null, + primaryRole: null, + dbUuid: null, + dbKey: null, + context: null, + availableContexts: [], + + login: (token, refreshToken) => { + set({ token, refreshToken, isAuthenticated: true }); + get().fetchMe(); + }, + logout: () => - set({ token: null, refreshToken: null, isAuthenticated: false }), + set({ + token: null, + refreshToken: null, + isAuthenticated: false, + userUuid: null, + userName: null, + primaryRole: null, + dbUuid: null, + dbKey: null, + context: null, + availableContexts: [], + }), + + fetchMe: async () => { + try { + const res = await apiFetch('/oauth/userinfo'); + if (!res.success) return; + const d = res.data; + set({ + userUuid: d.uuid, + userName: d.realName, + primaryRole: d.primary_role ?? null, + dbUuid: d.db_uuid ?? null, + dbKey: d.db_key ?? null, + context: d.context ?? null, + availableContexts: d.available_contexts ?? [], + }); + } catch { + // شبکه در دسترس نیست — state دست‌نخورده بماند + } + }, + + switchContext: async (dbUuid: string) => { + const res = await apiFetch('/api/v1/auth/switch-context', { + method: 'POST', + body: JSON.stringify({ db_uuid: dbUuid }), + }); + if (res.success) { + set({ + dbUuid: res.data.db_uuid, + dbKey: res.data.db_key, + context: res.data.context, + }); + } + }, }), - { name: 'clinicpro-auth' } + { + name: 'clinicpro-auth', + partialize: (s) => ({ + token: s.token, + refreshToken: s.refreshToken, + isAuthenticated: s.isAuthenticated, + userUuid: s.userUuid, + userName: s.userName, + primaryRole: s.primaryRole, + dbUuid: s.dbUuid, + dbKey: s.dbKey, + context: s.context, + availableContexts: s.availableContexts, + }), + } ) ); diff --git a/docs/api/admin.md b/docs/api/admin.md index 0912d976..3a652231 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -677,3 +677,48 @@ List all SMS templates. | `POST /api/v1/admin/clinic/invitation/{invUuid}/resend` | Resend SMS | | `PATCH /api/v1/admin/clinic/invitation/{invUuid}/status` | Change status | | `DELETE /api/v1/admin/clinic/invitation/{invUuid}` | Delete | + +--- + +## Settings + +### GET /api/v1/admin/settings + +Returns all site configuration values. + +**Response `200`** +```json +{ + "success": true, + "data": { + "commission_enabled": "0", + "commission_percent": "0", + "site_name": "ClinicPro", + "support_phone": "", + "max_cancel_hours_before": "24", + "appointment_reminder_hours": "2" + } +} +``` + +All values are strings. Missing keys return their default values. + +### PATCH /api/v1/admin/settings + +Update one or more settings. Unknown keys are silently ignored. + +**Request body** (partial update — send only keys to change): +```json +{ + "commission_enabled": "1", + "commission_percent": "5", + "site_name": "کلینیک‌پرو" +} +``` + +**Response `200`** — same shape as GET, returns all settings after save. + +**Commission rules:** +- `commission_enabled` — `"1"` = active, `"0"` = inactive +- `commission_percent` — integer string, `0`–`100` +- Commission applies only to regular users (`booked_by = user`); secretaries are exempt diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 3e08a625..6f1a8191 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -275,3 +275,54 @@ Updated appointment object. | `ERR_NOT_FOUND_001` | 404 | Appointment not found | | `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) | | `ERR_VALIDATION_001` | 422 | Invalid status value | + +--- + +## GET /api/v1/my/appointments + +Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see. + +**Auth:** `IS_AUTHENTICATED_FULLY` (any role) + +**Role behavior:** +| Role | Scope | +|------|-------| +| `ROLE_ADMIN` | All appointments | +| `ROLE_CLINIC` | Appointments for doctors in this clinic | +| `ROLE_DOCTOR` | Appointments for this doctor | +| `ROLE_SECRETARY` | Appointments for the linked doctor (empty if `appointments.view` permission is false) | + +### Query Parameters +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `page` | int | 1 | Page number | +| `limit` | int | 15 | Items per page (max 100) | +| `search` | string | — | Search by mobile, real name, or doctor name | +| `status` | string | — | Filter by appointment status | +| `date` | string | — | Filter by date in `Y-m-d` format | + +### Response `200` +```json +{ + "success": true, + "data": [ + { + "uuid": "string", + "patient_name": "string", + "patient_mobile": "string", + "doctor_name": "string", + "clinic_name": "string | null", + "appointment_date": "2026-07-25", + "appointment_time": "14:30", + "slot_start": 1700000000, + "status": "reserved", + "amount": 0, + "created_at": "ISO 8601 string" + } + ], + "meta": { + "totalRecords": 8000, + "totalPages": 533, + "currentPage": 1 + } +} diff --git a/docs/api/auth.md b/docs/api/auth.md index 163255f2..5a9d154e 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -225,7 +225,7 @@ Refresh expired JWT using refresh token. ## GET `/oauth/userinfo` -Get authenticated user info. +Get authenticated user info — extended with multi-context support. **Permission:** `AUTH` — requires valid JWT @@ -239,12 +239,96 @@ Authorization: Bearer { "success": true, "data": { + "id": 4766, "uuid": "550e8400-e29b-41d4-a716-446655440000", "mobile_number": "09123456789", - "real_name": "علی احمدی", - "roles": ["ROLE_USER"], - "status": "active", - "created_at": 1717000000 + "realName": "دکتر وحید درویشی", + "status": 1, + "roles": ["ROLE_USER", "ROLE_DOCTOR"], + "primary_role": "doctor", + "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966", + "db_key": "hmac-sha256-hash...", + "context": { + "type": "doctor", + "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966", + "name": "مطب شخصی دکتر وحید درویشی", + "role": "doctor" + }, + "available_contexts": [ + { + "type": "doctor", + "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966", + "name": "مطب شخصی دکتر وحید درویشی", + "role": "doctor" + }, + { + "type": "clinic", + "db_uuid": "clinic-uuid-...", + "name": "کلینیک سلامت", + "role": "doctor" + } + ] + } +} +``` + +| فیلد | نوع | توضیح | +|------|-----|-------| +| `primary_role` | string | نقش اصلی: `admin` \| `clinic` \| `doctor` \| `secretary` \| `user` | +| `db_uuid` | string\|null | UUID موجودیت فعال (null = هنوز context انتخاب نشده) | +| `db_key` | string\|null | `HMAC-SHA256(db_uuid, APP_SECRET)` برای اعتبارسنجی | +| `context` | object\|null | context فعال انتخاب‌شده | +| `available_contexts` | array | همه محیط‌های کاری قابل انتخاب | + +**قانون `primary_role`** (اولویت‌بندی): +- `ROLE_ADMIN` → `"admin"` +- `ROLE_CLINIC` → `"clinic"` +- `ROLE_DOCTOR` → `"doctor"` +- `ROLE_SECRETARY` → `"secretary"` +- بقیه → `"user"` + +**قانون `db_uuid`**: +- اگر یک context وجود دارد: خودکار فعال می‌شود +- اگر چند context وجود دارد و کاربر هنوز انتخاب نکرده: `null` — frontend باید صفحه انتخاب نشان دهد +- پس از `POST /api/v1/auth/switch-context`: برابر context انتخاب‌شده + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_AUTH_001` | 401 | Invalid or missing token | + +--- + +## POST `/api/v1/auth/switch-context` + +تغییر محیط کاری فعال — باید از لیست `available_contexts` انتخاب شود. + +**Permission:** `AUTH` + +### Request Body +```json +{ + "db_uuid": "clinic-uuid-..." +} +``` + +| فیلد | نوع | Required | توضیح | +|------|-----|----------|-------| +| `db_uuid` | string (UUID) | ✅ | UUID محیط کاری از لیست `available_contexts` | + +### Response `200` +```json +{ + "success": true, + "data": { + "db_uuid": "clinic-uuid-...", + "db_key": "new-hmac-hash...", + "context": { + "type": "clinic", + "db_uuid": "clinic-uuid-...", + "name": "کلینیک سلامت", + "role": "doctor" + } } } ``` @@ -252,7 +336,148 @@ Authorization: Bearer ### Errors | Code | HTTP | Description | |------|------|-------------| -| `ERR_AUTH_001` | 401 | Invalid or missing token | +| `ERR_AUTH_001` | 401 | Missing or invalid token | +| `ERR_AUTH_006` | 403 | `db_uuid` در لیست context های این کاربر نیست | +| `ERR_VALIDATION_001` | 422 | `db_uuid` ارسال نشده | + +--- + +## Notification Mobile (OTP) + +Endpoints for setting/verifying a separate SMS notification number for doctors and clinics. This number receives appointment SMS notifications instead of the account login mobile. + +> **Permission:** All 4 endpoints require `IS_AUTHENTICATED_FULLY` (JWT). + +--- + +## POST `/api/v1/notification-mobile/request-otp` + +Request an OTP code to verify a new notification mobile number. + +**Permission:** `AUTH` + +### Request Body +```json +{ + "target": "doctor", + "new_mobile": "09123456789" +} +``` + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `target` | string | ✅ | `doctor` or `clinic` | +| `new_mobile` | string | ✅ | Format: `09XXXXXXXXX` | + +### Response `200` +```json +{ + "success": true, + "data": { + "message": "کد تأیید ارسال شد", + "expires_in": 300 + } +} +``` + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_VALIDATION_001` | 422 | Invalid `target` or mobile format | +| `ERR_NOT_FOUND_001` | 404 | No doctor/clinic profile found for this user | + +### Notes +- Previous unused OTPs for the entity are deleted before creating a new one +- OTP is 6-digit, expires in 5 minutes +- Sends via SMS asynchronously + +--- + +## POST `/api/v1/notification-mobile/verify` + +Verify the OTP and save the notification mobile number. + +**Permission:** `AUTH` + +### Request Body +```json +{ + "target": "doctor", + "otp_code": "123456" +} +``` + +| Field | Type | Required | +|-------|------|----------| +| `target` | string | ✅ `doctor` or `clinic` | +| `otp_code` | string | ✅ 6-digit code | + +### Response `200` +```json +{ + "success": true, + "data": { + "notification_mobile": "09123456789", + "message": "شماره اعلان با موفقیت ذخیره شد" + } +} +``` + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_VALIDATION_001` | 422 | Invalid target / OTP expired / wrong code | +| `ERR_NOT_FOUND_001` | 404 | No OTP request found or profile not found | + +--- + +## GET `/api/v1/notification-mobile/{target}` + +Get the current notification mobile for the authenticated user. + +**Permission:** `AUTH` +**Path param:** `target` — `doctor` or `clinic` + +### Response `200` +```json +{ + "success": true, + "data": { + "notification_mobile": "09123456789" + } +} +``` + +- Returns `null` for `notification_mobile` if not set + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_NOT_FOUND_001` | 404 | No profile found for this user | + +--- + +## DELETE `/api/v1/notification-mobile/{target}` + +Remove the notification mobile number. + +**Permission:** `AUTH` +**Path param:** `target` — `doctor` or `clinic` + +### Response `200` +```json +{ + "success": true, + "data": { + "message": "شماره اعلان حذف شد" + } +} +``` + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_NOT_FOUND_001` | 404 | No profile found for this user | --- diff --git a/docs/api/dashboard.md b/docs/api/dashboard.md new file mode 100644 index 00000000..092d63dd --- /dev/null +++ b/docs/api/dashboard.md @@ -0,0 +1,167 @@ +# Dashboard API + +Role-specific dashboard endpoints. Each endpoint requires the corresponding role JWT. + +--- + +## GET /api/v1/dashboard/clinic + +Returns stats and today's schedule for the authenticated clinic owner. + +**Auth:** `ROLE_CLINIC` required + +### Response `200` + +```json +{ + "success": true, + "data": { + "clinic": { + "uuid": "string", + "name": "string", + "is_active": true, + "logo": "string | null" + }, + "stats": { + "total_doctors": 5, + "today_appointments": 12, + "this_month_appointments": 87, + "pending_invitations": 2 + }, + "today_appointments": [ + { + "uuid": "string", + "patient_name": "string | null", + "doctor_name": "string", + "slot_start": 1700000000, + "status": "reserved" + } + ], + "doctors": [ + { + "uuid": "string", + "name": "string", + "today_count": 3 + } + ] + } +} +``` + +`today_appointments` — up to 5 records, ordered by `slot_start ASC`. +`doctors` — all doctors belonging to this clinic; each includes their appointment count for today. + +### Errors + +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_NOT_FOUND_001` | 404 | Clinic not found for this user | + +--- + +## GET /api/v1/dashboard/doctor + +Returns stats and today's schedule for the authenticated doctor. + +**Auth:** `ROLE_DOCTOR` required + +### Response `200` + +```json +{ + "success": true, + "data": { + "doctor": { + "uuid": "string", + "name": "string", + "degree": "string | null" + }, + "stats": { + "today_appointments": 8, + "tomorrow_appointments": 5, + "this_month_appointments": 62, + "avg_rating": 4.6, + "total_ratings": 34 + }, + "today_appointments": [ + { + "uuid": "string", + "patient_name": "string | null", + "patient_mobile": "string", + "slot_start": 1700000000, + "status": "reserved" + } + ], + "clinics": [ + { + "uuid": "string", + "name": "string", + "logo": "string | null" + } + ] + } +} +``` + +`today_appointments` — up to 10 records, ordered by `slot_start ASC`. +`avg_rating` — rounded to 1 decimal; `null` if no ratings yet. +`clinics` — all clinics the doctor belongs to. + +### Errors + +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_NOT_FOUND_001` | 404 | Doctor profile not found for this user | + +--- + +## GET /api/v1/dashboard/secretary + +Returns stats for the authenticated secretary and (conditionally) today's appointments. + +**Auth:** `ROLE_SECRETARY` required + +### Response `200` + +```json +{ + "success": true, + "data": { + "doctor": { + "uuid": "string", + "name": "string", + "degree": "string | null" + }, + "permissions": { + "resources": { + "appointments": { + "view": true, + "edit": false + } + } + }, + "stats": { + "today_appointments": 8, + "tomorrow_appointments": 5 + }, + "today_appointments": [ + { + "uuid": "string", + "patient_name": "string | null", + "patient_mobile": "string", + "slot_start": 1700000000, + "status": "reserved" + } + ] + } +} +``` + +`today_appointments` — only populated when `permissions.resources.appointments.view === true`; otherwise empty array. +`today_appointments` — up to 10 records when visible. + +### Errors + +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_FORBIDDEN_001` | 403 | Secretary relation not configured or inactive | diff --git a/migrations/Version20260611075829.php b/migrations/Version20260611075829.php new file mode 100644 index 00000000..10ebbce6 --- /dev/null +++ b/migrations/Version20260611075829.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE user_active_context (db_uuid VARCHAR(36) NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (user_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE user_active_context ADD CONSTRAINT FK_A94E639A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('DROP TABLE site_config'); + $this->addSql('ALTER TABLE appointments DROP booked_by, DROP commission_rials'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE site_config (config_key VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, config_value LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, updated_at INT NOT NULL, PRIMARY KEY (config_key)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('ALTER TABLE user_active_context DROP FOREIGN KEY FK_A94E639A76ED395'); + $this->addSql('DROP TABLE user_active_context'); + $this->addSql('ALTER TABLE appointments ADD booked_by VARCHAR(20) NOT NULL, ADD commission_rials INT DEFAULT NULL'); + } +} diff --git a/migrations/Version20260611083424.php b/migrations/Version20260611083424.php new file mode 100644 index 00000000..d9f2e207 --- /dev/null +++ b/migrations/Version20260611083424.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE site_config (config_key VARCHAR(100) NOT NULL, config_value LONGTEXT DEFAULT NULL, updated_at INT NOT NULL, PRIMARY KEY (config_key)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE site_config'); + } +} diff --git a/migrations/Version20260611084046.php b/migrations/Version20260611084046.php new file mode 100644 index 00000000..bfb3bc66 --- /dev/null +++ b/migrations/Version20260611084046.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE mobile_verification_otp (id INT AUTO_INCREMENT NOT NULL, entity_type VARCHAR(20) NOT NULL, entity_id INT NOT NULL, new_mobile VARCHAR(15) NOT NULL, otp_code VARCHAR(6) NOT NULL, expires_at INT NOT NULL, is_used TINYINT NOT NULL, created_at INT NOT NULL, INDEX idx_otp_entity (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE clinics ADD notification_mobile VARCHAR(15) DEFAULT NULL'); + $this->addSql('ALTER TABLE doctors ADD notification_mobile VARCHAR(15) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE mobile_verification_otp'); + $this->addSql('ALTER TABLE clinics DROP notification_mobile'); + $this->addSql('ALTER TABLE doctors DROP notification_mobile'); + } +} diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php new file mode 100644 index 00000000..09eec05c --- /dev/null +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -0,0 +1,121 @@ +query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 15))); + $search = trim((string) $request->query->get('search', '')); + $status = trim((string) $request->query->get('status', '')); + $date = trim((string) $request->query->get('date', '')); + + $qb = $this->em->createQueryBuilder() + ->select( + 'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt', + 'd.uuid as doctor_uuid, d.name as doctor_name', + 'u.mobileNumber as patient_mobile, u.realName as patient_name', + 'c.name as clinic_name' + ) + ->from(Appointment::class, 'a') + ->join('a.doctor', 'd') + ->join('a.user', 'u') + ->leftJoin('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors') + ->orderBy('a.slotStart', 'DESC'); + + $roles = $user->getRoles(); + + if (in_array('ROLE_ADMIN', $roles, true)) { + // Admin voit tout + } elseif (in_array('ROLE_CLINIC', $roles, true)) { + $clinic = $this->clinicRepo->findByUser($user); + if ($clinic === null) { + return $this->paginated([], 0, $page, $limit); + } + $qb->andWhere(':clinic MEMBER OF d.clinics') + ->setParameter('clinic', $clinic); + } elseif (in_array('ROLE_DOCTOR', $roles, true)) { + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor === null) { + return $this->paginated([], 0, $page, $limit); + } + $qb->andWhere('a.doctor = :doctor') + ->setParameter('doctor', $doctor); + } elseif (in_array('ROLE_SECRETARY', $roles, true)) { + $rel = $this->secretaryRepo->findActiveBySecretary($user); + if ($rel === null) { + return $this->paginated([], 0, $page, $limit); + } + $canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false); + if (!$canView) { + return $this->paginated([], 0, $page, $limit); + } + $qb->andWhere('a.doctor = :doctor') + ->setParameter('doctor', $rel->getDoctor()); + } else { + return $this->paginated([], 0, $page, $limit); + } + + if ($search !== '') { + $qb->andWhere('u.mobileNumber LIKE :s OR d.name LIKE :s OR u.realName LIKE :s') + ->setParameter('s', '%' . $search . '%'); + } + if ($status !== '') { + $qb->andWhere('a.status = :status')->setParameter('status', $status); + } + if ($date !== '') { + $dayStart = strtotime($date . ' 00:00:00'); + $dayEnd = strtotime($date . ' 23:59:59'); + if ($dayStart && $dayEnd) { + $qb->andWhere('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd') + ->setParameter('dayStart', $dayStart) + ->setParameter('dayEnd', $dayEnd); + } + } + + $total = (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult(); + + $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) + ->getQuery()->getArrayResult(); + + $items = array_map(fn(array $a) => [ + 'uuid' => $a['uuid'], + 'patient_name' => $a['patient_name'] ?? '', + 'patient_mobile' => $a['patient_mobile'], + 'doctor_name' => $a['doctor_name'], + 'clinic_name' => $a['clinic_name'] ?? null, + 'appointment_date' => date('Y-m-d', (int) $a['slotStart']), + 'appointment_time' => date('H:i', (int) $a['slotStart']), + 'slot_start' => (int) $a['slotStart'], + 'status' => $a['status'], + 'amount' => 0, + 'created_at' => date('c', (int) $a['createdAt']), + ], $rows); + + return $this->paginated($items, (int) $total, $page, $limit); + } +} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 852e2679..0742e765 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -3,9 +3,14 @@ namespace App\Auth\Controller; use App\Auth\Entity\User; +use App\Auth\Entity\UserActiveContext; +use App\Auth\Repository\UserActiveContextRepository; use App\Auth\Repository\UserRepository; use App\Auth\Service\OtpService; use App\Auth\Service\TokenService; +use App\Clinic\Repository\ClinicRepository; +use App\Doctor\Repository\DoctorRepository; +use App\Secretary\Repository\DoctorSecretaryRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use OpenApi\Attributes as OA; @@ -14,15 +19,20 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; +use Symfony\Component\Security\Http\Attribute\IsGranted; #[OA\Tag(name: 'Auth')] class AuthController extends BaseController { public function __construct( - private readonly UserRepository $userRepo, - private readonly OtpService $otpService, - private readonly TokenService $tokenService, - private readonly RateLimiterFactory $sendCodeLimiter, + private readonly UserRepository $userRepo, + private readonly OtpService $otpService, + private readonly TokenService $tokenService, + private readonly RateLimiterFactory $sendCodeLimiter, + private readonly DoctorRepository $doctorRepo, + private readonly ClinicRepository $clinicRepo, + private readonly DoctorSecretaryRepository $secretaryRepo, + private readonly UserActiveContextRepository $contextRepo, ) {} /** @@ -459,16 +469,161 @@ class AuthController extends BaseController return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401); } + $primaryRole = $this->resolvePrimaryRole($user); + $availableContexts = $this->buildAvailableContexts($user); + + // اگر یک context داری، خودکار فعال کن + $activeCtx = $this->contextRepo->findByUser($user); + if ($activeCtx === null && count($availableContexts) === 1) { + $activeCtx = $this->contextRepo->upsert($user, $availableContexts[0]['db_uuid']); + } + + $dbUuid = $activeCtx?->getDbUuid(); + $dbKey = $dbUuid !== null ? $this->buildDbKey($dbUuid) : null; + $context = $dbUuid !== null ? $this->findContextByDbUuid($dbUuid, $availableContexts) : null; + return $this->success([ - 'id' => $user->getId(), - 'uuid' => $user->getUuid(), - 'mobile_number' => $user->getMobileNumber(), - 'realName' => $user->getRealName(), - 'status' => $user->getStatus(), - 'roles' => $user->getRoles(), + 'id' => $user->getId(), + 'uuid' => $user->getUuid(), + 'mobile_number' => $user->getMobileNumber(), + 'realName' => $user->getRealName(), + 'status' => $user->getStatus(), + 'roles' => $user->getRoles(), + 'primary_role' => $primaryRole, + 'db_uuid' => $dbUuid, + 'db_key' => $dbKey, + 'context' => $context, + 'available_contexts' => $availableContexts, ]); } + #[OA\Post( + path: '/api/v1/auth/switch-context', + summary: 'تغییر محیط کاری فعال', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['db_uuid'], + properties: [ + new OA\Property(property: 'db_uuid', type: 'string', format: 'uuid', description: 'UUID محیط کاری انتخاب‌شده از لیست available_contexts'), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Context تغییر کرد'), + new OA\Response(response: 401, description: 'توکن وجود ندارد'), + new OA\Response(response: 403, description: 'db_uuid در لیست context های این کاربر نیست'), + new OA\Response(response: 422, description: 'db_uuid ارسال نشده'), + ] + )] + #[Route('/api/v1/auth/switch-context', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function switchContext(Request $request, #[CurrentUser] ?User $user): JsonResponse + { + if ($user === null) { + return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401); + } + + $data = json_decode($request->getContent(), true) ?? []; + $dbUuid = trim($data['db_uuid'] ?? ''); + + if ($dbUuid === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'db_uuid الزامی است', 422); + } + + $availableContexts = $this->buildAvailableContexts($user); + $matched = $this->findContextByDbUuid($dbUuid, $availableContexts); + + if ($matched === null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی به این محیط کاری مجاز نیست', 403); + } + + $this->contextRepo->upsert($user, $dbUuid); + + return $this->success([ + 'db_uuid' => $dbUuid, + 'db_key' => $this->buildDbKey($dbUuid), + 'context' => $matched, + ]); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private function resolvePrimaryRole(User $user): string + { + $roles = $user->getRoles(); + if (in_array('ROLE_ADMIN', $roles, true)) return 'admin'; + if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic'; + if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor'; + if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary'; + return 'user'; + } + + private function buildAvailableContexts(User $user): array + { + $contexts = []; + + // دکتر: مطب شخصی + کلینیک‌های عضو + if ($doctor = $this->doctorRepo->findByUser($user)) { + $contexts[] = [ + 'type' => 'doctor', + 'db_uuid' => $doctor->getUuid(), + 'name' => 'مطب شخصی ' . $doctor->getName(), + 'role' => 'doctor', + ]; + foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName() ?? '', + 'role' => 'doctor', + ]; + } + } + + // صاحب کلینیک (اگر قبلاً اضافه نشده) + if ($clinic = $this->clinicRepo->findByUser($user)) { + $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinic->getUuid()); + if (empty($alreadyAdded)) { + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName() ?? '', + 'role' => 'clinic', + ]; + } + } + + // منشی: همه روابط فعال + foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { + $contexts[] = [ + 'type' => 'doctor', + 'db_uuid' => $rel->getDoctor()->getUuid(), + 'name' => 'مطب ' . $rel->getDoctor()->getName(), + 'role' => 'secretary', + 'permissions' => $rel->getPermissions(), + ]; + } + + return $contexts; + } + + private function findContextByDbUuid(string $dbUuid, array $contexts): ?array + { + foreach ($contexts as $ctx) { + if ($ctx['db_uuid'] === $dbUuid) { + return $ctx; + } + } + return null; + } + + private function buildDbKey(string $dbUuid): string + { + return hash_hmac('sha256', $dbUuid, $this->getParameter('kernel.secret')); + } + #[OA\Post( path: '/oauth/logout', summary: 'Logout and optionally revoke refresh token', diff --git a/src/Auth/Controller/NotificationMobileController.php b/src/Auth/Controller/NotificationMobileController.php new file mode 100644 index 00000000..aae43bd4 --- /dev/null +++ b/src/Auth/Controller/NotificationMobileController.php @@ -0,0 +1,162 @@ +getContent(), true) ?? []; + $target = trim((string) ($data['target'] ?? '')); + $mobile = trim((string) ($data['new_mobile'] ?? '')); + + if (!in_array($target, ['doctor', 'clinic'], true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'target باید doctor یا clinic باشد', 422); + } + if (!preg_match('/^09\d{9}$/', $mobile)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل معتبر نیست (فرمت: 09XXXXXXXXX)', 422); + } + + [$entity, $entityId] = $this->resolveEntity($target, $user); + if ($entity === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروفایل یافت نشد', 404); + } + + // حذف OTP های قبلی + $this->em->createQuery('DELETE FROM App\Auth\Entity\MobileVerificationOtp o WHERE o.entityType = :t AND o.entityId = :id') + ->setParameter('t', $target) + ->setParameter('id', $entityId) + ->execute(); + + $otp = new MobileVerificationOtp($target, $entityId, $mobile); + $this->em->persist($otp); + $this->em->flush(); + + // ارسال SMS + $this->smsService->dispatchAsync( + $mobile, + "کد تأیید شماره اعلان شما: {$otp->getOtpCode()}\nاعتبار: ۵ دقیقه" + ); + + return $this->success([ + 'message' => 'کد تأیید ارسال شد', + 'expires_in' => 300, + ]); + } + + // ── Verify OTP ──────────────────────────────────────────────────────────── + + #[Route('/api/v1/notification-mobile/verify', methods: ['POST'])] + public function verify(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $target = trim((string) ($data['target'] ?? '')); + $otpInput = trim((string) ($data['otp_code'] ?? '')); + + if (!in_array($target, ['doctor', 'clinic'], true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'target باید doctor یا clinic باشد', 422); + } + + [$entity, $entityId] = $this->resolveEntity($target, $user); + if ($entity === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروفایل یافت نشد', 404); + } + + /** @var MobileVerificationOtp|null $otp */ + $otp = $this->em->createQuery( + 'SELECT o FROM App\Auth\Entity\MobileVerificationOtp o + WHERE o.entityType = :t AND o.entityId = :id AND o.isUsed = false + ORDER BY o.createdAt DESC' + )->setParameter('t', $target) + ->setParameter('id', $entityId) + ->setMaxResults(1) + ->getOneOrNullResult(); + + if ($otp === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست OTP یافت نشد. ابتدا کد را درخواست دهید', 404); + } + if ($otp->isExpired()) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد تأیید منقضی شده. مجدداً درخواست دهید', 422); + } + if ($otp->getOtpCode() !== $otpInput) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد تأیید اشتباه است', 422); + } + + $otp->markUsed(); + $entity->setNotificationMobile($otp->getNewMobile()); + $this->em->flush(); + + return $this->success([ + 'notification_mobile' => $otp->getNewMobile(), + 'message' => 'شماره اعلان با موفقیت ذخیره شد', + ]); + } + + // ── GET current notification mobile ────────────────────────────────────── + + #[Route('/api/v1/notification-mobile/{target}', methods: ['GET'], requirements: ['target' => 'doctor|clinic'])] + public function getCurrent(string $target, #[CurrentUser] User $user): JsonResponse + { + [$entity] = $this->resolveEntity($target, $user); + if ($entity === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروفایل یافت نشد', 404); + } + + return $this->success([ + 'notification_mobile' => $entity->getNotificationMobile(), + ]); + } + + // ── REMOVE notification mobile ──────────────────────────────────────────── + + #[Route('/api/v1/notification-mobile/{target}', methods: ['DELETE'], requirements: ['target' => 'doctor|clinic'])] + public function remove(string $target, #[CurrentUser] User $user): JsonResponse + { + [$entity] = $this->resolveEntity($target, $user); + if ($entity === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروفایل یافت نشد', 404); + } + + $entity->setNotificationMobile(null); + $this->em->flush(); + + return $this->success(['message' => 'شماره اعلان حذف شد']); + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + private function resolveEntity(string $target, User $user): array + { + if ($target === 'doctor') { + $entity = $this->doctorRepo->findByUser($user); + return [$entity, $entity?->getId()]; + } + $entity = $this->clinicRepo->findByUser($user); + return [$entity, $entity?->getId()]; + } +} diff --git a/src/Auth/Entity/MobileVerificationOtp.php b/src/Auth/Entity/MobileVerificationOtp.php new file mode 100644 index 00000000..c7266e78 --- /dev/null +++ b/src/Auth/Entity/MobileVerificationOtp.php @@ -0,0 +1,57 @@ +entityType = $entityType; + $this->entityId = $entityId; + $this->newMobile = $newMobile; + $this->otpCode = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $this->expiresAt = time() + 300; // 5 minutes + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getEntityType(): string { return $this->entityType; } + public function getEntityId(): int { return $this->entityId; } + public function getNewMobile(): string { return $this->newMobile; } + public function getOtpCode(): string { return $this->otpCode; } + public function isExpired(): bool { return time() > $this->expiresAt; } + public function isUsed(): bool { return $this->isUsed; } + + public function markUsed(): void { $this->isUsed = true; } +} diff --git a/src/Auth/Entity/UserActiveContext.php b/src/Auth/Entity/UserActiveContext.php new file mode 100644 index 00000000..ac92317c --- /dev/null +++ b/src/Auth/Entity/UserActiveContext.php @@ -0,0 +1,39 @@ +user = $user; + $this->dbUuid = $dbUuid; + $this->updatedAt = time(); + } + + public function getUser(): User { return $this->user; } + public function getDbUuid(): string { return $this->dbUuid; } + public function getUpdatedAt(): int { return $this->updatedAt; } + + public function setDbUuid(string $dbUuid): self + { + $this->dbUuid = $dbUuid; + $this->updatedAt = time(); + return $this; + } +} diff --git a/src/Auth/Repository/UserActiveContextRepository.php b/src/Auth/Repository/UserActiveContextRepository.php new file mode 100644 index 00000000..a5d1b1d0 --- /dev/null +++ b/src/Auth/Repository/UserActiveContextRepository.php @@ -0,0 +1,34 @@ +findOneBy(['user' => $user]); + } + + public function upsert(User $user, string $dbUuid): UserActiveContext + { + $ctx = $this->findByUser($user); + if ($ctx === null) { + $ctx = new UserActiveContext($user, $dbUuid); + $this->getEntityManager()->persist($ctx); + } else { + $ctx->setDbUuid($dbUuid); + } + $this->getEntityManager()->flush(); + return $ctx; + } +} diff --git a/src/Clinic/Entity/Clinic.php b/src/Clinic/Entity/Clinic.php index c4858632..c49dbd34 100644 --- a/src/Clinic/Entity/Clinic.php +++ b/src/Clinic/Entity/Clinic.php @@ -73,6 +73,9 @@ class Clinic #[ORM\Column(name: 'clinic_logo', type: 'string', length: 500, nullable: true)] private ?string $clinicLogo = null; + #[ORM\Column(name: 'notification_mobile', type: 'string', length: 15, nullable: true)] + private ?string $notificationMobile = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -139,8 +142,9 @@ class Clinic public function getRepresentationId(): ?int { return $this->representationId; } public function isActive(): bool { return $this->isActive; } public function getImagesClinic(): ?array { return $this->imagesClinic; } - public function getClinicLogo(): ?string { return $this->clinicLogo; } - public function getCreatedAt(): int { return $this->createdAt; } + public function getClinicLogo(): ?string { return $this->clinicLogo; } + public function getNotificationMobile(): ?string { return $this->notificationMobile; } + public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } public function getDoctors(): Collection { return $this->doctors; } public function getSpecialties(): Collection { return $this->specialties; } @@ -160,7 +164,8 @@ class Clinic public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; } public function setIsActive(bool $v): self { $this->isActive = $v; $this->touch(); return $this; } public function setImagesClinic(?array $v): self { $this->imagesClinic = $v; $this->touch(); return $this; } - public function setClinicLogo(?string $v): self { $this->clinicLogo = $v; $this->touch(); return $this; } + public function setClinicLogo(?string $v): self { $this->clinicLogo = $v; $this->touch(); return $this; } + public function setNotificationMobile(?string $v): self { $this->notificationMobile = $v; $this->touch(); return $this; } private function touch(): void { $this->updatedAt = time(); } diff --git a/src/Config/Controller/SiteConfigController.php b/src/Config/Controller/SiteConfigController.php new file mode 100644 index 00000000..71de8333 --- /dev/null +++ b/src/Config/Controller/SiteConfigController.php @@ -0,0 +1,52 @@ +success($this->configRepo->getAll()); + } + + #[Route('/api/v1/admin/settings', methods: ['PATCH'])] + public function patch(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + + foreach ($data as $key => $value) { + if (!in_array($key, self::ALLOWED_KEYS, true)) { + continue; + } + $this->configRepo->set($key, $value === null ? null : (string) $value); + } + + $this->em->flush(); + + return $this->success($this->configRepo->getAll()); + } +} diff --git a/src/Config/Entity/SiteConfig.php b/src/Config/Entity/SiteConfig.php new file mode 100644 index 00000000..4c57e3f2 --- /dev/null +++ b/src/Config/Entity/SiteConfig.php @@ -0,0 +1,37 @@ +configKey = $key; + $this->configValue = $value; + $this->updatedAt = time(); + } + + public function getKey(): string { return $this->configKey; } + public function getValue(): ?string { return $this->configValue; } + + public function setValue(?string $value): self + { + $this->configValue = $value; + $this->updatedAt = time(); + return $this; + } +} diff --git a/src/Config/Repository/SiteConfigRepository.php b/src/Config/Repository/SiteConfigRepository.php new file mode 100644 index 00000000..5bda6ca8 --- /dev/null +++ b/src/Config/Repository/SiteConfigRepository.php @@ -0,0 +1,61 @@ + '0', + 'commission_percent' => '0', + 'site_name' => 'ClinicPro', + 'support_phone' => '', + 'max_cancel_hours_before' => '24', + 'appointment_reminder_hours' => '2', + ]; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SiteConfig::class); + } + + public function get(string $key): ?string + { + $row = $this->find($key); + if ($row !== null) { + return $row->getValue(); + } + return self::DEFAULTS[$key] ?? null; + } + + public function getAll(): array + { + $rows = $this->findAll(); + $map = []; + foreach ($rows as $row) { + $map[$row->getKey()] = $row->getValue(); + } + // Fill missing keys with defaults + foreach (self::DEFAULTS as $key => $default) { + if (!isset($map[$key])) { + $map[$key] = $default; + } + } + return $map; + } + + public function set(string $key, ?string $value): void + { + $row = $this->find($key); + if ($row === null) { + $row = new SiteConfig($key, $value); + $this->getEntityManager()->persist($row); + } else { + $row->setValue($value); + } + } +} diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php new file mode 100644 index 00000000..21147e02 --- /dev/null +++ b/src/Dashboard/Controller/DashboardController.php @@ -0,0 +1,280 @@ +clinicRepo->findByUser($user); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404); + } + + $clinicId = $clinic->getId(); + $todayStart = strtotime('today midnight'); + $todayEnd = strtotime('tomorrow midnight') - 1; + $monthStart = strtotime('first day of this month midnight'); + + // آمار نوبت‌های امروز و این ماه + $stats = $this->em->createQuery(' + SELECT + COUNT(a.id) AS today_appointments, + SUM(CASE WHEN a.slotStart >= :monthStart THEN 1 ELSE 0 END) AS this_month_appointments + FROM App\Appointment\Entity\Appointment a + JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d + JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors + WHERE c.id = :clinicId + AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd + ')->setParameters([ + 'clinicId' => $clinicId, + 'todayStart' => $todayStart, + 'todayEnd' => $todayEnd, + 'monthStart' => $monthStart, + ])->getOneOrNullResult() ?? []; + + // شمارش کل نوبت‌های این ماه (query جداگانه) + $monthCount = (int) $this->em->createQuery(' + SELECT COUNT(a.id) + FROM App\Appointment\Entity\Appointment a + JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d + JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors + WHERE c.id = :clinicId + AND a.slotStart >= :monthStart + ')->setParameters([ + 'clinicId' => $clinicId, + 'monthStart' => $monthStart, + ])->getSingleScalarResult(); + + // تعداد دعوتنامه‌های در انتظار + $pendingInvitations = (int) $this->em->createQuery(' + SELECT COUNT(i.id) + FROM App\ClinicInvitation\Entity\ClinicDoctorInvitation i + WHERE i.clinic = :clinic AND i.status = :status + ')->setParameters([ + 'clinic' => $clinic, + 'status' => 'pending', + ])->getSingleScalarResult(); + + // ۵ نوبت امروز این کلینیک + $todayAppts = $this->em->createQuery(' + SELECT a.uuid, u.realName AS patient_name, d.name AS doctor_name, + a.slotStart AS slot_start, a.status + FROM App\Appointment\Entity\Appointment a + JOIN a.doctor d + JOIN a.user u + JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors + WHERE c.id = :clinicId + AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd + ORDER BY a.slotStart ASC + ')->setMaxResults(5)->setParameters([ + 'clinicId' => $clinicId, + 'todayStart' => $todayStart, + 'todayEnd' => $todayEnd, + ])->getArrayResult(); + + // لیست پزشکان با شمارش نوبت امروز + $doctors = $this->em->createQuery(' + SELECT d.uuid, d.name, + COUNT(a.id) AS today_count + FROM App\Doctor\Entity\Doctor d + JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors + LEFT JOIN App\Appointment\Entity\Appointment a + WITH a.doctor = d + AND a.slotStart >= :todayStart + AND a.slotStart <= :todayEnd + WHERE c.id = :clinicId + GROUP BY d.id + ')->setParameters([ + 'clinicId' => $clinicId, + 'todayStart' => $todayStart, + 'todayEnd' => $todayEnd, + ])->getArrayResult(); + + return $this->success([ + 'clinic' => [ + 'uuid' => $clinic->getUuid(), + 'name' => $clinic->getName(), + 'is_active' => $clinic->isActive(), + 'logo' => $clinic->getClinicLogo(), + ], + 'stats' => [ + 'total_doctors' => count($doctors), + 'today_appointments' => (int) ($stats['today_appointments'] ?? 0), + 'this_month_appointments' => $monthCount, + 'pending_invitations' => $pendingInvitations, + ], + 'today_appointments' => $todayAppts, + 'doctors' => $doctors, + ]); + } + + // ── Doctor Dashboard ───────────────────────────────────────────────────── + + #[Route('/api/v1/dashboard/doctor', methods: ['GET'])] + #[IsGranted('ROLE_DOCTOR')] + public function doctor(#[CurrentUser] User $user): JsonResponse + { + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); + } + + $doctorId = $doctor->getId(); + $todayStart = strtotime('today midnight'); + $todayEnd = strtotime('tomorrow midnight') - 1; + $tmrStart = strtotime('tomorrow midnight'); + $tmrEnd = strtotime('tomorrow midnight') + 86399; + $monthStart = strtotime('first day of this month midnight'); + + // آمار + $todayCount = (int) $this->em->createQuery(' + SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a + WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e + ')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd]) + ->getSingleScalarResult(); + + $tmrCount = (int) $this->em->createQuery(' + SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a + WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e + ')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd]) + ->getSingleScalarResult(); + + $monthCount = (int) $this->em->createQuery(' + SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a + WHERE a.doctor = :doctor AND a.slotStart >= :s + ')->setParameters(['doctor' => $doctor, 's' => $monthStart]) + ->getSingleScalarResult(); + + // میانگین و تعداد امتیاز + $ratingRow = $this->em->createQuery(' + SELECT AVG(r.score) AS avg_score, COUNT(r.id) AS total + FROM App\Rating\Entity\Rate r WHERE r.doctor = :doctor + ')->setParameter('doctor', $doctor)->getOneOrNullResult() ?? []; + + // نوبت‌های امروز + $todayAppts = $this->em->createQuery(' + SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile, + a.slotStart AS slot_start, a.status + FROM App\Appointment\Entity\Appointment a + JOIN a.user u + WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e + ORDER BY a.slotStart ASC + ')->setMaxResults(10)->setParameters([ + 'doctor' => $doctor, + 's' => $todayStart, + 'e' => $todayEnd, + ])->getArrayResult(); + + // کلینیک‌های عضو + $clinics = $this->em->createQuery(' + SELECT c.uuid, c.name, c.clinicLogo AS logo + FROM App\Clinic\Entity\Clinic c + JOIN c.doctors d + WHERE d.id = :doctorId + ')->setParameter('doctorId', $doctorId)->getArrayResult(); + + return $this->success([ + 'doctor' => [ + 'uuid' => $doctor->getUuid(), + 'name' => $doctor->getName(), + 'degree' => $doctor->getDegree(), + ], + 'stats' => [ + 'today_appointments' => $todayCount, + 'tomorrow_appointments' => $tmrCount, + 'this_month_appointments' => $monthCount, + 'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null, + 'total_ratings' => (int) ($ratingRow['total'] ?? 0), + ], + 'today_appointments' => $todayAppts, + 'clinics' => $clinics, + ]); + } + + // ── Secretary Dashboard ────────────────────────────────────────────────── + + #[Route('/api/v1/dashboard/secretary', methods: ['GET'])] + #[IsGranted('ROLE_SECRETARY')] + public function secretary(#[CurrentUser] User $user): JsonResponse + { + $rel = $this->secretaryRepo->findActiveBySecretary($user); + if ($rel === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); + } + + $doctor = $rel->getDoctor(); + $permissions = $rel->getPermissions(); + $canView = (bool) ($permissions['resources']['appointments']['view'] ?? false); + + $todayStart = strtotime('today midnight'); + $todayEnd = strtotime('tomorrow midnight') - 1; + $tmrStart = strtotime('tomorrow midnight'); + $tmrEnd = strtotime('tomorrow midnight') + 86399; + + $todayCount = (int) $this->em->createQuery(' + SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a + WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e + ')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd]) + ->getSingleScalarResult(); + + $tmrCount = (int) $this->em->createQuery(' + SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a + WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e + ')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd]) + ->getSingleScalarResult(); + + $todayAppts = []; + if ($canView) { + $todayAppts = $this->em->createQuery(' + SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile, + a.slotStart AS slot_start, a.status + FROM App\Appointment\Entity\Appointment a + JOIN a.user u + WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e + ORDER BY a.slotStart ASC + ')->setMaxResults(10)->setParameters([ + 'doctor' => $doctor, + 's' => $todayStart, + 'e' => $todayEnd, + ])->getArrayResult(); + } + + return $this->success([ + 'doctor' => [ + 'uuid' => $doctor->getUuid(), + 'name' => $doctor->getName(), + 'degree' => $doctor->getDegree(), + ], + 'permissions' => $permissions, + 'stats' => [ + 'today_appointments' => $todayCount, + 'tomorrow_appointments' => $tmrCount, + ], + 'today_appointments' => $todayAppts, + ]); + } +} diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php index b716cf0f..c7dc1e64 100644 --- a/src/Doctor/Entity/Doctor.php +++ b/src/Doctor/Entity/Doctor.php @@ -69,6 +69,9 @@ class Doctor #[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)] private ?int $representationId = null; + #[ORM\Column(name: 'notification_mobile', type: 'string', length: 15, nullable: true)] + private ?string $notificationMobile = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -138,7 +141,8 @@ class Doctor public function getDoctorRate(): float { return $this->doctorRate; } public function getDoctorRatePercentage(): float { return $this->doctorRatePercentage; } public function isActiveDoctorAppointment(): bool { return $this->activeDoctorAppointment; } - public function getRepresentationId(): ?int { return $this->representationId; } + public function getRepresentationId(): ?int { return $this->representationId; } + public function getNotificationMobile(): ?string { return $this->notificationMobile; } public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } public function getSpecialties(): Collection { return $this->specialties; } @@ -158,7 +162,8 @@ class Doctor public function setDoctorRate(float $v): self { $this->doctorRate = $v; $this->touch(); return $this; } public function setDoctorRatePercentage(float $v): self { $this->doctorRatePercentage = $v; $this->touch(); return $this; } public function setActiveDoctorAppointment(bool $v): self { $this->activeDoctorAppointment = $v; $this->touch(); return $this; } - public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; } + public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; } + public function setNotificationMobile(?string $v): self { $this->notificationMobile = $v; $this->touch(); return $this; } private function touch(): void { $this->updatedAt = time(); } diff --git a/src/Secretary/Repository/DoctorSecretaryRepository.php b/src/Secretary/Repository/DoctorSecretaryRepository.php index 76b3407c..92249b56 100644 --- a/src/Secretary/Repository/DoctorSecretaryRepository.php +++ b/src/Secretary/Repository/DoctorSecretaryRepository.php @@ -2,6 +2,7 @@ namespace App\Secretary\Repository; +use App\Auth\Entity\User; use App\Doctor\Entity\Doctor; use App\Secretary\Entity\DoctorSecretary; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; @@ -36,6 +37,17 @@ class DoctorSecretaryRepository extends ServiceEntityRepository return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']); } + public function findActiveBySecretary(User $user): ?DoctorSecretary + { + return $this->findOneBy(['secretary' => $user, 'active' => true]); + } + + /** @return DoctorSecretary[] */ + public function findAllActiveBySecretary(User $user): array + { + return $this->findBy(['secretary' => $user, 'active' => true]); + } + public function save(DoctorSecretary $entity, bool $flush = true): void { $this->getEntityManager()->persist($entity);