feat(api): add dashboard endpoints for clinic, doctor, and secretary roles
- 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.
This commit is contained in:
@@ -39,6 +39,7 @@
|
|||||||
|
|
||||||
| Endpoint | فایل کنترلر | توضیح |
|
| 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/clinic` | `src/Dashboard/Controller/DashboardController.php` | داشبورد صاحب کلینیک |
|
||||||
| `GET /api/v1/dashboard/doctor` | همان | داشبورد دکتر |
|
| `GET /api/v1/dashboard/doctor` | همان | داشبورد دکتر |
|
||||||
| `GET /api/v1/dashboard/secretary` | همان | داشبورد منشی |
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -110,14 +181,34 @@ endpoint موجود `/oauth/userinfo` را گسترش بده — همان route
|
|||||||
"status": 1,
|
"status": 1,
|
||||||
"roles": ["ROLE_USER", "ROLE_DOCTOR"],
|
"roles": ["ROLE_USER", "ROLE_DOCTOR"],
|
||||||
"primary_role": "doctor",
|
"primary_role": "doctor",
|
||||||
|
"db_uuid": "clinic-uuid-currently-active",
|
||||||
|
"db_key": "hmac-sha256-hash...",
|
||||||
"context": {
|
"context": {
|
||||||
"doctor_uuid": "...",
|
"type": "clinic",
|
||||||
"doctor_name": "دکتر وحید درویشی"
|
"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`** (اولویتبندی):
|
**قانون `primary_role`** (اولویتبندی):
|
||||||
- `ROLE_ADMIN` → `"admin"`
|
- `ROLE_ADMIN` → `"admin"`
|
||||||
- `ROLE_CLINIC` → `"clinic"`
|
- `ROLE_CLINIC` → `"clinic"`
|
||||||
@@ -125,14 +216,78 @@ endpoint موجود `/oauth/userinfo` را گسترش بده — همان route
|
|||||||
- `ROLE_SECRETARY` → `"secretary"`
|
- `ROLE_SECRETARY` → `"secretary"`
|
||||||
- بقیه → `"user"`
|
- بقیه → `"user"`
|
||||||
|
|
||||||
**پر کردن `context`**:
|
**ساختن `available_contexts` در بکاند**:
|
||||||
- `ROLE_DOCTOR`: از `DoctorRepository::findByUser($user)` → `{doctor_uuid, doctor_name}`
|
|
||||||
- `ROLE_CLINIC`: از `ClinicRepository::findByUser($user)` → `{clinic_uuid, clinic_name, clinic_logo}`
|
برای هر کاربر، همه context های ممکن را جمع میکنیم:
|
||||||
- `ROLE_SECRETARY`: از `DoctorSecretaryRepository::findActiveBySecretary($user)` (متد جدید) → `{secretary_uuid, doctor_uuid, doctor_name, permissions}`
|
|
||||||
- اگر موجودیت پیدا نشد: `context: null`
|
|
||||||
|
|
||||||
**متد جدید در `DoctorSecretaryRepository`**:
|
|
||||||
```php
|
```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
|
public function findActiveBySecretary(User $user): ?DoctorSecretary
|
||||||
{
|
{
|
||||||
return $this->findOneBy(['secretary' => $user, 'active' => true]);
|
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` (فرانتاند)
|
## مرحله ۲ — بهروز کردن `authStore.ts` (فرانتاند)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// assets/admin/stores/authStore.ts
|
// 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 {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
@@ -156,13 +387,68 @@ interface AuthState {
|
|||||||
userUuid: string | null;
|
userUuid: string | null;
|
||||||
userName: string | null;
|
userName: string | null;
|
||||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
|
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
|
||||||
context: Record<string, any> | null;
|
dbUuid: string | null; // UUID موجودیت فعال
|
||||||
|
dbKey: string | null; // hash امنیتی context فعال
|
||||||
|
context: ContextItem | null; // context فعال انتخابشده
|
||||||
|
availableContexts: ContextItem[]; // همه context های قابل انتخاب
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- متد `login(token, refreshToken)`: بعد از ذخیره token، یک `GET /oauth/userinfo` بزند و نتیجه را ذخیره کند
|
- متد `login(token, refreshToken)`: بعد از ذخیره token، یک `GET /oauth/userinfo` بزند و نتیجه را ذخیره کند
|
||||||
- متد `logout()`: همه فیلدها را پاک کند
|
- متد `logout()`: همه فیلدها را پاک کند
|
||||||
- متد جدید `fetchMe()`: `GET /oauth/userinfo` و update store — در `App.tsx` هنگام mount فراخوانی شود (اگر token موجود بود اما `primaryRole` خالی بود، تا بعد از reload صفحه role بازیابی شود)
|
- متد جدید `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`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -472,6 +758,225 @@ const endpoint = primaryRole === 'admin'
|
|||||||
|
|
||||||
> برای ادمین از endpoint موجود استفاده میشود؛ برای بقیه نقشها از endpoint جدید.
|
> برای ادمین از 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`
|
- نوبتهای «امروز»: بازه `strtotime('today midnight')` تا `strtotime('tomorrow midnight') - 1`
|
||||||
|
|
||||||
### ترتیب اجرا (پیشنهادی)
|
### ترتیب اجرا (پیشنهادی)
|
||||||
1. `DoctorSecretaryRepository::findActiveBySecretary()` — متد جدید
|
1. `UserActiveContext` entity جدید → migration
|
||||||
2. `AuthController::userInfo()` — گسترش برای `primary_role` + `context`، تست با curl
|
2. `DoctorSecretaryRepository` — اضافه: `findAllActiveBySecretary()` و `findActiveBySecretary()`
|
||||||
3. `authStore.ts` — اضافه کردن fetchMe + فیلدهای جدید
|
3. `AuthController::userInfo()` — گسترش: `primary_role`، `db_uuid`، `db_key`، `context`، `available_contexts`
|
||||||
4. `App.tsx` — fetchMe در mount
|
4. `AuthController::switchContext()` — endpoint جدید `POST /api/v1/auth/switch-context`
|
||||||
5. `DashboardController` — هر سه endpoint
|
5. `authStore.ts` — اضافه: `dbUuid`، `dbKey`، `availableContexts`، `fetchMe()`، `switchContext()`
|
||||||
6. `DashboardPage.tsx` — sub-dashboardها
|
6. `App.tsx` — fetchMe در mount + redirect به `/admin/select-context` اگر چند-context
|
||||||
7. `Sidebar.tsx` — پویا
|
7. `SelectContextPage.tsx` — صفحه انتخاب محیط کاری
|
||||||
8. `App.tsx` — RoleRoute
|
8. `DashboardController` — هر سه endpoint
|
||||||
9. `MyClinicPage.tsx`
|
9. `DashboardPage.tsx` — sub-dashboardها
|
||||||
10. `MyAppointmentsController` — بکاند
|
10. `Sidebar.tsx` — پویا + دکمه تغییر محیط کاری
|
||||||
11. `AppointmentsPage.tsx` — فیلتر endpoint
|
11. `App.tsx` — RoleRoute
|
||||||
12. بعد از هر مرحله بکاند: `ddev exec php bin/console cache:clear`
|
12. `MyClinicPage.tsx`
|
||||||
13. بعد از هر مرحله فرانتاند: `ddev exec yarn dev`
|
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/Auth/Controller/AuthController.php` — گسترش `userInfo()`: اضافه کردن `primary_role`، `db_uuid`، `db_key`، `context`، `available_contexts` + endpoint جدید `switch-context`
|
||||||
- `src/Secretary/Repository/DoctorSecretaryRepository.php` — اضافه: `findActiveBySecretary()`
|
- `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/Dashboard/Controller/DashboardController.php`
|
||||||
- `src/Appointment/Controller/MyAppointmentsController.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/stores/authStore.ts` — اضافه: primaryRole، dbUuid، dbKey، context، availableContexts، fetchMe()، switchContext()
|
||||||
- `assets/admin/App.tsx` — اضافه: RoleRoute، fetchMe در mount، route های جدید
|
- `assets/admin/App.tsx` — اضافه: RoleRoute، fetchMe در mount، redirect به select-context اگر چند-context، route های جدید + `/admin/settings`
|
||||||
- `assets/admin/components/layout/Sidebar.tsx` — تبدیل به پویا
|
- `assets/admin/components/layout/Sidebar.tsx` — تبدیل به پویا + دکمه تغییر محیط کاری
|
||||||
- `assets/admin/pages/DashboardPage.tsx` — multi-role
|
- `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/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/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`
|
- `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 داشبورد
|
- `docs/api/dashboard.md` — **فایل جدید** برای سه endpoint داشبورد
|
||||||
|
|||||||
+77
-50
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
import AdminLayout from './components/layout/AdminLayout';
|
import AdminLayout from './components/layout/AdminLayout';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
|
import SelectContextPage from './pages/SelectContextPage';
|
||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import UserDetailPage from './pages/UserDetailPage';
|
import UserDetailPage from './pages/UserDetailPage';
|
||||||
import DoctorsPage from './pages/DoctorsPage';
|
import DoctorsPage from './pages/DoctorsPage';
|
||||||
@@ -25,10 +26,33 @@ import CategoriesPage from './pages/CategoriesPage';
|
|||||||
import BlogsPage from './pages/BlogsPage';
|
import BlogsPage from './pages/BlogsPage';
|
||||||
import BlogFormPage from './pages/BlogFormPage';
|
import BlogFormPage from './pages/BlogFormPage';
|
||||||
import SecretariesPage from './pages/SecretariesPage';
|
import SecretariesPage from './pages/SecretariesPage';
|
||||||
|
import MyClinicPage from './pages/MyClinicPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
|
||||||
|
// ── Guards ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe } = useAuthStore();
|
||||||
return isAuthenticated ? <>{children}</> : <Navigate to="/admin/login" replace />;
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && !primaryRole) {
|
||||||
|
fetchMe();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, primaryRole, fetchMe]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) return <Navigate to="/admin/login" replace />;
|
||||||
|
|
||||||
|
// اگر context هنوز لود نشده — صبر کن
|
||||||
|
if (isAuthenticated && !primaryRole) {
|
||||||
|
return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// اگر چند context دارد و هنوز انتخاب نشده — به صفحه انتخاب برو
|
||||||
|
if (availableContexts.length > 1 && !dbUuid) {
|
||||||
|
return <Navigate to="/admin/select-context" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -36,74 +60,77 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoleRoute({ roles, children }: { roles: string[]; children: React.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}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public */}
|
||||||
<Route
|
<Route
|
||||||
path="/admin/login"
|
path="/admin/login"
|
||||||
element={
|
element={<PublicRoute><LoginPage /></PublicRoute>}
|
||||||
<PublicRoute>
|
|
||||||
<LoginPage />
|
|
||||||
</PublicRoute>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* انتخاب محیط کاری — نیاز به auth دارد اما خارج از AdminLayout */}
|
||||||
<Route
|
<Route
|
||||||
path="/admin/*"
|
path="/admin/select-context"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AdminLayout />
|
<SelectContextPage />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Protected */}
|
||||||
|
<Route
|
||||||
|
path="/admin/*"
|
||||||
|
element={<PrivateRoute><AdminLayout /></PrivateRoute>}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
|
|
||||||
|
{/* داشبورد — همه نقشها */}
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
{/* Users */}
|
{/* نوبتها — همه نقشها */}
|
||||||
<Route path="users" element={<UsersPage />} />
|
|
||||||
<Route path="users/:uuid" element={<UserDetailPage />} />
|
|
||||||
|
|
||||||
{/* Doctors */}
|
|
||||||
<Route path="doctors" element={<DoctorsPage />} />
|
|
||||||
<Route path="doctors/new" element={<DoctorFormPage />} />
|
|
||||||
<Route path="doctors/:uuid" element={<DoctorDetailPage />} />
|
|
||||||
|
|
||||||
{/* Clinics */}
|
|
||||||
<Route path="clinics" element={<ClinicsPage />} />
|
|
||||||
<Route path="clinics/:uuid" element={<ClinicDetailPage />} />
|
|
||||||
|
|
||||||
{/* Appointments */}
|
|
||||||
<Route path="appointments" element={<AppointmentsPage />} />
|
<Route path="appointments" element={<AppointmentsPage />} />
|
||||||
<Route path="appointments/:uuid" element={<AppointmentDetailPage />} />
|
<Route path="appointments/:uuid" element={<AppointmentDetailPage />} />
|
||||||
|
|
||||||
{/* Payments */}
|
{/* فقط ادمین */}
|
||||||
<Route path="payments" element={<PaymentsPage />} />
|
<Route path="users" element={<RoleRoute roles={['admin']}><UsersPage /></RoleRoute>} />
|
||||||
<Route path="payments/:uuid" element={<PaymentDetailPage />} />
|
<Route path="users/:uuid" element={<RoleRoute roles={['admin']}><UserDetailPage /></RoleRoute>} />
|
||||||
|
<Route path="payments" element={<RoleRoute roles={['admin']}><PaymentsPage /></RoleRoute>} />
|
||||||
|
<Route path="payments/:uuid" element={<RoleRoute roles={['admin']}><PaymentDetailPage /></RoleRoute>} />
|
||||||
|
<Route path="settlements" element={<RoleRoute roles={['admin']}><SettlementsPage /></RoleRoute>} />
|
||||||
|
<Route path="representations" element={<RoleRoute roles={['admin']}><RepresentationsPage /></RoleRoute>} />
|
||||||
|
<Route path="representations/:uuid" element={<RoleRoute roles={['admin']}><RepresentationDetailPage /></RoleRoute>} />
|
||||||
|
<Route path="comments" element={<RoleRoute roles={['admin']}><CommentsPage /></RoleRoute>} />
|
||||||
|
<Route path="ratings" element={<RoleRoute roles={['admin']}><RatingsPage /></RoleRoute>} />
|
||||||
|
<Route path="sms" element={<RoleRoute roles={['admin']}><SmsPage /></RoleRoute>} />
|
||||||
|
<Route path="categories" element={<RoleRoute roles={['admin']}><CategoriesPage /></RoleRoute>} />
|
||||||
|
<Route path="blogs" element={<RoleRoute roles={['admin']}><BlogsPage /></RoleRoute>} />
|
||||||
|
<Route path="blogs/new" element={<RoleRoute roles={['admin']}><BlogFormPage /></RoleRoute>} />
|
||||||
|
<Route path="blogs/:uuid/edit" element={<RoleRoute roles={['admin']}><BlogFormPage /></RoleRoute>} />
|
||||||
|
<Route path="secretaries" element={<RoleRoute roles={['admin']}><SecretariesPage /></RoleRoute>} />
|
||||||
|
<Route path="clinics" element={<RoleRoute roles={['admin']}><ClinicsPage /></RoleRoute>} />
|
||||||
|
<Route path="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
|
||||||
|
|
||||||
{/* Settlements */}
|
{/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */}
|
||||||
<Route path="settlements" element={<SettlementsPage />} />
|
<Route path="my-clinic" element={<RoleRoute roles={['clinic']}><MyClinicPage /></RoleRoute>} />
|
||||||
|
|
||||||
{/* Representations */}
|
{/* ادمین + کلینیک */}
|
||||||
<Route path="representations" element={<RepresentationsPage />} />
|
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic']}><ClinicDetailPage /></RoleRoute>} />
|
||||||
<Route path="representations/:uuid" element={<RepresentationDetailPage />} />
|
<Route path="doctors" element={<RoleRoute roles={['admin', 'clinic']}><DoctorsPage /></RoleRoute>} />
|
||||||
|
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'clinic']}><DoctorFormPage /></RoleRoute>} />
|
||||||
{/* Comments & Ratings */}
|
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
|
||||||
<Route path="comments" element={<CommentsPage />} />
|
|
||||||
<Route path="ratings" element={<RatingsPage />} />
|
|
||||||
|
|
||||||
{/* SMS */}
|
|
||||||
<Route path="sms" element={<SmsPage />} />
|
|
||||||
|
|
||||||
{/* Categories */}
|
|
||||||
<Route path="categories" element={<CategoriesPage />} />
|
|
||||||
|
|
||||||
{/* Blogs */}
|
|
||||||
<Route path="blogs" element={<BlogsPage />} />
|
|
||||||
<Route path="blogs/new" element={<BlogFormPage />} />
|
|
||||||
<Route path="blogs/:uuid/edit" element={<BlogFormPage />} />
|
|
||||||
|
|
||||||
{/* Secretaries */}
|
|
||||||
<Route path="secretaries" element={<SecretariesPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ArrowLeftOnRectangleIcon,
|
ArrowLeftOnRectangleIcon,
|
||||||
|
ArrowsRightLeftIcon,
|
||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
BuildingOffice2Icon,
|
BuildingOffice2Icon,
|
||||||
|
Cog6ToothIcon,
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
@@ -19,76 +21,140 @@ import { NavLink, useNavigate } from "react-router-dom";
|
|||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
|
||||||
const sections = [
|
type SectionItem = { to: string; icon: React.ElementType; label: string };
|
||||||
{
|
type Section = { label: string; items: SectionItem[] };
|
||||||
label: "عمومی",
|
|
||||||
items: [
|
function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] {
|
||||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
if (primaryRole === 'admin') {
|
||||||
{ to: "/admin/users", icon: UserGroupIcon, label: "کاربران" },
|
return [
|
||||||
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
|
||||||
{
|
{
|
||||||
to: "/admin/clinics",
|
label: 'عمومی',
|
||||||
icon: BuildingOffice2Icon,
|
items: [
|
||||||
label: "کلینیکها",
|
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
||||||
|
{ to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' },
|
||||||
|
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
|
||||||
|
{ to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیکها' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "مدیریت",
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
to: "/admin/appointments",
|
label: 'مدیریت',
|
||||||
icon: CalendarDaysIcon,
|
items: [
|
||||||
label: "نوبتها",
|
{ 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",
|
label: 'محتوا',
|
||||||
icon: BanknotesIcon,
|
items: [
|
||||||
label: "تسویهحساب",
|
{ to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' },
|
||||||
|
{ to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' },
|
||||||
|
{ to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' },
|
||||||
|
{ to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "محتوا",
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
to: "/admin/comments",
|
label: 'سیستم',
|
||||||
icon: ChatBubbleLeftEllipsisIcon,
|
items: [
|
||||||
label: "نظرات",
|
{ 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: "پیامک" },
|
|
||||||
],
|
if (primaryRole === 'clinic') {
|
||||||
},
|
const clinicTo = dbUuid ? `/admin/clinics/${dbUuid}` : '/admin/my-clinic';
|
||||||
{
|
return [
|
||||||
label: "سیستم",
|
|
||||||
items: [
|
|
||||||
{ to: "/admin/categories", icon: TagIcon, label: "دستهبندیها" },
|
|
||||||
{
|
{
|
||||||
to: "/admin/representations",
|
label: 'عمومی',
|
||||||
icon: UsersIcon,
|
items: [
|
||||||
label: "نمایندگان",
|
{ 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<string, string> = {
|
||||||
|
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 {
|
interface Props {
|
||||||
mobileOpen?: boolean;
|
mobileOpen?: boolean;
|
||||||
onMobileClose?: () => void;
|
onMobileClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||||
mobileOpen: _mobileOpen,
|
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
||||||
onMobileClose: _onMobileClose,
|
const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore();
|
||||||
}: Props) {
|
|
||||||
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
|
||||||
const logout = useAuthStore((s) => s.logout);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const sections = buildSections(primaryRole, dbUuid);
|
||||||
|
const initials = (userName ?? 'U').charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
@@ -112,56 +178,49 @@ export default function Sidebar({
|
|||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
title={!sidebarOpen ? label : undefined}
|
title={!sidebarOpen ? label : undefined}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||||
`nav-item${isActive ? " active" : ""}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
||||||
style={{
|
|
||||||
width: 19,
|
|
||||||
height: 19,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* تغییر محیط کاری — فقط اگر چند context دارد */}
|
||||||
|
{availableContexts.length > 1 && (
|
||||||
|
<div className="nav-group">
|
||||||
|
<span className="nav-label">محیط کاری</span>
|
||||||
|
<NavLink
|
||||||
|
to="/admin/select-context"
|
||||||
|
title={!sidebarOpen ? 'تغییر محیط' : undefined}
|
||||||
|
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
<ArrowsRightLeftIcon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
||||||
|
<span>تغییر محیط</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User footer */}
|
{/* User footer */}
|
||||||
<div className="sidebar-foot">
|
<div className="sidebar-foot">
|
||||||
<div
|
<div
|
||||||
className="user-chip"
|
className="user-chip"
|
||||||
onClick={() => {
|
onClick={() => { logout(); navigate('/admin/login'); }}
|
||||||
logout();
|
|
||||||
navigate("/admin/login");
|
|
||||||
}}
|
|
||||||
title="خروج از سیستم"
|
title="خروج از سیستم"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="avatar sm"
|
className="avatar sm"
|
||||||
style={{
|
style={{ background: avatarBg(userName ?? 'U'), flexShrink: 0 }}
|
||||||
background:
|
|
||||||
"linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
A
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<div className="user-meta">
|
<div className="user-meta">
|
||||||
<b>Admin</b>
|
<b>{userName ?? 'کاربر'}</b>
|
||||||
<span>مدیر سیستم</span>
|
<span>{ROLE_LABELS[primaryRole ?? ''] ?? primaryRole ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<ArrowLeftOnRectangleIcon
|
<ArrowLeftOnRectangleIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} />
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
flexShrink: 0,
|
|
||||||
color: "var(--text-3)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['notification-mobile', target],
|
||||||
|
queryFn: () => api.get<ApiResponse<NotificationMobileData>>(`/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<ApiResponse<{ message: string }>>('/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<ApiResponse<{ notification_mobile: string }>>('/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<ApiResponse<unknown>>(`/api/v1/notification-mobile/${target}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-mobile', target] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="skeleton" style={{ height: 100, borderRadius: 'var(--r)' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||||
|
<div className="card-title-row" style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div className="ico" style={{ background: 'var(--info-bg)', color: 'var(--info)', width: 36, height: 36, borderRadius: 10 }}>
|
||||||
|
<DevicePhoneMobileIcon style={{ width: 18, height: 18 }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: 14.5, fontWeight: 600 }}>شماره اعلان نوبت</h3>
|
||||||
|
<p className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||||
|
پیامک نوبت جدید به این شماره ارسال میشود
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نمایش شماره فعلی */}
|
||||||
|
{current && step === 'idle' && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px',
|
||||||
|
background: 'var(--success-bg)', borderRadius: 'var(--r-sm)',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}>
|
||||||
|
<CheckCircleIcon style={{ width: 18, height: 18, color: 'var(--success)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14, direction: 'ltr', flex: 1 }}>{current}</span>
|
||||||
|
<button
|
||||||
|
className="mini-btn"
|
||||||
|
onClick={() => remove.mutate()}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
title="حذف"
|
||||||
|
>
|
||||||
|
<XMarkIcon style={{ width: 15, height: 15 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!current && step === 'idle' && (
|
||||||
|
<p className="muted" style={{ fontSize: 13, marginBottom: '1rem' }}>
|
||||||
|
شمارهای تنظیم نشده است.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* مرحله ۱: ورود شماره جدید */}
|
||||||
|
{step === 'idle' && (
|
||||||
|
<button
|
||||||
|
className="btn ghost sm"
|
||||||
|
onClick={() => { setStep('enter_mobile'); setError(null); }}
|
||||||
|
>
|
||||||
|
{current ? 'تغییر شماره' : 'تنظیم شماره اعلان'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'enter_mobile' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 5 }}>
|
||||||
|
شماره موبایل جدید
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={newMobile}
|
||||||
|
onChange={e => 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: 12.5 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn primary sm"
|
||||||
|
onClick={() => requestOtp.mutate(newMobile)}
|
||||||
|
disabled={requestOtp.isPending || !/^09\d{9}$/.test(newMobile)}
|
||||||
|
>
|
||||||
|
{requestOtp.isPending ? 'در حال ارسال...' : 'ارسال کد تأیید'}
|
||||||
|
</button>
|
||||||
|
<button className="btn ghost sm" onClick={() => { setStep('idle'); setError(null); }}>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* مرحله ۲: ورود کد OTP */}
|
||||||
|
{step === 'enter_otp' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
||||||
|
کد ۶ رقمی ارسالشده به <b style={{ direction: 'ltr', display: 'inline-block' }}>{newMobile}</b> را وارد کنید.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={e => 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: 12.5 }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn primary sm"
|
||||||
|
onClick={() => verify.mutate(otpCode)}
|
||||||
|
disabled={verify.isPending || otpCode.length !== 6}
|
||||||
|
>
|
||||||
|
{verify.isPending ? 'در حال تأیید...' : 'تأیید'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ghost sm"
|
||||||
|
onClick={() => requestOtp.mutate(newMobile)}
|
||||||
|
disabled={requestOtp.isPending}
|
||||||
|
>
|
||||||
|
ارسال مجدد
|
||||||
|
</button>
|
||||||
|
<button className="btn ghost sm" onClick={() => { setStep('idle'); setError(null); }}>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import React, { useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon, EyeIcon,
|
MagnifyingGlassIcon, EyeIcon, TableCellsIcon, CalendarDaysIcon as CalendarViewIcon,
|
||||||
CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon,
|
CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon, FunnelIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { PaginatedResponse } 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 DataTable, { Column } from '../components/ui/DataTable';
|
||||||
import StatusBadge from '../components/ui/StatusBadge';
|
import StatusBadge from '../components/ui/StatusBadge';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
// ── Status helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATUS_FILTERS = [
|
const STATUS_FILTERS = [
|
||||||
{ value: '', label: 'همه' },
|
{ value: '', label: 'همه' },
|
||||||
{ value: 'waiting_for_payment', label: 'در انتظار پرداخت' },
|
{ value: 'waiting_for_payment', label: 'در انتظار پرداخت' },
|
||||||
{ value: 'reserved', label: 'رزرو شده' },
|
{ value: 'reserved', label: 'رزرو شده' },
|
||||||
|
{ value: 'checked_in', label: 'ورود به مطب' },
|
||||||
|
{ value: 'waiting', label: 'صف انتظار' },
|
||||||
|
{ value: 'in_progress', label: 'در حال ویزیت' },
|
||||||
{ value: 'visited', 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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '1rem' }}>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
|
<div className="skeleton" style={{ width: 48, height: 48, borderRadius: 10, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="skeleton" style={{ height: 14, borderRadius: 5, width: '55%', marginBottom: 6 }} />
|
||||||
|
<div className="skeleton" style={{ height: 12, borderRadius: 5, width: '35%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return <p className="muted" style={{ textAlign: 'center', padding: '3rem 0', fontSize: 13.5 }}>هیچ نوبتی یافت نشد</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// گروهبندی بر اساس تاریخ
|
||||||
|
const grouped = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
||||||
|
const key = a.appointment_date;
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(a);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0 1rem 1rem' }}>
|
||||||
|
{Object.entries(grouped).map(([date, appts]) => (
|
||||||
|
<div key={date} style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-3)', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 24, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
||||||
|
{new Date(date).toLocaleDateString('fa-IR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{appts.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.uuid}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, padding: '0.75rem 1rem',
|
||||||
|
background: 'var(--surface-alt, #f8fafc)', borderRadius: 10,
|
||||||
|
border: '1px solid var(--border)', cursor: 'pointer', transition: 'box-shadow .15s',
|
||||||
|
}}
|
||||||
|
onClick={() => 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')}
|
||||||
|
>
|
||||||
|
{/* ساعت */}
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: 'var(--primary-soft, #eef2ff)', color: 'var(--primary)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontWeight: 700, fontSize: 15, lineHeight: 1.2,
|
||||||
|
}}>
|
||||||
|
{a.appointment_time}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* اطلاعات */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{a.patient_name || maskMobile(a.patient_mobile)}
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||||
|
دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* وضعیت */}
|
||||||
|
<span className={`badge ${APPT_CLS[a.status] ?? 'gray'}`}>
|
||||||
|
<span className="bdot" />{APPT_LABEL[a.status] ?? a.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<EyeIcon style={{ width: 16, height: 16, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AppointmentsPage() {
|
export default function AppointmentsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [dateFilter, setDateFilter] = useState('');
|
||||||
|
const [viewMode, setViewMode] = useState<'table' | 'timeline'>('table');
|
||||||
const limit = 15;
|
const limit = 15;
|
||||||
|
|
||||||
|
const isAdmin = primaryRole === 'admin';
|
||||||
|
const endpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['appointments', page, search, statusFilter],
|
queryKey: ['appointments', endpoint, page, search, statusFilter, dateFilter],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
if (statusFilter) params.set('status', statusFilter);
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
return api.get<PaginatedResponse<Appointment>>(`/api/v1/admin/appointments?${params}`);
|
if (dateFilter) params.set('date', dateFilter);
|
||||||
|
return api.get<PaginatedResponse<Appointment>>(`${endpoint}?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,11 +208,13 @@ export default function AppointmentsPage() {
|
|||||||
const items = data?.data ?? [];
|
const items = data?.data ?? [];
|
||||||
const total = data?.meta?.totalRecords ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
|
|
||||||
|
const pageTitle = isAdmin ? 'نوبتها' : (primaryRole === 'doctor' ? 'نوبتهای من' : primaryRole === 'secretary' ? 'نوبتهای پزشک' : 'نوبتهای کلینیک');
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: 'کل نوبتها', value: total > 0 ? String(total) : null, bg: 'var(--info-bg)', color: 'var(--info)', Icon: CalendarIcon },
|
{ 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(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon },
|
||||||
{ label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon },
|
{ 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: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,7 +222,7 @@ export default function AppointmentsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">نوبتها</h1>
|
<h1 className="section-title">{pageTitle}</h1>
|
||||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت و پیگیری نوبتهای درمانی</div>
|
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت و پیگیری نوبتهای درمانی</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary sm">
|
<button className="btn primary sm">
|
||||||
@@ -125,45 +251,93 @@ export default function AppointmentsPage() {
|
|||||||
{/* Main card */}
|
{/* Main card */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
||||||
<div className="toolbar">
|
<div className="toolbar" style={{ flexWrap: 'wrap', gap: 10 }}>
|
||||||
<div className="field" style={{ minWidth: 240 }}>
|
{/* جستجو */}
|
||||||
|
<div className="field" style={{ minWidth: 220 }}>
|
||||||
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
||||||
<input
|
<input
|
||||||
placeholder="جستجو بر اساس موبایل یا نام..."
|
placeholder="جستجو (موبایل / نام)..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="seg">
|
|
||||||
{STATUS_FILTERS.map((f) => (
|
{/* فیلتر تاریخ */}
|
||||||
<button
|
<div className="field" style={{ minWidth: 160 }}>
|
||||||
key={f.value}
|
<FunnelIcon style={{ width: 15, height: 15 }} />
|
||||||
className={statusFilter === f.value ? 'on' : ''}
|
<input
|
||||||
onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
type="date"
|
||||||
>
|
value={dateFilter}
|
||||||
{f.label}
|
onChange={(e) => { setDateFilter(e.target.value); setPage(1); }}
|
||||||
</button>
|
style={{ direction: 'ltr' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* فیلتر وضعیت */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||||
|
style={{
|
||||||
|
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)', color: 'var(--text)', fontSize: 13, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_FILTERS.map(f => (
|
||||||
|
<option key={f.value} value={f.value}>{f.label}</option>
|
||||||
))}
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div style={{ marginRight: 'auto' }} />
|
||||||
|
|
||||||
|
{/* تغییر نما */}
|
||||||
|
<div className="seg">
|
||||||
|
<button
|
||||||
|
className={viewMode === 'table' ? 'on' : ''}
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
title="نمای جدول"
|
||||||
|
>
|
||||||
|
<TableCellsIcon style={{ width: 15, height: 15 }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={viewMode === 'timeline' ? 'on' : ''}
|
||||||
|
onClick={() => setViewMode('timeline')}
|
||||||
|
title="نمای زمانی"
|
||||||
|
>
|
||||||
|
<CalendarViewIcon style={{ width: 15, height: 15 }} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable<Appointment>
|
{viewMode === 'table' ? (
|
||||||
columns={columns}
|
<>
|
||||||
data={items}
|
<DataTable<Appointment>
|
||||||
loading={isLoading}
|
columns={columns}
|
||||||
emptyMessage="هیچ نوبتی یافت نشد"
|
data={items}
|
||||||
actions={(appt) => (
|
loading={isLoading}
|
||||||
<button
|
emptyMessage="هیچ نوبتی یافت نشد"
|
||||||
className="mini-btn"
|
actions={(appt) => (
|
||||||
onClick={() => navigate(`/admin/appointments/${appt.uuid}`)}
|
<button
|
||||||
title="مشاهده"
|
className="mini-btn"
|
||||||
>
|
onClick={() => navigate(`/admin/appointments/${appt.uuid}`)}
|
||||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
title="مشاهده"
|
||||||
</button>
|
>
|
||||||
)}
|
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||||
/>
|
</button>
|
||||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
)}
|
||||||
|
/>
|
||||||
|
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TimelineView
|
||||||
|
items={items}
|
||||||
|
loading={isLoading}
|
||||||
|
onView={(uuid) => navigate(`/admin/appointments/${uuid}`)}
|
||||||
|
/>
|
||||||
|
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
|||||||
import type { ClinicDetail } from '../types';
|
import type { ClinicDetail } from '../types';
|
||||||
import { formatNumber } from '../lib/utils';
|
import { formatNumber } from '../lib/utils';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
// Fix leaflet icons
|
// Fix leaflet icons
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@@ -568,6 +570,7 @@ export default function ClinicDetailPage() {
|
|||||||
const { uuid } = useParams<{ uuid: string }>();
|
const { uuid } = useParams<{ uuid: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
|
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
@@ -1031,6 +1034,10 @@ export default function ClinicDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{primaryRole === 'clinic' && (
|
||||||
|
<NotificationMobileCard target="clinic" />
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,54 +4,14 @@ import { Link } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon,
|
UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon,
|
||||||
CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon,
|
CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon,
|
||||||
|
ClockIcon, StarIcon, UserIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
|
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────
|
// ── Shared Status Maps ────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const APPT_LABEL: Record<string, string> = {
|
const APPT_LABEL: Record<string, string> = {
|
||||||
waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب',
|
waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب',
|
||||||
@@ -80,7 +40,7 @@ const PAY_CLS: Record<string, string> = {
|
|||||||
pending: 'amber', received: 'green', canceled: 'red', refund: 'blue',
|
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 }) {
|
function SvgLineChart({ data, color, h = 220 }: { data: number[]; color: string; h?: number }) {
|
||||||
if (data.length < 2) return null;
|
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' }) {
|
function AvatarEl({ initials, hue = 222, size }: { initials: string; hue?: number; size?: 'sm' | 'lg' }) {
|
||||||
const cls = 'avatar' + (size === 'sm' ? ' sm' : size === 'lg' ? ' 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 {
|
interface MiniRow {
|
||||||
title: string;
|
title: string; sub: string; meta: string;
|
||||||
sub: string;
|
badgeLabel: string; badgeCls: string; initials: string; hue: number;
|
||||||
meta: string;
|
|
||||||
badgeLabel: string;
|
|
||||||
badgeCls: string;
|
|
||||||
initials: string;
|
|
||||||
hue: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniList({ title, to, rows, loading }: { title: string; to: string; rows: MiniRow[]; loading?: boolean }) {
|
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() {
|
function KpiSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
@@ -243,62 +194,116 @@ function KpiSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main Component ────────────────────────────────────────────────────────
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="fade-in">
|
||||||
|
<div className="stat-grid">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => <KpiSkeleton key={i} />)}
|
||||||
|
</div>
|
||||||
|
<div className="skeleton" style={{ height: 300, borderRadius: 'var(--r)', marginTop: 'var(--gap)' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 <div className="skeleton" style={{ height: 180, borderRadius: 'var(--r)' }} />;
|
||||||
|
if (!appts.length) return <p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>نوبتی برای امروز ثبت نشده</p>;
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>بیمار</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>ساعت</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>وضعیت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{appts.map((a, i) => (
|
||||||
|
<tr key={a.uuid} style={{ borderBottom: i < appts.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||||
|
<td style={{ padding: '10px 12px' }}>{a.patient_name || a.patient_mobile || '—'}</td>
|
||||||
|
<td style={{ padding: '10px 12px', direction: 'ltr', textAlign: 'left' }}>
|
||||||
|
{new Date(a.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 12px' }}>
|
||||||
|
<span className={`badge ${APPT_CLS[a.status] ?? 'gray'}`}>
|
||||||
|
<span className="bdot" />{APPT_LABEL[a.status] ?? a.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 [chartMode, setChartMode] = useState<'appts' | 'rev'>('appts');
|
||||||
|
|
||||||
const statsQ = useQuery({
|
const statsQ = useQuery({ queryKey: ['dashboard-stats'], queryFn: () => api.get<ApiResponse<AdminStats>>('/api/v1/admin/dashboard/stats'), staleTime: 60_000 });
|
||||||
queryKey: ['dashboard-stats'],
|
const chartsQ = useQuery({ queryKey: ['dashboard-charts'], queryFn: () => api.get<ApiResponse<AdminCharts>>('/api/v1/admin/dashboard/charts'), staleTime: 120_000 });
|
||||||
queryFn: () => api.get<ApiResponse<DashboardStats>>('/api/v1/admin/dashboard/stats'),
|
const recentQ = useQuery({ queryKey: ['dashboard-recent'], queryFn: () => api.get<ApiResponse<AdminRecent>>('/api/v1/admin/dashboard/recent'), staleTime: 30_000 });
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
const chartsQ = useQuery({
|
|
||||||
queryKey: ['dashboard-charts'],
|
|
||||||
queryFn: () => api.get<ApiResponse<ChartData>>('/api/v1/admin/dashboard/charts'),
|
|
||||||
staleTime: 120_000,
|
|
||||||
});
|
|
||||||
const recentQ = useQuery({
|
|
||||||
queryKey: ['dashboard-recent'],
|
|
||||||
queryFn: () => api.get<ApiResponse<RecentData>>('/api/v1/admin/dashboard/recent'),
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const stats = useMemo<DashboardStats | undefined>(() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]);
|
const stats = useMemo<AdminStats | undefined>(() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const charts = useMemo<ChartData | undefined>( () => (chartsQ.data?.data as any)?.data ?? chartsQ.data?.data, [chartsQ.data]);
|
const charts = useMemo<AdminCharts | undefined>(() => (chartsQ.data?.data as any)?.data ?? chartsQ.data?.data, [chartsQ.data]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const recent = useMemo<RecentData | undefined>( () => (recentQ.data?.data as any)?.data ?? recentQ.data?.data, [recentQ.data]);
|
const recent = useMemo<AdminRecent | undefined>(() => (recentQ.data?.data as any)?.data ?? recentQ.data?.data, [recentQ.data]);
|
||||||
|
|
||||||
const fn = (n?: number) => n !== undefined ? formatNumber(n) : '—';
|
const fn = (n?: number) => n !== undefined ? formatNumber(n) : '—';
|
||||||
const fr = (n?: number) => n !== undefined ? formatRial(n) : '—';
|
const fr = (n?: number) => n !== undefined ? formatRial(n) : '—';
|
||||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
const isFetching = statsQ.isFetching || chartsQ.isFetching || recentQ.isFetching;
|
const isFetching = statsQ.isFetching || chartsQ.isFetching || recentQ.isFetching;
|
||||||
|
|
||||||
// Chart data transformations
|
|
||||||
const apptSeries = useMemo(() => charts?.appointments_30d?.map(d => d.count) ?? [], [charts]);
|
const apptSeries = useMemo(() => charts?.appointments_30d?.map(d => d.count) ?? [], [charts]);
|
||||||
const revSeries = useMemo(() => charts?.revenue_30d?.map(d => d.amount) ?? [], [charts]);
|
const revSeries = useMemo(() => charts?.revenue_30d?.map(d => d.amount) ?? [], [charts]);
|
||||||
const donutData = useMemo(() =>
|
const donutData = useMemo(() =>
|
||||||
(charts?.appointment_status ?? []).slice(0, 7).map(s => ({
|
(charts?.appointment_status ?? []).slice(0, 7).map(s => ({
|
||||||
label: APPT_LABEL[s.status] ?? s.status,
|
label: APPT_LABEL[s.status] ?? s.status, value: s.count, color: APPT_COLOR[s.status] ?? '#94a3b8',
|
||||||
value: s.count,
|
|
||||||
color: APPT_COLOR[s.status] ?? '#94a3b8',
|
|
||||||
})), [charts]);
|
})), [charts]);
|
||||||
const hbarsData = useMemo(() =>
|
const hbarsData = useMemo(() => (charts?.top_specialties ?? []).map(s => ({ label: s.name, value: s.count })), [charts]);
|
||||||
(charts?.top_specialties ?? []).map(s => ({ label: s.name, value: s.count })), [charts]);
|
|
||||||
|
|
||||||
// KPI cards
|
|
||||||
const kpiCards = [
|
const kpiCards = [
|
||||||
{ label: 'کل کاربران', value: fn(stats?.total_users), hint: 'رشد نسبت به ماه قبل', icon: UserGroupIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' },
|
{ 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?.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?.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: 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: 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)' },
|
{ 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 = [
|
const quickActions = [
|
||||||
{ label: 'پزشکان', to: '/admin/doctors', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
{ 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)' },
|
{ 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)' },
|
{ label: 'کاربران', to: '/admin/users', icon: UserGroupIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Timeline events
|
|
||||||
const timelineEvents = useMemo(() => {
|
const timelineEvents = useMemo(() => {
|
||||||
if (!recent) return [];
|
if (!recent) return [];
|
||||||
const evs: { title: string; sub: string; time: string; color: string }[] = [];
|
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);
|
return evs.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()).slice(0, 8);
|
||||||
}, [recent]);
|
}, [recent]);
|
||||||
|
|
||||||
// MiniList rows
|
|
||||||
const apptRows = useMemo<MiniRow[]>(() =>
|
const apptRows = useMemo<MiniRow[]>(() =>
|
||||||
(recent?.appointments ?? []).slice(0, 5).map(a => ({
|
(recent?.appointments ?? []).slice(0, 5).map(a => ({
|
||||||
title: a.user_name || a.user_mobile,
|
title: a.user_name || a.user_mobile, sub: `دکتر ${a.doctor_name}`, meta: formatDateTime(a.slot_start),
|
||||||
sub: `دکتر ${a.doctor_name}`,
|
badgeLabel: APPT_LABEL[a.status] ?? a.status, badgeCls: APPT_CLS[a.status] ?? 'gray',
|
||||||
meta: formatDateTime(a.slot_start),
|
initials: (a.user_name || a.user_mobile).slice(0, 2), hue: 222,
|
||||||
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]);
|
})), [recent]);
|
||||||
|
|
||||||
const payRows = useMemo<MiniRow[]>(() =>
|
const payRows = useMemo<MiniRow[]>(() =>
|
||||||
(recent?.payments ?? []).slice(0, 5).map(p => ({
|
(recent?.payments ?? []).slice(0, 5).map(p => ({
|
||||||
title: p.user_name || p.user_mobile,
|
title: p.user_name || p.user_mobile, sub: formatRial(p.amount), meta: formatDateTime(p.created_at),
|
||||||
sub: formatRial(p.amount),
|
badgeLabel: PAY_LABEL[p.status] ?? p.status, badgeCls: PAY_CLS[p.status] ?? 'gray',
|
||||||
meta: formatDateTime(p.created_at),
|
initials: (p.user_name || p.user_mobile).slice(0, 2), hue: 162,
|
||||||
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]);
|
})), [recent]);
|
||||||
|
|
||||||
const userRows = useMemo<MiniRow[]>(() =>
|
const userRows = useMemo<MiniRow[]>(() =>
|
||||||
(recent?.users ?? []).slice(0, 5).map(u => ({
|
(recent?.users ?? []).slice(0, 5).map(u => ({
|
||||||
title: u.name || u.mobile,
|
title: u.name || u.mobile, sub: u.name ? u.mobile : '', meta: formatDateTime(u.created_at),
|
||||||
sub: u.name ? u.mobile : '',
|
badgeLabel: 'فعال', badgeCls: 'green',
|
||||||
meta: formatDateTime(u.created_at),
|
initials: (u.name || u.mobile).slice(0, 2), hue: 256,
|
||||||
badgeLabel: 'فعال',
|
|
||||||
badgeCls: 'green',
|
|
||||||
initials: (u.name || u.mobile).slice(0, 2),
|
|
||||||
hue: 256,
|
|
||||||
})), [recent]);
|
})), [recent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
|
|
||||||
{/* Page header */}
|
|
||||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">داشبورد</h1>
|
<h1 className="section-title">داشبورد مدیریت</h1>
|
||||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
@@ -377,7 +366,6 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat cards (6-col grid) */}
|
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
{statsQ.isLoading
|
{statsQ.isLoading
|
||||||
? Array.from({ length: 6 }).map((_, i) => <KpiSkeleton key={i} />)
|
? Array.from({ length: 6 }).map((_, i) => <KpiSkeleton key={i} />)
|
||||||
@@ -396,7 +384,6 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* dash-main: Donut (360px) + LineChart (1fr) */}
|
|
||||||
<div className="dash-main">
|
<div className="dash-main">
|
||||||
<div className="card card-pad">
|
<div className="card card-pad">
|
||||||
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>وضعیت نوبتها</h3></div>
|
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>وضعیت نوبتها</h3></div>
|
||||||
@@ -428,7 +415,6 @@ export default function DashboardPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card card-pad">
|
<div className="card card-pad">
|
||||||
<div className="card-title-row">
|
<div className="card-title-row">
|
||||||
<h3 style={{ fontSize: 16 }}>{chartMode === 'appts' ? 'نوبتها' : 'درآمد'} — ۳۰ روز اخیر</h3>
|
<h3 style={{ fontSize: 16 }}>{chartMode === 'appts' ? 'نوبتها' : 'درآمد'} — ۳۰ روز اخیر</h3>
|
||||||
@@ -448,7 +434,6 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top specialties (HBars) */}
|
|
||||||
<div className="card card-pad" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card card-pad" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div className="card-title-row">
|
<div className="card-title-row">
|
||||||
<h3 style={{ fontSize: 16 }}>پرتکرارترین تخصصها</h3>
|
<h3 style={{ fontSize: 16 }}>پرتکرارترین تخصصها</h3>
|
||||||
@@ -463,7 +448,6 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* grid-2: Quick access + Timeline */}
|
|
||||||
<div className="grid-2" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="grid-2" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div className="card card-pad">
|
<div className="card card-pad">
|
||||||
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>دسترسی سریع</h3></div>
|
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>دسترسی سریع</h3></div>
|
||||||
@@ -478,11 +462,8 @@ export default function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card card-pad">
|
<div className="card card-pad">
|
||||||
<div className="card-title-row">
|
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>آخرین رویدادها</h3></div>
|
||||||
<h3 style={{ fontSize: 16 }}>آخرین رویدادها</h3>
|
|
||||||
</div>
|
|
||||||
{recentQ.isLoading ? (
|
{recentQ.isLoading ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
@@ -514,13 +495,299 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* dash-3: Three MiniLists */}
|
|
||||||
<div className="dash-3">
|
<div className="dash-3">
|
||||||
<MiniList title="آخرین نوبتها" to="/admin/appointments" rows={apptRows} loading={recentQ.isLoading} />
|
<MiniList title="آخرین نوبتها" to="/admin/appointments" rows={apptRows} loading={recentQ.isLoading} />
|
||||||
<MiniList title="آخرین پرداختها" to="/admin/payments" rows={payRows} loading={recentQ.isLoading} />
|
<MiniList title="آخرین پرداختها" to="/admin/payments" rows={payRows} loading={recentQ.isLoading} />
|
||||||
<MiniList title="کاربران جدید" to="/admin/users" rows={userRows} loading={recentQ.isLoading} />
|
<MiniList title="کاربران جدید" to="/admin/users" rows={userRows} loading={recentQ.isLoading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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<ApiResponse<ClinicDashboardData>>('/api/v1/dashboard/clinic'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const d = useMemo<ClinicDashboardData | undefined>(() => (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 <LoadingSkeleton />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fade-in">
|
||||||
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
|
<div>
|
||||||
|
<h1 className="section-title">داشبورد کلینیک</h1>
|
||||||
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {d?.clinic.name ?? context?.name ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||||
|
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||||
|
بهروزرسانی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-grid">
|
||||||
|
{kpiCards.map(c => (
|
||||||
|
<div key={c.label} className="stat">
|
||||||
|
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||||
|
<c.icon style={{ width: 21, height: 21 }} />
|
||||||
|
</div>
|
||||||
|
<div className="lbl">{c.label}</div>
|
||||||
|
<div className="val">{c.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||||
|
<div className="card card-pad">
|
||||||
|
<div className="card-title-row">
|
||||||
|
<h3 style={{ fontSize: 16 }}>نوبتهای امروز</h3>
|
||||||
|
<Link to="/admin/appointments" className="link">همه نوبتها</Link>
|
||||||
|
</div>
|
||||||
|
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card card-pad">
|
||||||
|
<div className="card-title-row">
|
||||||
|
<h3 style={{ fontSize: 16 }}>پزشکان کلینیک</h3>
|
||||||
|
<Link to="/admin/doctors" className="link">همه</Link>
|
||||||
|
</div>
|
||||||
|
{!d?.doctors.length ? (
|
||||||
|
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>پزشکی ثبت نشده</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{d.doctors.map((doc, i) => (
|
||||||
|
<Link
|
||||||
|
key={doc.uuid}
|
||||||
|
to={`/admin/doctors/${doc.uuid}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.doctors.length - 1 ? '1px solid var(--border)' : 'none', textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
<AvatarEl initials={doc.name.slice(0, 1)} hue={162} size="sm" />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<b style={{ fontSize: 13.5 }}>دکتر {doc.name}</b>
|
||||||
|
</div>
|
||||||
|
<span className="badge blue"><span className="bdot" />{formatNumber(doc.today_count)} امروز</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<ApiResponse<DoctorDashboardData>>('/api/v1/dashboard/doctor'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const d = useMemo<DoctorDashboardData | undefined>(() => (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 <LoadingSkeleton />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fade-in">
|
||||||
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
|
<div>
|
||||||
|
<h1 className="section-title">داشبورد پزشک</h1>
|
||||||
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · دکتر {d?.doctor.name ?? context?.name ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||||
|
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||||
|
بهروزرسانی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-grid">
|
||||||
|
{kpiCards.map(c => (
|
||||||
|
<div key={c.label} className="stat">
|
||||||
|
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||||
|
<c.icon style={{ width: 21, height: 21 }} />
|
||||||
|
</div>
|
||||||
|
<div className="lbl">{c.label}</div>
|
||||||
|
<div className="val">{c.value}</div>
|
||||||
|
{c.label === 'میانگین امتیاز' && d?.stats.total_ratings ? (
|
||||||
|
<div className="hint">{formatNumber(d.stats.total_ratings)} نظر</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||||
|
<div className="card card-pad">
|
||||||
|
<div className="card-title-row">
|
||||||
|
<h3 style={{ fontSize: 16 }}>نوبتهای امروز</h3>
|
||||||
|
<Link to="/admin/appointments" className="link">همه نوبتها</Link>
|
||||||
|
</div>
|
||||||
|
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card card-pad">
|
||||||
|
<div className="card-title-row">
|
||||||
|
<h3 style={{ fontSize: 16 }}>کلینیکهای من</h3>
|
||||||
|
</div>
|
||||||
|
{!d?.clinics.length ? (
|
||||||
|
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>عضو کلینیکی نیستید</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{d.clinics.map((c, i) => (
|
||||||
|
<div key={c.uuid} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.clinics.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||||
|
<AvatarEl initials={c.name.slice(0, 1)} hue={205} size="sm" />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<b style={{ fontSize: 13.5 }}>{c.name}</b>
|
||||||
|
</div>
|
||||||
|
<span className="badge green"><span className="bdot" />فعال</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Secretary Dashboard ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SecretaryDashboardData {
|
||||||
|
doctor: { uuid: string; name: string; degree: string | null };
|
||||||
|
permissions: Record<string, unknown>;
|
||||||
|
stats: { today_appointments: number; tomorrow_appointments: number };
|
||||||
|
today_appointments: ApptRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecretaryDashboard() {
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['dashboard-secretary'],
|
||||||
|
queryFn: () => api.get<ApiResponse<SecretaryDashboardData>>('/api/v1/dashboard/secretary'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const d = useMemo<SecretaryDashboardData | undefined>(() => (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 <LoadingSkeleton />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fade-in">
|
||||||
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
|
<div>
|
||||||
|
<h1 className="section-title">داشبورد منشی</h1>
|
||||||
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی دکتر {d?.doctor.name ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||||
|
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||||
|
بهروزرسانی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} hue={256} size="lg" />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 16 }}>دکتر {d?.doctor.name ?? '—'}</div>
|
||||||
|
{d?.doctor.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginRight: 'auto', display: 'flex', gap: 8 }}>
|
||||||
|
<span className={`badge ${canViewAppts ? 'green' : 'gray'}`}>
|
||||||
|
<span className="bdot" />{canViewAppts ? 'دسترسی نوبتها: فعال' : 'دسترسی نوبتها: غیرفعال'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||||
|
{kpiCards.map(c => (
|
||||||
|
<div key={c.label} className="stat">
|
||||||
|
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||||
|
<c.icon style={{ width: 21, height: 21 }} />
|
||||||
|
</div>
|
||||||
|
<div className="lbl">{c.label}</div>
|
||||||
|
<div className="val">{c.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canViewAppts && (
|
||||||
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||||
|
<div className="card-title-row">
|
||||||
|
<h3 style={{ fontSize: 16 }}>نوبتهای امروز</h3>
|
||||||
|
<Link to="/admin/appointments" className="link">همه نوبتها</Link>
|
||||||
|
</div>
|
||||||
|
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canViewAppts && (
|
||||||
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)', textAlign: 'center', padding: '2rem' }}>
|
||||||
|
<UserIcon style={{ width: 40, height: 40, color: 'var(--text-3)', margin: '0 auto 1rem' }} />
|
||||||
|
<p className="muted" style={{ fontSize: 13.5 }}>دسترسی مشاهده نوبتها برای این منشی فعال نیست.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Dispatcher ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 <AdminDashboard />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { ApiResponse } from '../lib/api';
|
|||||||
import { formatNumber } from '../lib/utils';
|
import { formatNumber } from '../lib/utils';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
|
||||||
|
|
||||||
// Fix leaflet default marker icons
|
// Fix leaflet default marker icons
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@@ -1780,6 +1781,7 @@ export default function DoctorDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
|
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
@@ -2204,6 +2206,10 @@ export default function DoctorDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{primaryRole === 'doctor' && (
|
||||||
|
<NotificationMobileCard target="doctor" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||||
|
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuthStore, ContextItem } from '../stores/authStore';
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
admin: 'مدیر کل',
|
||||||
|
clinic: 'مالک کلینیک',
|
||||||
|
doctor: 'پزشک',
|
||||||
|
secretary: 'منشی',
|
||||||
|
user: 'کاربر',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
|
doctor: '🏥',
|
||||||
|
clinic: '🏢',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SelectContextPage() {
|
||||||
|
const { availableContexts, switchContext } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSelect = async (ctx: ContextItem) => {
|
||||||
|
setLoading(ctx.db_uuid);
|
||||||
|
await switchContext(ctx.db_uuid);
|
||||||
|
navigate('/admin/dashboard', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--surface-alt, #f4f6f9)',
|
||||||
|
padding: '2rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: '100%', maxWidth: 480 }}>
|
||||||
|
<div className="card card-pad" style={{ textAlign: 'center' }}>
|
||||||
|
<h2 style={{ marginBottom: '.25rem', fontSize: '1.25rem', fontWeight: 600 }}>
|
||||||
|
انتخاب محیط کاری
|
||||||
|
</h2>
|
||||||
|
<p className="muted" style={{ marginBottom: '1.5rem', fontSize: '.875rem' }}>
|
||||||
|
لطفاً محیط کاری مورد نظر خود را انتخاب کنید
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '.75rem' }}>
|
||||||
|
{availableContexts.map((ctx) => (
|
||||||
|
<button
|
||||||
|
key={ctx.db_uuid}
|
||||||
|
onClick={() => handleSelect(ctx)}
|
||||||
|
disabled={loading !== null}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '.875rem 1rem',
|
||||||
|
border: '1.5px solid var(--border, #e2e8f0)',
|
||||||
|
borderRadius: '0.625rem',
|
||||||
|
background: loading === ctx.db_uuid ? 'var(--surface-alt, #f4f6f9)' : '#fff',
|
||||||
|
cursor: loading !== null ? 'wait' : 'pointer',
|
||||||
|
textAlign: 'right',
|
||||||
|
transition: 'border-color .15s, box-shadow .15s',
|
||||||
|
opacity: loading !== null && loading !== ctx.db_uuid ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--primary, #6366f1)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border, #e2e8f0)')}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '1.5rem', lineHeight: 1 }}>
|
||||||
|
{TYPE_ICONS[ctx.type] ?? '👤'}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '.9375rem', marginBottom: '.125rem' }}>
|
||||||
|
{ctx.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '.8125rem' }}>
|
||||||
|
<span className="badge blue" style={{ fontSize: '.75rem' }}>
|
||||||
|
{ROLE_LABELS[ctx.role] ?? ctx.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading === ctx.db_uuid && (
|
||||||
|
<span className="skeleton" style={{ width: 20, height: 20, borderRadius: '50%' }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof schema>;
|
||||||
|
|
||||||
|
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<ApiResponse<Settings>>('/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<FormValues>({ 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<ApiResponse<Settings>>('/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 (
|
||||||
|
<div className="fade-in">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="card card-pad">
|
||||||
|
<div className="skeleton" style={{ height: 200, borderRadius: 'var(--r)' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fade-in">
|
||||||
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
|
<div>
|
||||||
|
<h1 className="section-title">تنظیمات سایت</h1>
|
||||||
|
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>پیکربندی کلی پلتفرم</div>
|
||||||
|
</div>
|
||||||
|
{mutation.isSuccess && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--success)', fontSize: 13.5 }}>
|
||||||
|
<CheckCircleIcon style={{ width: 18, height: 18 }} />
|
||||||
|
تنظیمات ذخیره شد
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||||
|
|
||||||
|
{/* اطلاعات پایه */}
|
||||||
|
<div className="card card-pad">
|
||||||
|
<div className="card-title-row" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div className="ico" style={{ background: 'var(--primary-soft)', color: 'var(--primary)', width: 36, height: 36, borderRadius: 10 }}>
|
||||||
|
<Cog6ToothIcon style={{ width: 18, height: 18 }} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: 15 }}>اطلاعات پایه</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>نام سایت</label>
|
||||||
|
<input
|
||||||
|
{...register('site_name')}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||||
|
border: `1px solid ${errors.site_name ? 'var(--danger)' : 'var(--border)'}`,
|
||||||
|
background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.site_name && <p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 4 }}>{errors.site_name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>شماره پشتیبانی</label>
|
||||||
|
<input
|
||||||
|
{...register('support_phone')}
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="021-12345678"
|
||||||
|
style={{
|
||||||
|
width: '100%', 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* تنظیمات کمیسیون */}
|
||||||
|
<div className="card card-pad">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||||
|
<div className="ico" style={{ background: 'var(--success-bg)', color: 'var(--success)', width: 36, height: 36, borderRadius: 10 }}>
|
||||||
|
<span style={{ fontSize: 18 }}>٪</span>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: 15 }}>کمیسیون سایت</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{/* toggle فعال/غیرفعال */}
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
|
||||||
|
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={commissionEnabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const target = e.target;
|
||||||
|
const input = document.querySelector<HTMLInputElement>('input[name="commission_enabled"]');
|
||||||
|
if (input) {
|
||||||
|
input.value = target.checked ? '1' : '0';
|
||||||
|
// Trigger react-hook-form change
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ opacity: 0, width: 0, height: 0, position: 'absolute' }}
|
||||||
|
/>
|
||||||
|
<input type="hidden" {...register('commission_enabled')} />
|
||||||
|
<div style={{
|
||||||
|
width: 44, height: 24, borderRadius: 12,
|
||||||
|
background: commissionEnabled ? 'var(--primary)' : 'var(--border)',
|
||||||
|
transition: 'background .2s', position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: '#fff',
|
||||||
|
transition: 'right .2s', right: commissionEnabled ? 2 : 22,
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,.2)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 14 }}>
|
||||||
|
{commissionEnabled ? 'کمیسیون فعال است' : 'کمیسیون غیرفعال است'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{commissionEnabled && (
|
||||||
|
<div style={{ maxWidth: 280 }}>
|
||||||
|
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
|
||||||
|
درصد کمیسیون از کاربر (۰–۱۰۰)
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
{...register('commission_percent')}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
style={{
|
||||||
|
width: 120, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||||
|
border: `1px solid ${errors.commission_percent ? 'var(--danger)' : 'var(--border)'}`,
|
||||||
|
background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: 13 }}>درصد</span>
|
||||||
|
</div>
|
||||||
|
{errors.commission_percent && (
|
||||||
|
<p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 4 }}>{errors.commission_percent.message}</p>
|
||||||
|
)}
|
||||||
|
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
||||||
|
کمیسیون فقط از کاربر دریافت میشود. منشیها کمیسیون ندارند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* تنظیمات نوبتدهی */}
|
||||||
|
<div className="card card-pad">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||||
|
<div className="ico" style={{ background: 'var(--warning-bg)', color: 'var(--warning)', width: 36, height: 36, borderRadius: 10 }}>
|
||||||
|
<span style={{ fontSize: 16 }}>⏱</span>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: 15 }}>تنظیمات نوبتدهی</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
|
||||||
|
حداکثر ساعت مجاز برای لغو نوبت
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
{...register('max_cancel_hours_before')}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
style={{
|
||||||
|
width: 100, 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: 13 }}>ساعت قبل از نوبت</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
|
||||||
|
ارسال یادآور قبل از نوبت
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
{...register('appointment_reminder_hours')}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
style={{
|
||||||
|
width: 100, 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: 13 }}>ساعت قبل از نوبت</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه ذخیره */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={() => settings && reset({
|
||||||
|
site_name: settings.site_name,
|
||||||
|
support_phone: settings.support_phone,
|
||||||
|
commission_enabled: settings.commission_enabled,
|
||||||
|
commission_percent: settings.commission_percent,
|
||||||
|
max_cancel_hours_before: settings.max_cancel_hours_before,
|
||||||
|
appointment_reminder_hours: settings.appointment_reminder_hours,
|
||||||
|
})}
|
||||||
|
disabled={!isDirty || mutation.isPending}
|
||||||
|
>
|
||||||
|
بازگشت
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={mutation.isPending || !isDirty}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,135 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface ContextItem {
|
||||||
|
type: 'doctor' | 'clinic';
|
||||||
|
db_uuid: string;
|
||||||
|
name: string;
|
||||||
|
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user';
|
||||||
|
permissions?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
isAuthenticated: boolean;
|
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;
|
login: (token: string, refreshToken: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
fetchMe: () => Promise<void>;
|
||||||
|
switchContext: (dbUuid: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
login: (token, refreshToken) =>
|
userUuid: null,
|
||||||
set({ token, refreshToken, isAuthenticated: true }),
|
userName: null,
|
||||||
|
primaryRole: null,
|
||||||
|
dbUuid: null,
|
||||||
|
dbKey: null,
|
||||||
|
context: null,
|
||||||
|
availableContexts: [],
|
||||||
|
|
||||||
|
login: (token, refreshToken) => {
|
||||||
|
set({ token, refreshToken, isAuthenticated: true });
|
||||||
|
get().fetchMe();
|
||||||
|
},
|
||||||
|
|
||||||
logout: () =>
|
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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -677,3 +677,48 @@ List all SMS templates.
|
|||||||
| `POST /api/v1/admin/clinic/invitation/{invUuid}/resend` | Resend SMS |
|
| `POST /api/v1/admin/clinic/invitation/{invUuid}/resend` | Resend SMS |
|
||||||
| `PATCH /api/v1/admin/clinic/invitation/{invUuid}/status` | Change status |
|
| `PATCH /api/v1/admin/clinic/invitation/{invUuid}/status` | Change status |
|
||||||
| `DELETE /api/v1/admin/clinic/invitation/{invUuid}` | Delete |
|
| `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
|
||||||
|
|||||||
@@ -275,3 +275,54 @@ Updated appointment object.
|
|||||||
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
|
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
|
||||||
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
|
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
|
||||||
| `ERR_VALIDATION_001` | 422 | Invalid status value |
|
| `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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+231
-6
@@ -225,7 +225,7 @@ Refresh expired JWT using refresh token.
|
|||||||
|
|
||||||
## GET `/oauth/userinfo`
|
## GET `/oauth/userinfo`
|
||||||
|
|
||||||
Get authenticated user info.
|
Get authenticated user info — extended with multi-context support.
|
||||||
|
|
||||||
**Permission:** `AUTH` — requires valid JWT
|
**Permission:** `AUTH` — requires valid JWT
|
||||||
|
|
||||||
@@ -239,12 +239,96 @@ Authorization: Bearer <token>
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
|
"id": 4766,
|
||||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"mobile_number": "09123456789",
|
"mobile_number": "09123456789",
|
||||||
"real_name": "علی احمدی",
|
"realName": "دکتر وحید درویشی",
|
||||||
"roles": ["ROLE_USER"],
|
"status": 1,
|
||||||
"status": "active",
|
"roles": ["ROLE_USER", "ROLE_DOCTOR"],
|
||||||
"created_at": 1717000000
|
"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 <token>
|
|||||||
### Errors
|
### Errors
|
||||||
| Code | HTTP | Description |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260611075829 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260611083424 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260611084046 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Appointment\Controller;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Clinic\Repository\ClinicRepository;
|
||||||
|
use App\Doctor\Repository\DoctorRepository;
|
||||||
|
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||||
|
use App\Shared\Controller\BaseController;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
class MyAppointmentsController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly DoctorRepository $doctorRepo,
|
||||||
|
private readonly ClinicRepository $clinicRepo,
|
||||||
|
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/v1/my/appointments', methods: ['GET'])]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,14 @@
|
|||||||
namespace App\Auth\Controller;
|
namespace App\Auth\Controller;
|
||||||
|
|
||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
|
use App\Auth\Entity\UserActiveContext;
|
||||||
|
use App\Auth\Repository\UserActiveContextRepository;
|
||||||
use App\Auth\Repository\UserRepository;
|
use App\Auth\Repository\UserRepository;
|
||||||
use App\Auth\Service\OtpService;
|
use App\Auth\Service\OtpService;
|
||||||
use App\Auth\Service\TokenService;
|
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\Constant\ErrorCodes;
|
||||||
use App\Shared\Controller\BaseController;
|
use App\Shared\Controller\BaseController;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
@@ -14,15 +19,20 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[OA\Tag(name: 'Auth')]
|
#[OA\Tag(name: 'Auth')]
|
||||||
class AuthController extends BaseController
|
class AuthController extends BaseController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserRepository $userRepo,
|
private readonly UserRepository $userRepo,
|
||||||
private readonly OtpService $otpService,
|
private readonly OtpService $otpService,
|
||||||
private readonly TokenService $tokenService,
|
private readonly TokenService $tokenService,
|
||||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
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);
|
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([
|
return $this->success([
|
||||||
'id' => $user->getId(),
|
'id' => $user->getId(),
|
||||||
'uuid' => $user->getUuid(),
|
'uuid' => $user->getUuid(),
|
||||||
'mobile_number' => $user->getMobileNumber(),
|
'mobile_number' => $user->getMobileNumber(),
|
||||||
'realName' => $user->getRealName(),
|
'realName' => $user->getRealName(),
|
||||||
'status' => $user->getStatus(),
|
'status' => $user->getStatus(),
|
||||||
'roles' => $user->getRoles(),
|
'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(
|
#[OA\Post(
|
||||||
path: '/oauth/logout',
|
path: '/oauth/logout',
|
||||||
summary: 'Logout and optionally revoke refresh token',
|
summary: 'Logout and optionally revoke refresh token',
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth\Controller;
|
||||||
|
|
||||||
|
use App\Auth\Entity\MobileVerificationOtp;
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Clinic\Repository\ClinicRepository;
|
||||||
|
use App\Doctor\Repository\DoctorRepository;
|
||||||
|
use App\Shared\Constant\ErrorCodes;
|
||||||
|
use App\Shared\Controller\BaseController;
|
||||||
|
use App\Sms\Service\SmsService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
class NotificationMobileController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly DoctorRepository $doctorRepo,
|
||||||
|
private readonly ClinicRepository $clinicRepo,
|
||||||
|
private readonly SmsService $smsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Request OTP ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/notification-mobile/request-otp', methods: ['POST'])]
|
||||||
|
public function requestOtp(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'mobile_verification_otp')]
|
||||||
|
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_otp_entity')]
|
||||||
|
class MobileVerificationOtp
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'entity_type', type: 'string', length: 20)]
|
||||||
|
private string $entityType;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||||
|
private int $entityId;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'new_mobile', type: 'string', length: 15)]
|
||||||
|
private string $newMobile;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'otp_code', type: 'string', length: 6)]
|
||||||
|
private string $otpCode;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'expires_at', type: 'integer')]
|
||||||
|
private int $expiresAt;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'is_used', type: 'boolean')]
|
||||||
|
private bool $isUsed = false;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||||
|
private int $createdAt;
|
||||||
|
|
||||||
|
public function __construct(string $entityType, int $entityId, string $newMobile)
|
||||||
|
{
|
||||||
|
$this->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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'user_active_context')]
|
||||||
|
class UserActiveContext
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\OneToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, 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;
|
||||||
|
|
||||||
|
public function __construct(User $user, string $dbUuid)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth\Repository;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Auth\Entity\UserActiveContext;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class UserActiveContextRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, UserActiveContext::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByUser(User $user): ?UserActiveContext
|
||||||
|
{
|
||||||
|
return $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ class Clinic
|
|||||||
#[ORM\Column(name: 'clinic_logo', type: 'string', length: 500, nullable: true)]
|
#[ORM\Column(name: 'clinic_logo', type: 'string', length: 500, nullable: true)]
|
||||||
private ?string $clinicLogo = null;
|
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')]
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||||
private int $createdAt;
|
private int $createdAt;
|
||||||
|
|
||||||
@@ -139,8 +142,9 @@ class Clinic
|
|||||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||||
public function isActive(): bool { return $this->isActive; }
|
public function isActive(): bool { return $this->isActive; }
|
||||||
public function getImagesClinic(): ?array { return $this->imagesClinic; }
|
public function getImagesClinic(): ?array { return $this->imagesClinic; }
|
||||||
public function getClinicLogo(): ?string { return $this->clinicLogo; }
|
public function getClinicLogo(): ?string { return $this->clinicLogo; }
|
||||||
public function getCreatedAt(): int { return $this->createdAt; }
|
public function getNotificationMobile(): ?string { return $this->notificationMobile; }
|
||||||
|
public function getCreatedAt(): int { return $this->createdAt; }
|
||||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||||
public function getDoctors(): Collection { return $this->doctors; }
|
public function getDoctors(): Collection { return $this->doctors; }
|
||||||
public function getSpecialties(): Collection { return $this->specialties; }
|
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 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 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 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(); }
|
private function touch(): void { $this->updatedAt = time(); }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Config\Controller;
|
||||||
|
|
||||||
|
use App\Config\Repository\SiteConfigRepository;
|
||||||
|
use App\Shared\Controller\BaseController;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
class SiteConfigController extends BaseController
|
||||||
|
{
|
||||||
|
private const ALLOWED_KEYS = [
|
||||||
|
'commission_enabled',
|
||||||
|
'commission_percent',
|
||||||
|
'site_name',
|
||||||
|
'support_phone',
|
||||||
|
'max_cancel_hours_before',
|
||||||
|
'appointment_reminder_hours',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SiteConfigRepository $configRepo,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/v1/admin/settings', methods: ['GET'])]
|
||||||
|
public function get(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Config\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'site_config')]
|
||||||
|
class SiteConfig
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(name: 'config_key', 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;
|
||||||
|
|
||||||
|
public function __construct(string $key, ?string $value = null)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Config\Repository;
|
||||||
|
|
||||||
|
use App\Config\Entity\SiteConfig;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class SiteConfigRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
// Default values returned when a key is missing from DB
|
||||||
|
private const DEFAULTS = [
|
||||||
|
'commission_enabled' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Controller;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
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 Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
class DashboardController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ClinicRepository $clinicRepo,
|
||||||
|
private readonly DoctorRepository $doctorRepo,
|
||||||
|
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Clinic Dashboard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[Route('/api/v1/dashboard/clinic', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_CLINIC')]
|
||||||
|
public function clinic(#[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$clinic = $this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ class Doctor
|
|||||||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||||||
private ?int $representationId = null;
|
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')]
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||||
private int $createdAt;
|
private int $createdAt;
|
||||||
|
|
||||||
@@ -138,7 +141,8 @@ class Doctor
|
|||||||
public function getDoctorRate(): float { return $this->doctorRate; }
|
public function getDoctorRate(): float { return $this->doctorRate; }
|
||||||
public function getDoctorRatePercentage(): float { return $this->doctorRatePercentage; }
|
public function getDoctorRatePercentage(): float { return $this->doctorRatePercentage; }
|
||||||
public function isActiveDoctorAppointment(): bool { return $this->activeDoctorAppointment; }
|
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 getCreatedAt(): int { return $this->createdAt; }
|
||||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||||
public function getSpecialties(): Collection { return $this->specialties; }
|
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 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 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 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(); }
|
private function touch(): void { $this->updatedAt = time(); }
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Secretary\Repository;
|
namespace App\Secretary\Repository;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
use App\Doctor\Entity\Doctor;
|
use App\Doctor\Entity\Doctor;
|
||||||
use App\Secretary\Entity\DoctorSecretary;
|
use App\Secretary\Entity\DoctorSecretary;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
@@ -36,6 +37,17 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
|||||||
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
|
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
|
public function save(DoctorSecretary $entity, bool $flush = true): void
|
||||||
{
|
{
|
||||||
$this->getEntityManager()->persist($entity);
|
$this->getEntityManager()->persist($entity);
|
||||||
|
|||||||
Reference in New Issue
Block a user