pwa setting

This commit is contained in:
hamed
2026-06-13 13:28:39 +03:30
parent 0b5d017b14
commit d50fb96542
15 changed files with 782 additions and 0 deletions
+200
View File
@@ -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 نشان می‌دهد
+4
View File
@@ -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 (
<>
<Routes>
{/* Public */}
<Route
@@ -150,5 +152,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
</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>
);
}
+167
View File
@@ -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
);
}
+47
View File
@@ -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 };
}
+6
View File
@@ -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(
<React.StrictMode>
+112
View File
@@ -2014,6 +2014,116 @@ const editSchema = z.object({
});
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 ──────────────────────────────────────────────────────────────
export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfile?: boolean }) {
@@ -2380,6 +2490,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{uuid && <ScheduleSection doctorUuid={uuid} />}
{isOwnProfile && <ClinicInvitationsSection />}
{doctor.clinics && doctor.clinics.length > 0 && (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
+3
View File
@@ -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 نسخه ۱.۰.۰
</p>
</div>
<PwaLoginCard />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+39
View File
@@ -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"
}
]
}
+78
View File
@@ -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;
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')) {
@@ -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();
+7
View File
@@ -4,6 +4,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap" rel="stylesheet">