From fe73fa1a059d67edb9f078be03b30874e7320c34 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 19 Jun 2026 13:20:40 +0330 Subject: [PATCH] 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. --- .../representation-admin-panel-access.md | 223 ++++++++++++++ assets/admin/App.tsx | 8 +- assets/admin/components/layout/Sidebar.tsx | 20 ++ assets/admin/pages/AppointmentsPage.tsx | 7 +- assets/admin/pages/ClinicsPage.tsx | 18 +- assets/admin/pages/DashboardPage.tsx | 94 +++++- assets/admin/pages/DoctorFormPage.tsx | 6 +- assets/admin/pages/DoctorsPage.tsx | 7 +- assets/admin/stores/authStore.ts | 4 +- docs/api/admin.md | 2 + docs/api/auth.md | 3 +- docs/api/representation.md | 132 +++++++++ src/Auth/Controller/AuthController.php | 1 + .../RepresentationActionController.php | 272 ++++++++++++++++++ 14 files changed, 778 insertions(+), 19 deletions(-) create mode 100644 .claude/prompt/representation-admin-panel-access.md create mode 100644 src/Representation/Controller/RepresentationActionController.php diff --git a/.claude/prompt/representation-admin-panel-access.md b/.claude/prompt/representation-admin-panel-access.md new file mode 100644 index 00000000..f6fdd91d --- /dev/null +++ b/.claude/prompt/representation-admin-panel-access.md @@ -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 ; + return <>{children}; +} +// ... +} /> {/* همه نقش‌ها */} +} /> {/* همه نقش‌ها */} +} /> +// 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 --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 با همان کاربر وارد شو: فقط ۴ آیتم منو دیده شود و افزودن پزشک/کلینیک کار کند. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index abc84792..5880b3dc 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> {/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */} @@ -156,8 +156,8 @@ export default function App() { } /> {/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه می‌کند */} - } /> - } /> + } /> + } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 8b7ad506..ed2657c8 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -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 = { clinic: "مالک کلینیک", doctor: "پزشک", secretary: "منشی", + representation: "نماینده", user: "کاربر", }; diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index f77266c2..b7041336 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -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); diff --git a/assets/admin/pages/ClinicsPage.tsx b/assets/admin/pages/ClinicsPage.tsx index 581783eb..12f0c0d2 100644 --- a/assets/admin/pages/ClinicsPage.tsx +++ b/assets/admin/pages/ClinicsPage.tsx @@ -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; 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>( - `/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({ resolver: zodResolver(addSchema) }); const addMutation = useMutation({ - mutationFn: (d: AddForm) => api.post>('/api/v1/admin/clinic', d), + mutationFn: (d: AddForm) => + api.post>( + 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), }); diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index c8aecfdf..3fe7cf66 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -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>('/api/v1/representation/me'), + staleTime: 300_000, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rep = useMemo(() => (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>( + `/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>( + `/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(() => ((monthlyQ.data?.data as any)?.data ?? monthlyQ.data?.data)?.stats, [monthlyQ.data]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const yearly = useMemo(() => ((yearlyQ.data?.data as any)?.data ?? yearlyQ.data?.data)?.stats, [yearlyQ.data]); + + if (meQ.isLoading) return ; + + 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 ( +
+
+
+

داشبورد نماینده

+
{today} · {rep?.full_name ?? ''}
+
+ +
+ +
+ {cards.map(c => ( +
+
+ +
+
{c.label}
+
{c.value}
+
+ ))} +
+ +
+
+

دسترسی سریع

+
+
+ پزشکان من + کلینیک‌ها + نوبت‌ها +
+
+
+ ); +} + // ── Main Dispatcher ─────────────────────────────────────────────────────── export default function DashboardPage() { const primaryRole = useAuthStore(s => s.primaryRole); if (!primaryRole) return ; - if (primaryRole === 'admin') return ; - if (primaryRole === 'clinic') return ; - if (primaryRole === 'doctor') return ; - if (primaryRole === 'secretary') return ; + if (primaryRole === 'admin') return ; + if (primaryRole === 'clinic') return ; + if (primaryRole === 'doctor') return ; + if (primaryRole === 'secretary') return ; + if (primaryRole === 'representation') return ; return ; } diff --git a/assets/admin/pages/DoctorFormPage.tsx b/assets/admin/pages/DoctorFormPage.tsx index cdbc4996..ab605c7c 100644 --- a/assets/admin/pages/DoctorFormPage.tsx +++ b/assets/admin/pages/DoctorFormPage.tsx @@ -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([]); const [gender, setGender] = useState<'man' | 'woman' | ''>(''); @@ -273,7 +277,7 @@ export default function DoctorFormPage() { const createMut = useMutation({ mutationFn: (values: FormValues) => - api.post>('/api/v1/admin/doctors', { + api.post>(createDoctorEndpoint, { mobile: values.mobile, name: values.name, gender: gender || undefined, diff --git a/assets/admin/pages/DoctorsPage.tsx b/assets/admin/pages/DoctorsPage.tsx index db3fee8a..e9862b3d 100644 --- a/assets/admin/pages/DoctorsPage.tsx +++ b/assets/admin/pages/DoctorsPage.tsx @@ -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>('/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>(`/api/v1/admin/doctors?${p}`); + const base = isRepresentation ? '/api/v1/doctors' : '/api/v1/admin/doctors'; + return api.get>(`${base}?${p}`); }, }); diff --git a/assets/admin/stores/authStore.ts b/assets/admin/stores/authStore.ts index 06e1016d..611113b1 100644 --- a/assets/admin/stores/authStore.ts +++ b/assets/admin/stores/authStore.ts @@ -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; } @@ -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; diff --git a/docs/api/admin.md b/docs/api/admin.md index c096ae63..51b0a1a5 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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. diff --git a/docs/api/auth.md b/docs/api/auth.md index 0e7fa281..f2b2c42f 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -276,7 +276,7 @@ Authorization: Bearer | فیلد | نوع | توضیح | |------|-----|-------| -| `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 - `ROLE_CLINIC` → `"clinic"` - `ROLE_DOCTOR` → `"doctor"` - `ROLE_SECRETARY` → `"secretary"` +- `ROLE_REPRESENTATION` → `"representation"` (نماینده؛ دسترسی محدود به پنل ادمین: افزودن پزشک/کلینیک، نوبت‌های پزشکانِ زیرمجموعه، داشبورد نماینده) - بقیه → `"user"` **قانون `context.role`** — نقشی که در آن محیط کاری فعال است: diff --git a/docs/api/representation.md b/docs/api/representation.md index c8464d02..cbfdefa1 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -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 | کاربر جاری نماینده نیست | diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 53aaf1cb..cc6a0f8a 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -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'; } diff --git a/src/Representation/Controller/RepresentationActionController.php b/src/Representation/Controller/RepresentationActionController.php new file mode 100644 index 00000000..9cf5392f --- /dev/null +++ b/src/Representation/Controller/RepresentationActionController.php @@ -0,0 +1,272 @@ + []]], + 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); + } +}