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:
hamed
2026-06-19 13:20:40 +03:30
parent a8d36d7455
commit fe73fa1a05
14 changed files with 778 additions and 19 deletions
@@ -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 با همان کاربر وارد شو: فقط ۴ آیتم منو دیده شود و افزودن پزشک/کلینیک کار کند.
+4 -4
View File
@@ -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: "کاربر",
};
+6 -1
View File
@@ -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);
+13 -5
View File
@@ -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),
});
+90 -4
View File
@@ -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 />;
}
+5 -1
View File
@@ -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,
+6 -1
View File
@@ -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}`);
},
});
+2 -2
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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`** — نقشی که در آن محیط کاری فعال است:
+132
View File
@@ -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 | کاربر جاری نماینده نیست |
+1
View File
@@ -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);
}
}