pwa setting
This commit is contained in:
@@ -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` این تگها را به `<head>` اضافه کن:
|
||||||
|
```html
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#6366f1">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ClinicPro">
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.png">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### قابلیت ۲ — 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 نشان میدهد
|
||||||
@@ -33,6 +33,7 @@ import MyPatientsPage from './pages/MyPatientsPage';
|
|||||||
import MyFinancialPage from './pages/MyFinancialPage';
|
import MyFinancialPage from './pages/MyFinancialPage';
|
||||||
import ClinicFormPage from './pages/ClinicFormPage';
|
import ClinicFormPage from './pages/ClinicFormPage';
|
||||||
import PreRegistrationsPage from './pages/PreRegistrationsPage';
|
import PreRegistrationsPage from './pages/PreRegistrationsPage';
|
||||||
|
import PwaInstallBanner from './components/ui/PwaInstallBanner';
|
||||||
|
|
||||||
// ── Guards ──────────────────────────────────────────────────────────────────
|
// ── Guards ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ function RoleRoute({ roles, children }: { roles: string[]; children: React.React
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public */}
|
{/* Public */}
|
||||||
<Route
|
<Route
|
||||||
@@ -150,5 +152,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<PwaInstallBanner />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', bottom: 20, right: 20, left: 20, zIndex: 9999,
|
||||||
|
maxWidth: 420, margin: '0 auto',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r)',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,.12)',
|
||||||
|
padding: '14px 16px',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
direction: 'rtl',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<ArrowDownTrayIcon style={{ width: 18, height: 18 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)', margin: 0 }}>نصب ClinicPro</p>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '2px 0 0' }}>دسترسی سریعتر از روی صفحه اصلی</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
style={{ height: 34, padding: '0 14px', borderRadius: 'var(--r-sm)', border: 'none', background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 13, fontWeight: 700, cursor: 'pointer', flexShrink: 0 }}>
|
||||||
|
نصب
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-3)', borderRadius: 6, flexShrink: 0 }}>
|
||||||
|
<XMarkIcon style={{ width: 16, height: 16 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<div
|
||||||
|
onClick={dismiss}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 10000,
|
||||||
|
background: 'rgba(0,0,0,0.45)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 20, direction: 'rtl',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => 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 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '16px 18px 14px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10,
|
||||||
|
background: 'var(--primary)', color: 'var(--on-primary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<ArrowDownTrayIcon style={{ width: 18, height: 18 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 800, color: 'var(--text)' }}>نصب ClinicPro</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={dismiss} style={{
|
||||||
|
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: 'none', background: 'transparent', cursor: 'pointer',
|
||||||
|
color: 'var(--text-3)', borderRadius: 8,
|
||||||
|
}}>
|
||||||
|
<XMarkIcon style={{ width: 18, height: 18 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: '20px 18px' }}>
|
||||||
|
|
||||||
|
{!showSteps ? (
|
||||||
|
<>
|
||||||
|
{/* App icon + name */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 72, height: 72, borderRadius: 18,
|
||||||
|
background: 'var(--primary-soft)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 36, marginBottom: 10,
|
||||||
|
}}>♥</div>
|
||||||
|
<p style={{ fontSize: 16, fontWeight: 800, color: 'var(--text)', margin: 0 }}>ClinicPro</p>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '4px 0 0' }}>پنل مدیریت کلینیک</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.7, margin: '0 0 20px' }}>
|
||||||
|
اپلیکیشن را روی دستگاه خود نصب کنید و بدون باز کردن مرورگر وارد شوید.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleInstallClick}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: 46, borderRadius: 'var(--r-sm)',
|
||||||
|
border: 'none', background: 'var(--primary)', color: 'var(--on-primary)',
|
||||||
|
fontSize: 15, fontWeight: 700, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
}}>
|
||||||
|
<ArrowDownTrayIcon style={{ width: 18, height: 18 }} />
|
||||||
|
نصب اپلیکیشن
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: 40, borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)', background: 'transparent',
|
||||||
|
color: 'var(--text-2)', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
بعداً
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)', margin: '0 0 16px' }}>
|
||||||
|
مراحل نصب:
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20 }}>
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: 'var(--primary-soft)', color: 'var(--primary-700)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.6, margin: 0 }}>{step}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: 44, borderRadius: 'var(--r-sm)',
|
||||||
|
border: 'none', background: 'var(--primary)', color: 'var(--on-primary)',
|
||||||
|
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
}}>
|
||||||
|
<CheckIcon style={{ width: 16, height: 16 }} />
|
||||||
|
متوجه شدم
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISMISSED_KEY = 'pwa-dismissed';
|
||||||
|
|
||||||
|
export function usePwaInstall() {
|
||||||
|
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(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<boolean> => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -33,6 +33,12 @@ const toastStyle: React.CSSProperties = {
|
|||||||
minWidth: 260,
|
minWidth: 260,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const root = document.getElementById('admin-root')!;
|
const root = document.getElementById('admin-root')!;
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -2014,6 +2014,116 @@ const editSchema = z.object({
|
|||||||
});
|
});
|
||||||
type EditForm = z.infer<typeof editSchema>;
|
type EditForm = z.infer<typeof editSchema>;
|
||||||
|
|
||||||
|
// ── 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<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['doctor-my-invitations'],
|
||||||
|
queryFn: () => api.get<ApiResponse<InvitationItem[]>>('/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<ApiResponse<any>>(`/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 (
|
||||||
|
<div className="cp-card p-6">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||||
|
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<BuildingOfficeIcon style={{ width: 16, height: 16 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>دعوتنامههای کلینیک</span>
|
||||||
|
{invitations.length > 0 && (
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--primary-700)', background: 'var(--primary-soft)', padding: '2px 10px', borderRadius: 999 }}>
|
||||||
|
{invitations.length} دعوت جدید
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{[1, 2].map(i => <div key={i} className="skeleton" style={{ height: 72, borderRadius: 'var(--r)' }} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{invitations.map(inv => {
|
||||||
|
const isExpired = Date.now() / 1000 > inv.expires_at;
|
||||||
|
const busy = respondMut.isPending && respondingUuid === inv.uuid;
|
||||||
|
return (
|
||||||
|
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ width: 44, height: 44, borderRadius: 10, overflow: 'hidden', flexShrink: 0, background: 'var(--surface)', border: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{inv.clinic.logo
|
||||||
|
? <img src={inv.clinic.logo} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: <BuildingOfficeIcon style={{ width: 20, height: 20, color: 'var(--text-3)' }} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', marginBottom: 3 }}>{inv.clinic.name}</p>
|
||||||
|
{inv.invited_specialty && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-2)' }}>تخصص: {inv.invited_specialty}</p>
|
||||||
|
)}
|
||||||
|
<p style={{ fontSize: 11, color: isExpired ? 'var(--danger)' : 'var(--text-3)', marginTop: 2 }}>
|
||||||
|
{isExpired ? 'منقضی شده' : `انقضا: ${new Date(inv.expires_at * 1000).toLocaleDateString('fa-IR')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{!isExpired && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => { setRespondingUuid(inv.uuid); respondMut.mutate({ uuid: inv.uuid, action: 'reject' }); }}
|
||||||
|
style={{ height: 34, padding: '0 14px', borderRadius: 'var(--r-sm)', border: '1.5px solid var(--border)', background: 'var(--surface)', color: 'var(--text-2)', fontSize: 13, fontWeight: 600, cursor: busy ? 'not-allowed' : 'pointer', opacity: busy ? 0.5 : 1 }}>
|
||||||
|
رد
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => { setRespondingUuid(inv.uuid); respondMut.mutate({ uuid: inv.uuid, action: 'accept' }); }}
|
||||||
|
style={{ height: 34, padding: '0 14px', borderRadius: 'var(--r-sm)', border: '1.5px solid var(--primary)', background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 13, fontWeight: 600, cursor: busy ? 'not-allowed' : 'pointer', opacity: busy ? 0.5 : 1 }}>
|
||||||
|
{busy ? '...' : 'پذیرفتن'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main Page ──────────────────────────────────────────────────────────────
|
// ── Main Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfile?: boolean }) {
|
export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfile?: boolean }) {
|
||||||
@@ -2380,6 +2490,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
|||||||
|
|
||||||
{uuid && <ScheduleSection doctorUuid={uuid} />}
|
{uuid && <ScheduleSection doctorUuid={uuid} />}
|
||||||
|
|
||||||
|
{isOwnProfile && <ClinicInvitationsSection />}
|
||||||
|
|
||||||
{doctor.clinics && doctor.clinics.length > 0 && (
|
{doctor.clinics && doctor.clinics.length > 0 && (
|
||||||
<div className="cp-card p-6">
|
<div className="cp-card p-6">
|
||||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
|
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import PwaLoginCard from '../components/ui/PwaLoginCard';
|
||||||
|
|
||||||
type Mode = 'password' | 'sms' | 'forgot';
|
type Mode = 'password' | 'sms' | 'forgot';
|
||||||
type SmsStep = 1 | 2;
|
type SmsStep = 1 | 2;
|
||||||
@@ -367,6 +368,8 @@ export default function LoginPage() {
|
|||||||
ClinicPro — نسخه ۱.۰.۰
|
ClinicPro — نسخه ۱.۰.۰
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PwaLoginCard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 413 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\ClinicInvitation\Controller;
|
namespace App\ClinicInvitation\Controller;
|
||||||
|
|
||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
|
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||||
use App\ClinicInvitation\Service\ClinicInvitationService;
|
use App\ClinicInvitation\Service\ClinicInvitationService;
|
||||||
use App\Clinic\Repository\ClinicRepository;
|
use App\Clinic\Repository\ClinicRepository;
|
||||||
|
use App\Doctor\Repository\DoctorRepository;
|
||||||
use App\Shared\Controller\BaseController;
|
use App\Shared\Controller\BaseController;
|
||||||
use App\Shared\Exception\AppException;
|
use App\Shared\Exception\AppException;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
@@ -20,6 +22,7 @@ class ClinicInvitationController extends BaseController
|
|||||||
private readonly ClinicInvitationService $invitationService,
|
private readonly ClinicInvitationService $invitationService,
|
||||||
private readonly ClinicDoctorInvitationRepository $invRepo,
|
private readonly ClinicDoctorInvitationRepository $invRepo,
|
||||||
private readonly ClinicRepository $clinicRepo,
|
private readonly ClinicRepository $clinicRepo,
|
||||||
|
private readonly DoctorRepository $doctorRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Admin endpoints ──────────────────────────────────────────────────────
|
// ── Admin endpoints ──────────────────────────────────────────────────────
|
||||||
@@ -130,6 +133,63 @@ class ClinicInvitationController extends BaseController
|
|||||||
return $this->success(null, 204);
|
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
|
private function assertClinicAccess(\App\Clinic\Entity\Clinic $clinic, User $user): void
|
||||||
{
|
{
|
||||||
if ($user->hasRole('ROLE_ADMIN')) {
|
if ($user->hasRole('ROLE_ADMIN')) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\ClinicInvitation\Repository;
|
namespace App\ClinicInvitation\Repository;
|
||||||
|
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
@@ -32,6 +33,19 @@ class ClinicDoctorInvitationRepository extends ServiceEntityRepository
|
|||||||
->getOneOrNullResult();
|
->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
|
public function save(ClinicDoctorInvitation $invitation): void
|
||||||
{
|
{
|
||||||
$em = $this->getEntityManager();
|
$em = $this->getEntityManager();
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ClinicPro Admin</title>
|
<title>ClinicPro Admin</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#5b4fd8">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ClinicPro">
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.png">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap" rel="stylesheet">
|
||||||
|
|||||||
Reference in New Issue
Block a user