feat: add ROLE_REPRESENTATION access to admin panel for managing doctors and clinics
- Updated authStore to include 'representation' role. - Modified DoctorFormPage and DoctorsPage to handle different endpoints based on user role. - Created new RepresentationActionController for handling doctor and clinic creation by representatives. - Added new API endpoints for representatives to manage doctors, clinics, and view appointments. - Updated documentation to reflect new role and API changes.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
# دسترسی نقش نماینده (ROLE_REPRESENTATION) به پنل ادمین
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend + Admin React SPA). کاملاً داخل همین پروژه است؛ پرامپت همتای frontend عمومی لازم نیست.
|
||||
|
||||
## زمینه
|
||||
|
||||
کاربرانِ دارای نقش `ROLE_REPRESENTATION` (نمایندهی فروش/شهر) باید بتوانند وارد پنل ادمین (`https://clinic-pro.ddev.site/admin/dashboard`) شوند و کارهای محدودِ خودشان را انجام دهند:
|
||||
|
||||
1. **افزودن پزشک**
|
||||
2. **افزودن کلینیک**
|
||||
3. **دیدن نوبتهای پزشکانی که زیرمجموعهی همان نمایندهاند** (پزشکانی که `Doctor.representationId` = id همان نماینده است)
|
||||
4. **داشبورد اختصاصی خودشان** (درآمد/کمیسیون ماهانه و سالانه که از قبل موجود است)
|
||||
|
||||
الان این نقش عملاً به پنل راه ندارد و حتی اگر داشته باشد همهچیز مسدود است. شکافهای دقیق:
|
||||
|
||||
- `App\Auth\Controller\AuthController::resolvePrimaryRole()` هیچ شاخهای برای `ROLE_REPRESENTATION` ندارد → چنین کاربری `primary_role: 'user'` میگیرد و پنل او را نمیشناسد.
|
||||
- `App\Admin\Controller\AdminApiController` در سطح کلاس `#[IsGranted('ROLE_ADMIN')]` است → endpointهای `createDoctor` (`POST /api/v1/admin/doctors`) و `createClinic` (`POST /api/v1/admin/clinic`) برای نماینده مسدودند.
|
||||
- Admin SPA: `assets/admin/App.tsx` تابع `RoleRoute` فقط `['admin']` را برای کلینیکها/پزشکان میپذیرد؛ `Sidebar.tsx` فقط برای `admin|clinic|doctor|secretary` منو دارد (نماینده ندارد)؛ `DashboardPage.tsx` فقط endpointهای `/api/v1/admin/dashboard/*` را صدا میزند (admin-only).
|
||||
- هیچ endpointی برای «نوبتهای پزشکانِ یک نماینده» وجود ندارد.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
به نقش `ROLE_REPRESENTATION` اجازهی ورود به پنل و دسترسی به ۴ قابلیت بالا داده شود — بدون اینکه دسترسیهای ادمین (کاربران، پرداختها، تسویه، بلاگ و...) برای او باز شود.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Auth/Controller/AuthController.php` | `resolvePrimaryRole()` و gate لاگین staff (`/oauth/userinfo` → `primary_role`) |
|
||||
| `src/Admin/Controller/AdminApiController.php` | کلاس `#[IsGranted('ROLE_ADMIN')]`؛ متدهای `createDoctor`, `createClinic`, `appointments` |
|
||||
| `src/Representation/Controller/RepresentationController.php` | الگوی permission نماینده (`$rep->getUser()->getId() === $user->getId()`)؛ dashboard ماهانه/سالانه |
|
||||
| `src/Representation/Repository/RepresentationRepository.php` | یافتن نماینده از روی user (`findByUser`) |
|
||||
| `src/Doctor/Entity/Doctor.php` | `representationId` (FK به نماینده) — مبنای «پزشکان این نماینده» |
|
||||
| `src/Appointment/...` (Repository/Controller) | منبع لیست نوبتها برای endpoint جدید |
|
||||
| `assets/admin/App.tsx` | `RoleRoute` + ثبت routeهای جدید |
|
||||
| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش `representation` |
|
||||
| `assets/admin/pages/DashboardPage.tsx` | شاخهی داشبورد نماینده |
|
||||
| `assets/admin/pages/AppointmentsPage.tsx` | انتخاب endpoint نوبتها بر اساس نقش |
|
||||
| `assets/admin/stores/authStore.ts` | `primaryRole` (از `primary_role` میآید — تغییر لازم نیست، فقط مقدار جدید) |
|
||||
| `docs/api/representation.md`, `docs/api/admin.md`, `docs/api/auth.md` | مستندسازی |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
`resolvePrimaryRole` — بدون شاخهی نماینده:
|
||||
```php
|
||||
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';
|
||||
}
|
||||
```
|
||||
|
||||
`AdminApiController` — همه چیز admin-only:
|
||||
```php
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class AdminApiController extends BaseController
|
||||
{
|
||||
// ... createDoctor(), createClinic(), appointments() همگی ذیل این قانوناند
|
||||
}
|
||||
```
|
||||
|
||||
`Doctor` — رابطه با نماینده:
|
||||
```php
|
||||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $representationId = null;
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
```
|
||||
|
||||
Admin SPA `App.tsx` — RoleRoute و routeهای فعلی:
|
||||
```tsx
|
||||
function RoleRoute({ roles, children }: { roles: string[]; children: React.ReactNode }) {
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
// ...
|
||||
<Route path="dashboard" element={<DashboardPage />} /> {/* همه نقشها */}
|
||||
<Route path="appointments" element={<AppointmentsPage />} /> {/* همه نقشها */}
|
||||
<Route path="clinics" element={<RoleRoute roles={['admin']}><ClinicsPage /></RoleRoute>} />
|
||||
// doctors هم اکنون فقط ذیل admin در دسترس است
|
||||
```
|
||||
|
||||
`Sidebar.tsx` — فقط ۴ نقش:
|
||||
```tsx
|
||||
if (primaryRole === "admin") { ... }
|
||||
if (primaryRole === "clinic") { ... }
|
||||
if (primaryRole === "doctor") { ... }
|
||||
if (primaryRole === "secretary") { ... }
|
||||
// representation وجود ندارد
|
||||
```
|
||||
|
||||
`DashboardPage.tsx` — admin-only endpoints:
|
||||
```tsx
|
||||
const statsQ = useQuery({ queryFn: () => api.get('/api/v1/admin/dashboard/stats') });
|
||||
const chartsQ = useQuery({ queryFn: () => api.get(`/api/v1/admin/dashboard/charts?...`) });
|
||||
const recentQ = useQuery({ queryFn: () => api.get('/api/v1/admin/dashboard/recent') });
|
||||
```
|
||||
|
||||
`AppointmentsPage.tsx` — انتخاب endpoint فعلی بر اساس نقش:
|
||||
```ts
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
const apptEndpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. Backend — شناختن نقش نماینده در `primary_role`
|
||||
|
||||
در `resolvePrimaryRole()` شاخهی نماینده را **قبل از `return 'user'`** اضافه کن (اولویت پایینتر از admin/clinic/doctor/secretary، چون یک کاربر ممکن است همزمان چند نقش داشته باشد):
|
||||
|
||||
```php
|
||||
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
|
||||
if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
|
||||
return 'user';
|
||||
```
|
||||
|
||||
اگر در `AuthController` یک gate صریح برای «نقش staff مجاز ورود» هست (لیست ROLE_ADMIN/DOCTOR/CLINIC/SECRETARY که کاربر عادی را رد میکند)، `ROLE_REPRESENTATION` را هم به آن لیست اضافه کن تا با `/oauth/token` + `/oauth/userinfo` وارد شود. (اگر چنین gateی وجود ندارد، نیازی نیست.)
|
||||
|
||||
### ۲. Backend — اجازهی createDoctor و createClinic به نماینده
|
||||
|
||||
`AdminApiController` در سطح کلاس `ROLE_ADMIN` است و این قانون بر method-level مقدم میشود؛ پس صرفِ گذاشتن قانونِ بازتر روی متد کافی نیست. **کمریسکترین راه: یک controller جدید بساز** — `src/Representation/Controller/RepresentationActionController.php` — با دو route نازک که منطق ساخت پزشک/کلینیک را اجرا کنند (یا منطق مشترک را به یک سرویس استخراج کن و هر دو controller از آن استفاده کنند تا کد تکراری نشود):
|
||||
|
||||
```php
|
||||
#[IsGranted(new Expression("is_granted('ROLE_ADMIN') or is_granted('ROLE_REPRESENTATION')"))]
|
||||
#[Route('/api/v1/representation/doctor', methods: ['POST'])]
|
||||
public function createDoctor(Request $request, #[CurrentUser] User $user): JsonResponse { /* همان منطق createDoctor */ }
|
||||
|
||||
#[IsGranted(new Expression("is_granted('ROLE_ADMIN') or is_granted('ROLE_REPRESENTATION')"))]
|
||||
#[Route('/api/v1/representation/clinic', methods: ['POST'])]
|
||||
public function createClinic(Request $request, #[CurrentUser] User $user): JsonResponse { /* همان منطق createClinic */ }
|
||||
```
|
||||
- **مالکیت داده (مهم):** وقتی نماینده پزشک میسازد، نمایندهی کاربر جاری را با `RepresentationRepository::findByUser($user)` بگیر و `Doctor::setRepresentationId($rep->getId())` را ست کن تا بعداً در فیلتر «نوبتهای پزشکان من» دیده شود. برای کلینیک هم اگر فیلد مالکیت/شهر مرتبط هست همان را ست کن.
|
||||
- بدنهی request و شکل پاسخ همان قرارداد فعلی `createDoctor`/`createClinic` در `docs/api/admin.md` بماند (تا فرمهای موجود admin بدون تغییر کار کنند).
|
||||
- در Admin SPA، فرم افزودن پزشک/کلینیک برای نماینده باید این endpointهای جدید را صدا بزند و برای ادمین همان endpointهای `/api/v1/admin/*` را (انتخاب بر اساس `primaryRole`).
|
||||
|
||||
> اگر تیم ترجیح میدهد بهجای controller جدا، قانون کلاس `AdminApiController` را به Expression تبدیل کند: مراقب باش که در آن صورت **همهی** متدهای آن کلاس باز میشوند؛ پس باید روی تکتک متدهای admin-only دیگر `#[IsGranted('ROLE_ADMIN')]` گذاشته شود. این پرریسک است؛ controller جدا توصیه میشود.
|
||||
|
||||
### ۳. Backend — endpoint نوبتهای پزشکانِ نماینده
|
||||
|
||||
```
|
||||
GET /api/v1/representation/appointments?page=&limit=&status=&from=&to=
|
||||
```
|
||||
- `#[IsGranted(new Expression("is_granted('ROLE_REPRESENTATION') or is_granted('ROLE_ADMIN')"))]`
|
||||
- نمایندهی کاربر جاری را با `findByUser($user)` پیدا کن؛ اگر نبود → `403`.
|
||||
- در Appointment Repository یک متد `findByRepresentation(int $representationId, array $filters)` اضافه کن که join بزند: `appointment.doctor d` و شرط `d.representationId = :repId`. خروجی با `$this->paginated($items, $total, $page, $limit)`.
|
||||
- شکل هر آیتم همان `Appointment::toArray()` که ادمین مصرف میکند، تا `AppointmentsPage` بدون تغییر ساختاری آن را نشان دهد.
|
||||
|
||||
### ۴. Backend — endpoint نمایندهی کاربر جاری (برای داشبورد)
|
||||
|
||||
داشبورد فرانت به uuid نماینده نیاز دارد. یک endpoint بساز:
|
||||
```
|
||||
GET /api/v1/representation/me
|
||||
```
|
||||
- `#[IsGranted('ROLE_REPRESENTATION')]` (یا ادمینیانماینده)
|
||||
- نمایندهی `#[CurrentUser]` را با `findByUser` برگردان (`$this->success(['data' => $rep->toArray()])`). اگر نبود → `404`.
|
||||
|
||||
### ۵. Admin SPA — مسیرها و گارد نقش
|
||||
|
||||
در `App.tsx`:
|
||||
- مسیرهای `doctors` (لیست/افزودن) و `clinics` (لیست/افزودن) را به `RoleRoute roles={['admin','representation']}` تغییر بده (هر دو زیرمسیر فرم افزودن هم).
|
||||
- `dashboard` و `appointments` (الان «همه نقشها») برای نماینده باز بمانند.
|
||||
- هیچ مسیر admin-only دیگری (`users`, `payments`, `settlements`, `blogs`, `categories`, `sms`, `representations`, ...) را برای نماینده باز **نکن**.
|
||||
|
||||
### ۶. Admin SPA — منوی Sidebar برای نماینده
|
||||
|
||||
در `Sidebar.tsx` شاخهی `if (primaryRole === "representation") { ... }` با آیتمها:
|
||||
- داشبورد (`/admin/dashboard`)
|
||||
- پزشکان (`/admin/doctors`)
|
||||
- کلینیکها (`/admin/clinics`)
|
||||
- نوبتها (`/admin/appointments`)
|
||||
|
||||
از همان ساختار `Section`/`SectionItem` و آیکنهای موجود استفاده کن.
|
||||
|
||||
### ۷. Admin SPA — داشبورد نماینده
|
||||
|
||||
در `DashboardPage.tsx` بر اساس `primaryRole` شاخه بزن:
|
||||
- اگر `representation`: اول `GET /api/v1/representation/me` را بگیر تا `uuid` نماینده را داشته باشی، سپس:
|
||||
- `GET /api/v1/representation/{uuid}/dashboard/monthly`
|
||||
- `GET /api/v1/representation/{uuid}/dashboard/yearly`
|
||||
(شکل پاسخ در `docs/api/representation.md`: `total_appointments`، کمیسیون، و...)
|
||||
- کارتها/نمودار را با همین دادهها بساز؛ از کارتهای admin-only (کاربران/پرداخت/تسویه) استفاده نکن.
|
||||
- endpointهای `/api/v1/admin/dashboard/*` نباید برای نماینده صدا زده شوند (۴۰۳ میدهند).
|
||||
|
||||
### ۸. Admin SPA — صفحه نوبتها برای نماینده
|
||||
|
||||
در `AppointmentsPage.tsx`:
|
||||
```ts
|
||||
const isRepresentation = primaryRole === 'representation';
|
||||
const apptEndpoint = isAdmin
|
||||
? '/api/v1/admin/appointments'
|
||||
: isRepresentation
|
||||
? '/api/v1/representation/appointments'
|
||||
: '/api/v1/my/appointments';
|
||||
```
|
||||
- بقیهی فیلترها (status/from/to/page/limit) همانطور پاس داده شوند.
|
||||
|
||||
### ۹. مستندسازی
|
||||
|
||||
- `docs/api/auth.md`: مقدار جدید `primary_role: "representation"` و دسترسی این نقش به پنل.
|
||||
- `docs/api/admin.md`: کنار `createDoctor`/`createClinic` ذکر کن که نسخهی نماینده (`/api/v1/representation/doctor` و `/api/v1/representation/clinic`) هم وجود دارد و `representation_id` پزشک خودکار ست میشود.
|
||||
- `docs/api/representation.md`: endpointهای جدید `GET /api/v1/representation/appointments`، `GET /api/v1/representation/me`، و `POST /api/v1/representation/doctor|clinic` با method/path/permission/query/response و نمونه JSON واقعی.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **اصل امنیت:** نماینده فقط به دادهی خودش دسترسی دارد. در همهی endpointهای نماینده، نماینده را از `#[CurrentUser]` و `findByUser` پیدا کن — **هرگز** به `uuid`/`representationId` ورودیِ کلاینت برای تعیین مالکیت اعتماد نکن (الگوی موجود در `RepresentationController`: `$rep->getUser()->getId() !== $user->getId() && !hasRole('ROLE_ADMIN')` → 403).
|
||||
- قانون سطح کلاس `#[IsGranted('ROLE_ADMIN')]` روی `AdminApiController` نباید ضعیف شود؛ ساخت پزشک/کلینیکِ نماینده را در controller جدا بگذار (وظیفهی ۲).
|
||||
- `Doctor.representationId` ستون scalar است (نه association)؛ join/شرط در DQL مستقیم روی همین ستون.
|
||||
- تاریخها Unix timestamp؛ لیستهای admin با `paginated()` (items از `data`, total از `meta.totalRecords`). در Admin SPA: paginated → `data?.data` / `data?.meta?.totalRecords`؛ single → `data?.data` (گاهی double-nested).
|
||||
- اگر هیچ Entityی تغییر نکرد migration لازم نیست (این feature عمدتاً منطق/route/permission است). اگر برای مالکیت کلینیکِ نماینده فیلد جدیدی لازم شد، migration بساز و اجرا کن.
|
||||
- بعد از تغییر هر controller، فایل docs مربوطه را همان session بهروز کن (قانون استاندارد پروژه).
|
||||
- **تستها:** با توکن یک کاربر `ROLE_REPRESENTATION` (`ddev exec php bin/console lexik:jwt:generate-token <mobile> --user-class="App\\Auth\\Entity\\User"`) چک کن:
|
||||
- `GET /oauth/userinfo` → `primary_role: "representation"`
|
||||
- `POST /api/v1/representation/doctor` و `/clinic` → ۲۰۱ و `representation_id` ستشده
|
||||
- `GET /api/v1/representation/appointments` → فقط نوبتهای پزشکانِ همان نماینده
|
||||
- `GET /api/v1/representation/me` و dashboard ماهانه/سالانه → ۲۰۰
|
||||
- `GET /api/v1/admin/users` با همان توکن → **۴۰۳** (نباید دسترسی داشته باشد)
|
||||
- در Admin SPA با همان کاربر وارد شو: فقط ۴ آیتم منو دیده شود و افزودن پزشک/کلینیک کار کند.
|
||||
@@ -43,7 +43,7 @@ import PwaInstallBanner from './components/ui/PwaInstallBanner';
|
||||
|
||||
// ── Guards ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary'] as const;
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe, logout } = useAuthStore();
|
||||
@@ -146,7 +146,7 @@ export default function App() {
|
||||
<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="clinics" element={<RoleRoute roles={['admin', 'representation']}><ClinicsPage /></RoleRoute>} />
|
||||
<Route path="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
|
||||
|
||||
{/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */}
|
||||
@@ -156,8 +156,8 @@ export default function App() {
|
||||
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic']}><ClinicDetailPage /></RoleRoute>} />
|
||||
|
||||
{/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه میکند */}
|
||||
<Route path="doctors" element={<RoleRoute roles={['admin']}><DoctorsPage /></RoleRoute>} />
|
||||
<Route path="doctors/new" element={<RoleRoute roles={['admin']}><DoctorFormPage /></RoleRoute>} />
|
||||
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} />
|
||||
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
|
||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic']}><DoctorDetailPage /></RoleRoute>} />
|
||||
<Route path="profile" element={<RoleRoute roles={['doctor']}><DoctorProfilePage /></RoleRoute>} />
|
||||
|
||||
|
||||
@@ -310,6 +310,25 @@ function buildSections(
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === "representation") {
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "مدیریت",
|
||||
items: [
|
||||
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
||||
{ to: "/admin/clinics", icon: BuildingOffice2Icon, label: "کلینیکها" },
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتها" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
@@ -329,6 +348,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
clinic: "مالک کلینیک",
|
||||
doctor: "پزشک",
|
||||
secretary: "منشی",
|
||||
representation: "نماینده",
|
||||
user: "کاربر",
|
||||
};
|
||||
|
||||
|
||||
@@ -530,6 +530,7 @@ export default function AppointmentsPage() {
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
const isClinic = primaryRole === 'clinic';
|
||||
const isDoctor = primaryRole === 'doctor';
|
||||
const isRepresentation = primaryRole === 'representation';
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
@@ -540,7 +541,11 @@ export default function AppointmentsPage() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// ── Appointments query
|
||||
const apptEndpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
|
||||
const apptEndpoint = isAdmin
|
||||
? '/api/v1/admin/appointments'
|
||||
: isRepresentation
|
||||
? '/api/v1/representation/appointments'
|
||||
: '/api/v1/my/appointments';
|
||||
const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid];
|
||||
const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' });
|
||||
if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { z } from 'zod';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Clinic } from '../types';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { formatDate, formatNumber } from '../lib/utils';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
@@ -31,6 +32,8 @@ type AddForm = z.infer<typeof addSchema>;
|
||||
export default function ClinicsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const isRepresentation = primaryRole === 'representation';
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
@@ -38,13 +41,14 @@ export default function ClinicsPage() {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const limit = 15;
|
||||
|
||||
const listBase = isRepresentation ? '/api/v1/clinics' : '/api/v1/admin/clinics';
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-clinics', page, search, statusFilter],
|
||||
queryKey: ['admin-clinics', page, search, statusFilter, isRepresentation],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<Clinic>>(
|
||||
`/api/v1/admin/clinics?page=${page}&limit=${limit}` +
|
||||
`${listBase}?page=${page}&limit=${limit}` +
|
||||
(search ? `&search=${encodeURIComponent(search)}` : '') +
|
||||
(statusFilter !== '' ? `&status=${statusFilter}` : ''),
|
||||
(!isRepresentation && statusFilter !== '' ? `&status=${statusFilter}` : ''),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -70,13 +74,17 @@ export default function ClinicsPage() {
|
||||
const addForm = useForm<AddForm>({ resolver: zodResolver(addSchema) });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (d: AddForm) => api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/clinic', d),
|
||||
mutationFn: (d: AddForm) =>
|
||||
api.post<ApiResponse<{ uuid: string }>>(
|
||||
isRepresentation ? '/api/v1/representation/clinic' : '/api/v1/admin/clinic',
|
||||
d,
|
||||
),
|
||||
onSuccess: (res) => {
|
||||
toast.success('کلینیک اضافه شد');
|
||||
setAddOpen(false);
|
||||
addForm.reset();
|
||||
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
||||
if (res?.data?.uuid) navigate(`/admin/clinics/${res.data.uuid}`);
|
||||
if (!isRepresentation && res?.data?.uuid) navigate(`/admin/clinics/${res.data.uuid}`);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
@@ -991,16 +991,102 @@ function SecretaryDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
function RepresentationDashboard() {
|
||||
const now = new Date();
|
||||
const jYear = Number(new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric' }).format(now));
|
||||
const jMonth = Number(new Intl.DateTimeFormat('en-US-u-ca-persian', { month: 'numeric' }).format(now));
|
||||
|
||||
const meQ = useQuery({
|
||||
queryKey: ['representation-me'],
|
||||
queryFn: () => api.get<ApiResponse<{ data: { uuid: string; full_name: string; commission_percent: string } }>>('/api/v1/representation/me'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rep = useMemo<any>(() => (meQ.data?.data as any)?.data ?? meQ.data?.data, [meQ.data]);
|
||||
const repUuid: string | undefined = rep?.uuid;
|
||||
|
||||
const monthlyQ = useQuery({
|
||||
queryKey: ['representation-monthly', repUuid, jYear, jMonth],
|
||||
queryFn: () => api.get<ApiResponse<{ data: { stats: { total_appointments: number; total_revenue_rials: number; commission_rials: number } } }>>(
|
||||
`/api/v1/representation/${repUuid}/dashboard/monthly?year=${jYear}&month=${jMonth}`,
|
||||
),
|
||||
enabled: !!repUuid,
|
||||
staleTime: 120_000,
|
||||
});
|
||||
const yearlyQ = useQuery({
|
||||
queryKey: ['representation-yearly', repUuid, jYear],
|
||||
queryFn: () => api.get<ApiResponse<{ data: { stats: { total_appointments: number; total_revenue_rials: number; commission_rials: number } } }>>(
|
||||
`/api/v1/representation/${repUuid}/dashboard/yearly?year=${jYear}`,
|
||||
),
|
||||
enabled: !!repUuid,
|
||||
staleTime: 120_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const monthly = useMemo<any>(() => ((monthlyQ.data?.data as any)?.data ?? monthlyQ.data?.data)?.stats, [monthlyQ.data]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const yearly = useMemo<any>(() => ((yearlyQ.data?.data as any)?.data ?? yearlyQ.data?.data)?.stats, [yearlyQ.data]);
|
||||
|
||||
if (meQ.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const cards = [
|
||||
{ label: 'نوبتهای این ماه', value: formatNumber(monthly?.total_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'کمیسیون این ماه', value: formatRial(monthly?.commission_rials ?? 0), icon: CreditCardIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
{ label: 'نوبتهای امسال', value: formatNumber(yearly?.total_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
{ label: 'کمیسیون امسال', value: formatRial(yearly?.commission_rials ?? 0), icon: CreditCardIcon, color: 'var(--violet)', bg: 'var(--violet-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} · {rep?.full_name ?? ''}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => { monthlyQ.refetch(); yearlyQ.refetch(); }}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||
{cards.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="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>دسترسی سریع</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Link to="/admin/doctors" className="btn sm">پزشکان من</Link>
|
||||
<Link to="/admin/clinics" className="btn sm">کلینیکها</Link>
|
||||
<Link to="/admin/appointments" className="btn sm">نوبتها</Link>
|
||||
</div>
|
||||
</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 />;
|
||||
if (primaryRole === 'admin') return <AdminDashboard />;
|
||||
if (primaryRole === 'clinic') return <ClinicDashboard />;
|
||||
if (primaryRole === 'doctor') return <DoctorDashboard />;
|
||||
if (primaryRole === 'secretary') return <SecretaryDashboard />;
|
||||
if (primaryRole === 'representation') return <RepresentationDashboard />;
|
||||
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -253,6 +254,9 @@ function SpecialtyPicker({ selected, onChange, specialties }: {
|
||||
|
||||
export default function DoctorFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const createDoctorEndpoint =
|
||||
primaryRole === 'representation' ? '/api/v1/representation/doctor' : '/api/v1/admin/doctors';
|
||||
const [selectedSpecialties, setSelectedSpecialties] = useState<number[]>([]);
|
||||
const [gender, setGender] = useState<'man' | 'woman' | ''>('');
|
||||
|
||||
@@ -273,7 +277,7 @@ export default function DoctorFormPage() {
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/doctors', {
|
||||
api.post<ApiResponse<{ uuid: string }>>(createDoctorEndpoint, {
|
||||
mobile: values.mobile,
|
||||
name: values.name,
|
||||
gender: gender || undefined,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { formatDate, formatNumber } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -91,6 +92,8 @@ function DoctorAvatar({ name, id, image, size = 'sm' }: {
|
||||
export default function DoctorsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const isRepresentation = primaryRole === 'representation';
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(25);
|
||||
@@ -114,6 +117,7 @@ export default function DoctorsPage() {
|
||||
queryKey: ['doctors-stats'],
|
||||
queryFn: () => api.get<ApiResponse<DoctorStats>>('/api/v1/admin/doctors/stats'),
|
||||
staleTime: 30_000,
|
||||
enabled: !isRepresentation,
|
||||
});
|
||||
|
||||
const specialtiesQ = useQuery({
|
||||
@@ -129,7 +133,8 @@ export default function DoctorsPage() {
|
||||
if (search) p.set('search', search);
|
||||
if (status) p.set('status', status);
|
||||
if (specialtyId) p.set('specialty_id', specialtyId);
|
||||
return api.get<PaginatedResponse<AdminDoctor>>(`/api/v1/admin/doctors?${p}`);
|
||||
const base = isRepresentation ? '/api/v1/doctors' : '/api/v1/admin/doctors';
|
||||
return api.get<PaginatedResponse<AdminDoctor>>(`${base}?${p}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface ContextItem {
|
||||
type: 'doctor' | 'clinic';
|
||||
db_uuid: string;
|
||||
name: string;
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user';
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user';
|
||||
doctor_uuid?: string;
|
||||
permissions?: Record<string, any>;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
userUuid: string | null;
|
||||
userName: string | null;
|
||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
|
||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user' | null;
|
||||
dbUuid: string | null;
|
||||
dbKey: string | null;
|
||||
doctorUuid: string | null;
|
||||
|
||||
@@ -282,6 +282,8 @@ Delete a user.
|
||||
|
||||
## Doctor Management
|
||||
|
||||
> **نماینده (ROLE_REPRESENTATION):** افزودن پزشک و کلینیک برای نماینده از طریق endpointهای جدا انجام میشود — `POST /api/v1/representation/doctor` و `POST /api/v1/representation/clinic` (به `docs/api/representation.md` مراجعه کنید). در نسخهی نماینده، `representation_id` پزشک خودکار روی نمایندهی کاربر جاری ست میشود. endpointهای `/api/v1/admin/*` همچنان فقط `ROLE_ADMIN` هستند.
|
||||
|
||||
### GET `/api/v1/admin/doctors`
|
||||
|
||||
List all doctors with pagination.
|
||||
|
||||
+2
-1
@@ -276,7 +276,7 @@ Authorization: Bearer <token>
|
||||
|
||||
| فیلد | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| `primary_role` | string | نقش اصلی: `admin` \| `clinic` \| `doctor` \| `secretary` \| `user` |
|
||||
| `primary_role` | string | نقش اصلی: `admin` \| `clinic` \| `doctor` \| `secretary` \| `representation` \| `user` |
|
||||
| `db_uuid` | string\|null | UUID موجودیت فعال (null = هنوز context انتخاب نشده) |
|
||||
| `db_key` | string\|null | `HMAC-SHA256(db_uuid, APP_SECRET)` برای اعتبارسنجی |
|
||||
| `doctor_uuid` | string\|null | UUID دکتر — ثابت است حتی در context کلینیک که `db_uuid` برابر UUID کلینیک است. برای کاربران غیر دکتر: `null` |
|
||||
@@ -288,6 +288,7 @@ Authorization: Bearer <token>
|
||||
- `ROLE_CLINIC` → `"clinic"`
|
||||
- `ROLE_DOCTOR` → `"doctor"`
|
||||
- `ROLE_SECRETARY` → `"secretary"`
|
||||
- `ROLE_REPRESENTATION` → `"representation"` (نماینده؛ دسترسی محدود به پنل ادمین: افزودن پزشک/کلینیک، نوبتهای پزشکانِ زیرمجموعه، داشبورد نماینده)
|
||||
- بقیه → `"user"`
|
||||
|
||||
**قانون `context.role`** — نقشی که در آن محیط کاری فعال است:
|
||||
|
||||
@@ -225,3 +225,135 @@ Get yearly earnings dashboard for a representation.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## پنل نماینده (ROLE_REPRESENTATION)
|
||||
|
||||
این endpointها برای کاربرِ دارای نقش `ROLE_REPRESENTATION` در پنل ادمین (`/admin`) هستند. مالکیت همیشه از کاربر جاری (`#[CurrentUser]` + `findByUser`) تعیین میشود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمیشود.
|
||||
|
||||
> **Permission (همهی این بخش):** `ROLE_REPRESENTATION`
|
||||
|
||||
### GET `/api/v1/representation/me`
|
||||
|
||||
پروفایل نمایندهی کاربر جاری.
|
||||
|
||||
#### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"full_name": "حامد حسینی",
|
||||
"mobile_number": "09120671756",
|
||||
"city_id": 132,
|
||||
"commission_percent": "10.00",
|
||||
"bank_account": null,
|
||||
"active": true,
|
||||
"created_at": 1718000000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
> double-nested: مقدار با `data.data` استخراج میشود.
|
||||
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/v1/representation/doctor`
|
||||
|
||||
افزودن پزشک توسط نماینده. `representation_id` پزشک بهصورت خودکار روی نمایندهی کاربر جاری ست میشود.
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
{ "mobile": "0935...", "name": "دکتر ...", "gender": "man", "degree": "...", "medical_system_code": "...", "specialties": [1,2] }
|
||||
```
|
||||
| Field | Type | Required |
|
||||
|-------|------|----------|
|
||||
| `mobile` | string | ✅ |
|
||||
| `name` | string | ✅ |
|
||||
| `gender` / `degree` / `medical_system_code` / `info` | string | ❌ |
|
||||
| `specialties` | integer[] | ❌ |
|
||||
|
||||
#### Response `201`
|
||||
```json
|
||||
{ "success": true, "data": { "uuid": "..." } }
|
||||
```
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_002` | 422 | موبایل یا نام خالی |
|
||||
| `ERR_CONFLICT_001` | 409 | این کاربر قبلاً پزشک است |
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/v1/representation/clinic`
|
||||
|
||||
افزودن کلینیک توسط نماینده.
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
{ "owner_mobile": "0935...", "name": "کلینیک ...", "telephone": "...", "address": "..." }
|
||||
```
|
||||
| Field | Type | Required |
|
||||
|-------|------|----------|
|
||||
| `owner_mobile` | string | ✅ |
|
||||
| `name` | string | ✅ |
|
||||
| `telephone` / `address` / `info` | string | ❌ |
|
||||
|
||||
#### Response `200`
|
||||
```json
|
||||
{ "success": true, "data": { "uuid": "...", "name": "...", "is_active": true } }
|
||||
```
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_002` | 422 | موبایل یا نام خالی |
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/v1/representation/appointments`
|
||||
|
||||
نوبتهای همهی پزشکانی که `representation_id` آنها = نمایندهی کاربر جاری است (paginated، با شکل آیتمِ یکسان با `/api/v1/admin/appointments`).
|
||||
|
||||
#### Query Parameters
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `page` | integer | ❌ | پیشفرض 1 |
|
||||
| `limit` | integer | ❌ | پیشفرض 15، حداکثر 500 |
|
||||
| `status` | string | ❌ | فیلتر وضعیت |
|
||||
| `date` | string (YYYY-MM-DD) | ❌ | فیلتر تاریخِ نوبت |
|
||||
| `search` | string | ❌ | جستجو در موبایل/نام بیمار یا نام پزشک |
|
||||
|
||||
#### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"patient_name": "...",
|
||||
"patient_mobile": "0912...",
|
||||
"doctor_uuid": "...",
|
||||
"doctor_name": "دکتر ...",
|
||||
"slot_start": 1718000000,
|
||||
"slot_end": 1718001800,
|
||||
"appointment_date": "2025-06-15",
|
||||
"appointment_time": "10:00",
|
||||
"end_time": "10:30",
|
||||
"status": "confirmed",
|
||||
"created_at": 1717900000
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 12, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||||
|
||||
@@ -610,6 +610,7 @@ class AuthController extends BaseController
|
||||
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';
|
||||
if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace App\Representation\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
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;
|
||||
|
||||
/**
|
||||
* اکشنهای پنل نماینده: افزودن پزشک/کلینیک، نوبتهای پزشکانِ زیرمجموعه، و پروفایل نمایندهی جاری.
|
||||
* هر اکشن برای ROLE_REPRESENTATION (و ROLE_ADMIN) باز است؛ مالکیت همیشه از کاربر جاری تعیین میشود.
|
||||
*/
|
||||
#[OA\Tag(name: 'Representations')]
|
||||
#[IsGranted('ROLE_REPRESENTATION')]
|
||||
class RepresentationActionController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RepresentationRepository $representationRepo,
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/representation/me',
|
||||
summary: 'پروفایل نمایندهی کاربر جاری',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'پروفایل نماینده'),
|
||||
new OA\Response(response: 404, description: 'کاربر جاری نماینده نیست'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/representation/me', methods: ['GET'])]
|
||||
public function me(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نمایندهای برای این کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $rep->toArray()]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/representation/doctor',
|
||||
summary: 'افزودن پزشک توسط نماینده',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['mobile', 'name'],
|
||||
properties: [
|
||||
new OA\Property(property: 'mobile', type: 'string'),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'gender', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'degree', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'medical_system_code', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'پزشک ساخته شد'),
|
||||
new OA\Response(response: 409, description: 'این کاربر قبلاً پزشک است'),
|
||||
new OA\Response(response: 422, description: 'فیلد الزامی وارد نشده'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/representation/doctor', methods: ['POST'])]
|
||||
public function createDoctor(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim((string) ($data['mobile'] ?? ''));
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
|
||||
if ($mobile === '' || $name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'موبایل و نام الزامی هستند', 422);
|
||||
}
|
||||
|
||||
$doctorUser = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
if (!$doctorUser) {
|
||||
$doctorUser = new User($mobile);
|
||||
$doctorUser->setRealName($name);
|
||||
$doctorUser->setPasswordHash(password_hash(bin2hex(random_bytes(8)), PASSWORD_BCRYPT));
|
||||
$this->em->persist($doctorUser);
|
||||
}
|
||||
|
||||
if ($this->em->getRepository(Doctor::class)->findOneBy(['user' => $doctorUser]) !== null) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً پروفایل پزشک دارد', 409);
|
||||
}
|
||||
|
||||
$doctor = new Doctor($doctorUser, $name);
|
||||
if (!empty($data['gender'])) $doctor->setGender($data['gender']);
|
||||
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
|
||||
if (!empty($data['medical_system_code'])) $doctor->setMedicalSystemCode($data['medical_system_code']);
|
||||
if (!empty($data['info'])) $doctor->setInfo($data['info']);
|
||||
if (!empty($data['mobile_number'])) $doctor->setMobileNumber($data['mobile_number']);
|
||||
|
||||
if (!empty($data['specialties']) && is_array($data['specialties'])) {
|
||||
foreach ($data['specialties'] as $id) {
|
||||
$s = $this->em->getRepository(Specialty::class)->find((int) $id);
|
||||
if ($s !== null) $doctor->getSpecialties()->add($s);
|
||||
}
|
||||
}
|
||||
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep !== null) {
|
||||
$doctor->setRepresentationId($rep->getId());
|
||||
}
|
||||
|
||||
$roles = $doctorUser->getRoles();
|
||||
if (!in_array('ROLE_DOCTOR', $roles, true)) {
|
||||
$roles[] = 'ROLE_DOCTOR';
|
||||
$doctorUser->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['uuid' => $doctor->getUuid()], 201);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/representation/clinic',
|
||||
summary: 'افزودن کلینیک توسط نماینده',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['owner_mobile', 'name'],
|
||||
properties: [
|
||||
new OA\Property(property: 'owner_mobile', type: 'string'),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'telephone', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'address', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'کلینیک ساخته شد'),
|
||||
new OA\Response(response: 422, description: 'فیلد الزامی وارد نشده'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/representation/clinic', methods: ['POST'])]
|
||||
public function createClinic(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim((string) ($data['owner_mobile'] ?? ''));
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
|
||||
if ($mobile === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شماره موبایل الزامی است', 422);
|
||||
}
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام کلینیک الزامی است', 422);
|
||||
}
|
||||
|
||||
$ownerUser = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
if (!$ownerUser) {
|
||||
$ownerUser = new User($mobile);
|
||||
$this->em->persist($ownerUser);
|
||||
}
|
||||
|
||||
$roles = $ownerUser->getRoles();
|
||||
if (!in_array('ROLE_CLINIC', $roles, true)) {
|
||||
$roles[] = 'ROLE_CLINIC';
|
||||
$ownerUser->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
|
||||
$clinic = new Clinic($ownerUser);
|
||||
$clinic->setName($name);
|
||||
if (!empty($data['telephone'])) $clinic->setTelephone($data['telephone']);
|
||||
if (!empty($data['address'])) $clinic->setAddress($data['address']);
|
||||
if (!empty($data['info'])) $clinic->setInfo($data['info']);
|
||||
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
'is_active' => $clinic->isActive(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/representation/appointments',
|
||||
summary: 'نوبتهای پزشکانِ زیرمجموعهی نمایندهی جاری (paginated)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
|
||||
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date')),
|
||||
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'لیست نوبتها')]
|
||||
)]
|
||||
#[Route('/api/v1/representation/appointments', methods: ['GET'])]
|
||||
public function appointments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نمایندهای برای این کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(500, 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, a.version',
|
||||
'd.uuid as doctor_uuid, d.name as doctor_name',
|
||||
'u.mobileNumber as patient_mobile, u.realName as patient_name',
|
||||
)
|
||||
->from(Appointment::class, 'a')
|
||||
->join('a.doctor', 'd')
|
||||
->join('a.user', 'u')
|
||||
->where('d.representationId = :repId')
|
||||
->setParameter('repId', $rep->getId())
|
||||
->orderBy('a.slotStart', 'ASC');
|
||||
|
||||
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 !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$dayEnd = (int) strtotime($date . ' 23:59:59');
|
||||
$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_uuid' => $a['doctor_uuid'],
|
||||
'doctor_name' => $a['doctor_name'],
|
||||
'slot_start' => (int) $a['slotStart'],
|
||||
'slot_end' => (int) $a['slotEnd'],
|
||||
'appointment_date' => date('Y-m-d', (int) $a['slotStart']),
|
||||
'appointment_time' => date('H:i', (int) $a['slotStart']),
|
||||
'end_time' => date('H:i', (int) $a['slotEnd']),
|
||||
'status' => $a['status'],
|
||||
'created_at' => (int) $a['createdAt'],
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user