diff --git a/.claude/prompt/admin-pwa.md b/.claude/prompt/admin-pwa.md new file mode 100644 index 00000000..efa9c12d --- /dev/null +++ b/.claude/prompt/admin-pwa.md @@ -0,0 +1,200 @@ +# Admin Panel PWA + +پنل ادمین (`/admin`) را به یک Progressive Web App تبدیل کن تا کاربران بتوانند آن را روی دستگاه خود نصب کنند و آفلاین هم shell اولیه را ببینند. + +--- + +## وضعیت فعلی پروژه + +- **Twig template**: `templates/admin/index.html.twig` — فایل HTML ورودی پنل ادمین +- **Webpack Encore** با `addEntry('admin', './assets/admin/index.tsx')` +- **Output path**: `public/build/` +- **Public path**: `/build` +- آیکون‌های موجود: `public/favicon.ico` و `public/favicon.png` +- دامنه local: `https://clinic-pro.ddev.site` + +**قابلیت‌های ۱ تا ۴ قبلاً اجرا شده‌اند:** +- `public/manifest.json` ✅ موجود است +- `public/sw.js` ✅ موجود است +- ثبت SW در `assets/admin/index.tsx` ✅ انجام شده +- `assets/admin/components/ui/PwaInstallBanner.tsx` ✅ موجود است (برای کاربران logged-in) + +--- + +## قابلیت‌های مورد نیاز + +### قابلیت ۱ — Web App Manifest + +فایل `public/manifest.json` بساز با این مشخصات: + +```json +{ + "name": "ClinicPro Admin", + "short_name": "ClinicPro", + "description": "پنل مدیریت کلینیک پرو", + "start_url": "/admin", + "scope": "/admin", + "display": "standalone", + "orientation": "portrait-primary", + "theme_color": "#6366f1", + "background_color": "#f8fafc", + "lang": "fa", + "dir": "rtl", + "icons": [ + { "src": "/favicon.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/favicon.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} +``` + +- رنگ `theme_color` را از CSS variable `--primary` پروژه بگیر (مقدار واقعی را از `assets/admin/styles.css` بخوان) +- در `templates/admin/index.html.twig` این تگ‌ها را به `` اضافه کن: + ```html + + + + + + + + ``` + +--- + +### قابلیت ۲ — Service Worker (Cache Shell) + +یک service worker در `public/sw.js` بنویس. **بدون workbox** — native Service Worker API: + +**استراتژی:** +- `install` event: کش کردن shell (فایل‌های ضروری برای render اولیه) +- `activate` event: پاک کردن کش‌های قدیمی +- `fetch` event: + - درخواست‌های API (`/api/`) → **فقط Network** (هرگز کش نکن، اگر آفلاین بود خطا بده) + - فایل‌های build (`/build/`) → **Cache First** (سریع‌تر، stale-while-revalidate) + - navigation (`/admin*`) → **Network First با fallback** به shell کش‌شده + +**فایل‌هایی که در install کش می‌شوند:** +``` +/admin +/manifest.json +/favicon.png +``` +فایل‌های `/build/` را در install کش **نکن** (hash در نام آن‌هاست و هر deploy عوض می‌شود) — آن‌ها را در fetch با Cache First مدیریت کن. + +**Cache name** شامل version باشد تا activate بتواند قدیمی‌ها را پاک کند: +```js +const CACHE_NAME = 'clinicpro-admin-v1'; +const BUILD_CACHE = 'clinicpro-build-v1'; +``` + +--- + +### قابلیت ۳ — ثبت Service Worker در React + +در `assets/admin/index.tsx` (فایل entry point) بعد از `ReactDOM.render` / `createRoot`: + +```typescript +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js') + .catch(() => { /* silent fail in dev */ }); + }); +} +``` + +**نکته:** registration باید فقط در production یا به‌صورت همیشگی باشد — در هر دو حالت کار می‌کند چون sw.js در `/public` است و مستقیم serve می‌شود. + +--- + +### قابلیت ۴ — Install Prompt Banner (اختیاری اما مطلوب) + +یک کامپوننت کوچک React در `assets/admin/components/ui/PwaInstallBanner.tsx` بساز: + +- `beforeinstallprompt` event را listen می‌کند +- اگر prompt در دسترس بود یک banner کوچک در پایین صفحه نشان می‌دهد: + ``` + [آیکون] ClinicPro را نصب کنید — دسترسی سریع‌تر [نصب] [×] + ``` +- بعد از dismiss در `localStorage` ذخیره کن (`pwa-dismissed`) تا دوباره نشان داده نشود +- در `assets/admin/App.tsx` یا `AdminLayout` این کامپوننت را اضافه کن + +**طراحی**: از CSS variables پروژه استفاده کن (`--surface`, `--border`, `--primary`, `--text`). RTL رعایت شود. + +--- + +## تست پس از هر قابلیت + +```bash +# Build +ddev exec yarn dev + +# بررسی manifest +curl https://clinic-pro.ddev.site/manifest.json + +# بررسی sw.js +curl https://clinic-pro.ddev.site/sw.js | head -5 + +# بررسی Twig +curl https://clinic-pro.ddev.site/admin | grep manifest +``` + +**تست PWA در Chrome:** +1. مرورگر Chrome → `https://clinic-pro.ddev.site/admin` +2. DevTools → Application → Service Workers → باید registered باشد +3. DevTools → Application → Manifest → باید parse شده و installable باشد +4. آیکون نصب در address bar باید ظاهر شود + +--- + +### قابلیت ۵ — نمایش پرامپت نصب در صفحه لاگین + +در صفحه لاگین (`assets/admin/pages/LoginPage.tsx`) یک بخش نصب اپلیکیشن اضافه کن که **همیشه قابل مشاهده** باشد — نه فقط وقتی `beforeinstallprompt` فایر شده. + +**منطق نمایش:** + +- اگر app قبلاً نصب شده (`window.matchMedia('(display-mode: standalone)').matches`) → این بخش را نشان **نده** +- اگر `pwa-dismissed` در localStorage بود → این بخش را نشان **نده** +- در غیر این صورت همیشه نشان بده + +**دو حالت:** + +۱. **وقتی `beforeinstallprompt` در دسترس است** (Chrome/Edge desktop و Android): + - دکمه «نصب اپلیکیشن» نشان بده + - با کلیک روی دکمه، native install prompt مرورگر را باز کن + +۲. **وقتی `beforeinstallprompt` در دسترس نیست** (Safari iOS، Firefox، یا قبل از fire شدن event): + - یک راهنمای متنی نشان بده: + - iOS: «در Safari: دکمه Share را بزن، سپس «Add to Home Screen» را انتخاب کن» + - سایر: «در منوی مرورگر گزینه «Install app» یا «Add to Home Screen» را انتخاب کن» + +**طراحی:** + +یک card زیبا زیر فرم لاگین (یا بالای آن در موبایل) با این ظاهر: + +``` +┌─────────────────────────────────────────┐ +│ [آیکون ClinicPro] │ +│ ClinicPro را نصب کنید │ +│ دسترسی سریع‌تر — بدون نیاز به مرورگر │ +│ │ +│ [دکمه نصب / راهنما] [بعداً] │ +└─────────────────────────────────────────┘ +``` + +- از CSS variables پروژه استفاده کن (`--primary-soft`, `--primary-700`, `--surface`, `--border`) +- RTL رعایت شود +- دکمه «بعداً» مثل قابلیت ۴ عمل کند: `localStorage.setItem('pwa-dismissed', '1')` و بخش را مخفی کند +- کامپوننت جداگانه بساز: `assets/admin/components/ui/PwaLoginCard.tsx` +- در `LoginPage.tsx` این کامپوننت را import و زیر card اصلی فرم لاگین قرار بده + +**نکته مهم:** این کامپوننت مستقل از `PwaInstallBanner` است — banner برای کاربران logged-in است، این card برای صفحه لاگین است. منطق `beforeinstallprompt` را در یک custom hook مشترک (`assets/admin/hooks/usePwaInstall.ts`) بگذار تا هر دو کامپوننت از آن استفاده کنند و event را دو بار capture نکنند. + +--- + +## محدودیت‌ها و نکات + +- **workbox اضافه نکن** — native API کافی است و dependency غیرضروری اضافه نمی‌کند +- **Webpack Encore را تغییر نده** — sw.js مستقیم در `public/` می‌نشیند و نیازی به bundle ندارد +- **DDEV HTTPS**: mkcert باید قبلاً نصب شده باشد (`mkcert -install`)؛ اگر نبود service worker register نمی‌شود +- **Scope**: `/admin` — service worker فقط این path را intercept می‌کند، `/api/` را تحت تأثیر قرار نمی‌دهد مگر از داخل scope فراخوانی شود +- **آیکون‌ها**: اگر `favicon.png` کوچک بود (زیر 192px)، یک آیکون مناسب در `public/icons/` بساز یا از همان استفاده کن — Chrome حتی با آیکون کوچک هم install prompt نشان می‌دهد diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 11c87a1f..4bf6c640 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -33,6 +33,7 @@ import MyPatientsPage from './pages/MyPatientsPage'; import MyFinancialPage from './pages/MyFinancialPage'; import ClinicFormPage from './pages/ClinicFormPage'; import PreRegistrationsPage from './pages/PreRegistrationsPage'; +import PwaInstallBanner from './components/ui/PwaInstallBanner'; // ── Guards ────────────────────────────────────────────────────────────────── @@ -77,6 +78,7 @@ function RoleRoute({ roles, children }: { roles: string[]; children: React.React export default function App() { return ( + <> {/* Public */} } /> + + ); } diff --git a/assets/admin/components/ui/PwaInstallBanner.tsx b/assets/admin/components/ui/PwaInstallBanner.tsx new file mode 100644 index 00000000..c1fc263f --- /dev/null +++ b/assets/admin/components/ui/PwaInstallBanner.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { ArrowDownTrayIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { usePwaInstall } from '../../hooks/usePwaInstall'; + +export default function PwaInstallBanner() { + const { promptEvent, isInstalled, isDismissed, install, dismiss } = usePwaInstall(); + + if (isInstalled || isDismissed || !promptEvent) return null; + + const handleInstall = async () => { + await install(); + }; + + return ( +
+
+ +
+
+

