- Implemented GET /api/v1/dashboard/clinic to return clinic stats and today's schedule for clinic owners. - Implemented GET /api/v1/dashboard/doctor to return doctor's stats and today's schedule for doctors. - Implemented GET /api/v1/dashboard/secretary to return stats and conditional appointments for secretaries. feat(migrations): create user_active_context and mobile_verification_otp tables - Added migration to create user_active_context table for tracking active user sessions. - Added migration to create mobile_verification_otp table for handling mobile number verification. feat(migrations): create site_config table for application settings - Added migration to create site_config table to store various site configuration settings. feat(appointments): create MyAppointmentsController for user-specific appointments - Added MyAppointmentsController to handle fetching user-specific appointments with pagination and filtering. feat(auth): implement NotificationMobileController for mobile number verification - Added NotificationMobileController to handle OTP requests and verification for mobile number changes. feat(auth): create MobileVerificationOtp entity for OTP management - Created MobileVerificationOtp entity to manage OTP records for mobile verification. feat(auth): create UserActiveContext entity for user session management - Created UserActiveContext entity to manage user active sessions. feat(config): implement SiteConfigController for managing site settings - Added SiteConfigController to handle fetching and updating site configuration settings. feat(config): create SiteConfig entity and repository for configuration management - Created SiteConfig entity and repository to manage site configuration data.
1071 lines
46 KiB
Markdown
1071 lines
46 KiB
Markdown
# پرامپت: داشبورد چند-نقشه (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<string, any>;
|
||
}
|
||
|
||
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 <Navigate to="/admin/select-context" replace />;
|
||
}
|
||
```
|
||
|
||
**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 (
|
||
<div className="select-context-page">
|
||
<h2>محیط کاری خود را انتخاب کنید</h2>
|
||
<div className="context-list">
|
||
{availableContexts.map(ctx => (
|
||
<button
|
||
key={ctx.db_uuid}
|
||
className="context-card"
|
||
onClick={() => handleSelect(ctx.db_uuid)}
|
||
>
|
||
<span className="context-type-badge">{ctx.role}</span>
|
||
<span className="context-name">{ctx.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- هر کارت: نام محیط کاری + نقش (مطب شخصی / کلینیک / منشی)
|
||
- بعد از انتخاب: `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 <div style={{padding:40, textAlign:'center'}}>در حال بارگذاری...</div>;
|
||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||
return <>{children}</>;
|
||
}
|
||
```
|
||
|
||
**Route های فقط ادمین** (با `<RoleRoute roles={['admin']}>` بپوشان):
|
||
- `/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` → `<MyClinicPage />` — فقط `ROLE_CLINIC`
|
||
|
||
---
|
||
|
||
## مرحله ۴ — Sidebar پویا (فرانتاند)
|
||
|
||
### فایل `assets/admin/components/layout/Sidebar.tsx`
|
||
|
||
ساختار `sections` را به یک تابع تبدیل کن که `primaryRole` و `context` میگیرد:
|
||
|
||
```tsx
|
||
function buildSections(
|
||
primaryRole: string | null,
|
||
context: Record<string, any> | 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 <LoadingSkeleton />;
|
||
if (primaryRole === 'admin') return <AdminDashboard />;
|
||
if (primaryRole === 'clinic') return <ClinicDashboard />;
|
||
if (primaryRole === 'doctor') return <DoctorDashboard />;
|
||
if (primaryRole === 'secretary') return <SecretaryDashboard />;
|
||
return <div className="card card-pad"><p className="muted">نقش شما برای داشبورد تعریف نشده</p></div>;
|
||
}
|
||
```
|
||
|
||
**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 (
|
||
<div className="card card-pad">
|
||
<p>کلینیک شما هنوز ثبت نشده است.</p>
|
||
</div>
|
||
);
|
||
|
||
// از 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 داشبورد
|