# پرامپت: داشبورد چند-نقشه (Multi-Role Dashboard) ## هدف کلی پنل `/admin/` باید علاوه بر ادمین، برای نقش‌های زیر نیز کار کند — هر نقش فقط بخش‌هایی می‌بیند که به آن دسترسی دارد: | نقش | نام فارسی | ROLE در Symfony | |-----|-----------|-----------------| | ادمین سیستم | مدیر کل | `ROLE_ADMIN` | | صاحب کلینیک | مالک کلینیک | `ROLE_CLINIC` | | دکتر عضو کلینیک | پزشک | `ROLE_DOCTOR` | | منشی | منشی | `ROLE_SECRETARY` | --- ## وضعیت فعلی API (مهم — قبل از پیاده‌سازی بخوان) ### API های موجود (استفاده کن، تغییر نده) | Endpoint | داکیومنت | توضیح | |----------|----------|-------| | `GET /oauth/userinfo` | [auth.md](../docs/api/auth.md) | اطلاعات کاربر — باید `primary_role` و `context` به آن اضافه شود | | `GET /api/v1/admin/dashboard/stats` | [admin.md](../docs/api/admin.md) | KPI های داشبورد ادمین | | `GET /api/v1/admin/dashboard/charts` | [admin.md](../docs/api/admin.md) | نمودار ۳۰ روزه ادمین | | `GET /api/v1/admin/dashboard/recent` | [admin.md](../docs/api/admin.md) | آخرین فعالیت‌ها برای ادمین | | `GET /api/v1/admin/appointments` | [admin.md](../docs/api/admin.md) | لیست همه نوبت‌ها — فقط ROLE_ADMIN | | `GET /api/v1/appointments/doctor/{doctorUuid}` | [appointment.md](../docs/api/appointment.md) | نوبت‌های یک دکتر خاص | | `GET /api/v1/appointments/user` | [appointment.md](../docs/api/appointment.md) | نوبت‌های کاربر جاری | | `GET /api/v1/clinics/{uuid}` | [clinic.md](../docs/api/clinic.md) | جزئیات کلینیک | | `GET /api/v1/admin/clinics` | [admin.md](../docs/api/admin.md) | لیست کلینیک‌ها — فقط ROLE_ADMIN | ### تغییر روی API موجود | Endpoint | فایل | توضیح | |----------|------|-------| | `GET /oauth/userinfo` | `src/Auth/Controller/AuthController.php` | اضافه کردن `primary_role` و `context` به پاسخ موجود | ### API های جدید که باید ساخته شوند | 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` | همان | داشبورد منشی | | `GET /api/v1/my/appointments` | `src/Appointment/Controller/MyAppointmentsController.php` | نوبت‌های فیلترشده بر اساس نقش | > بعد از ساخت هر API جدید، فایل مستندات متناظر در `docs/api/` را نیز به‌روز کن. --- ## مفاهیم کلیدی معماری 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` بر اساس انتخاب کاربر به‌روز می‌شوند --- ## وضعیت فعلی کد (مهم — قبل از تغییر بخوان) ### بک‌اند - کلاس `AdminApiController` با `#[IsGranted('ROLE_ADMIN')]` روی کل کلاس — تمام endpoint های داشبورد فعلی فقط برای ادمین - موجودیت‌ها: - `User`: فیلد `roles: array` — مقادیر ممکن: `ROLE_USER`, `ROLE_ADMIN`, `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY` - `Clinic`: فیلد `user` (ManyToOne به User) — صاحب کلینیک. رابطه ManyToMany با `Doctor` از طریق جدول `clinic_doctors` - `Doctor`: فیلد `user` (OneToOne به User). دارای `mobileNumber` و رابطه با `Specialty`. متد `findByUser(User)` در `DoctorRepository` موجود است. - `DoctorSecretary`: فیلد `doctor` (ManyToOne)، `secretary` (ManyToOne به User)، `permissions` (JSON): ``` { version:1, resources: { appointments: { view, create, cancel, update_status }, addresses: { view, create, update, delete }, clinic_info: { view, update }, insurances: { view, create, update, delete } }} ``` - `ClinicRepository::findByUser(User $user)` موجود است. - `DoctorRepository::findByUser(User $user)` موجود است. - JWT: از LexikJWTBundle — payload شامل: `username` (mobile_number)، `roles` (آرایه)، `iat`، `exp` ### فرانت‌اند - `authStore.ts` (Zustand + persist در `clinicpro-auth`): فقط `token`، `refreshToken`، `isAuthenticated` - `Sidebar.tsx`: لیست ثابت — بدون هیچ فیلتر نقشی - `App.tsx`: همه routes با `PrivateRoute` (فقط isAuthenticated بررسی می‌شود) - `DashboardPage.tsx`: سه query به `/api/v1/admin/dashboard/*` + نمودار Recharts + mini lists --- ## مرحله ۱ — گسترش `/oauth/userinfo` (بک‌اند) ### فایل: `src/Auth/Controller/AuthController.php` endpoint موجود `/oauth/userinfo` را گسترش بده — همان route، همان permission، فقط دو فیلد جدید به پاسخ اضافه می‌شود. پاسخ فعلی: ```json { "success": true, "data": { "id": 4766, "uuid": "...", "mobile_number": "09...", "realName": "دکتر وحید درویشی", "status": 1, "roles": ["ROLE_USER", "ROLE_DOCTOR"] } } ``` پاسخ بعد از تغییر (فیلدهای جدید اضافه): ```json { "success": true, "data": { "id": 4766, "uuid": "...", "mobile_number": "09...", "realName": "دکتر وحید درویشی", "status": 1, "roles": ["ROLE_USER", "ROLE_DOCTOR"], "primary_role": "doctor", "db_uuid": "clinic-uuid-currently-active", "db_key": "hmac-sha256-hash...", "context": { "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"` - `ROLE_DOCTOR` → `"doctor"` - `ROLE_SECRETARY` → `"secretary"` - بقیه → `"user"` **ساختن `available_contexts` در بک‌اند**: برای هر کاربر، همه context های ممکن را جمع می‌کنیم: ```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]); } ``` **داکیومنت**: بعد از پیاده‌سازی، پاسخ `/oauth/userinfo` را در `docs/api/auth.md` به‌روز کن. --- ## مرحله ۱.۵ — 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; isAuthenticated: boolean; // فیلدهای جدید: userUuid: string | null; userName: string | null; primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | 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`) --- ## مرحله ۳ — محافظت route ها (فرانت‌اند) ### در `App.tsx`، کامپوننت `RoleRoute` اضافه کن: ```tsx function RoleRoute({ roles, children }: { roles: string[]; children: ReactNode }) { const primaryRole = useAuthStore(s => s.primaryRole); if (!primaryRole) return
در حال بارگذاری...
; if (!roles.includes(primaryRole)) return ; return <>{children}; } ``` **Route های فقط ادمین** (با `` بپوشان): - `/admin/users`, `/admin/users/:uuid` - `/admin/payments` - `/admin/settlements` - `/admin/representations`, `/admin/representations/:uuid` - `/admin/comments` - `/admin/ratings` - `/admin/sms` - `/admin/categories` - `/admin/blogs`, `/admin/blogs/new`, `/admin/blogs/:uuid/edit` - `/admin/secretaries` - `/admin/clinics` (لیست کل کلینیک‌ها) **Route های admin + clinic**: - `/admin/clinics/:uuid` — ادمین همه را می‌بیند، clinic فقط کلینیک خودش را - `/admin/doctors` — ادمین همه، clinic فقط پزشکان کلینیکش **Route های مشترک همه نقش‌ها**: - `/admin/dashboard` - `/admin/appointments`, `/admin/appointments/:uuid` **Route های جدید**: - `/admin/my-clinic` → `` — فقط `ROLE_CLINIC` --- ## مرحله ۴ — Sidebar پویا (فرانت‌اند) ### فایل `assets/admin/components/layout/Sidebar.tsx` ساختار `sections` را به یک تابع تبدیل کن که `primaryRole` و `context` می‌گیرد: ```tsx function buildSections( primaryRole: string | null, context: Record | null ): Section[] ``` #### ادمین — همه لینک‌های فعلی (بدون تغییر) #### صاحب کلینیک (`clinic`): ``` عمومی: • داشبورد /admin/dashboard • کلینیک من /admin/my-clinic • پزشکان /admin/doctors مدیریت: • نوبت‌ها /admin/appointments ``` #### دکتر (`doctor`): ``` عمومی: • داشبورد /admin/dashboard مدیریت: • نوبت‌های من /admin/appointments ``` #### منشی (`secretary`) — بر اساس `context.permissions.resources`: ``` عمومی: • داشبورد /admin/dashboard مدیریت (شرطی): • نوبت‌ها /admin/appointments ← اگر appointments.view = true ``` در Sidebar، permissions را از `useAuthStore(s => s.context)` بخوان. --- ## مرحله ۵ — endpoint های داشبورد جدید (بک‌اند) — API های جدید ### فایل جدید: `src/Dashboard/Controller/DashboardController.php` سه endpoint جداگانه — هر سه از `BaseController` extend می‌کنند. پاسخ با `$this->success($data)` — یعنی فرانت با `data?.data` می‌خواند. --- ### `GET /api/v1/dashboard/clinic` `[ROLE_CLINIC]` پاسخ: ```json { "success": true, "data": { "clinic": { "uuid":"...", "name":"...", "is_active": true, "logo":"..." }, "stats": { "total_doctors": 5, "today_appointments": 12, "this_month_appointments": 87, "pending_invitations": 2 }, "today_appointments": [ { "uuid":"...", "patient_name":"...", "doctor_name":"...", "slot_start": 1234567890, "status":"reserved" } ], "doctors": [ { "uuid":"...", "name":"دکتر ...", "specialty":"...", "today_count": 3 } ] } } ``` پیاده‌سازی: - کلینیک را از `ClinicRepository::findByUser($user)` بگیر - اگر نبود: `return $this->error('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404)` - `today_appointments` و `this_month_appointments`: از جدول `appointments` با JOIN به `clinic_doctors` فیلتر کن - `pending_invitations`: از `clinic_doctor_invitations` با `status='pending'` بشمار - `today_appointments` لیست: ۵ نوبت اخیر امروز این کلینیک (از طریق JOIN `clinic_doctors`) - `doctors`: لیست پزشکان کلینیک با شمارش نوبت امروز آن‌ها - از DQL array hydration استفاده کن (`.getArrayResult()`) --- ### `GET /api/v1/dashboard/doctor` `[ROLE_DOCTOR]` پاسخ: ```json { "success": true, "data": { "doctor": { "uuid":"...", "name":"...", "degree":"..." }, "stats": { "today_appointments": 5, "tomorrow_appointments": 3, "this_month_appointments": 42, "avg_rating": 4.7, "total_ratings": 18 }, "today_appointments": [ { "uuid":"...", "patient_name":"...", "patient_mobile":"...", "slot_start": 1234567890, "status":"reserved" } ], "clinics": [ { "uuid":"...", "name":"کلینیک ...", "logo":"..." } ] } } ``` پیاده‌سازی: - دکتر از `DoctorRepository::findByUser($user)` — اگر نبود `return $this->error(..., 404)` - `today_appointments`: نوبت‌های این دکتر با `slot_start` در بازه ابتدا تا انتهای امروز - `tomorrow_appointments`: همان برای فردا - `avg_rating`: AVG(overall) از جدول `ratings` برای این دکتر — با DQL - `clinics`: کلینیک‌هایی که این دکتر در `clinic_doctors` آن‌هاست - از DQL array hydration استفاده کن --- ### `GET /api/v1/dashboard/secretary` `[ROLE_SECRETARY]` پاسخ: ```json { "success": true, "data": { "doctor": { "uuid":"...", "name":"...", "degree":"..." }, "permissions": { "version": 1, "resources": { ... } }, "stats": { "today_appointments": 4, "tomorrow_appointments": 2 }, "today_appointments": [ { "uuid":"...", "patient_name":"...", "patient_mobile":"...", "slot_start": 1234567890, "status":"reserved" } ] } } ``` پیاده‌سازی: - از `DoctorSecretaryRepository::findActiveBySecretary($user)` اولین رابطه فعال بگیر - اگر نبود: `return $this->error('ERR_FORBIDDEN_001', 'دسترسی منشی تنظیم نشده', 403)` - بررسی `appointments.view = true` در permissions — اگر false بود، `today_appointments` آرایه خالی برگردان - نوبت‌های دکتر مربوطه را برگردان **داکیومنت**: بعد از پیاده‌سازی، فایل `docs/api/dashboard.md` جدید بساز. --- ## مرحله ۶ — DashboardPage.tsx چند-نقشه (فرانت‌اند) فایل `assets/admin/pages/DashboardPage.tsx` را به این شکل بازنویسی کن: ```tsx 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

نقش شما برای داشبورد تعریف نشده

; } ``` **AdminDashboard**: کد فعلی DashboardPage عیناً — فقط در یک تابع بپیچ. از endpointهای موجود `/api/v1/admin/dashboard/*` استفاده می‌کند. **ClinicDashboard**: - یک query به `GET /api/v1/dashboard/clinic` (جدید) - استخراج: `data?.data` (چون `$this->success()` یک‌بار nest می‌کند) - ۴ کارت KPI: تعداد پزشکان / نوبت امروز / نوبت این ماه / دعوتنامه در انتظار - جدول نوبت‌های امروز (ستون: بیمار، پزشک، زمان، وضعیت) - لیست پزشکان با تعداد نوبت امروز - دکمه "مدیریت کلینیک" → navigate به `/admin/my-clinic` **DoctorDashboard**: - یک query به `GET /api/v1/dashboard/doctor` (جدید) - استخراج: `data?.data` - ۴ کارت KPI: نوبت امروز / فردا / این ماه / میانگین امتیاز (با ستاره) - جدول نوبت‌های امروز (ستون: بیمار، موبایل `dir="ltr"`, زمان، وضعیت) - لیست کلینیک‌های عضو به شکل badge **SecretaryDashboard**: - یک query به `GET /api/v1/dashboard/secretary` (جدید) - استخراج: `data?.data` - نام دکتر مربوطه در header کارت - ۲ کارت KPI: نوبت امروز / فردا - جدول نوبت‌های امروز - لیست مجوزهای فعال با آیکون ✓ --- ## مرحله ۷ — صفحه "کلینیک من" (فرانت‌اند) ### فایل جدید: `assets/admin/pages/MyClinicPage.tsx` ```tsx export default function MyClinicPage() { const context = useAuthStore(s => s.context); const clinicUuid = context?.clinic_uuid; if (!clinicUuid) return (

کلینیک شما هنوز ثبت نشده است.

); // از endpoint موجود GET /api/v1/clinics/{uuid} استفاده کن // همان محتوای ClinicDetailPage — اما uuid از context // دکمه "حذف کلینیک" نشان داده نشود // بقیه همه فعال: ویرایش، تغییر وضعیت، آپلود لوگو، گالری، دعوت پزشک } ``` بهترین رویکرد: کد مشترک را از `ClinicDetailPage.tsx` در یک کامپوننت `ClinicDetailView` جدا کن که `uuid` و `showDeleteButton` را به عنوان prop می‌گیرد. هر دو صفحه از آن استفاده کنند. > `GET /api/v1/clinics/{uuid}` و `PATCH /api/v1/clinic/{uuid}` هر دو موجودند — نیازی به API جدید نیست. --- ## مرحله ۸ — نوبت‌های فیلترشده (بک‌اند + فرانت‌اند) — API جدید ### بک‌اند — endpoint جدید: `GET /api/v1/my/appointments` `[IS_AUTHENTICATED_FULLY]` فایل جدید: `src/Appointment/Controller/MyAppointmentsController.php` ``` GET /api/v1/my/appointments?page=1&limit=15&status=...&search=... ``` بر اساس نقش فیلتر: - `ROLE_ADMIN`: forward به همان query موجود در `AdminApiController::appointments()` - `ROLE_CLINIC`: نوبت‌هایی که doctor آن در `clinic_doctors` این کلینیک است - `ROLE_DOCTOR`: نوبت‌های این دکتر — از `DoctorRepository::findByUser($user)` uuid بگیر - `ROLE_SECRETARY`: نوبت‌های دکتری که این منشی به آن وصل است (اگر `appointments.view = true`) پاسخ: همان فرمت `$this->paginated()` موجود: ```json { "success": true, "data": [ { "uuid":"...", "doctor_name":"...", "patient_name":"...", "slot_start":..., "status":"..." } ], "meta": { "totalRecords": 100, "totalPages": 7, "currentPage": 1 } } ``` **داکیومنت**: بعد از پیاده‌سازی، endpoint را به `docs/api/appointment.md` اضافه کن. ### فرانت‌اند — `AppointmentsPage.tsx` ```tsx const primaryRole = useAuthStore(s => s.primaryRole); const endpoint = primaryRole === 'admin' ? `/api/v1/admin/appointments` : `/api/v1/my/appointments`; ``` > برای ادمین از 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 --- ## نکات مهم پیاده‌سازی ### CSS / UI — فقط template CSS - `.card`, `.card-pad`, `.badge.green/.blue/.amber/.violet/.gray` - `.btn.primary/.ghost/.soft/.sm` - `.skeleton` برای loading - `.empty` برای حالت خالی - گرادیان آواتار: `HUES_LIST = [256, 205, 162, 295, 272]` با OKLCH: `background: \`linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))\`` - هیچ Tailwind نیست ### پاسخ‌های API - `$this->success($data)` → `{ success, data: $data }` — فرانت با `data?.data` می‌خواند - `$this->paginated($items, $total, $page, $limit)` → `{ success, data: $items[], meta: {...} }` — فرانت با `data?.data` و `data?.meta?.totalRecords` - `$this->error(...)` → `{ success:false, errors:[...] }` ### بک‌اند — قوانین کلی - همه Admin queries از DQL array hydration استفاده کنند (`.getArrayResult()`) - Timestamps همه integer Unix هستند - نوبت‌های «امروز»: بازه `strtotime('today midnight')` تا `strtotime('tomorrow midnight') - 1` ### ترتیب اجرا (پیشنهادی) 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`، `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، 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/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` — به‌روز: پاسخ `/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 داشبورد