نصب ClinicPro

+

دسترسی سریع‌تر از روی صفحه اصلی

+
+ + +
+ ); +} diff --git a/assets/admin/components/ui/PwaLoginCard.tsx b/assets/admin/components/ui/PwaLoginCard.tsx new file mode 100644 index 00000000..f0cefde9 --- /dev/null +++ b/assets/admin/components/ui/PwaLoginCard.tsx @@ -0,0 +1,167 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ArrowDownTrayIcon, XMarkIcon, CheckIcon } from '@heroicons/react/24/outline'; +import { usePwaInstall } from '../../hooks/usePwaInstall'; + +function detectIOS(): boolean { + return /iphone|ipad|ipod/i.test(navigator.userAgent); +} + +export default function PwaLoginCard() { + const { promptEvent, isInstalled, isDismissed, install, dismiss } = usePwaInstall(); + const [showSteps, setShowSteps] = useState(false); + + if (isInstalled || isDismissed) return null; + + const isIOS = detectIOS(); + + const handleInstallClick = async () => { + if (promptEvent) { + const accepted = await install(); + if (accepted) return; + } + // native prompt not available or dismissed → show manual steps + setShowSteps(true); + }; + + const steps = isIOS + ? [ + 'Safari را باز کنید (فقط Safari از نصب پشتیبانی می‌کند)', + 'دکمه Share (مربع با فلش رو به بالا) را در پایین صفحه بزنید', + 'گزینه «Add to Home Screen» را انتخاب کنید', + '«Add» را بزنید — تمام!', + ] + : [ + 'در Chrome منوی سه‌نقطه (⋮) را در گوشه بالا باز کنید', + 'گزینه «Install ClinicPro» یا «Install app» را انتخاب کنید', + 'در پنجره‌ای که باز می‌شود «Install» را بزنید', + ]; + + return createPortal( +
+
e.stopPropagation()} + style={{ + width: '100%', maxWidth: 380, + background: 'var(--surface)', + borderRadius: 'var(--r)', + boxShadow: '0 20px 60px rgba(0,0,0,0.2)', + overflow: 'hidden', + }} + > + {/* Header */} +
+
+
+ +
+ نصب ClinicPro +
+ +
+ + {/* Body */} +
+ + {!showSteps ? ( + <> + {/* App icon + name */} +
+
+

ClinicPro

+

پنل مدیریت کلینیک

+
+ +

+ اپلیکیشن را روی دستگاه خود نصب کنید و بدون باز کردن مرورگر وارد شوید. +

+ +
+ + +
+ + ) : ( + <> +

+ مراحل نصب: +

+
+ {steps.map((step, i) => ( +
+
+ {i + 1} +
+

{step}

+
+ ))} +
+ + + )} +
+
+
, + document.body + ); +} diff --git a/assets/admin/hooks/usePwaInstall.ts b/assets/admin/hooks/usePwaInstall.ts new file mode 100644 index 00000000..a6349d76 --- /dev/null +++ b/assets/admin/hooks/usePwaInstall.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +export interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +const DISMISSED_KEY = 'pwa-dismissed'; + +export function usePwaInstall() { + const [promptEvent, setPromptEvent] = useState(null); + const [isInstalled, setIsInstalled] = useState(false); + const [isDismissed, setIsDismissed] = useState(() => !!localStorage.getItem(DISMISSED_KEY)); + + useEffect(() => { + if (window.matchMedia('(display-mode: standalone)').matches) { + setIsInstalled(true); + return; + } + + const handler = (e: Event) => { + e.preventDefault(); + setPromptEvent(e as BeforeInstallPromptEvent); + }; + + window.addEventListener('beforeinstallprompt', handler); + return () => window.removeEventListener('beforeinstallprompt', handler); + }, []); + + const install = async (): Promise => { + if (!promptEvent) return false; + await promptEvent.prompt(); + const { outcome } = await promptEvent.userChoice; + if (outcome === 'accepted') { + setPromptEvent(null); + return true; + } + return false; + }; + + const dismiss = () => { + localStorage.setItem(DISMISSED_KEY, '1'); + setIsDismissed(true); + }; + + return { promptEvent, isInstalled, isDismissed, install, dismiss }; +} diff --git a/assets/admin/index.tsx b/assets/admin/index.tsx index 06ecf6cf..4c7230aa 100644 --- a/assets/admin/index.tsx +++ b/assets/admin/index.tsx @@ -33,6 +33,12 @@ const toastStyle: React.CSSProperties = { minWidth: 260, }; +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +} + const root = document.getElementById('admin-root')!; createRoot(root).render( diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 466168aa..d9a39c1e 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -2014,6 +2014,116 @@ const editSchema = z.object({ }); type EditForm = z.infer; +// ── Clinic Invitations Section ───────────────────────────────────────────── + +interface InvitationItem { + uuid: string; + status: string; + invited_name: string | null; + invited_specialty: string | null; + invited_at: number; + expires_at: number; + clinic: { uuid: string; name: string; logo: string | null }; +} + +function ClinicInvitationsSection() { + const qc = useQueryClient(); + const [respondingUuid, setRespondingUuid] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['doctor-my-invitations'], + queryFn: () => api.get>('/api/v1/doctor/invitations'), + staleTime: 30_000, + }); + + const invitations: InvitationItem[] = useMemo(() => { + const raw = data?.data; + return (raw as any)?.data ?? raw ?? []; + }, [data]); + + const respondMut = useMutation({ + mutationFn: ({ uuid, action }: { uuid: string; action: 'accept' | 'reject' }) => + api.post>(`/api/v1/doctor/invitation/${uuid}/respond`, { action }), + onSuccess: (_, vars) => { + toast.success(vars.action === 'accept' ? 'دعوتنامه پذیرفته شد' : 'دعوتنامه رد شد'); + setRespondingUuid(null); + qc.invalidateQueries({ queryKey: ['doctor-my-invitations'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!isLoading && invitations.length === 0) return null; + + return ( +
+
+
+ +
+ دعوت‌نامه‌های کلینیک + {invitations.length > 0 && ( + + {invitations.length} دعوت جدید + + )} +
+ + {isLoading ? ( +
+ {[1, 2].map(i =>
)} +
+ ) : ( +
+ {invitations.map(inv => { + const isExpired = Date.now() / 1000 > inv.expires_at; + const busy = respondMut.isPending && respondingUuid === inv.uuid; + return ( +
+ {/* Logo */} +
+ {inv.clinic.logo + ? + : + } +
+ + {/* Info */} +
+

{inv.clinic.name}

+ {inv.invited_specialty && ( +

تخصص: {inv.invited_specialty}

+ )} +

+ {isExpired ? 'منقضی شده' : `انقضا: ${new Date(inv.expires_at * 1000).toLocaleDateString('fa-IR')}`} +

+
+ + {/* Actions */} + {!isExpired && ( +
+ + +
+ )} +
+ ); + })} +
+ )} +
+ ); +} + // ── Main Page ────────────────────────────────────────────────────────────── export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfile?: boolean }) { @@ -2380,6 +2490,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil {uuid && } + {isOwnProfile && } + {doctor.clinics && doctor.clinics.length > 0 && (

diff --git a/assets/admin/pages/LoginPage.tsx b/assets/admin/pages/LoginPage.tsx index 190cfb7f..7f912183 100644 --- a/assets/admin/pages/LoginPage.tsx +++ b/assets/admin/pages/LoginPage.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; import { useAuthStore } from '../stores/authStore'; +import PwaLoginCard from '../components/ui/PwaLoginCard'; type Mode = 'password' | 'sms' | 'forgot'; type SmsStep = 1 | 2; @@ -367,6 +368,8 @@ export default function LoginPage() { ClinicPro — نسخه ۱.۰.۰

+ +
); } diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 00000000..99068603 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 00000000..10d0ce33 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..bc38c136 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,39 @@ +{ + "name": "ClinicPro Admin", + "short_name": "ClinicPro", + "description": "پنل مدیریت کلینیک پرو", + "start_url": "/admin", + "scope": "/admin", + "display": "standalone", + "orientation": "portrait-primary", + "theme_color": "#5b4fd8", + "background_color": "#f8fafc", + "lang": "fa", + "dir": "rtl", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..9e125811 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,78 @@ +const CACHE_NAME = 'clinicpro-admin-v1'; +const BUILD_CACHE = 'clinicpro-build-v1'; + +const SHELL_URLS = [ + '/admin', + '/manifest.json', + '/icons/icon-192.png', + '/icons/icon-512.png', +]; + +// ── Install: cache the app shell ────────────────────────────────────────────── +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)) + ); + self.skipWaiting(); +}); + +// ── Activate: clean up old caches ───────────────────────────────────────────── +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== CACHE_NAME && k !== BUILD_CACHE) + .map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +// ── Fetch ───────────────────────────────────────────────────────────────────── +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Only handle same-origin requests + if (url.origin !== self.location.origin) return; + + // API calls → Network only, never cache + if (url.pathname.startsWith('/api/')) { + return; + } + + // Build assets (/build/) → Cache First + if (url.pathname.startsWith('/build/')) { + event.respondWith( + caches.open(BUILD_CACHE).then(async (cache) => { + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + }) + ); + return; + } + + // Navigation to /admin/* → Network First, fallback to cached shell + if (request.mode === 'navigate' && url.pathname.startsWith('/admin')) { + event.respondWith( + fetch(request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }) + .catch(() => caches.match('/admin')) + ); + return; + } + + // Everything else → Network First with cache fallback + event.respondWith( + fetch(request).catch(() => caches.match(request)) + ); +}); diff --git a/src/ClinicInvitation/Controller/ClinicInvitationController.php b/src/ClinicInvitation/Controller/ClinicInvitationController.php index 4a652783..ed02cc31 100644 --- a/src/ClinicInvitation/Controller/ClinicInvitationController.php +++ b/src/ClinicInvitation/Controller/ClinicInvitationController.php @@ -3,9 +3,11 @@ namespace App\ClinicInvitation\Controller; use App\Auth\Entity\User; +use App\ClinicInvitation\Entity\ClinicDoctorInvitation; use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository; use App\ClinicInvitation\Service\ClinicInvitationService; use App\Clinic\Repository\ClinicRepository; +use App\Doctor\Repository\DoctorRepository; use App\Shared\Controller\BaseController; use App\Shared\Exception\AppException; use Symfony\Component\HttpFoundation\JsonResponse; @@ -20,6 +22,7 @@ class ClinicInvitationController extends BaseController private readonly ClinicInvitationService $invitationService, private readonly ClinicDoctorInvitationRepository $invRepo, private readonly ClinicRepository $clinicRepo, + private readonly DoctorRepository $doctorRepo, ) {} // ── Admin endpoints ────────────────────────────────────────────────────── @@ -130,6 +133,63 @@ class ClinicInvitationController extends BaseController return $this->success(null, 204); } + // ── Doctor-facing endpoints ────────────────────────────────────────────── + + #[Route('/api/v1/doctor/invitations', methods: ['GET'])] + #[IsGranted('ROLE_DOCTOR')] + public function myInvitations(#[CurrentUser] User $user): JsonResponse + { + $doctor = $this->doctorRepo->findByUser($user); + if (!$doctor) { + throw new AppException('ERR_NOT_FOUND_001', 'پروفایل پزشک یافت نشد', 404); + } + + $invitations = $this->invRepo->findPendingByDoctor($doctor); + + $data = array_map(function (ClinicDoctorInvitation $inv): array { + $clinic = $inv->getClinic(); + $arr = $inv->toArray(); + $arr['clinic'] = [ + 'uuid' => $clinic->getUuid(), + 'name' => $clinic->getName(), + 'logo' => $clinic->getClinicLogo(), + ]; + return $arr; + }, $invitations); + + return $this->success($data); + } + + #[Route('/api/v1/doctor/invitation/{invUuid}/respond', methods: ['POST'])] + #[IsGranted('ROLE_DOCTOR')] + public function respondToInvitation(string $invUuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $doctor = $this->doctorRepo->findByUser($user); + if (!$doctor) { + throw new AppException('ERR_NOT_FOUND_001', 'پروفایل پزشک یافت نشد', 404); + } + + $inv = $this->invRepo->findOneBy(['uuid' => $invUuid]); + if (!$inv || $inv->getDoctor()?->getId() !== $doctor->getId()) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); + } + + $body = json_decode($request->getContent(), true) ?? []; + $action = $body['action'] ?? ''; + + if ($action === 'accept') { + $this->invitationService->accept($inv); + return $this->success(['message' => 'دعوتنامه پذیرفته شد']); + } + + if ($action === 'reject') { + $this->invitationService->reject($inv); + return $this->success(['message' => 'دعوتنامه رد شد']); + } + + throw new AppException('ERR_VALIDATION_001', 'action باید accept یا reject باشد', 422); + } + private function assertClinicAccess(\App\Clinic\Entity\Clinic $clinic, User $user): void { if ($user->hasRole('ROLE_ADMIN')) { diff --git a/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php b/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php index c4c52b08..4a852165 100644 --- a/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php +++ b/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php @@ -2,6 +2,7 @@ namespace App\ClinicInvitation\Repository; +use App\Doctor\Entity\Doctor; use App\ClinicInvitation\Entity\ClinicDoctorInvitation; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -32,6 +33,19 @@ class ClinicDoctorInvitationRepository extends ServiceEntityRepository ->getOneOrNullResult(); } + /** @return ClinicDoctorInvitation[] */ + public function findPendingByDoctor(Doctor $doctor): array + { + return $this->createQueryBuilder('i') + ->where('i.doctor = :doctor') + ->andWhere('i.status = :status') + ->setParameter('doctor', $doctor) + ->setParameter('status', ClinicDoctorInvitation::STATUS_PENDING) + ->orderBy('i.invitedAt', 'DESC') + ->getQuery() + ->getResult(); + } + public function save(ClinicDoctorInvitation $invitation): void { $em = $this->getEntityManager(); diff --git a/templates/admin/index.html.twig b/templates/admin/index.html.twig index 581c2601..f56727ac 100644 --- a/templates/admin/index.html.twig +++ b/templates/admin/index.html.twig @@ -4,6 +4,13 @@ ClinicPro Admin + + + + + + +