Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2459625c41 | ||
|
|
ee2682e222 | ||
|
|
68b8b05630 | ||
|
|
231ce793bc | ||
|
|
bb2dbc3371 | ||
|
|
7073377122 | ||
|
|
3365a0427e | ||
|
|
8a18457751 | ||
|
|
505ab412a3 | ||
|
|
a8699065b8 | ||
|
|
ecdefa3c24 | ||
|
|
20c8eaaad9 | ||
|
|
7716b40f6a | ||
|
|
2471c90cbb |
@@ -0,0 +1,520 @@
|
|||||||
|
# راهنمای قدمبهقدم صفحات پنل ادمین — فاز ۱ (زیرساخت + صفحهٔ نوبتها)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`clinicpro` — پنل ادمین React داخل `assets/admin/`.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
پنل ادمین حدود ۵۰ صفحه دارد و هیچ راهنمای درونبرنامهای ندارد.
|
||||||
|
کاربر تازهوارد نمیداند هر بخش صفحه چه کار میکند.
|
||||||
|
تصمیم گرفته شد راهنما به شکل **تور قدمبهقدم** باشد.
|
||||||
|
یعنی المانها یکییکی highlight میشوند و کنارشان یک popover فارسی توضیح میدهد.
|
||||||
|
|
||||||
|
این فایل فقط **فاز ۱** است.
|
||||||
|
فاز ۱ = زیرساخت تور + پیادهسازی روی یک صفحهٔ نمونه.
|
||||||
|
صفحهٔ نمونه: `AppointmentsPage`.
|
||||||
|
دلیل انتخابش: شلوغترین صفحهٔ پنل است و المانهای نقشمحور دارد.
|
||||||
|
بعد از تأیید ظاهر و رفتار تور، فاز ۲ نوشته میشود که همین الگو را روی بقیهٔ صفحات تکرار میکند.
|
||||||
|
|
||||||
|
**در این فاز هیچ صفحهٔ دیگری را دست نزن.**
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
هدف:
|
||||||
|
|
||||||
|
- یک زیرساخت واحد برای تعریف تور هر صفحه.
|
||||||
|
- تعریف تور بهصورت داده باشد، نه کد پراکنده در صفحات.
|
||||||
|
- المانهای هدف با اتریبیوت `data-tour` مشخص شوند.
|
||||||
|
- استپی که المانش در DOM نیست بیسروصدا حذف شود، نه اینکه تور بشکند.
|
||||||
|
- بار اول ورود کاربر به صفحه، تور خودکار اجرا شود.
|
||||||
|
- بعد از دیدن، دیگر خودکار اجرا نشود؛ ولی با یک دکمهٔ `؟` قابل اجرای دوباره باشد.
|
||||||
|
- محتوای تور نسخهدار باشد؛ با بالا بردن نسخه، تور دوباره یکبار خودکار اجرا شود.
|
||||||
|
|
||||||
|
## تصمیم فنی — چرا driver.js
|
||||||
|
|
||||||
|
سه گزینه بررسی شد:
|
||||||
|
|
||||||
|
- `react-joyride` — سنگینتر و روی React 19 مشکوک است. `react-floater` هنوز peer آن React 18 است.
|
||||||
|
- کامپوننت دستساز — منطق overlay و اسکرول و resize و sticky header باید از صفر نوشته شود. کد زیاد و باگخیز.
|
||||||
|
- `driver.js` نسخهٔ ۱ — بدون dependency، حدود ۵ کیلوبایت gzip، مستقل از فریمورک، خودش overlay و اسکرول و reposition را دارد.
|
||||||
|
|
||||||
|
انتخاب: **`driver.js`**.
|
||||||
|
استایلش با توکنهای `styles.css` override میشود تا با تم روشن و تیره یکی شود.
|
||||||
|
راستچین بودن مشکلی ندارد چون ریشهٔ اپ `dir="rtl"` است.
|
||||||
|
|
||||||
|
نصب:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec npm install driver.js@^1.3.6
|
||||||
|
```
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
- ✅ موفق: ورود با `09390039833` به `/admin/appointments` برای اولین بار → تور خودکار اجرا میشود. متنها فارسیاند. شمارنده «۱ از N» است. دکمهها «بعدی / قبلی / باشه، فهمیدم». بعد از پایان، در `localStorage['clinicpro-tours']` کلید `seen.appointments` برابر نسخهٔ تور میشود. رفرش صفحه → تور دیگر خودکار اجرا نمیشود. کلیک روی دکمهٔ `؟` کنار عنوان → تور دوباره از استپ اول اجرا میشود.
|
||||||
|
- ❌ خطا: `useTour('does-not-exist')` → هیچ دکمهای رندر نمیشود، هیچ خطایی throw نمیشود و تور اجرا نمیشود. همچنین اگر هیچکدام از المانهای تور در DOM نباشد، `start()` هیچ کاری نمیکند و crash نمیدهد.
|
||||||
|
- ⚠️ مرزی: ورود با نقش `doctor` که تب پزشکان ندارد، و منشیِ بدون مجوز `appointments.create` که دکمهٔ «افزودن نوبت» ندارد → استپهای مربوط به آن المانها حذف میشوند، تور با استپهای کمتر اجرا میشود و شمارنده درست است، مثلاً «۱ از ۵» نه «۱ از ۷». همچنین بالا بردن `version` تور → یکبار دیگر خودکار اجرا میشود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `assets/admin/lib/tour/types.ts` | جدید — تعریف تایپ استپ و تور |
|
||||||
|
| `assets/admin/lib/tour/resolveSteps.ts` | جدید — تابع خالص فیلتر استپها بر اساس وجود المان |
|
||||||
|
| `assets/admin/lib/tour/registry.ts` | جدید — رجیستری تورها بر اساس id |
|
||||||
|
| `assets/admin/lib/tour/tours/appointments.ts` | جدید — تعریف تور صفحهٔ نوبتها |
|
||||||
|
| `assets/admin/stores/tourStore.ts` | جدید — zustand persist برای تورهای دیدهشده |
|
||||||
|
| `assets/admin/hooks/useTour.ts` | جدید — اجرای تور و اجرای خودکار بار اول |
|
||||||
|
| `assets/admin/components/ui/TourButton.tsx` | جدید — دکمهٔ `؟` راهنمای صفحه |
|
||||||
|
| `assets/admin/components/ui/PageHeader.tsx` | تغییر — پراپ اختیاری `tourId` |
|
||||||
|
| `assets/admin/pages/AppointmentsPage.tsx` | تغییر — افزودن `data-tour` و دکمهٔ راهنما |
|
||||||
|
| `assets/admin/styles.css` | تغییر — override استایل popover با توکنها |
|
||||||
|
| `package.json` | تغییر — افزودن `driver.js` |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
`PageHeader` هیچ جای راهنما ندارد. کد فعلی:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
breadcrumbs?: Crumb[];
|
||||||
|
action?: React.ReactNode;
|
||||||
|
description?: string;
|
||||||
|
backTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader({ title, breadcrumbs, action, description, backTo }: Props) {
|
||||||
|
...
|
||||||
|
<h1 className="section-title">{title}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
`AppointmentsPage` از `PageHeader` استفاده نمیکند و عنوان دستساز دارد. کد فعلی از خط ۴۲۰:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px 24px' }}>
|
||||||
|
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
||||||
|
{/* عنوان */}
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
|
||||||
|
|
||||||
|
{/* نوار آمار */}
|
||||||
|
<TurnsStatInfo stats={stats} />
|
||||||
|
|
||||||
|
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
|
||||||
|
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
|
||||||
|
|
||||||
|
<ServiceFilterSelect ... />
|
||||||
|
|
||||||
|
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
||||||
|
<button aria-label="فیلترها" className="btn sm" onClick={() => setFiltersOpen(true)} ... >
|
||||||
|
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isRepresentation && canCreateAppt && (
|
||||||
|
<button className="btn primary sm" onClick={...}>
|
||||||
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||||
|
افزودن نوبت
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
الگوی persist موجود در `stores/uiStore.ts` مرجع است:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const useUiStore = create<UiState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({ ... }),
|
||||||
|
{ name: 'clinicpro-ui', onRehydrateStorage: ... },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. نصب driver.js
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec npm install driver.js@^1.3.6
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** `driver.js` در `dependencies` فایل `package.json` باشد و `ddev exec yarn dev` بدون خطای resolve تمام شود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۲. تایپها و تابع خالص resolve
|
||||||
|
|
||||||
|
`assets/admin/lib/tour/types.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface TourStep {
|
||||||
|
/** مقدار اتریبیوت data-tour روی المان هدف */
|
||||||
|
anchor: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourDefinition {
|
||||||
|
/** شناسهٔ یکتا؛ معمولاً همنام مسیر صفحه */
|
||||||
|
id: string;
|
||||||
|
/** با هر تغییر محتوای تور یکی زیاد شود تا تور یکبار دیگر خودکار اجرا شود */
|
||||||
|
version: number;
|
||||||
|
steps: TourStep[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`assets/admin/lib/tour/resolveSteps.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { TourStep } from './types';
|
||||||
|
|
||||||
|
export function anchorSelector(anchor: string): string {
|
||||||
|
return `[data-tour="${anchor}"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فقط استپهایی میمانند که المانشان همین حالا در DOM هست.
|
||||||
|
* دلیلش نقشمحور بودن صفحات است: دکمهٔ «افزودن نوبت» برای منشیِ بدون مجوز
|
||||||
|
* اصلاً رندر نمیشود و تور نباید روی یک المان غایب گیر کند.
|
||||||
|
*/
|
||||||
|
export function resolveSteps(steps: TourStep[], root: ParentNode = document): TourStep[] {
|
||||||
|
return steps.filter((s) => root.querySelector(anchorSelector(s.anchor)) !== null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** تست واحد `lib/tour/resolveSteps.test.ts` با vitest و jsdom:
|
||||||
|
استپ موجود میماند، استپ غایب حذف میشود، ترتیب استپهای باقیمانده حفظ میشود، آرایهٔ خالی → خروجی خالی.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۳. رجیستری تورها
|
||||||
|
|
||||||
|
`assets/admin/lib/tour/registry.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { TourDefinition } from './types';
|
||||||
|
import { appointmentsTour } from './tours/appointments';
|
||||||
|
|
||||||
|
/** هر صفحه یک فایل جدا در tours/ دارد؛ اینجا فقط ثبت میشود. */
|
||||||
|
export const TOURS: Record<string, TourDefinition> = {
|
||||||
|
[appointmentsTour.id]: appointmentsTour,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTour(id?: string): TourDefinition | null {
|
||||||
|
return id ? TOURS[id] ?? null : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
هر تور در فایل خودش، تا فاز ۲ فقط «فایل جدید + یک خط ثبت» باشد.
|
||||||
|
|
||||||
|
`assets/admin/lib/tour/tours/appointments.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const appointmentsTour: TourDefinition = {
|
||||||
|
id: 'appointments',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'appointments-stats', title: 'آمار امروز', body: 'تعداد کل نوبتها، انجامشدهها، در انتظار و لغوشدههای همین روز.', side: 'bottom' },
|
||||||
|
{ anchor: 'appointments-date', title: 'انتخاب روز', body: 'با فلشها یک روز جلو و عقب بروید یا از تقویم یک تاریخ را انتخاب کنید.', side: 'bottom' },
|
||||||
|
{ anchor: 'appointments-service', title: 'فیلتر خدمت', body: 'فقط نوبتهای یک خدمت مشخص را ببینید.', side: 'bottom' },
|
||||||
|
{ anchor: 'appointments-view', title: 'نمای تایملاین یا جدول', body: 'تایملاین ساعتهای روز را نشان میدهد و جدول فهرست ساده است.', side: 'bottom' },
|
||||||
|
{ anchor: 'appointments-filters', title: 'فیلترهای بیشتر', body: 'فیلتر بر اساس وضعیت نوبت، بیمه و بیمار.', side: 'bottom' },
|
||||||
|
{ anchor: 'appointments-new', title: 'ثبت نوبت جدید', body: 'برای همان روز و همان پزشکِ انتخابشده نوبت ثبت میکند.', side: 'bottom' },
|
||||||
|
{ anchor: 'appointments-doctors', title: 'تب پزشکان', body: 'در کلینیک چندپزشکه، برنامهٔ هر پزشک را جدا ببینید.', side: 'bottom' },
|
||||||
|
{ anchor: 'appointments-list', title: 'فهرست نوبتها', body: 'با کلیک روی هر نوبت وارد جزئیات و عملیات آن میشوید.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** تست واحد بررسی کند `id` تور خالی نیست، `version` عدد مثبت است و `anchor`ها تکراری نیستند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۴. استور تورهای دیدهشده
|
||||||
|
|
||||||
|
`assets/admin/stores/tourStore.ts` — دقیقاً الگوی `uiStore`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
interface TourState {
|
||||||
|
/** tourId → نسخهای که کاربر دیده است */
|
||||||
|
seen: Record<string, number>;
|
||||||
|
markSeen: (id: string, version: number) => void;
|
||||||
|
isSeen: (id: string, version: number) => boolean;
|
||||||
|
/** بدون آرگومان یعنی پاک کردن همهٔ تورها */
|
||||||
|
reset: (id?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTourStore = create<TourState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
seen: {},
|
||||||
|
markSeen: (id, version) => set((s) => ({ seen: { ...s.seen, [id]: version } })),
|
||||||
|
isSeen: (id, version) => (get().seen[id] ?? 0) >= version,
|
||||||
|
reset: (id) => set((s) => {
|
||||||
|
if (!id) return { seen: {} };
|
||||||
|
const next = { ...s.seen };
|
||||||
|
delete next[id];
|
||||||
|
return { seen: next };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ name: 'clinicpro-tours' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** `stores/tourStore.test.ts` — ابتدا `isSeen('x', 1) === false`؛ بعد از `markSeen('x', 1)` برابر `true`؛ با `isSeen('x', 2)` دوباره `false`؛ `reset('x')` پاکش میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۵. هوک useTour
|
||||||
|
|
||||||
|
`assets/admin/hooks/useTour.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import 'driver.js/dist/driver.css';
|
||||||
|
import { getTour } from '../lib/tour/registry';
|
||||||
|
import { anchorSelector, resolveSteps } from '../lib/tour/resolveSteps';
|
||||||
|
import { useTourStore } from '../stores/tourStore';
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** وقتی true شد یعنی دادهٔ صفحه آمده و المانها رندر شدهاند */
|
||||||
|
ready?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTour(tourId?: string, { ready = true }: Options = {}) {
|
||||||
|
const tour = getTour(tourId);
|
||||||
|
const markSeen = useTourStore((s) => s.markSeen);
|
||||||
|
const isSeen = useTourStore((s) => s.isSeen);
|
||||||
|
const autoStarted = useRef(false);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (!tour) return;
|
||||||
|
const steps = resolveSteps(tour.steps);
|
||||||
|
if (steps.length === 0) return;
|
||||||
|
|
||||||
|
const d = driver({
|
||||||
|
showProgress: true,
|
||||||
|
allowClose: true,
|
||||||
|
overlayOpacity: 0.55,
|
||||||
|
popoverClass: 'cp-tour',
|
||||||
|
nextBtnText: 'بعدی',
|
||||||
|
prevBtnText: 'قبلی',
|
||||||
|
doneBtnText: 'باشه، فهمیدم',
|
||||||
|
progressText: '{{current}} از {{total}}',
|
||||||
|
steps: steps.map((s) => ({
|
||||||
|
element: anchorSelector(s.anchor),
|
||||||
|
popover: { title: s.title, description: s.body, side: s.side ?? 'bottom', align: 'start' },
|
||||||
|
})),
|
||||||
|
onDestroyed: () => markSeen(tour.id, tour.version),
|
||||||
|
});
|
||||||
|
d.drive();
|
||||||
|
}, [tour, markSeen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tour || !ready || autoStarted.current) return;
|
||||||
|
if (isSeen(tour.id, tour.version)) return;
|
||||||
|
autoStarted.current = true;
|
||||||
|
// یک فریم صبر تا چیدمان نهایی بنشیند و highlight سرِ جای درست بیفتد.
|
||||||
|
const t = window.setTimeout(start, 300);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [tour, ready, isSeen, start]);
|
||||||
|
|
||||||
|
return { available: tour !== null, start };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
نکته: `autoStarted` جلوی اجرای دوبارهٔ تور در رندرهای بعدی همان صفحه را میگیرد.
|
||||||
|
|
||||||
|
**نحوه تست:** تست کامپوننتی با mock کردن ماژول `driver.js`:
|
||||||
|
با تور دیدهنشده و `ready: true`، بعد از پیشرفتن تایمر، `drive()` صدا زده میشود؛
|
||||||
|
با تور دیدهشده صدا زده نمیشود؛ با `tourId` ناشناس هم صدا زده نمیشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۶. دکمهٔ راهنما
|
||||||
|
|
||||||
|
`assets/admin/components/ui/TourButton.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useTour } from '../../hooks/useTour';
|
||||||
|
|
||||||
|
/** دکمهٔ «؟» صفحه. اگر برای این صفحه توری ثبت نشده باشد، چیزی رندر نمیکند. */
|
||||||
|
export default function TourButton({ tourId }: { tourId?: string }) {
|
||||||
|
const { available, start } = useTour(tourId, { ready: false });
|
||||||
|
if (!available) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="راهنمای این صفحه"
|
||||||
|
title="راهنمای این صفحه"
|
||||||
|
onClick={start}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 30, height: 30, borderRadius: 'var(--r-pill)',
|
||||||
|
background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QuestionMarkCircleIcon style={{ width: 20, height: 20 }} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
مهم: در `TourButton` مقدار `ready: false` داده میشود تا **دکمه** مسئول اجرای خودکار نباشد.
|
||||||
|
اجرای خودکار وظیفهٔ خودِ صفحه است که میداند دادهاش کی آماده است.
|
||||||
|
|
||||||
|
`PageHeader` یک پراپ اختیاری میگیرد و دکمه را کنار عنوان میگذارد:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
breadcrumbs?: Crumb[];
|
||||||
|
action?: React.ReactNode;
|
||||||
|
description?: string;
|
||||||
|
backTo?: string;
|
||||||
|
/** شناسهٔ تور راهنمای این صفحه؛ اگر ثبت نشده باشد دکمهای نمیآید */
|
||||||
|
tourId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<h1 className="section-title">{title}</h1>
|
||||||
|
<TourButton tourId={tourId} />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`action` دستنخورده میماند.
|
||||||
|
|
||||||
|
**نحوه تست:** `components/ui/TourButton.test.tsx` — با `tourId="appointments"` دکمه با `aria-label` «راهنمای این صفحه» رندر میشود؛ با `tourId="nope"` و بدون `tourId` هیچ دکمهای رندر نمیشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۷. استایل popover با توکنهای پروژه
|
||||||
|
|
||||||
|
در `assets/admin/styles.css` بعد از توکنها:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* تور راهنما — ظاهر driver.js با توکنهای پنل یکی میشود (روشن و تیره) */
|
||||||
|
.driver-popover.cp-tour {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-family: inherit;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.driver-popover.cp-tour .driver-popover-title { color: var(--text); font-size: 14px; font-weight: 700; }
|
||||||
|
.driver-popover.cp-tour .driver-popover-description { color: var(--text-2); font-size: 13px; line-height: 1.9; }
|
||||||
|
.driver-popover.cp-tour .driver-popover-progress-text { color: var(--text-3); font-size: 12px; }
|
||||||
|
.driver-popover.cp-tour .driver-popover-navigation-btns button {
|
||||||
|
background: var(--surface-2); color: var(--text-2);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||||
|
font-family: inherit; font-size: 12px; text-shadow: none;
|
||||||
|
}
|
||||||
|
.driver-popover.cp-tour .driver-popover-navigation-btns button:last-child {
|
||||||
|
background: var(--primary); color: var(--on-primary); border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.driver-popover.cp-tour .driver-popover-arrow-side-top { border-top-color: var(--surface); }
|
||||||
|
.driver-popover.cp-tour .driver-popover-arrow-side-bottom { border-bottom-color: var(--surface); }
|
||||||
|
.driver-popover.cp-tour .driver-popover-arrow-side-left { border-left-color: var(--surface); }
|
||||||
|
.driver-popover.cp-tour .driver-popover-arrow-side-right { border-right-color: var(--surface); }
|
||||||
|
```
|
||||||
|
|
||||||
|
**نحوه تست:** چشمی. یکبار در تم روشن و یکبار در تم تیره تور را اجرا کن. متن و دکمهها باید خوانا باشند و رنگ دکمهٔ آخر همان رنگ برند باشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۸. اتصال به صفحهٔ نوبتها
|
||||||
|
|
||||||
|
در `pages/AppointmentsPage.tsx`:
|
||||||
|
|
||||||
|
اجرای خودکار وقتی دادهٔ صفحه آمد:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { start: startTour } = useTour('appointments', { ready: !isLoading });
|
||||||
|
```
|
||||||
|
|
||||||
|
`isLoading` را از همان `useQuery` نوبتهای صفحه بگیر؛ اسم متغیر واقعی را از کد بردار، نگذار حدس زده شود.
|
||||||
|
|
||||||
|
عنوان صفحه دکمهٔ راهنما بگیرد:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>نوبت ها</h1>
|
||||||
|
<TourButton tourId="appointments" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
اتریبیوتها روی همان hostهای موجود، بدون تغییر در کامپوننتهای فرزند:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div data-tour="appointments-stats"><TurnsStatInfo stats={stats} /></div>
|
||||||
|
|
||||||
|
<div data-tour="appointments-date"><DateNavigator date={selectedDate} onChange={setSelectedDate} /></div>
|
||||||
|
|
||||||
|
<div data-tour="appointments-service"><ServiceFilterSelect ... /></div>
|
||||||
|
|
||||||
|
<div data-tour="appointments-view"><TurnsViewToggle viewMode={viewMode} onChange={setViewMode} /></div>
|
||||||
|
|
||||||
|
<button data-tour="appointments-filters" aria-label="فیلترها" ... />
|
||||||
|
|
||||||
|
<button data-tour="appointments-new" className="btn primary sm" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
روی کارت اصلی `data-tour="appointments-list"` و روی بلوک `showDoctorTabs` مقدار `data-tour="appointments-doctors"`.
|
||||||
|
|
||||||
|
قید مهم: `TurnsStatInfo`، `TurnsViewToggle`، `DoctorTabs` و `ServiceFilterSelect` **تغییر نکنند**.
|
||||||
|
فقط دور آنها یک `div` با `data-tour` گذاشته شود.
|
||||||
|
دلیلش این است که این کامپوننتها جای دیگری هم استفاده میشوند و تور نباید داخلشان نشت کند.
|
||||||
|
مواظب باش `div` اضافه چیدمان `flex` نوار ابزار را نشکند؛ اگر شکست، `display: 'contents'` روی wrapper بگذار
|
||||||
|
یا `data-tour` را مستقیم روی ریشهٔ همان کامپوننت از طریق پراپ عبور بده — گزینهٔ دوم فقط اگر گزینهٔ اول جواب نداد.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec npx tsc --noEmit --project tsconfig.json
|
||||||
|
ddev exec yarn test
|
||||||
|
ddev exec yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
بعد ورود دستی با `09390039833 / 09390039833` و باز کردن `/admin/appointments`.
|
||||||
|
سناریوهای بخش «معیار پذیرش» یکییکی چک شوند.
|
||||||
|
برای تست دوبارهٔ اجرای خودکار، در کنسول مرورگر:
|
||||||
|
|
||||||
|
```js
|
||||||
|
localStorage.removeItem('clinicpro-tours'); location.reload();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- محتوای تور فقط **داده** است، در `lib/tour/tours/*.ts`. هیچ متن راهنمایی داخل JSX صفحات نوشته نشود. هدف این است که فاز ۲ برای هر صفحه فقط «یک فایل تور + چند `data-tour` + یک `tourId`» باشد.
|
||||||
|
- `resolveSteps` عمداً یک تابع خالص جداست تا بدون رندر کردن صفحه تست شود.
|
||||||
|
- استپ غایب = حذف بیصدا. هیچ استپی نباید «اجباری» باشد، چون همهٔ صفحات پنل نقشمحورند.
|
||||||
|
- `version` تور دلیل وجودی دارد: متن راهنما که عوض شد، کاربر قدیمی هم باید یکبار ببیندش. بدون version هیچوقت دوباره نمایش داده نمیشود.
|
||||||
|
- کلید `localStorage` جدید `clinicpro-tours` است. با `clinicpro-auth` و `clinicpro-ui` قاطی نشود.
|
||||||
|
- اجرای خودکار حتماً به `ready` گره بخورد. اگر قبل از آمدن داده اجرا شود، المانها هنوز نیستند و تور خالی میماند.
|
||||||
|
- خروجیِ این فاز باید همان تصمیمِ نهایی «نوع helper» باشد. اگر ظاهر یا لحن متنها مطلوب نبود، فقط `tours/appointments.ts` و بلوک CSS عوض میشوند، نه معماری.
|
||||||
|
- طبق قاعدهٔ پروژه هیچ تسکی بدون تست موفق و خطا و مرزی تمام نیست. تستهای بند ۲ و ۴ و ۵ و ۶ اجباریاند.
|
||||||
|
- این تغییر backend ندارد، پس `docs/api/` دست نمیخورد.
|
||||||
|
- فاز ۲ بعد از تأیید نوشته میشود: تکرار همین الگو روی بقیهٔ صفحات، با تکیه بر پراپ `tourId` در `PageHeader` که ۸۲ نقطهٔ استفاده دارد.
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
# هفت لندینگپیج سئویی با تم صفحهٔ اصلی
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
سایت عمومی کلینیکپرو الان فقط یک صفحه دارد: `/` که با
|
||||||
|
`src/Shared/Controller/HomeController.php` رندر میشود و کل محتوایش در یک فایل
|
||||||
|
۹۹۳ خطی `templates/public/home.html.twig` هاردکد است — شامل `<head>` کامل، متای سئو،
|
||||||
|
JSON-LD، هدر، شش بخش محتوا، مودال ثبتنام و فوتر.
|
||||||
|
|
||||||
|
`sitemap.xml` هم فقط همان یک URL را دارد
|
||||||
|
(`src/Shared/Controller/SeoController.php`).
|
||||||
|
|
||||||
|
برای هفت کلیدواژهٔ تجاری، هفت صفحهٔ فرود جدا لازم است. هرکدام باید عنوان، توضیحات،
|
||||||
|
canonical، `h1` و متن مخصوص خودش را داشته باشد، ولی ظاهرش دقیقاً همان تم صفحهٔ اصلی
|
||||||
|
باشد.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
هفت لندینگ با این کلیدواژهها:
|
||||||
|
|
||||||
|
| کلیدواژه | آدرس |
|
||||||
|
|---|---|
|
||||||
|
| نرمافزار مدیریت کلینیک زیبایی | `/نرم-افزار-مدیریت-کلینیک-زیبایی` |
|
||||||
|
| نرمافزار مدیریت کلینیک دندانپزشکی | `/نرم-افزار-مدیریت-کلینیک-دندانپزشکی` |
|
||||||
|
| نرمافزار مدیریت کلینیک فیزیوتراپی | `/نرم-افزار-مدیریت-کلینیک-فیزیوتراپی` |
|
||||||
|
| سیستمهای جامع درمانگاهی | `/سیستم-جامع-درمانگاهی` |
|
||||||
|
| نرمافزار مجانی مدیریت کلینیک | `/نرم-افزار-رایگان-مدیریت-کلینیک` |
|
||||||
|
| نرمافزار مدیریت مطب | `/نرم-افزار-مدیریت-مطب` |
|
||||||
|
| CRM کلینیکها | `/crm-کلینیک` |
|
||||||
|
|
||||||
|
**تصمیم معماری (تأییدشده):** یک قالب مشترک + رجیستری PHP. کپیکردن
|
||||||
|
`home.html.twig` هفت بار یعنی هر تغییر تم باید هشت بار تکرار شود و بعد از دو ماه هشت
|
||||||
|
نسخهٔ واگرا داریم. متن هر صفحه داده است، نه کد.
|
||||||
|
|
||||||
|
## معیار پذیرش
|
||||||
|
|
||||||
|
- ✅ موفق: `GET /نرم-افزار-مدیریت-کلینیک-زیبایی` → ۲۰۰ با `<title>` و
|
||||||
|
`<meta name="description">` و `<link rel="canonical">` و `<h1>` مخصوص همان صفحه؛
|
||||||
|
ظاهرش همان تم صفحهٔ اصلی است (همان CSS، همان هدر و فوتر)؛ مودال «ثبت نام» باز
|
||||||
|
میشود و فرمش به `/api/v1/pre-registration` ارسال میکند.
|
||||||
|
- ❌ خطا: `GET /یک-اسلاگ-ناموجود` → ۴۰۴ استاندارد Symfony، نه صفحهٔ خالی و نه ۲۰۰
|
||||||
|
با محتوای پیشفرض.
|
||||||
|
- ⚠️ مرزی: `GET /sitemap.xml` هر هشت URL را دارد (خانه + هفت لندینگ) و XML معتبر
|
||||||
|
است؛ هیچ دو صفحهای `title` یا `h1` یا `canonical` یکسان ندارند؛ روی دامنهٔ
|
||||||
|
`.ddev.site` مقدار `robots.txt` همچنان `Disallow: /` میماند.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `src/Shared/Controller/HomeController.php` | کنترلر فعلی صفحهٔ اصلی |
|
||||||
|
| `src/Shared/Controller/SeoController.php` | robots.txt و sitemap.xml — باید لندینگها را بشناسد |
|
||||||
|
| `templates/public/home.html.twig` | تم مرجع؛ هدر، فوتر، مودال و بخشها از اینجا میآیند |
|
||||||
|
| `assets/home/styles.css` | تمام کلاسهای تم (`hero`, `fcols`, `band`, `devices`, `specs`, `btn-*`, `wrap`) |
|
||||||
|
| `webpack.config.js` | entry بهنام `home` که لندینگها هم از آن استفاده میکنند |
|
||||||
|
| `src/Shared/Landing/` | **جدید** — رجیستری و مدل لندینگ |
|
||||||
|
| `templates/public/landing.html.twig` | **جدید** — قالب مشترک |
|
||||||
|
| `templates/public/_reg_modal.html.twig` | **جدید** — مودال ثبتنام، مشترک بین خانه و لندینگها |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
کنترلر فعلی:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class HomeController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AltchaService $altcha) {}
|
||||||
|
|
||||||
|
#[Route('/', name: 'home', methods: ['GET'])]
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return $this->render('public/home.html.twig', [
|
||||||
|
'altcha_enabled' => $this->altcha->enabled(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sitemap.xml` فقط یک URL دارد:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Route('/sitemap.xml', name: 'seo_sitemap', methods: ['GET'])]
|
||||||
|
public function sitemap(Request $request): Response
|
||||||
|
{
|
||||||
|
$base = $request->getSchemeAndHttpHost();
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
|
||||||
|
. '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n"
|
||||||
|
. ' <url>' . "\n"
|
||||||
|
. " <loc>{$base}/</loc>\n"
|
||||||
|
. ' <changefreq>weekly</changefreq>' . "\n"
|
||||||
|
. ' <priority>1.0</priority>' . "\n"
|
||||||
|
. ' </url>' . "\n"
|
||||||
|
. '</urlset>' . "\n";
|
||||||
|
```
|
||||||
|
|
||||||
|
اسکلت صفحهٔ اصلی — خطوطی که مرزهای قابل استخراجاند:
|
||||||
|
|
||||||
|
```
|
||||||
|
66: {{ encore_entry_link_tags('home') }}
|
||||||
|
74: <header class="site-header" id="header"> … تا 99
|
||||||
|
101: <main> … تا 744
|
||||||
|
600: <script> ← منطق مودال ثبتنام (submitReg)
|
||||||
|
747: <footer class="site-footer" id="contact"> … تا 822
|
||||||
|
991: {{ encore_entry_script_tags('home') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
مودال ثبتنام به `altcha_enabled` وابسته است و به این اندپوینت میفرستد:
|
||||||
|
|
||||||
|
```js
|
||||||
|
fetch('/api/v1/pre-registration', {
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. مدل و رجیستری لندینگ
|
||||||
|
|
||||||
|
`src/Shared/Landing/LandingPage.php` — یک value object فقطخواندنی:
|
||||||
|
|
||||||
|
```php
|
||||||
|
final readonly class LandingPage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{title: string, body: string}> $features
|
||||||
|
* @param list<array{q: string, a: string}> $faq
|
||||||
|
* @param list<string> $related اسلاگ لندینگهای مرتبط
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $slug,
|
||||||
|
public string $metaTitle,
|
||||||
|
public string $metaDescription,
|
||||||
|
public string $keywords,
|
||||||
|
public string $h1,
|
||||||
|
public string $lead,
|
||||||
|
public array $features,
|
||||||
|
public array $faq,
|
||||||
|
public array $related,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/Shared/Landing/LandingRegistry.php` — تنها منبع تعریف هفت صفحه:
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class LandingRegistry
|
||||||
|
{
|
||||||
|
/** @return array<string, LandingPage> کلید = اسلاگ */
|
||||||
|
public function all(): array { … }
|
||||||
|
|
||||||
|
public function find(string $slug): ?LandingPage { … }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**چرا رجیستری و نه دیتابیس:** متن این صفحات محتوای بازاریابی است و با کد دیپلوی
|
||||||
|
میشود؛ جدول و CRUD برای هفت رکوردِ کمتغییر، پیچیدگی بیمصرف است (guidelines §۵).
|
||||||
|
اگر بعداً لازم شد از پنل ویرایش شود، همین interface جای تعویض دارد.
|
||||||
|
|
||||||
|
**نحوه تست:** یک تست PHPUnit در `tests/Shared/LandingRegistryTest.php`:
|
||||||
|
هر هفت اسلاگ وجود دارند؛ هیچ `metaTitle` یا `h1` تکراری نیست؛ طول
|
||||||
|
`metaDescription` بین ۱۲۰ و ۱۶۰ نویسه است؛ هر `related` به اسلاگی اشاره میکند که
|
||||||
|
واقعاً در رجیستری هست.
|
||||||
|
|
||||||
|
### ۲. استخراج هدر، فوتر و مودال از صفحهٔ اصلی
|
||||||
|
|
||||||
|
سه partial بساز و `home.html.twig` را طوری تغییر بده که همانها را `include` کند —
|
||||||
|
یعنی خروجی رندرشدهٔ `/` **هیچ تغییری نکند**:
|
||||||
|
|
||||||
|
- `templates/public/_header.html.twig` (خطوط ۷۴ تا ۹۹)
|
||||||
|
- `templates/public/_footer.html.twig` (خطوط ۷۴۷ تا ۸۲۲)
|
||||||
|
- `templates/public/_reg_modal.html.twig` (مودال + اسکریپت `submitReg`)
|
||||||
|
|
||||||
|
**نحوه تست:** خروجی `/` را قبل و بعد ذخیره کن و diff بگیر؛ باید یکسان باشد:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec curl -s http://localhost/ > /tmp/home-before.html
|
||||||
|
# … بعد از تغییر
|
||||||
|
ddev exec curl -s http://localhost/ > /tmp/home-after.html
|
||||||
|
diff /tmp/home-before.html /tmp/home-after.html # باید خالی باشد
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳. قالب مشترک لندینگ
|
||||||
|
|
||||||
|
`templates/public/landing.html.twig` — همان اسکلت `home.html.twig` ولی دادهمحور:
|
||||||
|
|
||||||
|
- `<head>` با `page.metaTitle`، `page.metaDescription`، `page.keywords` و
|
||||||
|
`<link rel="canonical" href="{{ base }}/{{ page.slug }}">`
|
||||||
|
- `{{ encore_entry_link_tags('home') }}` و `{{ encore_entry_script_tags('home') }}` —
|
||||||
|
همان entry، پس تم دقیقاً یکی است و CSS دومی ساخته نمیشود
|
||||||
|
- هدر و فوتر و مودال با `include` از وظیفهٔ ۲
|
||||||
|
- بخش hero با `page.h1` و `page.lead` و همان کلاسهای `hero`, `hero-grid`,
|
||||||
|
`hero-copy`, `btn btn-coral`, `btn btn-blue`
|
||||||
|
- بخش امکانات با کلاسهای `fcols`, `fcols-grid`, `fcol` روی `page.features`
|
||||||
|
- بخش پرسشهای متداول روی `page.faq`
|
||||||
|
- بخش «راهنماهای مرتبط» با لینک داخلی به `page.related` — لینک داخلی بین لندینگها
|
||||||
|
برای سئو لازم است و صفحات را یتیم نمیگذارد
|
||||||
|
|
||||||
|
**قید مهم:** هیچ کلاس CSS جدیدی تعریف نکن. اگر بخشی از تم لازم است که کلاسش وجود
|
||||||
|
ندارد، از همان بخشهای موجود (`band`, `specs`, `devices`) استفاده کن. تم باید یکی
|
||||||
|
بماند، نه شبیه.
|
||||||
|
|
||||||
|
**نحوه تست:** بعد از وظیفهٔ ۴، هر هفت آدرس را باز کن و با اسکرینشات با `/` مقایسه
|
||||||
|
کن.
|
||||||
|
|
||||||
|
### ۳.۱ JSON-LD هر صفحه
|
||||||
|
|
||||||
|
در قالب، سه schema بگذار:
|
||||||
|
|
||||||
|
- `SoftwareApplication` با `applicationCategory: "BusinessApplication"` و
|
||||||
|
`offers` — برای صفحهٔ رایگان `price: "0"`
|
||||||
|
- `FAQPage` از `page.faq` — این برای کلیدواژههای تجاری در نتایج گوگل شانس
|
||||||
|
rich result دارد
|
||||||
|
- `BreadcrumbList` با دو سطح: خانه ← همین صفحه
|
||||||
|
|
||||||
|
**نحوه تست:** خروجی هر صفحه را در
|
||||||
|
[validator.schema.org](https://validator.schema.org) بگذار، یا حداقل با
|
||||||
|
`python3 -c "import json,sys; json.load(sys.stdin)"` روی محتوای هر بلاک
|
||||||
|
`application/ld+json` صحت JSON را بسنج — Twig با نقلقول فارسی راحت JSON را خراب
|
||||||
|
میکند.
|
||||||
|
|
||||||
|
### ۴. کنترلر لندینگ
|
||||||
|
|
||||||
|
`src/Shared/Controller/LandingController.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class LandingController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly LandingRegistry $registry,
|
||||||
|
private readonly AltchaService $altcha,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/{slug}', name: 'landing_show', methods: ['GET'], priority: -10)]
|
||||||
|
public function show(string $slug): Response
|
||||||
|
{
|
||||||
|
$page = $this->registry->find($slug);
|
||||||
|
if ($page === null) {
|
||||||
|
throw $this->createNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('public/landing.html.twig', [
|
||||||
|
'page' => $page,
|
||||||
|
'altcha_enabled' => $this->altcha->enabled(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**چرا `priority: -10`:** روت `/{slug}` هر مسیر تکبخشی را میگیرد. بدون اولویت
|
||||||
|
منفی، ممکن است جلوی `/admin`، `/robots.txt` یا مسیرهای دیگر را بگیرد. با اولویت
|
||||||
|
منفی، آخرین گزینه است.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec php bin/console debug:router | grep landing
|
||||||
|
for s in نرم-افزار-مدیریت-کلینیک-زیبایی crm-کلینیک; do
|
||||||
|
curl -sk -o /dev/null -w "%{http_code} $s\n" "https://clinic-pro.ddev.site/$s"
|
||||||
|
done
|
||||||
|
curl -sk -o /dev/null -w "%{http_code} اسلاگ-ناموجود\n" https://clinic-pro.ddev.site/اسلاگ-ناموجود # باید 404
|
||||||
|
curl -sk -o /dev/null -w "%{http_code} /admin\n" https://clinic-pro.ddev.site/admin # باید 200
|
||||||
|
curl -sk -o /dev/null -w "%{http_code} /robots.txt\n" https://clinic-pro.ddev.site/robots.txt # باید 200
|
||||||
|
```
|
||||||
|
|
||||||
|
سه خط آخر مهمتریناند: اگر روت لندینگ حریص باشد، پنل ادمین را میخورد.
|
||||||
|
|
||||||
|
### ۵. sitemap و لینک داخلی
|
||||||
|
|
||||||
|
`SeoController::sitemap()` را طوری تغییر بده که `LandingRegistry` را تزریق بگیرد و
|
||||||
|
هر هفت لندینگ را با `priority: 0.8` اضافه کند. اسلاگ فارسی باید در XML
|
||||||
|
**percent-encode** شود، وگرنه XML نامعتبر است:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$loc = $base . '/' . rawurlencode($page->slug);
|
||||||
|
```
|
||||||
|
|
||||||
|
در فوتر (`_footer.html.twig`) یک ستون «راهکارها» با لینک به هر هفت صفحه اضافه کن.
|
||||||
|
بدون این، لندینگها فقط از sitemap دیده میشوند و از داخل سایت لینک نمیگیرند.
|
||||||
|
|
||||||
|
**نحوه تست:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk https://clinic-pro.ddev.site/sitemap.xml | python3 -c "
|
||||||
|
import sys, xml.etree.ElementTree as ET
|
||||||
|
root = ET.fromstring(sys.stdin.read())
|
||||||
|
ns = {'s': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
|
||||||
|
locs = [u.find('s:loc', ns).text for u in root.findall('s:url', ns)]
|
||||||
|
print(len(locs), 'url'); [print(' ', l) for l in locs]
|
||||||
|
assert len(locs) == 8, 'باید هشت آدرس باشد'
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۶. تست خودکار محتوای صفحات
|
||||||
|
|
||||||
|
`tests/Shared/LandingPageTest.php` با `ApiTestCase` یا `WebTestCase`:
|
||||||
|
|
||||||
|
- هر هفت اسلاگ → ۲۰۰
|
||||||
|
- `title` و `h1` و `canonical` هر صفحه یکتا و متعلق به خودش است
|
||||||
|
- اسلاگ ناموجود → ۴۰۴
|
||||||
|
- `/` هنوز ۲۰۰ میدهد و `h1` قدیمیاش را دارد
|
||||||
|
- `sitemap.xml` هشت `<loc>` دارد
|
||||||
|
|
||||||
|
**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/LandingPageTest.php`
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **متن هر صفحه باید واقعاً متفاوت باشد.** هفت صفحه با یک متن و فقط عوضشدن کلمهٔ
|
||||||
|
«زیبایی/دندانپزشکی/فیزیوتراپی» از نگاه گوگل محتوای تکراری است و هر هفتتا را
|
||||||
|
پایین میکشد. برای هر صنف، دردِ خودش را بنویس: کلینیک زیبایی → دورههای چندجلسهای
|
||||||
|
و مصرف کالا؛ دندانپزشکی → پروندهٔ دندان و بیمهٔ تکمیلی؛ فیزیوتراپی → جلسات
|
||||||
|
درمانی و نوبت تکراری؛ درمانگاه → چند پزشک و چند بخش و منابع مشترک؛ مطب → سادگی و
|
||||||
|
یکنفره بودن؛ CRM → پیگیری بیمار و یادآوری و بازگشت مراجع.
|
||||||
|
- **صفحهٔ «رایگان» باید صادق باشد.** پلن `free` واقعاً در محصول هست
|
||||||
|
(`SubscriptionPlan` با نام `free`)، پس ادعای رایگان درست است — ولی در همان صفحه
|
||||||
|
صریح بنویس چه چیزی در پلن رایگان هست و چه چیزی نیست. وعدهٔ نادرست، هم نرخ تبدیل
|
||||||
|
را خراب میکند هم اعتماد را.
|
||||||
|
- **تم را کپی نکن، مشترک کن.** اگر بعد از این کار، تغییر رنگ دکمه در صفحهٔ اصلی
|
||||||
|
بهطور خودکار در هر هفت لندینگ دیده نشد، یعنی وظیفهٔ ۲ و ۳ درست انجام نشدهاند.
|
||||||
|
- **الگو: Registry + Value Object.** دلیل انتخاب: هفت نمونهٔ همشکل که فقط دادهشان
|
||||||
|
فرق دارد. این «abstraction برای آینده» نیست؛ همین حالا هفت مصرفکننده دارد.
|
||||||
|
- روت `/{slug}` با اسلاگ فارسی کار میکند ولی در لاگ و ابزارها percent-encoded دیده
|
||||||
|
میشود. این عادی است و نباید «درست» شود.
|
||||||
|
- این تغییر backend عمومی است و API ندارد، پس `docs/api/` دست نمیخورد. در عوض یک
|
||||||
|
یادداشت کوتاه در `README.MD` یا `docs/` بنویس که لندینگها کجا تعریف میشوند —
|
||||||
|
وگرنه نفر بعدی دنبال فایل Twig هر صفحه میگردد.
|
||||||
|
- `nobat724_front` از این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیکپرو
|
||||||
|
هستند.
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* guide-shots.mjs — تصویربردار راهنمای کاربری.
|
||||||
|
*
|
||||||
|
* برای هر نقش لاگین میکند، فهرست صفحاتش را میپیماید و از هرکدام یک PNG میگیرد.
|
||||||
|
* خروجی: <out>/<role>/<NN-slug>.png بههمراه shots.json که ترتیب و عنوانها را
|
||||||
|
* برای متن راهنما نگه میدارد.
|
||||||
|
*
|
||||||
|
* node guide-shots.mjs --role clinic-owner --out /tmp/guide
|
||||||
|
* node guide-shots.mjs --all --out /tmp/guide
|
||||||
|
*
|
||||||
|
* وابستگی ندارد: Node 22 با WebSocket سراسری مستقیم با CDP حرف میزند.
|
||||||
|
*/
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
|
||||||
|
const CHROME = process.env.CHROME_BIN
|
||||||
|
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
const PASSWORD = process.env.GUIDE_PASSWORD ?? 'QaTest@1234';
|
||||||
|
const PORT = Number(process.env.CDP_PORT ?? 9500);
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پنج شخصیتِ راهنما. هر صفحه یک گام از داستانِ همان نقش است، پس ترتیب مهم است و
|
||||||
|
* الفبایی نیست: کاربر تازهوارد از داشبورد شروع میکند، نه از تنظیمات.
|
||||||
|
*/
|
||||||
|
const ROLES = {
|
||||||
|
'independent-doctor': {
|
||||||
|
title: 'پزشک مستقل',
|
||||||
|
mobile: '0912000101',
|
||||||
|
pages: [
|
||||||
|
['dashboard', 'داشبورد'],
|
||||||
|
['appointments', 'نوبتهای امروز'],
|
||||||
|
['appointments/new', 'ثبت نوبت جدید'],
|
||||||
|
['patients', 'پروندهها'],
|
||||||
|
['my-patients', 'بیماران من'],
|
||||||
|
['clinic-services', 'سرویسها'],
|
||||||
|
['service-categories', 'دستهبندی سرویسها'],
|
||||||
|
['insurance-pricing', 'بیمه و تعرفه'],
|
||||||
|
['my-payments', 'پرداختها'],
|
||||||
|
['claims', 'مطالبات بیمه'],
|
||||||
|
['treatment-cases', 'دورههای درمان'],
|
||||||
|
['resources', 'منابع'],
|
||||||
|
['inventory', 'انبارداری'],
|
||||||
|
['my-secretaries', 'منشیها'],
|
||||||
|
['staff', 'پرسنل'],
|
||||||
|
['settings-menu', 'تنظیمات'],
|
||||||
|
['appointment-settings', 'تنظیمات نوبتدهی'],
|
||||||
|
['holidays', 'تعطیلات'],
|
||||||
|
['record-number-settings', 'شمارهٔ پرونده'],
|
||||||
|
['tags-settings', 'برچسبها'],
|
||||||
|
['sms-wallet', 'کیف پول پیامک'],
|
||||||
|
['subscription', 'اشتراک'],
|
||||||
|
['discounts', 'تخفیفها'],
|
||||||
|
['profile', 'پروفایل پزشک'],
|
||||||
|
['account-settings', 'حساب کاربری'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'clinic-owner-doctor': {
|
||||||
|
title: 'پزشکی که کلینیک دارد',
|
||||||
|
mobile: '0912000201',
|
||||||
|
// این حساب هم مطب شخصی دارد هم کلینیک، پس اپ اول «انتخاب محیط» میپرسد.
|
||||||
|
context: 'کلینیک',
|
||||||
|
pages: [
|
||||||
|
['dashboard', 'داشبورد'],
|
||||||
|
['appointments', 'نوبتهای کلینیک'],
|
||||||
|
['my-clinic', 'کلینیک من'],
|
||||||
|
['settings/clinic-doctors', 'پزشکان کلینیک'],
|
||||||
|
['settings/appointment-settings', 'نوبتدهی کلینیک'],
|
||||||
|
['settings/practice-domain', 'حوزهٔ فعالیت'],
|
||||||
|
['clinic-services', 'سرویسهای کلینیک'],
|
||||||
|
['resources', 'منابع'],
|
||||||
|
['resources/types', 'نوع منابع'],
|
||||||
|
['resources/pools', 'استخر منابع'],
|
||||||
|
['resources/skills', 'مهارتها'],
|
||||||
|
['staff', 'پرسنل'],
|
||||||
|
['my-secretaries', 'منشیها'],
|
||||||
|
['patients', 'پروندهها'],
|
||||||
|
['treatment-cases', 'دورههای درمان'],
|
||||||
|
['my-payments', 'پرداختها'],
|
||||||
|
['my-financial', 'مدیریت پرداخت'],
|
||||||
|
['claims', 'مطالبات بیمه'],
|
||||||
|
['inventory', 'انبارداری'],
|
||||||
|
['settings-menu', 'تنظیمات'],
|
||||||
|
['subscription', 'اشتراک'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'clinic-manager': {
|
||||||
|
title: 'مدیر کلینیک',
|
||||||
|
mobile: '0912000301',
|
||||||
|
pages: [
|
||||||
|
['dashboard', 'داشبورد'],
|
||||||
|
['appointments', 'نوبتهای درمانگاه'],
|
||||||
|
['my-clinic', 'کلینیک من'],
|
||||||
|
['settings/clinic-doctors', 'پزشکان'],
|
||||||
|
['settings/appointment-settings', 'نوبتدهی'],
|
||||||
|
['clinic-services', 'سرویسها'],
|
||||||
|
['service-categories', 'دستهبندیها'],
|
||||||
|
['resources', 'منابع'],
|
||||||
|
['staff', 'پرسنل'],
|
||||||
|
['my-secretaries', 'منشیها'],
|
||||||
|
['patients', 'پروندهها'],
|
||||||
|
['my-payments', 'پرداختها'],
|
||||||
|
['claims', 'مطالبات بیمه'],
|
||||||
|
['insurance-pricing', 'بیمه و تعرفه'],
|
||||||
|
['inventory', 'انبارداری'],
|
||||||
|
['treatment-cases', 'دورههای درمان'],
|
||||||
|
['settings-menu', 'تنظیمات'],
|
||||||
|
['sms-wallet', 'کیف پول پیامک'],
|
||||||
|
['subscription', 'اشتراک'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
secretary: {
|
||||||
|
title: 'منشی',
|
||||||
|
mobile: '0912000209',
|
||||||
|
pages: [
|
||||||
|
['appointments', 'نوبتها'],
|
||||||
|
['appointments/new', 'ثبت نوبت'],
|
||||||
|
['appointments/reserve', 'نوبت رزرو'],
|
||||||
|
['patients', 'پروندهها'],
|
||||||
|
['patients/new', 'تشکیل پرونده'],
|
||||||
|
['my-payments', 'پرداختها'],
|
||||||
|
['claims', 'مطالبات بیمه'],
|
||||||
|
['treatment-cases', 'دورههای درمان'],
|
||||||
|
['secretary-earnings', 'درآمد من'],
|
||||||
|
['secretary-settlement', 'تسویه حساب'],
|
||||||
|
['account-settings', 'حساب کاربری'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
staff: {
|
||||||
|
title: 'پرسنل',
|
||||||
|
mobile: '09120002105',
|
||||||
|
pages: [
|
||||||
|
['my-sessions', 'جلسات امروز من'],
|
||||||
|
['account-settings', 'حساب کاربری'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const argOf = (name, fallback = null) => {
|
||||||
|
const i = args.indexOf(`--${name}`);
|
||||||
|
return i === -1 ? fallback : args[i + 1];
|
||||||
|
};
|
||||||
|
const OUT = argOf('out', '/tmp/guide');
|
||||||
|
const WIDTH = Number(argOf('w', 1440));
|
||||||
|
const HEIGHT = Number(argOf('h', 900));
|
||||||
|
const WAIT = Number(argOf('wait', 3500));
|
||||||
|
const wanted = args.includes('--all') ? Object.keys(ROLES) : [argOf('role')].filter(Boolean);
|
||||||
|
|
||||||
|
if (wanted.length === 0) {
|
||||||
|
console.error('استفاده: --role <name> یا --all');
|
||||||
|
console.error('نقشها: ' + Object.keys(ROLES).join(', '));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(mobile) {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/user/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mobile_number: mobile, password: PASSWORD }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body.access_token) {
|
||||||
|
throw new Error(`ورود ${mobile} ناموفق: ${JSON.stringify(body).slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cdpUrl() {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||||||
|
return (await r.json()).webSocketDebuggerUrl;
|
||||||
|
} catch { await new Promise((r) => setTimeout(r, 300)); }
|
||||||
|
}
|
||||||
|
throw new Error('Chrome بالا نیامد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chrome = spawn(CHROME, [
|
||||||
|
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
||||||
|
'--ignore-certificate-errors', '--force-device-scale-factor=2',
|
||||||
|
`--remote-debugging-port=${PORT}`, `--user-data-dir=/tmp/guide-shots-${process.pid}`,
|
||||||
|
`--window-size=${WIDTH},${HEIGHT}`, 'about:blank',
|
||||||
|
], { stdio: 'ignore' });
|
||||||
|
|
||||||
|
const ws = new WebSocket(await cdpUrl());
|
||||||
|
await new Promise((r) => ws.addEventListener('open', r, { once: true }));
|
||||||
|
|
||||||
|
let msgId = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
ws.addEventListener('message', (e) => {
|
||||||
|
const m = JSON.parse(e.data);
|
||||||
|
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
|
||||||
|
});
|
||||||
|
const rpc = (method, params, sessionId) => new Promise((resolve) => {
|
||||||
|
const id = ++msgId;
|
||||||
|
pending.set(id, resolve);
|
||||||
|
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { targetId } = await rpc('Target.createTarget', { url: 'about:blank' });
|
||||||
|
const { sessionId } = await rpc('Target.attachToTarget', { targetId, flatten: true });
|
||||||
|
const S = (m, p) => rpc(m, p, sessionId);
|
||||||
|
await S('Page.enable');
|
||||||
|
await S('Runtime.enable');
|
||||||
|
const evalJs = async (expression) => {
|
||||||
|
const { result } = await S('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
||||||
|
return result?.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// اجرای تکنقشی نباید فهرست نقشهای قبلی را پاک کند.
|
||||||
|
const manifestPath = join(OUT, 'shots.json');
|
||||||
|
const manifest = existsSync(manifestPath)
|
||||||
|
? JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
for (const roleKey of wanted) {
|
||||||
|
const role = ROLES[roleKey];
|
||||||
|
const dir = join(OUT, roleKey);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const { access_token, refresh_token } = await login(role.mobile);
|
||||||
|
|
||||||
|
// localStorage به origin وابسته است؛ اول باید روی همان دامنه باشیم.
|
||||||
|
await S('Page.navigate', { url: `${BASE}/admin/login` });
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
await evalJs(`localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify({
|
||||||
|
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true }, version: 0,
|
||||||
|
}))});
|
||||||
|
localStorage.setItem('pwa-dismissed','1');
|
||||||
|
// تور راهنما روی تصویرها میافتد و متنِ خودِ راهنما را میپوشاند.
|
||||||
|
localStorage.setItem('clinicpro-tours-suppress','1');`);
|
||||||
|
|
||||||
|
// حسابِ چندمحیطی تا محیطش را انتخاب نکند، هر مسیری به «انتخاب محیط» میرود.
|
||||||
|
if (role.context) {
|
||||||
|
await S('Page.navigate', { url: `${BASE}/admin/select-context` });
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
const picked = await evalJs(`(() => {
|
||||||
|
const btn = [...document.querySelectorAll('button')]
|
||||||
|
.find(b => b.textContent.includes(${JSON.stringify(role.context)}));
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.click();
|
||||||
|
return btn.textContent.trim().slice(0, 60);
|
||||||
|
})()`);
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
console.log(` محیط انتخاب شد: ${picked || 'پیدا نشد ⚠'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest[roleKey] = { title: role.title, mobile: role.mobile, shots: [] };
|
||||||
|
console.log(`\n### ${role.title} (${role.mobile})`);
|
||||||
|
|
||||||
|
for (const [index, [path, label]] of role.pages.entries()) {
|
||||||
|
const file = `${String(index + 1).padStart(2, '0')}-${path.replace(/\//g, '-')}.png`;
|
||||||
|
await S('Page.navigate', { url: `${BASE}/admin/${path}` });
|
||||||
|
await new Promise((r) => setTimeout(r, WAIT));
|
||||||
|
|
||||||
|
// تور خودکار روی صفحه مینشیند؛ بسته میشود تا تصویر خودِ صفحه را نشان دهد.
|
||||||
|
await evalJs(`(() => {
|
||||||
|
const close = document.querySelector('.driver-popover-close-btn');
|
||||||
|
if (close) close.click();
|
||||||
|
})()`);
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
|
||||||
|
const landed = await evalJs('location.pathname');
|
||||||
|
const empty = await evalJs('(document.body.innerText || "").trim().length < 40');
|
||||||
|
const shot = await S('Page.captureScreenshot', { format: 'png' });
|
||||||
|
writeFileSync(join(dir, file), Buffer.from(shot.data, 'base64'));
|
||||||
|
|
||||||
|
const note = landed.endsWith(path) ? '' : ` (منتقل شد به ${landed})`;
|
||||||
|
console.log(` ${empty ? '⚠' : '✓'} ${label} → ${file}${note}`);
|
||||||
|
manifest[roleKey].shots.push({ file, path, label, landed, empty });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||||
|
console.log(`\nفهرست: ${manifestPath}`);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
chrome.kill();
|
||||||
@@ -59,3 +59,6 @@ clinicpro/*
|
|||||||
# Database backups (never commit dumps to the repo)
|
# Database backups (never commit dumps to the repo)
|
||||||
*.sql
|
*.sql
|
||||||
*.sql.gz
|
*.sql.gz
|
||||||
|
|
||||||
|
# اسکرینشاتهای راهنمای کاربری — با guide-shots.mjs بازتولید میشوند و ۱۷ مگابایتاند
|
||||||
|
docs/guide/images/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline';
|
|||||||
import SearchableSelect from './ui/SearchableSelect';
|
import SearchableSelect from './ui/SearchableSelect';
|
||||||
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
|
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
|
||||||
import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils';
|
import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
export type WalletMode = 'charge' | 'withdraw';
|
export type WalletMode = 'charge' | 'withdraw';
|
||||||
|
|
||||||
@@ -59,12 +60,7 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
|
|||||||
if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); }
|
if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); }
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
const dismiss = useOverlayDismiss(onClose, open);
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -103,8 +99,8 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 560 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
|
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
|
||||||
<button type="button" className="mini-btn" onClick={onClose}>
|
<button type="button" className="mini-btn" onClick={onClose}>
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ function render() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// payment-methods (pos / bank-accounts) و بقیهٔ GETها
|
// مودال همیشه جزئیات نوبت را از سرور میگیرد — ردیفِ فهرست مبلغ ویزیت ندارد.
|
||||||
get.mockResolvedValue({ success: true, data: [] });
|
get.mockImplementation((url: string) =>
|
||||||
|
url.startsWith('/api/v1/appointment/')
|
||||||
|
? Promise.resolve({ success: true, data: appointment })
|
||||||
|
// payment-methods (pos / bank-accounts) و بقیهٔ GETها
|
||||||
|
: Promise.resolve({ success: true, data: [] }));
|
||||||
post.mockResolvedValue({ success: true, data: {} });
|
post.mockResolvedValue({ success: true, data: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +49,14 @@ function amountInputs() {
|
|||||||
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
|
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مودال تا رسیدنِ جزئیات نوبت از سرور، دکمههایش قفل است — مبلغ نباید از ردیفِ
|
||||||
|
* ناقصِ فهرست خوانده شود. هر تست اول منتظر همین میماند.
|
||||||
|
*/
|
||||||
|
async function settle() {
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).not.toBeDisabled());
|
||||||
|
}
|
||||||
|
|
||||||
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» میرود و ردیفهای مبلغ را برمیگرداند. */
|
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» میرود و ردیفهای مبلغ را برمیگرداند. */
|
||||||
function goToPayment() {
|
function goToPayment() {
|
||||||
nextStep();
|
nextStep();
|
||||||
@@ -57,31 +69,35 @@ function goToReview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ConfirmAppointmentModal', () => {
|
describe('ConfirmAppointmentModal', () => {
|
||||||
it('بیمار، اقلام هزینه و جمع کل را نشان میدهد', () => {
|
it('بیمار، اقلام هزینه و جمع کل را نشان میدهد', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
|
expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
|
||||||
expect(screen.getByText('ویزیت')).toBeInTheDocument();
|
expect(screen.getByText('ویزیت')).toBeInTheDocument();
|
||||||
expect(screen.getByText('لیزر')).toBeInTheDocument();
|
expect(screen.getByText('لیزر')).toBeInTheDocument();
|
||||||
expect(screen.getByText('جمع کل')).toBeInTheDocument();
|
expect(screen.getByText('جمع کل')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ردیفِ اول پیشفرض برابر کل هزینه است و وضعیت «تسویه کامل» میشود', () => {
|
it('ردیفِ اول پیشفرض برابر کل هزینه است و وضعیت «تسویه کامل» میشود', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
|
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
|
||||||
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
|
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
|
||||||
goToReview();
|
goToReview();
|
||||||
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» میشود', () => {
|
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» میشود', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||||
goToReview();
|
goToReview();
|
||||||
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
|
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال میشود', () => {
|
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال میشود', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
const rows = goToPayment();
|
const rows = goToPayment();
|
||||||
|
|
||||||
fireEvent.change(rows[0], { target: { value: '9000000' } });
|
fireEvent.change(rows[0], { target: { value: '9000000' } });
|
||||||
@@ -96,6 +112,7 @@ describe('ConfirmAppointmentModal', () => {
|
|||||||
|
|
||||||
it('پرداخت جزئی مجاز است و همان یک روش را ثبت میکند', async () => {
|
it('پرداخت جزئی مجاز است و همان یک روش را ثبت میکند', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||||
goToReview();
|
goToReview();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||||
@@ -108,6 +125,7 @@ describe('ConfirmAppointmentModal', () => {
|
|||||||
|
|
||||||
it('تقسیم پرداخت بین دو روش: مجموع ردیفها بهصورت آرایه ثبت میشود', async () => {
|
it('تقسیم پرداخت بین دو روش: مجموع ردیفها بهصورت آرایه ثبت میشود', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم میکنیم
|
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم میکنیم
|
||||||
fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
|
fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
|
||||||
// افزودن روش دوم — پیشفرض با باقیماندهٔ ۱۰۰٬۰۰۰ تومان پر میشود
|
// افزودن روش دوم — پیشفرض با باقیماندهٔ ۱۰۰٬۰۰۰ تومان پر میشود
|
||||||
@@ -131,8 +149,9 @@ describe('ConfirmAppointmentModal', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('حذف ردیف اضافهشده مجموع را دوباره محاسبه میکند', () => {
|
it('حذف ردیف اضافهشده مجموع را دوباره محاسبه میکند', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
goToPayment();
|
goToPayment();
|
||||||
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
||||||
expect(amountInputs()).toHaveLength(2);
|
expect(amountInputs()).toHaveLength(2);
|
||||||
@@ -162,12 +181,20 @@ const SUPP_CONTRACT = {
|
|||||||
category_coverages: { outpatient: 90, inpatient: 60 },
|
category_coverages: { outpatient: 90, inpatient: 60 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param detail نوبتی که اندپوینت جزئیات برمیگرداند — مودال همیشه آن را میخواند،
|
||||||
|
* چون ردیفِ فهرست مبلغ ویزیت و بیمه ندارد.
|
||||||
|
*/
|
||||||
function mockInsurance(
|
function mockInsurance(
|
||||||
categories: { key: string; label: string; enabled: boolean }[],
|
categories: { key: string; label: string; enabled: boolean }[],
|
||||||
freeVisitPriceRials = 0,
|
freeVisitPriceRials = 0,
|
||||||
contracts: unknown[] = [CONTRACT],
|
contracts: unknown[] = [CONTRACT],
|
||||||
|
detail: unknown = referenceAppointment,
|
||||||
) {
|
) {
|
||||||
get.mockImplementation((url: string) => {
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.startsWith('/api/v1/appointment/')) {
|
||||||
|
return Promise.resolve({ success: true, data: detail });
|
||||||
|
}
|
||||||
if (url === '/api/v1/insurance-pricing') {
|
if (url === '/api/v1/insurance-pricing') {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -211,6 +238,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده میشود', async () => {
|
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده میشود', async () => {
|
||||||
mockInsurance(BOTH);
|
mockInsurance(BOTH);
|
||||||
renderReference();
|
renderReference();
|
||||||
|
await settle();
|
||||||
|
|
||||||
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
|
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
|
||||||
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
|
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
|
||||||
@@ -222,6 +250,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
{ key: 'inpatient', label: 'خدمات بستری', enabled: false },
|
{ key: 'inpatient', label: 'خدمات بستری', enabled: false },
|
||||||
]);
|
]);
|
||||||
renderReference();
|
renderReference();
|
||||||
|
await settle();
|
||||||
|
|
||||||
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
|
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
|
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
|
||||||
@@ -230,6 +259,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
|
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
|
||||||
mockInsurance(BOTH);
|
mockInsurance(BOTH);
|
||||||
renderReference();
|
renderReference();
|
||||||
|
await settle();
|
||||||
|
|
||||||
await screen.findByText('نوع خدمت');
|
await screen.findByText('نوع خدمت');
|
||||||
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
||||||
@@ -239,6 +269,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال میشوند (سرپایی ۷۰٪)', async () => {
|
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال میشوند (سرپایی ۷۰٪)', async () => {
|
||||||
mockInsurance(BOTH);
|
mockInsurance(BOTH);
|
||||||
renderReference();
|
renderReference();
|
||||||
|
await settle();
|
||||||
|
|
||||||
await screen.findByText('نوع خدمت');
|
await screen.findByText('نوع خدمت');
|
||||||
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
||||||
@@ -261,6 +292,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمیشود', async () => {
|
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمیشود', async () => {
|
||||||
mockInsurance(BOTH);
|
mockInsurance(BOTH);
|
||||||
renderReference();
|
renderReference();
|
||||||
|
await settle();
|
||||||
|
|
||||||
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
|
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
|
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
|
||||||
@@ -269,6 +301,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
it('سهم پایه و تکمیلی جدا و زنجیرهای محاسبه میشوند و هر دو ارسال میگردند', async () => {
|
it('سهم پایه و تکمیلی جدا و زنجیرهای محاسبه میشوند و هر دو ارسال میگردند', async () => {
|
||||||
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
|
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
|
||||||
renderReference();
|
renderReference();
|
||||||
|
await settle();
|
||||||
|
|
||||||
await screen.findByText('نوع خدمت');
|
await screen.findByText('نوع خدمت');
|
||||||
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
||||||
@@ -296,6 +329,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال میشود', async () => {
|
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال میشود', async () => {
|
||||||
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
|
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
|
||||||
renderReference();
|
renderReference();
|
||||||
|
await settle();
|
||||||
|
|
||||||
await screen.findByText('نوع خدمت');
|
await screen.findByText('نوع خدمت');
|
||||||
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
||||||
@@ -308,15 +342,17 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان میدهد (نه صفر)', async () => {
|
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان میدهد (نه صفر)', async () => {
|
||||||
mockInsurance(BOTH, 5_952_000);
|
const priceless = { uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] };
|
||||||
|
mockInsurance(BOTH, 5_952_000, [CONTRACT], priceless);
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<ConfirmAppointmentModal
|
<ConfirmAppointmentModal
|
||||||
open
|
open
|
||||||
appointmentUuid="a1"
|
appointmentUuid="a1"
|
||||||
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }}
|
appointment={priceless}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
await settle();
|
||||||
|
|
||||||
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
||||||
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه میگذارد.
|
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه میگذارد.
|
||||||
@@ -336,6 +372,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
it('نوع بستری درصد خودش را میگیرد (۳۰٪)', async () => {
|
it('نوع بستری درصد خودش را میگیرد (۳۰٪)', async () => {
|
||||||
mockInsurance(BOTH);
|
mockInsurance(BOTH);
|
||||||
renderReference();
|
renderReference();
|
||||||
|
await settle();
|
||||||
|
|
||||||
await screen.findByText('نوع خدمت');
|
await screen.findByText('نوع خدمت');
|
||||||
await pick('انتخاب نوع خدمت', 'خدمات بستری');
|
await pick('انتخاب نوع خدمت', 'خدمات بستری');
|
||||||
@@ -353,8 +390,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
|
|||||||
Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li'))
|
Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li'))
|
||||||
.map(li => li.textContent?.replace(/^\d+/, '').trim());
|
.map(li => li.textContent?.replace(/^\d+/, '').trim());
|
||||||
|
|
||||||
it('سه مرحله دارد و از «بیمه و هزینه» شروع میشود', () => {
|
it('سه مرحله دارد و از «بیمه و هزینه» شروع میشود', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
|
|
||||||
expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']);
|
expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']);
|
||||||
// جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه.
|
// جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه.
|
||||||
@@ -362,16 +400,18 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
|
|||||||
expect(screen.queryByPlaceholderText('0')).toBeNull();
|
expect(screen.queryByPlaceholderText('0')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', () => {
|
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('مرحلهٔ قبل مقادیر واردشده را نگه میدارد', () => {
|
it('مرحلهٔ قبل مقادیر واردشده را نگه میدارد', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
|
|
||||||
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' }));
|
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' }));
|
||||||
@@ -382,8 +422,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
|
|||||||
expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰');
|
expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('مرحلهٔ آخر روشهای پرداخت را قبل از ثبت خلاصه میکند', () => {
|
it('مرحلهٔ آخر روشهای پرداخت را قبل از ثبت خلاصه میکند', async () => {
|
||||||
render();
|
render();
|
||||||
|
await settle();
|
||||||
|
|
||||||
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||||
goToReview();
|
goToReview();
|
||||||
@@ -392,3 +433,119 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** جمع نوبتِ تست: ۲۱۲۰۰۰۰ + ۸۸۰۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان. */
|
||||||
|
describe('ConfirmAppointmentModal — میانبُرهای درصدی', () => {
|
||||||
|
const percentButton = (percent: string) =>
|
||||||
|
screen.getAllByRole('button', { name: `${percent}٪` })[0];
|
||||||
|
|
||||||
|
it('کلیک روی هر درصد، همان کسر از سهم بیمار را در مبلغ میگذارد', async () => {
|
||||||
|
render();
|
||||||
|
await settle();
|
||||||
|
const [amount] = goToPayment();
|
||||||
|
|
||||||
|
fireEvent.click(percentButton('۲۰'));
|
||||||
|
expect(amount).toHaveValue('۶۰٬۰۰۰');
|
||||||
|
|
||||||
|
fireEvent.click(percentButton('۵۰'));
|
||||||
|
expect(amount).toHaveValue('۱۵۰٬۰۰۰');
|
||||||
|
|
||||||
|
fireEvent.click(percentButton('۷۰'));
|
||||||
|
expect(amount).toHaveValue('۲۱۰٬۰۰۰');
|
||||||
|
|
||||||
|
fireEvent.click(percentButton('۱۰۰'));
|
||||||
|
expect(amount).toHaveValue('۳۰۰٬۰۰۰');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('درصدِ اعمالشده تا مرحلهٔ ثبت میماند و به سرور میرسد', async () => {
|
||||||
|
render();
|
||||||
|
await settle();
|
||||||
|
goToPayment();
|
||||||
|
|
||||||
|
fireEvent.click(percentButton('۵۰'));
|
||||||
|
goToReview();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||||
|
expect(post.mock.calls[0][1].payments).toEqual([
|
||||||
|
{ method: 'cash', amount_rials: 1_500_000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('هر ردیف درصد خودش را دارد؛ ردیف دیگر دست نمیخورد', async () => {
|
||||||
|
render();
|
||||||
|
await settle();
|
||||||
|
goToPayment();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
||||||
|
const rows = amountInputs();
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
|
||||||
|
// ۲۰٪ روی ردیف دوم، بدون اثر روی ردیف اول که پیشفرضِ کامل دارد.
|
||||||
|
const secondRowPercent = screen.getAllByRole('button', { name: '۲۰٪' })[1];
|
||||||
|
fireEvent.click(secondRowPercent);
|
||||||
|
|
||||||
|
expect(rows[0]).toHaveValue('۳۰۰٬۰۰۰');
|
||||||
|
expect(rows[1]).toHaveValue('۶۰٬۰۰۰');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('نوبتِ بدون هزینه اصلاً دکمهٔ درصدی ندارد', async () => {
|
||||||
|
const free = { uuid: 'a2', version: 1, visit_price_rials: 0, service_items: [] };
|
||||||
|
// جزئیات هم باید همان نوبتِ بیهزینه باشد؛ مودال مبلغ را از سرور میخواند.
|
||||||
|
get.mockImplementation((url: string) =>
|
||||||
|
Promise.resolve({ success: true, data: url.startsWith('/api/v1/appointment/') ? free : [] }));
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ConfirmAppointmentModal
|
||||||
|
open
|
||||||
|
appointmentUuid="a2"
|
||||||
|
appointment={free}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
nextStep();
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: '۵۰٪' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* باگ واقعی: تایملاین ردیفِ فهرست نوبتها را پاس میداد و آن ردیف اصلاً
|
||||||
|
* `visit_price_rials` ندارد. مودال به همان اعتماد میکرد، «۰ تومان» نشان میداد و
|
||||||
|
* همان صفر را ثبت میکرد — هزینهٔ ویزیتِ ثبتشده در نوبت نادیده میماند.
|
||||||
|
*/
|
||||||
|
describe('ConfirmAppointmentModal — ردیفِ ناقصِ فهرست', () => {
|
||||||
|
/** دقیقاً شکل ردیفِ `/api/v1/my/appointments`: بدون مبلغ و بدون بیمه. */
|
||||||
|
const listRow = {
|
||||||
|
uuid: 'a1',
|
||||||
|
version: 1,
|
||||||
|
patient_name: 'محمد رضایی',
|
||||||
|
service_items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('مبلغ را از جزئیات سرور میگیرد، نه از ردیفِ پاسدادهشده', async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={listRow} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// ۳٬۰۰۰٬۰۰۰ ریالِ جزئیات = ۳۰۰٬۰۰۰ تومان، نه صفرِ ردیفِ فهرست.
|
||||||
|
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تا نیامدنِ جزئیات، رفتن به مرحلهٔ پرداخت قفل است', () => {
|
||||||
|
let resolveDetail: (v: unknown) => void = () => {};
|
||||||
|
get.mockImplementation((url: string) =>
|
||||||
|
url.startsWith('/api/v1/appointment/')
|
||||||
|
? new Promise((resolve) => { resolveDetail = resolve; })
|
||||||
|
: Promise.resolve({ success: true, data: [] }));
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={listRow} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
|
||||||
|
resolveDetail({ success: true, data: appointment });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { UserCircleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline
|
|||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import type { ApiResponse } from '../../lib/api';
|
import type { ApiResponse } from '../../lib/api';
|
||||||
import type { BankAccount, Pos } from '../../hooks/usePaymentMethods';
|
import type { BankAccount, Pos } from '../../hooks/usePaymentMethods';
|
||||||
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
|
import { formatNumber, formatRial, rialToToman, tomanToRial } from '../../lib/utils';
|
||||||
import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares';
|
import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares';
|
||||||
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
@@ -13,6 +13,9 @@ import PriceInput from '../ui/PriceInput';
|
|||||||
import SearchableSelect from '../ui/SearchableSelect';
|
import SearchableSelect from '../ui/SearchableSelect';
|
||||||
import Stepper from '../ui/Stepper';
|
import Stepper from '../ui/Stepper';
|
||||||
|
|
||||||
|
/** میانبُرهای مبلغ روی هر ردیف پرداخت — درصدی از سهم بیمار. */
|
||||||
|
const PAYMENT_PERCENTS = [20, 50, 70, 100];
|
||||||
|
|
||||||
/** همان چهار روشِ SessionPayment::METHODS در بکاند. */
|
/** همان چهار روشِ SessionPayment::METHODS در بکاند. */
|
||||||
const METHOD_OPTIONS = [
|
const METHOD_OPTIONS = [
|
||||||
{ value: 'cash', label: 'پرداخت نقدی' },
|
{ value: 'cash', label: 'پرداخت نقدی' },
|
||||||
@@ -116,12 +119,14 @@ export default function ConfirmAppointmentModal({
|
|||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
const [stepIdx, setStepIdx] = useState(0);
|
const [stepIdx, setStepIdx] = useState(0);
|
||||||
|
|
||||||
// وقتی صفحهی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را میگیریم:
|
// جزئیات همیشه گرفته میشود، حتی وقتی صفحهٔ میزبان نوبتی پاس داده است: ردیفِ
|
||||||
// مبلغ ویزیت و قیمت سرویسها فقط در detail هستند.
|
// فهرست نوبتها `visit_price_rials` و بیمه را ندارد، و مودال با اعتماد به همان
|
||||||
|
// ردیف، هزینهٔ ویزیتِ ثبتشده را صفر نشان میداد و همان صفر را هم ثبت میکرد.
|
||||||
|
// مبلغ چیزی نیست که از یک payload ناقص حدس زده شود.
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: ['appointment', appointmentUuid],
|
queryKey: ['appointment', appointmentUuid],
|
||||||
queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`),
|
queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`),
|
||||||
enabled: open && !appointment,
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
// روشهای پرداختِ ثبتشده — فقط وقتی مودال باز است.
|
// روشهای پرداختِ ثبتشده — فقط وقتی مودال باز است.
|
||||||
@@ -148,8 +153,10 @@ export default function ConfirmAppointmentModal({
|
|||||||
[bankQuery.data],
|
[bankQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const appt: AppointmentLike | null = appointment
|
// پاسخ سرور مرجع است؛ نوبتِ پاسدادهشده فقط تا رسیدنِ آن، صفحه را خالی نگه نمیدارد.
|
||||||
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null);
|
const detail: AppointmentLike | null =
|
||||||
|
(detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null;
|
||||||
|
const appt: AppointmentLike | null = detail ?? appointment ?? null;
|
||||||
|
|
||||||
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
|
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
|
||||||
const insurance = useAppointmentInsurance(open);
|
const insurance = useAppointmentInsurance(open);
|
||||||
@@ -254,6 +261,11 @@ export default function ConfirmAppointmentModal({
|
|||||||
patchRow(id, { method, methodUuid: '', reference: '' });
|
patchRow(id, { method, methodUuid: '', reference: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** درصدِ سهم بیمار روی همان ردیف مینشیند؛ ردیفهای دیگر دستنخورده میمانند. */
|
||||||
|
function applyPercent(id: number, percent: number) {
|
||||||
|
patchRow(id, { amountToman: rialToToman(Math.round((payable * percent) / 100)) });
|
||||||
|
}
|
||||||
|
|
||||||
function addRow() {
|
function addRow() {
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
// ردیفِ جدید پیشفرض با باقیمانده پر میشود تا تسویه سریعتر باشد.
|
// ردیفِ جدید پیشفرض با باقیمانده پر میشود تا تسویه سریعتر باشد.
|
||||||
@@ -270,7 +282,9 @@ export default function ConfirmAppointmentModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = detailQuery.isLoading && !appointment;
|
// تا رسیدنِ جزئیات، دکمهها قفلاند: کاربر نباید روی مبلغی که هنوز از سرور نیامده
|
||||||
|
// «مرحلهٔ بعد» بزند و پرداختِ صفر ثبت کند.
|
||||||
|
const loading = detailQuery.isLoading;
|
||||||
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
|
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
|
||||||
const isLastStep = currentStep === 'review';
|
const isLastStep = currentStep === 'review';
|
||||||
|
|
||||||
@@ -492,6 +506,39 @@ export default function ConfirmAppointmentModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* میانبُرهای درصدی — پرداخت جزئی رایج است و تایپ دستیِ مبلغ خطا میآورد. */}
|
||||||
|
{payable > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{PAYMENT_PERCENTS.map((percent) => {
|
||||||
|
const active = tomanToRial(r.amountToman) === Math.round((payable * percent) / 100);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={percent}
|
||||||
|
type="button"
|
||||||
|
className="btn sm"
|
||||||
|
onClick={() => applyPercent(r.id, percent)}
|
||||||
|
title={formatRial(Math.round((payable * percent) / 100))}
|
||||||
|
style={{
|
||||||
|
height: 30,
|
||||||
|
padding: '0 12px',
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 'var(--r-pill)',
|
||||||
|
border: `1px solid ${active ? 'var(--primary)' : 'var(--border)'}`,
|
||||||
|
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
||||||
|
color: active ? 'var(--primary)' : 'var(--text-2)',
|
||||||
|
fontWeight: active ? 700 : 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatNumber(percent)}٪
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||||
|
از {formatRial(payable)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* جزئیاتِ کارتخوان: انتخاب دستگاهِ ثبتشده + شناسه تراکنش */}
|
{/* جزئیاتِ کارتخوان: انتخاب دستگاهِ ثبتشده + شناسه تراکنش */}
|
||||||
{r.method === 'pos' && (
|
{r.method === 'pos' && (
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||||
import Portal from './Portal';
|
import Portal from './Portal';
|
||||||
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -28,12 +29,14 @@ export default function ConfirmDialog({
|
|||||||
onCancel,
|
onCancel,
|
||||||
children,
|
children,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const dismiss = useOverlayDismiss(onCancel, open);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={onCancel}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 420 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|||||||
@@ -63,9 +63,11 @@ export default function DataTable<T extends object>({
|
|||||||
<div>
|
<div>
|
||||||
{/* Toolbar row */}
|
{/* Toolbar row */}
|
||||||
{(onSearchChange !== undefined || headerExtra) && (
|
{(onSearchChange !== undefined || headerExtra) && (
|
||||||
<div className="toolbar">
|
// لنگرهای data-tour اینجا تعریف میشوند تا هر صفحهای که DataTable دارد
|
||||||
|
// بدون تغییر کد خودش، در تور راهنما قابل اشاره باشد.
|
||||||
|
<div className="toolbar" data-tour="page-toolbar">
|
||||||
{onSearchChange !== undefined && (
|
{onSearchChange !== undefined && (
|
||||||
<div className="field" style={{ flex: '0 0 auto', minWidth: 240 }}>
|
<div className="field" data-tour="page-search" style={{ flex: '0 0 auto', minWidth: 240 }}>
|
||||||
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0 }} />
|
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -79,7 +81,7 @@ export default function DataTable<T extends object>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="table-wrap">
|
<div className="table-wrap" data-tour="page-table">
|
||||||
<table className="t">
|
<table className="t">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ApiResponse } from '../../lib/api';
|
|||||||
import MobileInput from './MobileInput';
|
import MobileInput from './MobileInput';
|
||||||
import Portal from './Portal';
|
import Portal from './Portal';
|
||||||
import { iranMobileSchema } from '../../lib/utils';
|
import { iranMobileSchema } from '../../lib/utils';
|
||||||
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
const inviteSchema = z.object({
|
const inviteSchema = z.object({
|
||||||
mobile: iranMobileSchema,
|
mobile: iranMobileSchema,
|
||||||
@@ -39,10 +40,12 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dismiss = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 420 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<b>دعوت پزشک به کلینیک</b>
|
<b>دعوت پزشک به کلینیک</b>
|
||||||
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
@@ -21,22 +22,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
|
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
|
||||||
useEffect(() => {
|
const dismiss = useOverlayDismiss(onClose, open);
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div
|
<div className="modal" style={{ maxWidth: sizeMap[size] }}>
|
||||||
className="modal"
|
|
||||||
style={{ maxWidth: sizeMap[size] }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<button type="button" className="mini-btn" onClick={onClose} aria-label="بستن">
|
<button type="button" className="mini-btn" onClick={onClose} aria-label="بستن">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { ChevronLeftIcon } from '@heroicons/react/24/outline';
|
import { ChevronLeftIcon } from '@heroicons/react/24/outline';
|
||||||
import BackButton from './BackButton';
|
import BackButton from './BackButton';
|
||||||
|
import TourButton from './TourButton';
|
||||||
|
|
||||||
interface Crumb {
|
interface Crumb {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -18,9 +19,16 @@ interface Props {
|
|||||||
* مقدار، مقصدِ fallback است وقتی تاریخچهای برای برگشتن نیست.
|
* مقدار، مقصدِ fallback است وقتی تاریخچهای برای برگشتن نیست.
|
||||||
*/
|
*/
|
||||||
backTo?: string;
|
backTo?: string;
|
||||||
|
/** شناسهٔ تور راهنمای این صفحه؛ اگر در registry ثبت نشده باشد دکمهای نمیآید. */
|
||||||
|
tourId?: string;
|
||||||
|
/**
|
||||||
|
* محتوای صفحه نشسته است و تورِ بار اول میتواند خودکار اجرا شود.
|
||||||
|
* صفحهای که دادهاش دیر میآید، `!isLoading` بدهد تا تور روی صفحهٔ نیمهساخته نیفتد.
|
||||||
|
*/
|
||||||
|
tourReady?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageHeader({ title, breadcrumbs, action, description, backTo }: Props) {
|
export default function PageHeader({ title, breadcrumbs, action, description, backTo, tourId, tourReady = true }: Props) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
@@ -48,12 +56,15 @@ export default function PageHeader({ title, breadcrumbs, action, description, ba
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
<h1 className="section-title">{title}</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
|
||||||
|
<h1 className="section-title">{title}</h1>
|
||||||
|
<TourButton tourId={tourId} ready={tourReady} />
|
||||||
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p>
|
<p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{action && <div style={{ flexShrink: 0 }}>{action}</div>}
|
{action && <div style={{ flexShrink: 0 }} data-tour="page-action">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function Pagination({ page, total, limit, onPageChange }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pagination">
|
<div className="pagination" data-tour="page-pagination">
|
||||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||||
نمایش {formatNumber(from)}–{formatNumber(to)} از {formatNumber(total)} مورد
|
نمایش {formatNumber(from)}–{formatNumber(to)} از {formatNumber(total)} مورد
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
|
|||||||
while (cells.length % 7 !== 0) cells.push(null);
|
while (cells.length % 7 !== 0) cells.push(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{
|
<div ref={ref} className="cp-calendar" style={{
|
||||||
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
|
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export default function SearchableSelect({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select<SelectOption>
|
<Select<SelectOption>
|
||||||
|
// پیشوند پایدار برای کلاسها: کلاسهای emotion در build تولیدی برچسب ندارند و
|
||||||
|
// «منوی باز است یا نه» با آنها قابل تشخیص نیست. useOverlayDismiss به همین
|
||||||
|
// نشانه نگاه میکند تا Esc اول منو را ببندد نه مودال را.
|
||||||
|
classNamePrefix="cp-select"
|
||||||
options={options}
|
options={options}
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={(opt) => onChange?.(opt ? opt.value : null)}
|
onChange={(opt) => onChange?.(opt ? opt.value : null)}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders } from '@/test/utils';
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const drive = vi.fn();
|
||||||
|
vi.mock('driver.js', () => ({ driver: vi.fn(() => ({ drive })) }));
|
||||||
|
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||||
|
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import TourButton from './TourButton';
|
||||||
|
import PageHeader from './PageHeader';
|
||||||
|
import { appointmentsTour } from '@/lib/tour/tours/appointments';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
drive.mockReset();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TourButton', () => {
|
||||||
|
it('برای تور ثبتشده دکمهٔ راهنما میآورد', () => {
|
||||||
|
renderWithProviders(<TourButton tourId="appointments" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('برای شناسهٔ ناشناس یا بدون شناسه چیزی رندر نمیکند', () => {
|
||||||
|
const { container } = renderWithProviders(<TourButton tourId="does-not-exist" />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
|
||||||
|
const { container: bare } = renderWithProviders(<TourButton />);
|
||||||
|
expect(bare).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کلیک، تور را روی المانهای موجود اجرا میکند', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<>
|
||||||
|
<TourButton tourId="appointments" />
|
||||||
|
{appointmentsTour.steps.map((s) => (
|
||||||
|
<div key={s.anchor} data-tour={s.anchor} />
|
||||||
|
))}
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'راهنمای این صفحه' }));
|
||||||
|
|
||||||
|
expect(drive).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageHeader', () => {
|
||||||
|
it('با tourId دکمهٔ راهنما را کنار عنوان میگذارد', () => {
|
||||||
|
renderWithProviders(<PageHeader title="نوبتها" tourId="appointments" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'نوبتها' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بدون tourId هیچ دکمهٔ راهنمایی ندارد', () => {
|
||||||
|
renderWithProviders(<PageHeader title="نوبتها" />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'راهنمای این صفحه' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('صفحهٔ بدون تور هیچ درخواستی برای وضعیت تورها نمیفرستد', () => {
|
||||||
|
renderWithProviders(<PageHeader title="نوبتها" />);
|
||||||
|
|
||||||
|
expect(get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useTour } from '../../hooks/useTour';
|
||||||
|
import { getTour } from '../../lib/tour/registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* دکمهٔ «راهنمای این صفحه».
|
||||||
|
*
|
||||||
|
* وجود تور قبل از هر هوکی بررسی میشود تا صفحهای که راهنما ندارد — یعنی بیشتر
|
||||||
|
* صفحات پنل — هیچ درخواستی برای وضعیت تورها نفرستد.
|
||||||
|
*/
|
||||||
|
export default function TourButton({ tourId, ready = false }: { tourId?: string; ready?: boolean }) {
|
||||||
|
const tour = getTour(tourId);
|
||||||
|
|
||||||
|
if (!tour) return null;
|
||||||
|
|
||||||
|
return <TourLauncher tourId={tour.id} ready={ready} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ready` یعنی محتوای صفحه نشسته است و اجرای خودکارِ بار اول مجاز است.
|
||||||
|
* صفحهای که دکمه را مستقیم میگذارد و آمادهبودنش را نمیداند، آن را false میگذارد
|
||||||
|
* و تور فقط با کلیک اجرا میشود.
|
||||||
|
*/
|
||||||
|
function TourLauncher({ tourId, ready }: { tourId: string; ready: boolean }) {
|
||||||
|
const { start } = useTour(tourId, { ready });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="راهنمای این صفحه"
|
||||||
|
title="راهنمای این صفحه"
|
||||||
|
onClick={start}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 30, height: 30, borderRadius: 'var(--r-pill)',
|
||||||
|
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||||
|
color: 'var(--text-3)', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--primary)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-3)')}
|
||||||
|
>
|
||||||
|
<QuestionMarkCircleIcon style={{ width: 20, height: 20 }} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
import { renderHookWithClient } from '@/test/utils';
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const drive = vi.fn();
|
||||||
|
const destroyHandlers: Array<() => void> = [];
|
||||||
|
|
||||||
|
vi.mock('driver.js', () => ({
|
||||||
|
driver: vi.fn((config: { onDestroyed?: () => void }) => {
|
||||||
|
if (config.onDestroyed) destroyHandlers.push(config.onDestroyed);
|
||||||
|
return { drive };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||||
|
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useTour } from '@/hooks/useTour';
|
||||||
|
import { useTourProgress } from '@/hooks/useTourProgress';
|
||||||
|
import { appointmentsTour } from '@/lib/tour/tours/appointments';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const post = api.post as ReturnType<typeof vi.fn>;
|
||||||
|
const driverMock = driver as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function mountAppointmentsAnchors() {
|
||||||
|
document.body.innerHTML = appointmentsTour.steps
|
||||||
|
.map((s) => `<div data-tour="${s.anchor}"></div>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
destroyHandlers.length = 0;
|
||||||
|
get.mockReset();
|
||||||
|
post.mockReset();
|
||||||
|
drive.mockReset();
|
||||||
|
driverMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTourProgress', () => {
|
||||||
|
it('نقشهٔ دیدهشدهها را از سرور میخواند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: { appointments: 2 } } });
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTourProgress());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isReady).toBe(true));
|
||||||
|
expect(result.current.isSeen('appointments', 2)).toBe(true);
|
||||||
|
expect(result.current.isSeen('appointments', 3)).toBe(false);
|
||||||
|
expect(result.current.isSeen('patients', 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ثبت دیدهشدن، کش را بدون درخواست دوباره بهروز میکند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
post.mockResolvedValue({ data: { tourId: 'appointments', version: 1 } });
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTourProgress());
|
||||||
|
await waitFor(() => expect(result.current.isReady).toBe(true));
|
||||||
|
|
||||||
|
result.current.markSeen('appointments', 1);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSeen('appointments', 1)).toBe(true));
|
||||||
|
expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', { version: 1 });
|
||||||
|
expect(get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTour', () => {
|
||||||
|
it('تور دیدهنشده را بعد از آماده شدن صفحه خودکار اجرا میکند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(driverMock).toHaveBeenCalled());
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
|
||||||
|
expect(drive).toHaveBeenCalledTimes(1);
|
||||||
|
expect(driverMock.mock.calls[0][0].steps).toHaveLength(appointmentsTour.steps.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('شمارندهٔ استپها با ارقام فارسی است', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const steps = driverMock.mock.calls[0][0].steps;
|
||||||
|
expect(steps[0].popover.progressText).toBe('۱ از ۸');
|
||||||
|
expect(steps[7].popover.progressText).toBe('۸ از ۸');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('رندرهای پیاپی قبل از شلیک تایمر، اجرای خودکار را لغو نمیکنند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
const { rerender } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
// هر رندر تابع start تازهای میسازد؛ اگر وابستگیِ effect باشد، cleanup
|
||||||
|
// تایمر را پاک میکند و تور هرگز اجرا نمیشود.
|
||||||
|
for (let i = 0; i < 5; i++) rerender();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('استپِ بدون المان را حذف میکند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
document.body.innerHTML = '<div data-tour="appointments-stats"></div>';
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(driverMock.mock.calls[0][0].steps).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تور دیدهشده خودکار اجرا نمیشود', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: { appointments: appointmentsTour.version } } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// ولی دکمهٔ راهنما همچنان کار میکند
|
||||||
|
result.current.start();
|
||||||
|
expect(drive).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تا وقتی وضعیت از سرور نیامده اجرا نمیشود', async () => {
|
||||||
|
get.mockRejectedValue(new Error('network down'));
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('صفحهای که هنوز آماده نیست تور نمیگیرد', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: false }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('شناسهٔ ناشناس نه دکمه دارد نه اجرا', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTour('nope', { ready: true }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(result.current.available).toBe(false);
|
||||||
|
|
||||||
|
result.current.start();
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی هیچ المانی روی صفحه نیست، اجرا نمیشود', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
await waitFor(() => expect(result.current.available).toBe(true));
|
||||||
|
|
||||||
|
result.current.start();
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بستن تور، دیدهشدن را ثبت میکند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
post.mockResolvedValue({ data: { tourId: 'appointments', version: appointmentsTour.version } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await waitFor(() => expect(destroyHandlers).toHaveLength(1));
|
||||||
|
|
||||||
|
destroyHandlers[0]();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', {
|
||||||
|
version: appointmentsTour.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* باگی که این تستها میبندند: پسزمینه یک onClick ساده داشت، پس هر کلیکی که روی
|
||||||
|
* آن «تمام میشد» فرم نیمهپرشده را میبست — درگِ متن از داخل به بیرون، یا کلیک روی
|
||||||
|
* گزینهای که همان لحظه unmount میشد.
|
||||||
|
*/
|
||||||
|
function renderModal(onClose: () => void) {
|
||||||
|
return render(
|
||||||
|
<Modal open title="ثبت نوبت" onClose={onClose}>
|
||||||
|
<input placeholder="نام" />
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = () => document.querySelector('.overlay') as HTMLElement;
|
||||||
|
const modal = () => document.querySelector('.modal') as HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('بستن مودال با پسزمینه', () => {
|
||||||
|
it('فشردن و رها کردن روی پسزمینه، مودال را میبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(overlay());
|
||||||
|
fireEvent.click(overlay());
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('درگِ متن از داخل مودال به بیرون، آن را نمیبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
// شروع روی محتوای مودال، پایان روی پسزمینه — همان حرکتِ انتخاب متن.
|
||||||
|
fireEvent.mouseDown(screen.getByPlaceholderText('نام'));
|
||||||
|
fireEvent.click(overlay());
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کلیک داخل مودال هیچوقت آن را نمیبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(modal());
|
||||||
|
fireEvent.click(modal());
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کلیکِ بعدی روی پسزمینه پس از یک درگ، دوباره درست کار میکند', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByPlaceholderText('نام'));
|
||||||
|
fireEvent.click(overlay());
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(overlay());
|
||||||
|
fireEvent.click(overlay());
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('بستن مودال با Esc', () => {
|
||||||
|
it('Esc مودال را میبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی منوی انتخاب باز است، Esc مودال را نمیبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
// همان نشانهای که react-select با classNamePrefix میگذارد.
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'cp-select__menu';
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
menu.remove();
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی تقویم باز است، Esc مودال را نمیبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
const calendar = document.createElement('div');
|
||||||
|
calendar.className = 'cp-calendar';
|
||||||
|
document.body.appendChild(calendar);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کلیدهای دیگر کاری نمیکنند', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Enter' });
|
||||||
|
fireEvent.keyDown(document, { key: 'a' });
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مودالِ بسته به Esc گوش نمیدهد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<Modal open={false} title="ثبت نوبت" onClose={onClose}><p>محتوا</p></Modal>);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* لایههایی که خودشان با Esc بسته میشوند و مودال نباید جایشان بسته شود:
|
||||||
|
* منوی باز react-select و تقویم شمسی.
|
||||||
|
*/
|
||||||
|
const OPEN_LAYER_SELECTOR = '.cp-select__menu, .cp-calendar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* رفتار بستنِ یک مودال: کلیک روی پسزمینه و کلید Esc.
|
||||||
|
*
|
||||||
|
* قبلاً پسزمینه یک `onClick={onClose}` ساده داشت و هر کلیکی که *روی آن تمام میشد*
|
||||||
|
* مودال را میبست — انتخاب متن داخل فرم و رها کردن موس بیرون، یا کلیک روی گزینهای
|
||||||
|
* که همان لحظه unmount میشد. بستنِ ناخواستهٔ فرمِ نیمهپرشده آزاردهندهترین باگ
|
||||||
|
* پنل بود، پس شرط سختتر شد: هم فشردن و هم رها کردن باید روی خودِ پسزمینه باشد.
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* const dismiss = useOverlayDismiss(onClose);
|
||||||
|
* <div className="overlay" {...dismiss}> … </div>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useOverlayDismiss(onClose: () => void, enabled = true) {
|
||||||
|
const pressedOnOverlay = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
// دراپداون یا تقویمِ باز، خودش با Esc بسته میشود؛ مودال باید بماند.
|
||||||
|
if (document.querySelector(OPEN_LAYER_SELECTOR)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [enabled, onClose]);
|
||||||
|
|
||||||
|
const onMouseDown = useCallback((e: ReactMouseEvent<HTMLElement>) => {
|
||||||
|
pressedOnOverlay.current = e.target === e.currentTarget;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClick = useCallback((e: ReactMouseEvent<HTMLElement>) => {
|
||||||
|
const started = pressedOnOverlay.current;
|
||||||
|
pressedOnOverlay.current = false;
|
||||||
|
if (started && e.target === e.currentTarget) onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return { onMouseDown, onClick };
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ interface PaymentConfig {
|
|||||||
test_mode: boolean;
|
test_mode: boolean;
|
||||||
appointment_fee_rials: number;
|
appointment_fee_rials: number;
|
||||||
gateways: PaymentGatewayInfo[];
|
gateways: PaymentGatewayInfo[];
|
||||||
|
/** نرخ مالیات اشتراک و شارژ کیف پول؛ صفر یعنی خاموش. */
|
||||||
|
tax_percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePaymentConfig() {
|
export function usePaymentConfig() {
|
||||||
@@ -22,5 +24,6 @@ export function usePaymentConfig() {
|
|||||||
return {
|
return {
|
||||||
isTestMode: data?.data?.test_mode ?? false,
|
isTestMode: data?.data?.test_mode ?? false,
|
||||||
gateways: data?.data?.gateways ?? [],
|
gateways: data?.data?.gateways ?? [],
|
||||||
|
taxPercent: data?.data?.tax_percent ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import 'driver.js/dist/driver.css';
|
||||||
|
import { getTour } from '../lib/tour/registry';
|
||||||
|
import { anchorSelector, resolveSteps } from '../lib/tour/resolveSteps';
|
||||||
|
import { formatNumber } from '../lib/utils';
|
||||||
|
import { useTourProgress } from './useTourProgress';
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** وقتی true شد یعنی دادهٔ صفحه آمده و المانهای هدف رندر شدهاند */
|
||||||
|
ready?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* راهنمای قدمبهقدم یک صفحه. بار اول خودکار اجرا میشود و بعد از آن فقط با
|
||||||
|
* صدا زدن start — یعنی دکمهٔ «؟» صفحه.
|
||||||
|
*/
|
||||||
|
export function useTour(tourId?: string, { ready = false }: Options = {}) {
|
||||||
|
const tour = getTour(tourId);
|
||||||
|
const { isReady, isSeen, markSeen } = useTourProgress();
|
||||||
|
const autoStarted = useRef(false);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (!tour) return;
|
||||||
|
|
||||||
|
const steps = resolveSteps(tour.steps);
|
||||||
|
if (steps.length === 0) return;
|
||||||
|
|
||||||
|
driver({
|
||||||
|
showProgress: true,
|
||||||
|
allowClose: true,
|
||||||
|
overlayOpacity: 0.55,
|
||||||
|
popoverClass: 'cp-tour',
|
||||||
|
nextBtnText: 'بعدی',
|
||||||
|
prevBtnText: 'قبلی',
|
||||||
|
doneBtnText: 'باشه، فهمیدم',
|
||||||
|
steps: steps.map((s, i) => ({
|
||||||
|
element: anchorSelector(s.anchor),
|
||||||
|
popover: {
|
||||||
|
title: s.title,
|
||||||
|
description: s.body,
|
||||||
|
side: s.side ?? 'bottom',
|
||||||
|
align: 'start',
|
||||||
|
// قالبِ سراسری driver فقط {{current}} میدهد و آن ارقام لاتین است؛
|
||||||
|
// شمارنده باید مثل بقیهٔ پنل فارسی باشد.
|
||||||
|
progressText: `${formatNumber(i + 1)} از ${formatNumber(steps.length)}`,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
// بستن وسط تور هم «دیده شده» است؛ تکرارش برای کسی که ردش کرده آزار است.
|
||||||
|
onDestroyed: () => markSeen(tour.id, tour.version),
|
||||||
|
}).drive();
|
||||||
|
}, [tour, markSeen]);
|
||||||
|
|
||||||
|
// `start` با هر رندر بازساخته میشود؛ اگر وابستگیِ effect باشد، cleanup تایمرِ
|
||||||
|
// سیصد میلیثانیهای را قبل از شلیک پاک میکند و تور هرگز اجرا نمیشود.
|
||||||
|
const startRef = useRef(start);
|
||||||
|
startRef.current = start;
|
||||||
|
|
||||||
|
// مقدار boolean وابستگیِ پایداری است، برخلاف خودِ تابع isSeen.
|
||||||
|
const alreadySeen = tour ? isSeen(tour.id, tour.version) : true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tour || !ready || autoStarted.current) return;
|
||||||
|
// تا پاسخ سرور نیامده هیچ چیز اجرا نمیشود؛ وگرنه در خطای شبکه کاربر قدیمی
|
||||||
|
// هر بار رفرش یک تور میبیند.
|
||||||
|
if (!isReady || alreadySeen) return;
|
||||||
|
|
||||||
|
autoStarted.current = true;
|
||||||
|
// یک لحظه صبر تا چیدمان نهایی بنشیند و highlight سرِ جای درست بیفتد.
|
||||||
|
const timer = window.setTimeout(() => startRef.current(), 300);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [tour, ready, isReady, alreadySeen]);
|
||||||
|
|
||||||
|
return { available: tour !== null, start };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import type { ApiResponse } from '../lib/api';
|
||||||
|
|
||||||
|
/** tour id => بالاترین نسخهای که کاربر دیده است */
|
||||||
|
type SeenMap = Record<string, number>;
|
||||||
|
|
||||||
|
export const TOURS_QUERY_KEY = ['my-tours'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* وضعیت «دیدهشده» روی حساب کاربر ذخیره میشود نه روی مرورگر، تا با عوض کردن
|
||||||
|
* دستگاه یا مرورگر تورها دوباره از سر اجرا نشوند.
|
||||||
|
*/
|
||||||
|
export function useTourProgress() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: TOURS_QUERY_KEY,
|
||||||
|
queryFn: () => api.get<ApiResponse<{ seen: SeenMap }>>('/api/v1/my/tours'),
|
||||||
|
// در طول یک نشست عوض نمیشود مگر با همین mutation، پس refetch بیفایده است.
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen: SeenMap = query.data?.data?.seen ?? {};
|
||||||
|
|
||||||
|
const markSeen = useMutation({
|
||||||
|
mutationFn: ({ tourId, version }: { tourId: string; version: number }) =>
|
||||||
|
api.post<ApiResponse<{ tourId: string; version: number }>>(`/api/v1/my/tours/${tourId}/seen`, { version }),
|
||||||
|
// کش را بدون رفتوبرگشت اضافه بهروز میکند؛ سرور همین مقدار را برمیگرداند.
|
||||||
|
onSuccess: (_data, { tourId, version }) => {
|
||||||
|
queryClient.setQueryData<ApiResponse<{ seen: SeenMap }>>(TOURS_QUERY_KEY, (prev) => {
|
||||||
|
const previous = prev?.data?.seen ?? {};
|
||||||
|
return {
|
||||||
|
...(prev ?? { success: true, errors: [] as never[] }),
|
||||||
|
data: { seen: { ...previous, [tourId]: Math.max(previous[tourId] ?? 0, version) } },
|
||||||
|
} as ApiResponse<{ seen: SeenMap }>;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
seen,
|
||||||
|
/** تا وقتی پاسخ سرور نیامده، هیچ توری خودکار اجرا نمیشود */
|
||||||
|
isReady: query.isSuccess,
|
||||||
|
isSeen: (tourId: string, version: number) => (seen[tourId] ?? 0) >= version,
|
||||||
|
markSeen: (tourId: string, version: number) => markSeen.mutate({ tourId, version }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { TourDefinition } from './types';
|
||||||
|
import { appointmentsTour } from './tours/appointments';
|
||||||
|
import { appointmentDetailTour, reserveAppointmentsTour } from './tours/appointmentsMore';
|
||||||
|
import { appointmentCreateTour, appointmentEditTour, patientDetailTour } from './tours/patientDetail';
|
||||||
|
import { myPatientRecordTour, myPatientsTour } from './tours/myPatients';
|
||||||
|
import { patientsTour } from './tours/patients';
|
||||||
|
import {
|
||||||
|
claimDetailTour, claimsTour, discountsTour, insurancePricingTour, myFinancialTour,
|
||||||
|
myPaymentDetailTour, myPaymentsTour, smsWalletTour, subscriptionTour,
|
||||||
|
} from './tours/financial';
|
||||||
|
import {
|
||||||
|
clinicServicesTour, inventoryTour, resourceDetailTour, resourcePoolsTour, resourceSkillsTour,
|
||||||
|
resourceTypesTour, resourcesTour, serviceCategoriesTour, serviceDetailTour, treatmentCasesTour,
|
||||||
|
} from './tours/services';
|
||||||
|
import {
|
||||||
|
accountSettingsTour, appointmentSettingsTour, clinicAppointmentSettingsTour, clinicDoctorsTour,
|
||||||
|
holidaysTour, practiceDomainTour, recordNumberSettingsTour, settingsMenuTour, tagsSettingsTour,
|
||||||
|
} from './tours/settings';
|
||||||
|
import {
|
||||||
|
clinicDetailTour, clinicsTour, dashboardTour, doctorDetailTour, doctorFormTour, doctorsTour,
|
||||||
|
mySecretariesTour, secretaryEarningsTour, secretarySettlementTour, staffSessionDetailTour,
|
||||||
|
staffSessionsTour, staffTour,
|
||||||
|
} from './tours/clinicStaff';
|
||||||
|
import {
|
||||||
|
patientRecordFormTour, representationBlogFormTour, representationBlogsTour,
|
||||||
|
representationFinanceTour, representationProfileTour, representationSettlementTour,
|
||||||
|
sessionEditTour, sessionNewTour, sessionPaymentTour,
|
||||||
|
} from './tours/sessionsAndRepresentation';
|
||||||
|
|
||||||
|
/** هر صفحه تور خودش را در tours/ دارد و فقط اینجا ثبت میشود. */
|
||||||
|
const ALL: TourDefinition[] = [
|
||||||
|
appointmentsTour,
|
||||||
|
appointmentDetailTour,
|
||||||
|
reserveAppointmentsTour,
|
||||||
|
appointmentCreateTour,
|
||||||
|
appointmentEditTour,
|
||||||
|
patientsTour,
|
||||||
|
patientDetailTour,
|
||||||
|
myPatientsTour,
|
||||||
|
myPatientRecordTour,
|
||||||
|
myPaymentsTour,
|
||||||
|
myPaymentDetailTour,
|
||||||
|
myFinancialTour,
|
||||||
|
claimsTour,
|
||||||
|
claimDetailTour,
|
||||||
|
insurancePricingTour,
|
||||||
|
discountsTour,
|
||||||
|
subscriptionTour,
|
||||||
|
smsWalletTour,
|
||||||
|
clinicServicesTour,
|
||||||
|
serviceDetailTour,
|
||||||
|
serviceCategoriesTour,
|
||||||
|
resourcesTour,
|
||||||
|
resourceTypesTour,
|
||||||
|
resourcePoolsTour,
|
||||||
|
resourceSkillsTour,
|
||||||
|
resourceDetailTour,
|
||||||
|
inventoryTour,
|
||||||
|
treatmentCasesTour,
|
||||||
|
settingsMenuTour,
|
||||||
|
accountSettingsTour,
|
||||||
|
tagsSettingsTour,
|
||||||
|
recordNumberSettingsTour,
|
||||||
|
appointmentSettingsTour,
|
||||||
|
clinicAppointmentSettingsTour,
|
||||||
|
holidaysTour,
|
||||||
|
practiceDomainTour,
|
||||||
|
clinicDoctorsTour,
|
||||||
|
dashboardTour,
|
||||||
|
clinicsTour,
|
||||||
|
clinicDetailTour,
|
||||||
|
doctorsTour,
|
||||||
|
doctorDetailTour,
|
||||||
|
doctorFormTour,
|
||||||
|
mySecretariesTour,
|
||||||
|
staffTour,
|
||||||
|
staffSessionsTour,
|
||||||
|
staffSessionDetailTour,
|
||||||
|
secretaryEarningsTour,
|
||||||
|
secretarySettlementTour,
|
||||||
|
sessionNewTour,
|
||||||
|
sessionEditTour,
|
||||||
|
sessionPaymentTour,
|
||||||
|
patientRecordFormTour,
|
||||||
|
representationBlogsTour,
|
||||||
|
representationBlogFormTour,
|
||||||
|
representationFinanceTour,
|
||||||
|
representationProfileTour,
|
||||||
|
representationSettlementTour,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TOURS: Record<string, TourDefinition> = Object.fromEntries(
|
||||||
|
ALL.map((tour) => [tour.id, tour]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getTour(id?: string): TourDefinition | null {
|
||||||
|
return id ? TOURS[id] ?? null : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { TourStep } from './types';
|
||||||
|
|
||||||
|
export function anchorSelector(anchor: string): string {
|
||||||
|
return `[data-tour="${anchor}"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فقط استپهایی میمانند که المانشان همین حالا در DOM هست.
|
||||||
|
* صفحات پنل نقشمحورند: «افزودن نوبت» برای منشیِ بدون مجوز اصلاً رندر نمیشود و
|
||||||
|
* تور نباید روی المان غایب گیر کند یا شمارندهٔ اشتباه نشان بدهد.
|
||||||
|
*/
|
||||||
|
export function resolveSteps(steps: TourStep[], root: ParentNode = document): TourStep[] {
|
||||||
|
return steps.filter((s) => root.querySelector(anchorSelector(s.anchor)) !== null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { anchorSelector, resolveSteps } from './resolveSteps';
|
||||||
|
import { getTour, TOURS } from './registry';
|
||||||
|
import type { TourStep } from './types';
|
||||||
|
|
||||||
|
const STEPS: TourStep[] = [
|
||||||
|
{ anchor: 'one', title: 'یک', body: '…' },
|
||||||
|
{ anchor: 'two', title: 'دو', body: '…' },
|
||||||
|
{ anchor: 'three', title: 'سه', body: '…' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function mount(...anchors: string[]) {
|
||||||
|
document.body.innerHTML = anchors.map((a) => `<div data-tour="${a}"></div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSteps', () => {
|
||||||
|
it('استپهای موجود را با حفظ ترتیب نگه میدارد', () => {
|
||||||
|
mount('three', 'one');
|
||||||
|
|
||||||
|
expect(resolveSteps(STEPS).map((s) => s.anchor)).toEqual(['one', 'three']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی هیچ المانی نیست، خروجی خالی است', () => {
|
||||||
|
expect(resolveSteps(STEPS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('آرایهٔ خالی خروجی خالی میدهد', () => {
|
||||||
|
mount('one');
|
||||||
|
|
||||||
|
expect(resolveSteps([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سلکتور از روی anchor ساخته میشود', () => {
|
||||||
|
expect(anchorSelector('appointments-stats')).toBe('[data-tour="appointments-stats"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registry', () => {
|
||||||
|
it('برای شناسهٔ ناشناس یا خالی null میدهد', () => {
|
||||||
|
expect(getTour('does-not-exist')).toBeNull();
|
||||||
|
expect(getTour(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تور نوبتها ثبت شده است', () => {
|
||||||
|
expect(getTour('appointments')?.id).toBe('appointments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('هر تور نسخهٔ معتبر و anchorهای بدون تکرار دارد', () => {
|
||||||
|
for (const [id, tour] of Object.entries(TOURS)) {
|
||||||
|
expect(tour.id).toBe(id);
|
||||||
|
expect(tour.version).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(tour.steps.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const anchors = tour.steps.map((s) => s.anchor);
|
||||||
|
expect(new Set(anchors).size).toBe(anchors.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('متن هر استپ فارسی و غیرخالی است', () => {
|
||||||
|
for (const tour of Object.values(TOURS)) {
|
||||||
|
for (const step of tour.steps) {
|
||||||
|
expect(step.title.trim().length).toBeGreaterThan(2);
|
||||||
|
expect(step.body.trim().length).toBeGreaterThan(10);
|
||||||
|
// متن راهنما برای کاربر فارسیزبان است؛ استپِ انگلیسیمانده یعنی جا افتاده.
|
||||||
|
expect(step.title).toMatch(/[-ۿ]/);
|
||||||
|
expect(step.body).toMatch(/[-ۿ]/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* لنگرِ بیالمان بیصدا حذف میشود، پس تورِ خراب هیچ خطایی نمیدهد و فقط استپش را
|
||||||
|
* از دست میدهد. این تست همان سکوت را میشکند: هر anchor باید در کدِ صفحات وجود
|
||||||
|
* داشته باشد.
|
||||||
|
*/
|
||||||
|
describe('انطباق لنگرها با کد صفحات', () => {
|
||||||
|
it('هیچ استپی به data-tour ناموجود اشاره نمیکند', async () => {
|
||||||
|
// خواندن مستقیم فایلها، نه import.meta.glob: آن فقط زیر Vite کار میکند و
|
||||||
|
// ts-loaderِ Encore همین فایل را هم type-check میکند و build را قرمز میکرد.
|
||||||
|
const { readdirSync, readFileSync } = await import('node:fs');
|
||||||
|
const { join } = await import('node:path');
|
||||||
|
|
||||||
|
const walk = (dir: string): string[] =>
|
||||||
|
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return walk(path);
|
||||||
|
return entry.name.endsWith('.tsx') && !entry.name.includes('.test.') ? [path] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const sources = walk('assets/admin').map((path) => readFileSync(path, 'utf8'));
|
||||||
|
const declared = new Set(
|
||||||
|
sources.flatMap((src) => [...src.matchAll(/data-tour="([a-z0-9-]+)"/g)].map((m) => m[1])),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(declared.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const orphans = Object.values(TOURS).flatMap((tour) =>
|
||||||
|
tour.steps.filter((s) => !declared.has(s.anchor)).map((s) => `${tour.id}:${s.anchor}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orphans).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const appointmentsTour: TourDefinition = {
|
||||||
|
id: 'appointments',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'appointments-stats',
|
||||||
|
title: 'آمار همین روز',
|
||||||
|
body: 'تعداد کل نوبتها، انجامشده، در انتظار و لغوشده — همه برای روزی که پایین انتخاب کردهاید.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appointments-date',
|
||||||
|
title: 'انتخاب روز',
|
||||||
|
body: 'با فلشها یک روز جلو و عقب بروید، یا از آیکون تقویم یک تاریخ را مستقیم انتخاب کنید.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appointments-service',
|
||||||
|
title: 'فیلتر خدمت',
|
||||||
|
body: 'فقط نوبتهای یک خدمت مشخص را ببینید. برای روزهای شلوغ مفید است.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appointments-view',
|
||||||
|
title: 'تایملاین یا جدول',
|
||||||
|
body: 'تایملاین ساعتهای روز را کنار هم نشان میدهد. جدول همان نوبتها را فهرستوار میآورد.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appointments-filters',
|
||||||
|
title: 'فیلترهای بیشتر',
|
||||||
|
body: 'فیلتر بر اساس وضعیت نوبت، بیمه و بیمار. وقتی فیلتری فعال باشد، رنگ این دکمه عوض میشود.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appointments-new',
|
||||||
|
title: 'ثبت نوبت جدید',
|
||||||
|
body: 'نوبت را برای همان روز و همان پزشکِ انتخابشده باز میکند.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appointments-doctors',
|
||||||
|
title: 'تب پزشکان',
|
||||||
|
body: 'در کلینیک چندپزشکه، برنامهٔ هر پزشک را جدا ببینید.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appointments-list',
|
||||||
|
title: 'خودِ نوبتها',
|
||||||
|
body: 'با کلیک روی هر نوبت وارد جزئیات آن میشوید و میتوانید وضعیتش را عوض کنید.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const appointmentDetailTour: TourDefinition = {
|
||||||
|
id: 'appointment-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'appt-patient',
|
||||||
|
title: 'اطلاعات نوبت',
|
||||||
|
body: 'نام بیمار، پزشک، خدمت، ساعت و هزینهٔ همین نوبت.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appt-status',
|
||||||
|
title: 'وضعیت و اقدامات',
|
||||||
|
body: 'وضعیت فعلی نوبت را از اینجا عوض کنید — مثلاً تأیید، حضور بیمار یا لغو.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'appt-events',
|
||||||
|
title: 'تاریخچهٔ رویدادها',
|
||||||
|
body: 'هر تغییر وضعیت با زمان و عاملش ثبت میشود؛ برای پیگیری اختلاف مفید است.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reserveAppointmentsTour: TourDefinition = {
|
||||||
|
id: 'appointments-reserve',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'page-title',
|
||||||
|
title: 'نوبتهای رزرو شده',
|
||||||
|
body: 'نوبتهایی که بیرون از تقویم عادی و بهصورت دستی رزرو شدهاند.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'reserve-doctor',
|
||||||
|
title: 'انتخاب پزشک',
|
||||||
|
body: 'در کلینیک، اول پزشک را انتخاب کنید تا نوبتهای رزروش بیاید.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'reserve-new',
|
||||||
|
title: 'نوبت رزرو جدید',
|
||||||
|
body: 'تا وقتی پزشکی انتخاب نشده، این دکمه غیرفعال است.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'reserve-list',
|
||||||
|
title: 'فهرست رزروها',
|
||||||
|
body: 'هر ردیف یک نوبت رزروشده با بیمار و زمان آن.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const dashboardTour: TourDefinition = {
|
||||||
|
id: 'dashboard',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'داشبورد', body: 'خلاصهٔ امروز: نوبتها، درآمد، بیماران و کارهای در انتظار.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clinicsTour: TourDefinition = {
|
||||||
|
id: 'clinics',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'کلینیکها', body: 'کلینیکهایی که به آنها دسترسی دارید.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-pagination', title: 'صفحهبندی', body: 'تعداد کل و جابهجایی بین صفحهها.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clinicDetailTour: TourDefinition = {
|
||||||
|
id: 'clinic-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پروندهٔ کلینیک', body: 'اطلاعات، آدرس، تصاویر و تنظیمات این کلینیک.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doctorsTour: TourDefinition = {
|
||||||
|
id: 'doctors',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پزشکان', body: 'فهرست پزشکان و دسترسی به پروفایل هرکدام.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-pagination', title: 'صفحهبندی', body: 'تعداد کل پزشکان و حرکت بین صفحهها.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doctorDetailTour: TourDefinition = {
|
||||||
|
id: 'doctor-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پروفایل پزشک', body: 'مشخصات، تخصصها، آدرسها، تعرفهها و وضعیت فعالبودن پزشک.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doctorFormTour: TourDefinition = {
|
||||||
|
id: 'doctor-new',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'افزودن پزشک', body: 'پزشک جدید را با موبایل و تخصصش ثبت کنید.', side: 'bottom' },
|
||||||
|
{ anchor: 'doctor-form', title: 'فرم', body: 'موبایل کلید حساب است؛ اگر کاربری با آن شماره باشد، به همان وصل میشود.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mySecretariesTour: TourDefinition = {
|
||||||
|
id: 'my-secretaries',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'منشیها', body: 'منشیهای شما و دسترسیهایشان.', side: 'bottom' },
|
||||||
|
{ anchor: 'secretaries-tabs', title: 'فعلی و قبلی', body: 'منشی قطعهمکاریشده در تب دوم میماند و حذف نمیشود.', side: 'bottom' },
|
||||||
|
{ anchor: 'secretaries-add', title: 'افزودن منشی', body: 'بعد از ذخیره، پنجرهٔ مجوزها باز میشود تا دسترسیهایش را دقیق تعیین کنید. سقف تعداد به پلن اشتراک بستگی دارد.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staffTour: TourDefinition = {
|
||||||
|
id: 'staff',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'مدیریت پرسنل', body: 'کارکنان درمانی که جلسه انجام میدهند — با مهارتها و سهمشان.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'فهرست پرسنل', body: 'مهارت هر نفر تعیین میکند در چه سرویسی قابل انتخاب است.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staffSessionsTour: TourDefinition = {
|
||||||
|
id: 'my-sessions',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'جلسات امروز من', body: 'جلسههایی که امروز به شما سپرده شده است.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staffSessionDetailTour: TourDefinition = {
|
||||||
|
id: 'my-session-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'جزئیات جلسه', body: 'خدمت، بیمار، مصرف انبار و ثبت انجام جلسه.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secretaryEarningsTour: TourDefinition = {
|
||||||
|
id: 'secretary-earnings',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'درآمد نوبتهای آنلاین', body: 'سهم شما از نوبتهایی که آنلاین ثبت و پرداخت شدهاند.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'ریز نوبتها', body: 'هر ردیف یک نوبت با سهم شما از آن.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secretarySettlementTour: TourDefinition = {
|
||||||
|
id: 'secretary-settlement',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'تسویه حساب', body: 'برداشت سهم نوبتهای آنلاین به شماره شبای خودتان.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'درخواستها', body: 'وضعیت هر درخواست برداشت اینجا دیده میشود.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const myPaymentsTour: TourDefinition = {
|
||||||
|
id: 'my-payments',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'لیست پرداختها', body: 'همهٔ پرداختهای ثبتشدهٔ بیماران شما، جدیدترین بالا.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-search', title: 'جستجو', body: 'با نام بیمار یا شمارهٔ صورتحساب بگردید.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'ردیفهای پرداخت', body: 'با کلیک روی هر بیمار، صورتحسابها و آیتمهایش باز میشود.', side: 'top' },
|
||||||
|
{ anchor: 'page-pagination', title: 'صفحهبندی', body: 'تعداد کل و جابهجایی بین صفحهها.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const myPaymentDetailTour: TourDefinition = {
|
||||||
|
id: 'my-payment-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پرداختهای این بیمار', body: 'صورتحسابهای ثبتشده برای همین بیمار و ماندهحسابش.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'آیتمهای صورتحساب', body: 'هر ردیف یک خدمت با سهم بیمار و سهم بیمه.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const myFinancialTour: TourDefinition = {
|
||||||
|
id: 'my-financial',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'مدیریت پرداخت', body: 'درگاه، حساب بانکی و روشهای دریافت وجه از بیمار.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const claimsTour: TourDefinition = {
|
||||||
|
id: 'claims',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پروندههای بیمه', body: 'مطالبات بیمه به تفکیک بیمار؛ چقدر ثبت شده، چقدر وصول شده.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-search', title: 'جستجوی بیمار', body: 'با نام یا کد ملی بیمار پیدایش کنید.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'فهرست مطالبات', body: 'روی هر ردیف بزنید تا ریز خدمات و سهم بیمهاش باز شود.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const claimDetailTour: TourDefinition = {
|
||||||
|
id: 'claim-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'جزئیات مطالبه', body: 'خدمات بیمهایِ همین بیمار و وضعیت وصول هرکدام.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'ریز خدمات', body: 'سهم بیمه و سهم بیمار برای هر خدمت جدا آمده است.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insurancePricingTour: TourDefinition = {
|
||||||
|
id: 'insurance-pricing',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'مدیریت بیمه', body: 'قراردادهای بیمهٔ پایه و تکمیلی و درصد پوشش هرکدام.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const discountsTour: TourDefinition = {
|
||||||
|
id: 'discounts',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'مدیریت تخفیفها', body: 'قوانین تخفیف که هنگام صدور صورتحساب اعمال میشوند.', side: 'bottom' },
|
||||||
|
{ anchor: 'discounts-rules', title: 'قوانین', body: 'برای هر خدمت یا کل فاکتور، درصد یا مبلغ ثابت تعریف کنید.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscriptionTour: TourDefinition = {
|
||||||
|
id: 'subscription',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'اشتراک', body: 'پلن فعلی و پلنهای قابل خرید.', side: 'bottom' },
|
||||||
|
{ anchor: 'subscription-current', title: 'پلن فعلی', body: 'روزهای باقیمانده و دکمهٔ تمدید همینجاست.', side: 'bottom' },
|
||||||
|
{ anchor: 'subscription-plans', title: 'پلنها', body: 'امکانات هر پلن زیرش نوشته شده؛ خرید از روی همین کارتها انجام میشود.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const smsWalletTour: TourDefinition = {
|
||||||
|
id: 'sms-wallet',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'کیف پول پیامک', body: 'ارسال پیامک به بیماران از موجودی همین کیف پول کم میشود.', side: 'bottom' },
|
||||||
|
{ anchor: 'sms-balance', title: 'موجودی و مصرف', body: 'موجودی فعلی و آمار مصرف. وقتی موجودی کم شود، اینجا هشدار میگیرید.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-pagination', title: 'تاریخچه', body: 'شارژها و مصرفهای قبلی صفحهبندی شدهاند.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const myPatientsTour: TourDefinition = {
|
||||||
|
id: 'my-patients',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'page-title',
|
||||||
|
title: 'پروندهٔ بیماران',
|
||||||
|
body: 'فهرست مراجعهکنندگانی که برایشان پرونده ثبت شده است. با ثبت اولین نوبت، پرونده خودکار ساخته میشود.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'page-action',
|
||||||
|
title: 'پروندهٔ دستی',
|
||||||
|
body: 'برای بیماری که هنوز نوبت نگرفته، از اینجا پرونده بسازید.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'patients-search',
|
||||||
|
title: 'جستجو و نمای فهرست',
|
||||||
|
body: 'جستجو با نام، موبایل یا کد ملی. دو آیکون کنارش، نمای کارتی و لیستی را عوض میکنند.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'patients-records',
|
||||||
|
title: 'پروندهها',
|
||||||
|
body: 'با کلیک روی هر پرونده، اطلاعات بیمار و مراجعهها و پرداختهایش باز میشود.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'page-pagination',
|
||||||
|
title: 'صفحهبندی',
|
||||||
|
body: 'شمار کل پروندهها و جابهجایی بین صفحهها.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const myPatientRecordTour: TourDefinition = {
|
||||||
|
id: 'my-patient-record',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'record-banner',
|
||||||
|
title: 'شناسنامهٔ بیمار',
|
||||||
|
body: 'خلاصهٔ هویتی و تماس بیمار، شمارهٔ پرونده و تاریخ تشکیل آن.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'record-tabs',
|
||||||
|
title: 'بخشهای پرونده',
|
||||||
|
body: 'اطلاعات پرونده، مراجعهها، پرداختها و نوبتها؛ هر کدام در تب خودش.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'page-action',
|
||||||
|
title: 'مراجعهٔ جدید و بازگشت',
|
||||||
|
body: 'ثبت مراجعهٔ تازه برای همین بیمار، یا برگشتن به فهرست پروندهها.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const patientDetailTour: TourDefinition = {
|
||||||
|
id: 'patient-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'patient-banner',
|
||||||
|
title: 'شناسنامهٔ پرونده',
|
||||||
|
body: 'نام، شمارهٔ پرونده، برچسبها، نوبت و جلسهٔ بعدی و وضعیت بدهی — همه یکجا.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'patient-tabs',
|
||||||
|
title: 'بخشهای پرونده',
|
||||||
|
body: 'سرویسها، نوبتها، دورههای درمان، پرداختها، کیف پول، یادداشت، ضمیمه و پروندهٔ پزشکی.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appointmentCreateTour: TourDefinition = {
|
||||||
|
id: 'appointment-create',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'page-title',
|
||||||
|
title: 'ثبت نوبت جدید',
|
||||||
|
body: 'چهار قدم دارد: پزشک، مراجعهکننده، سرویس و زمان.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'create-doctor',
|
||||||
|
title: 'پزشک یا منبع',
|
||||||
|
body: 'اول پزشک را انتخاب کنید؛ سرویسها و ساعتهای آزاد از برنامهٔ همو میآیند.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'create-patient',
|
||||||
|
title: 'مراجعهکننده',
|
||||||
|
body: 'بیمار قبلی را جستجو کنید، یا مشخصات شخص جدید را وارد کنید تا پروندهاش ساخته شود.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'create-service',
|
||||||
|
title: 'سرویسها',
|
||||||
|
body: 'اول بخش، بعد یک یا چند سرویس. مدت و هزینهٔ نوبت از همینها حساب میشود.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'create-submit',
|
||||||
|
title: 'ثبت',
|
||||||
|
body: 'تا وقتی پزشک، بیمار، سرویس و زمان کامل نشده، دکمه غیرفعال است.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appointmentEditTour: TourDefinition = {
|
||||||
|
id: 'appointment-edit',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'edit-service',
|
||||||
|
title: 'ویرایش نوبت',
|
||||||
|
body: 'بخش، سرویس، پرسنل، زمان و وضعیت نوبت را از همینجا عوض کنید.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const patientsTour: TourDefinition = {
|
||||||
|
id: 'patients',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
anchor: 'page-title',
|
||||||
|
title: 'پروندهها',
|
||||||
|
body: 'فهرست کامل پروندههای بیماران این محیط، با امکان جستجو و فیلتر.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'patients-search',
|
||||||
|
title: 'جستجو',
|
||||||
|
body: 'کد ملی، شمارهٔ پرونده یا نام مراجعهکننده را بنویسید.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'patients-filters',
|
||||||
|
title: 'فیلترها',
|
||||||
|
body: 'جنسیت، بیمه، بازهٔ پذیرش، بدهی و برچسب. عدد روی دکمه یعنی چند فیلتر فعال است.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'patients-new',
|
||||||
|
title: 'تشکیل پرونده',
|
||||||
|
body: 'پروندهٔ جدید برای بیماری که هنوز ثبت نشده است.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchor: 'page-pagination',
|
||||||
|
title: 'صفحهبندی',
|
||||||
|
body: 'نمای کارتی ۱۶ و نمای جدولی ۱۲ پرونده در هر صفحه نشان میدهد.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const clinicServicesTour: TourDefinition = {
|
||||||
|
id: 'clinic-services',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'سرویسها', body: 'خدماتی که در نوبتدهی و صورتحساب استفاده میشوند؛ هرکدام مدت و تعرفهٔ خودش را دارد.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-action', title: 'سرویس جدید', body: 'خدمت تازه را اینجا تعریف کنید تا در ثبت نوبت قابل انتخاب شود.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serviceDetailTour: TourDefinition = {
|
||||||
|
id: 'service-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'جزئیات سرویس', body: 'تعرفه، مدت، بیمه و تنظیمات نوبتدهی همین خدمت.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serviceCategoriesTour: TourDefinition = {
|
||||||
|
id: 'service-categories',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'دستهبندیها', body: 'سرویسها را دستهبندی کنید تا در فهرستها و فیلترها مرتب بمانند.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'دستهها', body: 'هر دسته را میتوان ویرایش یا حذف کرد.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourcesTour: TourDefinition = {
|
||||||
|
id: 'resources',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'منابع', body: 'اتاق، تخت یا دستگاه — هر چیزی که خودش تقویم نوبت دارد.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-action', title: 'منبع جدید', body: 'منبع تازه بسازید و پزشک ناظرش را مشخص کنید.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'فهرست منابع', body: 'با کلیک روی هر منبع، تقویم و سرویسهایش باز میشود.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourceTypesTour: TourDefinition = {
|
||||||
|
id: 'resource-types',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'نوع منابع', body: 'دستهبندی منابع؛ مثلاً اتاق عمل، یونیت دندانپزشکی یا دستگاه لیزر.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'نوعها', body: 'هنگام ساخت منبع، یکی از همین نوعها انتخاب میشود.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourcePoolsTour: TourDefinition = {
|
||||||
|
id: 'resource-pools',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'استخر منابع', body: 'چند منبع همکار را در یک استخر بگذارید تا سیستم خودش یکی از آزادها را بدهد.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'استخرها', body: 'اعضای هر استخر از همینجا مدیریت میشوند.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourceSkillsTour: TourDefinition = {
|
||||||
|
id: 'resource-skills',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'مهارتها', body: 'مهارت لازم برای هر خدمت؛ فقط منبع یا پرسنلِ دارای آن مهارت قابل انتخاب میشود.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'فهرست مهارتها', body: 'مهارتها را بسازید و بعد به منابع و پرسنل نسبت دهید.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourceDetailTour: TourDefinition = {
|
||||||
|
id: 'resource-detail',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'جزئیات منبع', body: 'مشخصات منبع، سرویسهایش، مهارتها و دسترسی به تقویمش.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inventoryTour: TourDefinition = {
|
||||||
|
id: 'inventory',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'انبارداری', body: 'کالاهای مصرفی و پکیجها؛ مصرف هر جلسه از همین موجودی کم میشود.', side: 'bottom' },
|
||||||
|
{ anchor: 'inventory-tabs', title: 'دو بخش', body: 'کالای مصرفی و پکیج. پکیج چند کالا را با هم مصرف میکند.', side: 'bottom' },
|
||||||
|
{ anchor: 'inventory-stats', title: 'وضعیت انبار', body: 'ارزش موجودی و کالاهایی که به مرز هشدار رسیدهاند.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const treatmentCasesTour: TourDefinition = {
|
||||||
|
id: 'treatment-cases',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'دورههای درمان', body: 'درمانهای چندجلسهای بیماران و پیشرفت هر دوره.', side: 'bottom' },
|
||||||
|
{ anchor: 'treatment-tabs', title: 'بخشها', body: 'دورهها و جلسهها را جدا ببینید و بر اساس وضعیت فیلتر کنید.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const sessionNewTour: TourDefinition = {
|
||||||
|
id: 'session-new',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'ثبت مراجعه', body: 'یک مراجعهٔ تازه برای همین بیمار ثبت میکنید.', side: 'bottom' },
|
||||||
|
{ anchor: 'session-card', title: 'فرم مراجعه', body: 'خدمت انجامشده، پرسنل و مبلغ. بعد از ثبت، صورتحسابش قابل صدور است.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sessionEditTour: TourDefinition = {
|
||||||
|
id: 'session-edit',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'ویرایش مراجعه', body: 'خدمت، پرسنل یا مبلغ همین مراجعه را اصلاح کنید.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sessionPaymentTour: TourDefinition = {
|
||||||
|
id: 'session-payment',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پرداخت مراجعه', body: 'دریافت وجه بابت این مراجعه و ثبت روش پرداخت.', side: 'bottom' },
|
||||||
|
{ anchor: 'session-card', title: 'مبلغ و روش', body: 'سهم بیمه از سهم بیمار جدا حساب میشود؛ باقیمانده همان چیزی است که دریافت میکنید.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patientRecordFormTour: TourDefinition = {
|
||||||
|
id: 'patient-record-form',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پروندهٔ بیمار', body: 'ساخت یا ویرایش پروندهٔ یک مراجعهکننده.', side: 'bottom' },
|
||||||
|
{ anchor: 'record-form', title: 'فرم', body: 'موبایل کلید شناسایی بیمار است؛ کد ملی و بیمه بعداً هم قابل تکمیلاند.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const representationBlogsTour: TourDefinition = {
|
||||||
|
id: 'representation-blogs',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'وبلاگ من', body: 'مقالههایی که نوشتهاید و وضعیت بررسی هرکدام.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-action', title: 'مقالهٔ جدید', body: 'مقاله بعد از تأیید ادمین روی سایت منتشر میشود.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'فهرست مقالهها', body: 'وضعیت هر مقاله: پیشنویس، در انتظار بررسی یا منتشرشده.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const representationBlogFormTour: TourDefinition = {
|
||||||
|
id: 'representation-blog-form',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'نوشتن مقاله', body: 'عنوان، متن، تصویر و اطلاعات سئو مقاله.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const representationFinanceTour: TourDefinition = {
|
||||||
|
id: 'representation-finance',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'گزارش مالی', body: 'پورسانت شما از اشتراکهایی که مجموعههای تحت پوششتان خریدهاند.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-pagination', title: 'دورهها', body: 'رکوردهای مالی صفحهبندی شدهاند.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const representationProfileTour: TourDefinition = {
|
||||||
|
id: 'representation-profile',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پروفایل نماینده', body: 'مشخصات شما، شهر تحت پوشش و اطلاعات حساب بانکی برای تسویه.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const representationSettlementTour: TourDefinition = {
|
||||||
|
id: 'representation-settlement',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'تسویه حساب', body: 'درخواست برداشت پورسانت و وضعیت درخواستهای قبلی.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-pagination', title: 'تاریخچه', body: 'درخواستهای قبلی و نتیجهٔ هرکدام.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { TourDefinition } from '../types';
|
||||||
|
|
||||||
|
export const settingsMenuTour: TourDefinition = {
|
||||||
|
id: 'settings-menu',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'تنظیمات', body: 'همهٔ تنظیمات محیط از همینجا شاخه میگیرد: نوبتدهی، سرویسها، بیمه، برچسبها، اشتراک و کاربران.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const accountSettingsTour: TourDefinition = {
|
||||||
|
id: 'account-settings',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'حساب کاربری', body: 'شمارهٔ ورود، رمز عبور و اطلاعات شخصی حسابتان.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tagsSettingsTour: TourDefinition = {
|
||||||
|
id: 'tags-settings',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'برچسبها', body: 'برچسبهایی که به پروندهٔ بیماران میچسبانید و بعد با آنها فیلتر میکنید.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordNumberSettingsTour: TourDefinition = {
|
||||||
|
id: 'record-number-settings',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'شمارهٔ پرونده', body: 'الگوی شمارهگذاری پروندههای جدید — پیشوند، طول و شمارهٔ شروع.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appointmentSettingsTour: TourDefinition = {
|
||||||
|
id: 'appointment-settings',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'مدیریت نوبتدهی', body: 'برنامهٔ هفتگی، مدت نوبت، تعطیلیها و قواعد لغو نوبت.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clinicAppointmentSettingsTour: TourDefinition = {
|
||||||
|
id: 'clinic-appointment-settings',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'نوبتدهی کلینیک', body: 'تنظیمات نوبتدهی در سطح کلینیک؛ روی همهٔ پزشکان عضو اثر میگذارد.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const holidaysTour: TourDefinition = {
|
||||||
|
id: 'holidays',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'تعطیلات', body: 'روزهایی که نوبتدهی بسته است. تعطیلات رسمی کشور از قبل هست و میتوانید روز دلخواه هم اضافه کنید.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-table', title: 'فهرست روزها', body: 'هر ردیف یک روز تعطیل؛ حذفش نوبتدهی همان روز را باز میکند.', side: 'top' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const practiceDomainTour: TourDefinition = {
|
||||||
|
id: 'practice-domain',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'حوزهٔ فعالیت', body: 'نوع کار محیط شما؛ فرمهای پرونده و گردش درمان بر اساس همین انتخاب میشود.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clinicDoctorsTour: TourDefinition = {
|
||||||
|
id: 'clinic-doctors',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ anchor: 'page-title', title: 'پزشکان کلینیک', body: 'پزشکان عضو، دعوت پزشک جدید و قطع همکاری.', side: 'bottom' },
|
||||||
|
{ anchor: 'page-action', title: 'دعوت پزشک', body: 'با شمارهٔ موبایل دعوت بفرستید؛ پزشک بعد از پذیرش، در نوبتدهی کلینیک دیده میشود.', side: 'bottom' },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface TourStep {
|
||||||
|
/** مقدار اتریبیوت data-tour روی المان هدف */
|
||||||
|
anchor: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourDefinition {
|
||||||
|
/** شناسهٔ یکتا؛ همنام مسیر صفحه. سرور همین را ذخیره میکند، پس تغییرش یعنی تور از نو دیده میشود. */
|
||||||
|
id: string;
|
||||||
|
/** با هر بازنویسی متن تور یکی زیاد شود تا کاربر قدیمی هم یکبار دیگر آن را ببیند */
|
||||||
|
version: number;
|
||||||
|
steps: TourStep[];
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { toast } from 'sonner';
|
|||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
admin: 'مدیر', clinic: 'کلینیک', doctor: 'پزشک',
|
admin: 'مدیر', clinic: 'کلینیک', doctor: 'پزشک',
|
||||||
@@ -48,7 +49,7 @@ export default function AccountSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<SettingsLayout active="account">
|
<SettingsLayout active="account">
|
||||||
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 620 }}>
|
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 620 }}>
|
||||||
<h1 className="section-title">حساب کاربری</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">حساب کاربری</h1><TourButton tourId="account-settings" ready /></div>
|
||||||
|
|
||||||
{/* Profile summary */}
|
{/* Profile summary */}
|
||||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}>
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
|||||||
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
|
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
|
||||||
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
|
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
import { useTour } from '../hooks/useTour';
|
||||||
import { digitsOnly, todayIso, formatNumber } from '../lib/utils';
|
import { digitsOnly, todayIso, formatNumber } from '../lib/utils';
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
|
||||||
@@ -266,6 +268,8 @@ export default function AppointmentCreatePage() {
|
|||||||
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
|
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useTour('appointment-create', { ready: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px 24px', maxWidth: 1080, margin: '0 auto' }}>
|
<div style={{ padding: '20px 24px', maxWidth: 1080, margin: '0 auto' }}>
|
||||||
{/* بردکرامب */}
|
{/* بردکرامب */}
|
||||||
@@ -273,7 +277,8 @@ export default function AppointmentCreatePage() {
|
|||||||
<BackButton fallback="/admin/appointments" />
|
<BackButton fallback="/admin/appointments" />
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span>
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span>
|
||||||
<span style={{ color: 'var(--text-3)' }}>›</span>
|
<span style={{ color: 'var(--text-3)' }}>›</span>
|
||||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span>
|
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }} data-tour="page-title">ثبت نوبت جدید</span>
|
||||||
|
<TourButton tourId="appointment-create" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددورهای بدون این،
|
{/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددورهای بدون این،
|
||||||
@@ -313,7 +318,7 @@ export default function AppointmentCreatePage() {
|
|||||||
{!isDoctor && (
|
{!isDoctor && (
|
||||||
<>
|
<>
|
||||||
<div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div>
|
<div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div>
|
||||||
<label style={label}>انتخاب پزشک</label>
|
<label style={label} data-tour="create-doctor">انتخاب پزشک</label>
|
||||||
<div style={{ margin: '6px 0 4px', maxWidth: 400 }}>
|
<div style={{ margin: '6px 0 4px', maxWidth: 400 }}>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={doctorOptions}
|
options={doctorOptions}
|
||||||
@@ -386,7 +391,7 @@ export default function AppointmentCreatePage() {
|
|||||||
{/* حالت جستجوی مراجعهکنندهٔ موجود */}
|
{/* حالت جستجوی مراجعهکنندهٔ موجود */}
|
||||||
{!newPatient && (
|
{!newPatient && (
|
||||||
<>
|
<>
|
||||||
<label style={label}>انتخاب مراجعه کننده</label>
|
<label style={label} data-tour="create-patient">انتخاب مراجعه کننده</label>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12, margin: '6px 0 8px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12, margin: '6px 0 8px' }}>
|
||||||
<div className="field" style={{ width: 400, maxWidth: '100%' }}>
|
<div className="field" style={{ width: 400, maxWidth: '100%' }}>
|
||||||
<MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} />
|
<MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||||
@@ -487,7 +492,7 @@ export default function AppointmentCreatePage() {
|
|||||||
<>
|
<>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={label}>بخش</label>
|
<label style={label} data-tour="create-service">بخش</label>
|
||||||
<div style={{ marginTop: 6 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
inputId="appt-section-select"
|
inputId="appt-section-select"
|
||||||
@@ -702,7 +707,7 @@ export default function AppointmentCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||||
<button className="btn primary lg" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
<button className="btn primary lg" data-tour="create-submit" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||||
{create.isPending ? '...' : 'ثبت اطلاعات'}
|
{create.isPending ? '...' : 'ثبت اطلاعات'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export default function AppointmentDetailPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
backTo="/admin/appointments"
|
backTo="/admin/appointments"
|
||||||
title="جزئیات نوبت"
|
title="جزئیات نوبت"
|
||||||
|
tourId="appointment-detail"
|
||||||
|
tourReady={!isLoading}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||||
{ label: 'نوبتها', to: backTo },
|
{ label: 'نوبتها', to: backTo },
|
||||||
@@ -122,7 +124,7 @@ export default function AppointmentDetailPage() {
|
|||||||
) : appt ? (
|
) : appt ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
||||||
<h3 className="font-semibold text-[var(--text)] mb-4">اطلاعات بیمار</h3>
|
<h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-patient">اطلاعات بیمار</h3>
|
||||||
<InfoRow label="نام بیمار" value={appt.patient_name} />
|
<InfoRow label="نام بیمار" value={appt.patient_name} />
|
||||||
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
|
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
|
||||||
<InfoRow label="پزشک" value={appt.doctor?.name ?? null} />
|
<InfoRow label="پزشک" value={appt.doctor?.name ?? null} />
|
||||||
@@ -139,7 +141,7 @@ export default function AppointmentDetailPage() {
|
|||||||
<AppointmentSegmentsCard appointmentUuid={appt.uuid} />
|
<AppointmentSegmentsCard appointmentUuid={appt.uuid} />
|
||||||
|
|
||||||
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
||||||
<h3 className="font-semibold text-[var(--text)] mb-4">وضعیت و اقدامات</h3>
|
<h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-status">وضعیت و اقدامات</h3>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-sm text-[var(--text-2)] mb-2">وضعیت فعلی:</p>
|
<p className="text-sm text-[var(--text-2)] mb-2">وضعیت فعلی:</p>
|
||||||
<StatusBadge type="appointment" value={appt.status} />
|
<StatusBadge type="appointment" value={appt.status} />
|
||||||
@@ -198,7 +200,7 @@ export default function AppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6 lg:col-span-2">
|
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6 lg:col-span-2">
|
||||||
<h3 className="font-semibold text-[var(--text)] mb-4">تاریخچه رویدادها</h3>
|
<h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-events">تاریخچه رویدادها</h3>
|
||||||
{eventsQuery.isLoading ? (
|
{eventsQuery.isLoading ? (
|
||||||
<div className="h-6 w-40 rounded skeleton" />
|
<div className="h-6 w-40 rounded skeleton" />
|
||||||
) : events.length === 0 ? (
|
) : events.length === 0 ? (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
|||||||
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
||||||
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
|
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
|
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
|
||||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||||
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||||
@@ -194,12 +195,13 @@ export default function AppointmentEditPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}>
|
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
|
<div data-tour="page-title"><TourButton tourId="appointment-edit" ready /></div>
|
||||||
<BackButton fallback={`/admin/appointments?date=${date}`} />
|
<BackButton fallback={`/admin/appointments?date=${date}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}>
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>مشخصات سرویس:</div>
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }} data-tour="edit-service">مشخصات سرویس:</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={label}>بخش</label>
|
<label style={label}>بخش</label>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScheduleSection } from '../components/schedule/ScheduleSection';
|
|||||||
import type { AddressData } from '../components/schedule/ScheduleSection';
|
import type { AddressData } from '../components/schedule/ScheduleSection';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
const PERSONAL = 'personal';
|
const PERSONAL = 'personal';
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ export default function AppointmentSettingsPage() {
|
|||||||
style={{ background: 'var(--surface)', minWidth: 0 }}
|
style={{ background: 'var(--surface)', minWidth: 0 }}
|
||||||
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
||||||
>
|
>
|
||||||
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1><TourButton tourId="appointment-settings" ready /></div>
|
||||||
|
|
||||||
<FreeVisitPrice readOnly={apptReadOnly} />
|
<FreeVisitPrice readOnly={apptReadOnly} />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const drive = vi.fn();
|
||||||
|
vi.mock('driver.js', () => ({ driver: vi.fn(() => ({ drive })) }));
|
||||||
|
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||||
|
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import AppointmentsPage from './AppointmentsPage';
|
||||||
|
import { appointmentsTour } from '../lib/tour/tours/appointments';
|
||||||
|
import { resolveSteps } from '../lib/tour/resolveSteps';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const driverMock = driver as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
/** پاسخهای ثابت صفحه؛ فقط وضعیت تور بین تستها فرق میکند. */
|
||||||
|
function mockApi(seen: Record<string, number>) {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/my/tours')) return Promise.resolve({ success: true, data: { seen } });
|
||||||
|
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 7, completed: 3, waiting: 2, cancelled: 1 } });
|
||||||
|
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||||
|
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||||
|
return Promise.resolve({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
drive.mockReset();
|
||||||
|
driverMock.mockClear();
|
||||||
|
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1' } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AppointmentsPage — راهنمای صفحه', () => {
|
||||||
|
it('دکمهٔ راهنما کنار عنوان است', async () => {
|
||||||
|
mockApi({});
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('برای کاربری که تور را ندیده، خودکار اجرا میشود', async () => {
|
||||||
|
mockApi({});
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('برای کاربری که تور را دیده، خودکار اجرا نمیشود', async () => {
|
||||||
|
mockApi({ appointments: appointmentsTour.version });
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
await screen.findByText('این روز تعطیل است');
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پزشک مستقل تب پزشکان ندارد، پس آن استپ از تور حذف میشود', async () => {
|
||||||
|
mockApi({});
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalled(), { timeout: 3000 });
|
||||||
|
|
||||||
|
const anchors = driverMock.mock.calls[0][0].steps.map((s: { element: string }) => s.element);
|
||||||
|
expect(anchors).not.toContain('[data-tour="appointments-doctors"]');
|
||||||
|
expect(anchors).toContain('[data-tour="appointments-stats"]');
|
||||||
|
// شمارندهٔ تور همان تعداد استپِ واقعاً موجود است، نه کل تعریف
|
||||||
|
expect(anchors).toHaveLength(resolveSteps(appointmentsTour.steps).length);
|
||||||
|
expect(anchors.length).toBeLessThan(appointmentsTour.steps.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,8 @@ import { useUrlState } from '../hooks/useUrlState';
|
|||||||
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
||||||
import TurnsTable from '../components/appointments/TurnsTable';
|
import TurnsTable from '../components/appointments/TurnsTable';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { useTour } from '../hooks/useTour';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
|
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
|
||||||
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
|
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
|
||||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||||
@@ -68,7 +70,7 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
<div ref={ref} data-tour="appointments-date" style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
||||||
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
|
||||||
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
||||||
</button>
|
</button>
|
||||||
@@ -417,14 +419,23 @@ export default function AppointmentsPage() {
|
|||||||
navigate(`/admin/appointments/${a.uuid}`);
|
navigate(`/admin/appointments/${a.uuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// تور بار اول فقط وقتی راه میافتد که نوبتها آمده باشند؛ قبل از آن نیمی از
|
||||||
|
// المانهای هدف هنوز روی صفحه نیستند.
|
||||||
|
useTour('appointments', { ready: !apptQuery.isLoading });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px 24px' }}>
|
<div style={{ padding: '20px 24px' }}>
|
||||||
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
||||||
{/* عنوان */}
|
{/* عنوان */}
|
||||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>نوبت ها</h1>
|
||||||
|
<TourButton tourId="appointments" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* نوار آمار */}
|
{/* نوار آمار */}
|
||||||
<TurnsStatInfo stats={stats} />
|
<div data-tour="appointments-stats">
|
||||||
|
<TurnsStatInfo stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -454,13 +465,16 @@ export default function AppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
<div data-tour="appointments-view" style={{ display: 'flex' }}>
|
||||||
|
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
||||||
<button
|
<button
|
||||||
aria-label="فیلترها"
|
aria-label="فیلترها"
|
||||||
|
data-tour="appointments-filters"
|
||||||
className="btn sm"
|
className="btn sm"
|
||||||
onClick={() => setFiltersOpen(true)}
|
onClick={() => setFiltersOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
@@ -476,6 +490,7 @@ export default function AppointmentsPage() {
|
|||||||
{!isRepresentation && canCreateAppt && (
|
{!isRepresentation && canCreateAppt && (
|
||||||
<button
|
<button
|
||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
|
data-tour="appointments-new"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویسهای خودش.
|
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویسهای خودش.
|
||||||
if (activeResource) { setBookingResource(activeResource); return; }
|
if (activeResource) { setBookingResource(activeResource); return; }
|
||||||
@@ -491,21 +506,23 @@ export default function AppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
||||||
<div style={{
|
<div data-tour="appointments-list" style={{
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--r)', overflow: 'hidden',
|
borderRadius: 'var(--r)', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{showDoctorTabs && (
|
{showDoctorTabs && (
|
||||||
<DoctorTabs
|
<div data-tour="appointments-doctors">
|
||||||
doctors={doctors.map(d => ({
|
<DoctorTabs
|
||||||
uuid: d.uuid,
|
doctors={doctors.map(d => ({
|
||||||
name: d.name,
|
uuid: d.uuid,
|
||||||
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
|
name: d.name,
|
||||||
}))}
|
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
|
||||||
selected={selectedDoctorUuid}
|
}))}
|
||||||
onSelect={selectDoctor}
|
selected={selectedDoctorUuid}
|
||||||
showAll={isAdmin}
|
onSelect={selectDoctor}
|
||||||
/>
|
showAll={isAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
|
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
|
||||||
@@ -735,7 +752,7 @@ function ServiceFilterSelect({ value, options, onChange }: {
|
|||||||
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minWidth: 280 }}>
|
<div data-tour="appointments-service" style={{ minWidth: 280 }}>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={options.map(s => ({ value: s.uuid, label: s.name }))}
|
options={options.map(s => ({ value: s.uuid, label: s.name }))}
|
||||||
value={value || null}
|
value={value || null}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default function CatalogCategoriesPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="دستهبندیها"
|
title="دستهبندیها"
|
||||||
|
tourId="service-categories"
|
||||||
description="دستهبندی سراسری کلینیک؛ یک بار تعریف میشود و سرویسها و منابع از همینها انتخاب میکنند."
|
description="دستهبندی سراسری کلینیک؛ یک بار تعریف میشود و سرویسها و منابع از همینها انتخاب میکنند."
|
||||||
backTo="/admin/settings-menu"
|
backTo="/admin/settings-menu"
|
||||||
action={
|
action={
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export default function ClaimPatientDetailPage() {
|
|||||||
<FeatureGate feature="insurance">
|
<FeatureGate feature="insurance">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
backTo="/admin/claims"
|
backTo="/admin/claims"
|
||||||
|
tourId="claim-detail"
|
||||||
title={patient?.full_name ?? 'پرونده بیمه بیمار'}
|
title={patient?.full_name ?? 'پرونده بیمه بیمار'}
|
||||||
description={[patient?.mobile, patient?.national_code].filter(Boolean).join(' · ') || undefined}
|
description={[patient?.mobile, patient?.national_code].filter(Boolean).join(' · ') || undefined}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -202,7 +203,8 @@ export default function ClaimPatientDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal open={!!selected} title="جزئیات مطالبه" size="lg" onClose={() => setSelected(null)}>
|
<Modal open={!!selected} title="جزئیات مطالبه"
|
||||||
|
size="lg" onClose={() => setSelected(null)}>
|
||||||
{selected && (
|
{selected && (
|
||||||
<div dir="rtl" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
<div dir="rtl" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, fontSize: 13 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, fontSize: 13 }}>
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export default function ClaimsPage() {
|
|||||||
<FeatureGate feature="insurance">
|
<FeatureGate feature="insurance">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="پروندههای بیمه"
|
title="پروندههای بیمه"
|
||||||
|
tourId="claims"
|
||||||
description="پیگیری مطالبات بیمه به تفکیک بیمار"
|
description="پیگیری مطالبات بیمه به تفکیک بیمار"
|
||||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'پروندههای بیمه' }]}
|
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'پروندههای بیمه' }]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ function ClinicAppointmentSettingsContent() {
|
|||||||
در دید، بدون اسکرول. پایینتر از هدر، کاربر باید دنبالش میگشت. */}
|
در دید، بدون اسکرول. پایینتر از هدر، کاربر باید دنبالش میگشت. */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مدیریت نوبتدهی"
|
title="مدیریت نوبتدهی"
|
||||||
|
tourId="clinic-appointment-settings"
|
||||||
description={description}
|
description={description}
|
||||||
backTo="/admin/settings-menu"
|
backTo="/admin/settings-menu"
|
||||||
action={(
|
action={(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
|
|||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { latinDigitsField } from '../lib/forms';
|
import { latinDigitsField } from '../lib/forms';
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
// Fix leaflet icons
|
// Fix leaflet icons
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@@ -238,9 +239,11 @@ function EditModal({ clinic, onClose, onSaved }: {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
|
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
|
||||||
|
|
||||||
|
const dismiss = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<b>ویرایش کلینیک</b>
|
<b>ویرایش کلینیک</b>
|
||||||
<button className="mini-btn" onClick={onClose}>
|
<button className="mini-btn" onClick={onClose}>
|
||||||
@@ -587,6 +590,7 @@ export default function ClinicDetailPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
backTo="/admin/clinics"
|
backTo="/admin/clinics"
|
||||||
title={clinicName}
|
title={clinicName}
|
||||||
|
tourId="clinic-detail"
|
||||||
breadcrumbs={[{ label: 'کلینیکها', to: '/admin/clinics' }, { label: clinicName }]}
|
breadcrumbs={[{ label: 'کلینیکها', to: '/admin/clinics' }, { label: clinicName }]}
|
||||||
action={
|
action={
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function ClinicDoctorsContent() {
|
|||||||
{/* عنوان و بازگشت و اکشن هر سه در PageHeader — همان قاعدهٔ بقیهٔ صفحات پنل. */}
|
{/* عنوان و بازگشت و اکشن هر سه در PageHeader — همان قاعدهٔ بقیهٔ صفحات پنل. */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="پزشکان کلینیک"
|
title="پزشکان کلینیک"
|
||||||
|
tourId="clinic-doctors"
|
||||||
description="مدیریت پزشکان و دعوتنامههای کلینیک"
|
description="مدیریت پزشکان و دعوتنامههای کلینیک"
|
||||||
backTo="/admin/settings-menu"
|
backTo="/admin/settings-menu"
|
||||||
action={
|
action={
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ function ClinicServicesPageInner() {
|
|||||||
که نه دکمهٔ بازگشت دارد و نه میگوید چیست. */}
|
که نه دکمهٔ بازگشت دارد و نه میگوید چیست. */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="سرویسها"
|
title="سرویسها"
|
||||||
|
tourId="clinic-services"
|
||||||
description="بخشها و سرویسهای قابل رزرو. مدت و قیمت هر سرویس از همینجا میآید."
|
description="بخشها و سرویسهای قابل رزرو. مدت و قیمت هر سرویس از همینجا میآید."
|
||||||
backTo="/admin/settings-menu"
|
backTo="/admin/settings-menu"
|
||||||
action={
|
action={
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,8 @@ import Pagination from '../components/ui/Pagination';
|
|||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
|
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
|
||||||
import { latinDigitsField } from '../lib/forms';
|
import { latinDigitsField } from '../lib/forms';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||||
|
|
||||||
@@ -42,6 +44,8 @@ export default function ClinicsPage() {
|
|||||||
const isRepresentation = primaryRole === 'representation';
|
const isRepresentation = primaryRole === 'representation';
|
||||||
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
||||||
|
const closeAdd = useCallback(() => setAddOpen(false), []);
|
||||||
|
const addDismiss = useOverlayDismiss(closeAdd);
|
||||||
const page = pageOf(urlState.page);
|
const page = pageOf(urlState.page);
|
||||||
const search = urlState.search;
|
const search = urlState.search;
|
||||||
const statusFilter = urlState.status;
|
const statusFilter = urlState.status;
|
||||||
@@ -108,7 +112,7 @@ export default function ClinicsPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">کلینیکها</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">کلینیکها</h1><TourButton tourId="clinics" ready /></div>
|
||||||
<div className="muted">{formatNumber(total)} کلینیک ثبتشده</div>
|
<div className="muted">{formatNumber(total)} کلینیک ثبتشده</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary sm" onClick={() => setAddOpen(true)}>
|
<button className="btn primary sm" onClick={() => setAddOpen(true)}>
|
||||||
@@ -263,8 +267,8 @@ export default function ClinicsPage() {
|
|||||||
{/* Add Modal */}
|
{/* Add Modal */}
|
||||||
{addOpen && (
|
{addOpen && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={() => setAddOpen(false)}>
|
<div className="overlay" {...addDismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 420 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<b>افزودن کلینیک</b>
|
<b>افزودن کلینیک</b>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsP
|
|||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { STATUS_META } from '../components/ui/AppointmentStatusDropdown';
|
import { STATUS_META } from '../components/ui/AppointmentStatusDropdown';
|
||||||
import StatusBadge from '../components/ui/StatusBadge';
|
import StatusBadge from '../components/ui/StatusBadge';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
// ── Chart period (Jalali) ─────────────────────────────────────────────────
|
// ── Chart period (Jalali) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -437,7 +438,7 @@ function AdminDashboard() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">داشبورد مدیریت</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">داشبورد مدیریت</h1><TourButton tourId="dashboard" ready /></div>
|
||||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
import DiscountTab from '../components/DiscountTab';
|
import DiscountTab from '../components/DiscountTab';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
/** مدیریت تخفیفها — بخش تنظیمات برای تعریف قوانین تخفیف عمومی (owner-scoped). */
|
/** مدیریت تخفیفها — بخش تنظیمات برای تعریف قوانین تخفیف عمومی (owner-scoped). */
|
||||||
export default function DiscountsPage() {
|
export default function DiscountsPage() {
|
||||||
@@ -9,8 +10,11 @@ export default function DiscountsPage() {
|
|||||||
style={{ background: 'var(--surface)', minWidth: 0 }}
|
style={{ background: 'var(--surface)', minWidth: 0 }}
|
||||||
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
||||||
>
|
>
|
||||||
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت تخفیفها</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }} data-tour="page-title">
|
||||||
<DiscountTab />
|
<h1 className="section-title">مدیریت تخفیفها</h1>
|
||||||
|
<TourButton tourId="discounts" ready />
|
||||||
|
</div>
|
||||||
|
<div data-tour="discounts-rules"><DiscountTab /></div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { latinDigitsField } from '../lib/forms';
|
|||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
|
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
// Fix leaflet default marker icons
|
// Fix leaflet default marker icons
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@@ -1432,7 +1433,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 sm:mb-1">
|
<div className="flex-1 min-w-0 sm:mb-1">
|
||||||
<h1 className="text-xl font-bold text-[var(--text)]">{displayDoctorName(doctor.name)}</h1>
|
<div className="flex items-center gap-1" data-tour="page-title">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--text)]">{displayDoctorName(doctor.name)}</h1>
|
||||||
|
<TourButton tourId="doctor-detail" ready />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||||
{/* Active status badge */}
|
{/* Active status badge */}
|
||||||
{doctor.active
|
{doctor.active
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import PersianDatePicker from '../components/ui/PersianDatePicker';
|
|||||||
import { iranMobileSchema } from '../lib/utils';
|
import { iranMobileSchema } from '../lib/utils';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -319,10 +320,11 @@ export default function DoctorFormPage() {
|
|||||||
پزشکان
|
پزشکان
|
||||||
</button>
|
</button>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span style={{ color: 'var(--text-2)', fontWeight: 600 }}>افزودن پزشک جدید</span>
|
<span style={{ color: 'var(--text-2)', fontWeight: 600 }} data-tour="page-title">افزودن پزشک جدید</span>
|
||||||
|
<TourButton tourId="doctor-new" ready />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)} data-tour="doctor-form">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
{/* ── Section: اطلاعات حساب ──────────────────────────────── */}
|
{/* ── Section: اطلاعات حساب ──────────────────────────────── */}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
|
|||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -239,7 +240,7 @@ export default function DoctorsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">پزشکان</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">پزشکان</h1><TourButton tourId="doctors" ready /></div>
|
||||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت پزشکان، تخصصها و وضعیت همکاری</div>
|
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت پزشکان، تخصصها و وضعیت همکاری</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import CreateStep from '../components/session/CreateStep';
|
|||||||
import PaymentStep from '../components/session/PaymentStep';
|
import PaymentStep from '../components/session/PaymentStep';
|
||||||
import { CloseModalD } from '../components/icons/FilesServiceIcons';
|
import { CloseModalD } from '../components/icons/FilesServiceIcons';
|
||||||
import { Breadcrumb } from '../components/PatientCaseBanner';
|
import { Breadcrumb } from '../components/PatientCaseBanner';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
/** ویرایش مراجعهی ثبتشده — دو تب: ویرایش سرویسها + مدیریت پرداختها. */
|
/** ویرایش مراجعهی ثبتشده — دو تب: ویرایش سرویسها + مدیریت پرداختها. */
|
||||||
export default function EditSessionPage() {
|
export default function EditSessionPage() {
|
||||||
@@ -47,7 +48,10 @@ export default function EditSessionPage() {
|
|||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
|
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
<h2 style={{ fontSize: 16, fontWeight: 700 }}>ویرایش مراجعه</h2>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 700 }}>ویرایش مراجعه</h2>
|
||||||
|
<TourButton tourId="session-edit" ready />
|
||||||
|
</div>
|
||||||
<button type="button" aria-label="بستن" onClick={() => nav(-1)}
|
<button type="button" aria-label="بستن" onClick={() => nav(-1)}
|
||||||
style={{ minWidth: 40, height: 40, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
style={{ minWidth: 40, height: 40, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<CloseModalD />
|
<CloseModalD />
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export default function HolidaysSettingsPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="تعطیلات رسمی"
|
title="تعطیلات رسمی"
|
||||||
|
tourId="holidays"
|
||||||
description="تعطیلات کشوری برای همهٔ محیطها اعمال میشود. اگر این محیط روزی را باز است، همینجا استثنا بزنید."
|
description="تعطیلات کشوری برای همهٔ محیطها اعمال میشود. اگر این محیط روزی را باز است، همینجا استثنا بزنید."
|
||||||
backTo="/admin/settings-menu"
|
backTo="/admin/settings-menu"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function InsurancePricingPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مدیریت بیمه"
|
title="مدیریت بیمه"
|
||||||
|
tourId="insurance-pricing"
|
||||||
description="قراردادهای بیمه پایه و تکمیلی"
|
description="قراردادهای بیمه پایه و تکمیلی"
|
||||||
/>
|
/>
|
||||||
<InsuranceServiceCategoriesCard />
|
<InsuranceServiceCategoriesCard />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
|||||||
import { useInventory } from '../hooks/useInventory';
|
import { useInventory } from '../hooks/useInventory';
|
||||||
import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory';
|
import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory';
|
||||||
import InventoryStatCards from '../components/inventory/InventoryStatCards';
|
import InventoryStatCards from '../components/inventory/InventoryStatCards';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
import InventoryItemsTable from '../components/inventory/InventoryItemsTable';
|
import InventoryItemsTable from '../components/inventory/InventoryItemsTable';
|
||||||
import PackagesView from '../components/inventory/PackagesView';
|
import PackagesView from '../components/inventory/PackagesView';
|
||||||
import AddItemModal from '../components/inventory/AddItemModal';
|
import AddItemModal from '../components/inventory/AddItemModal';
|
||||||
@@ -56,8 +57,11 @@ export default function InventoryPage() {
|
|||||||
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)', marginBottom: 12 }}>انبارداری</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 12 }} data-tour="page-title">
|
||||||
<div className="inv-tabs">
|
<h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)' }}>انبارداری</h1>
|
||||||
|
<TourButton tourId="inventory" ready />
|
||||||
|
</div>
|
||||||
|
<div className="inv-tabs" data-tour="inventory-tabs">
|
||||||
<button className={`inv-tab${tab === 'stock' ? ' active' : ''}`} onClick={() => setTab('stock')}>کالاهای مصرفی</button>
|
<button className={`inv-tab${tab === 'stock' ? ' active' : ''}`} onClick={() => setTab('stock')}>کالاهای مصرفی</button>
|
||||||
<button className={`inv-tab${tab === 'packages' ? ' active' : ''}`} onClick={() => setTab('packages')}>پکیج</button>
|
<button className={`inv-tab${tab === 'packages' ? ' active' : ''}`} onClick={() => setTab('packages')}>پکیج</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +109,7 @@ export default function InventoryPage() {
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
{tab === 'stock' ? (
|
{tab === 'stock' ? (
|
||||||
<>
|
<>
|
||||||
<InventoryStatCards stats={stats} />
|
<div data-tour="inventory-stats"><InventoryStatCards stats={stats} /></div>
|
||||||
{itemsLoading ? (
|
{itemsLoading ? (
|
||||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||||
) : filteredItems.length === 0 ? (
|
) : filteredItems.length === 0 ? (
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function MyFinancialPageContent() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مدیریت پرداخت"
|
title="مدیریت پرداخت"
|
||||||
|
tourId="my-financial"
|
||||||
description={`روشهای پرداخت «${environmentName}» (حساب بانکی و کارتخوان)`}
|
description={`روشهای پرداخت «${environmentName}» (حساب بانکی و کارتخوان)`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -606,6 +606,8 @@ function MyPatientsPageInner() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="پرونده بیماران"
|
title="پرونده بیماران"
|
||||||
description="مراجعهکنندگان ثبتشده شما"
|
description="مراجعهکنندگان ثبتشده شما"
|
||||||
|
tourId="my-patients"
|
||||||
|
tourReady={!isLoading}
|
||||||
action={
|
action={
|
||||||
canCreate ? (
|
canCreate ? (
|
||||||
<button
|
<button
|
||||||
@@ -621,6 +623,7 @@ function MyPatientsPageInner() {
|
|||||||
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div
|
<div
|
||||||
|
data-tour="patients-search"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--surface)",
|
background: "var(--surface)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
@@ -709,6 +712,7 @@ function MyPatientsPageInner() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
data-tour="patients-records"
|
||||||
style={
|
style={
|
||||||
viewMode === "grid"
|
viewMode === "grid"
|
||||||
? { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 14 }
|
? { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 14 }
|
||||||
@@ -1025,6 +1029,7 @@ function MyPatientsPageInner() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={selectedPatientName}
|
title={selectedPatientName}
|
||||||
description={`تلفن: ${selectedPatientPhone} — پرونده از ${formatDate(selectedRecord.created_at)}`}
|
description={`تلفن: ${selectedPatientPhone} — پرونده از ${formatDate(selectedRecord.created_at)}`}
|
||||||
|
tourId="my-patient-record"
|
||||||
action={
|
action={
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
@@ -1047,6 +1052,7 @@ function MyPatientsPageInner() {
|
|||||||
|
|
||||||
{/* بنر اطلاعات بیمار — مطابق فیگما */}
|
{/* بنر اطلاعات بیمار — مطابق فیگما */}
|
||||||
<div
|
<div
|
||||||
|
data-tour="record-banner"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--surface)",
|
background: "var(--surface)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
@@ -1130,6 +1136,7 @@ function MyPatientsPageInner() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="cp-tabs"
|
className="cp-tabs"
|
||||||
|
data-tour="record-tabs"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ describe('MyPaymentDetailPage (پرداختهای ثبتشده)', () => {
|
|||||||
it('fetches invoices for the patient uuid from the route', async () => {
|
it('fetches invoices for the patient uuid from the route', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findAllByText('دنیا خلیلی');
|
await screen.findAllByText('دنیا خلیلی');
|
||||||
expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices');
|
// ترتیب درخواستها قرارداد نیست — صفحه کوئریهای جانبی هم دارد (مثل وضعیت تور راهنما).
|
||||||
|
const urls = get.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||||
|
expect(urls.some((u) => u.includes('/api/v1/my/billing/patients/abc/invoices'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expands a row to show its line items and payment methods on بیشتر', async () => {
|
it('expands a row to show its line items and payment methods on بیشتر', async () => {
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export default function MyPaymentDetailPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
backTo="/admin/my-payments"
|
backTo="/admin/my-payments"
|
||||||
|
tourId="my-payment-detail"
|
||||||
title="پرداختهای ثبتشده"
|
title="پرداختهای ثبتشده"
|
||||||
description="صورتحسابهای ثبتشدهی این بیمار"
|
description="صورتحسابهای ثبتشدهی این بیمار"
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export default function MyPaymentsPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="لیست پرداختها"
|
title="لیست پرداختها"
|
||||||
|
tourId="my-payments"
|
||||||
description="پرداختهای ثبتشدهی بیماران شما"
|
description="پرداختهای ثبتشدهی بیماران شما"
|
||||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداختها' }]}
|
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداختها' }]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useAuthStore } from "../stores/authStore";
|
|||||||
import type { Secretary, SecretaryPermissions } from "../types";
|
import type { Secretary, SecretaryPermissions } from "../types";
|
||||||
import SecretaryPermissionsModal from "../components/SecretaryPermissionsModal";
|
import SecretaryPermissionsModal from "../components/SecretaryPermissionsModal";
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
|
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
|
||||||
|
|
||||||
@@ -815,12 +816,15 @@ function MySecretariesPageContent() {
|
|||||||
return (
|
return (
|
||||||
<div dir="rtl">
|
<div dir="rtl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[var(--text)] text-[20px] font-bold">لیست منشی ها</p>
|
<div className="flex items-center gap-1" data-tour="page-title">
|
||||||
|
<p className="text-[var(--text)] text-[20px] font-bold">لیست منشی ها</p>
|
||||||
|
<TourButton tourId="my-secretaries" ready />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تبها + دکمه افزودن */}
|
{/* تبها + دکمه افزودن */}
|
||||||
<div className="w-full flex items-end justify-between mt-[20px]">
|
<div className="w-full flex items-end justify-between mt-[20px]">
|
||||||
<div className="flex items-center gap-[8px] border-b border-[var(--border)]">
|
<div className="flex items-center gap-[8px] border-b border-[var(--border)]" data-tour="secretaries-tabs">
|
||||||
{["منشی های فعلی", "منشی های قبلی"].map((label, idx) => (
|
{["منشی های فعلی", "منشی های قبلی"].map((label, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -837,6 +841,7 @@ function MySecretariesPageContent() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
data-tour="secretaries-add"
|
||||||
onClick={handleAddClick}
|
onClick={handleAddClick}
|
||||||
className="shadow-none gap-[8px] bg-[var(--primary)] text-[var(--on-primary)] text-[14px] md:text-[15px] lg:text-[16px]
|
className="shadow-none gap-[8px] bg-[var(--primary)] text-[var(--on-primary)] text-[14px] md:text-[15px] lg:text-[16px]
|
||||||
font-medium py-[10px] px-[16px] h-[43px] md:h-[45px] lg:h-[48px] rounded-[4px] cursor-pointer
|
font-medium py-[10px] px-[16px] h-[43px] md:h-[45px] lg:h-[48px] rounded-[4px] cursor-pointer
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import PaymentStep from '../components/session/PaymentStep';
|
|||||||
import DetailsStep from '../components/session/DetailsStep';
|
import DetailsStep from '../components/session/DetailsStep';
|
||||||
import { CloseModalD } from '../components/icons/FilesServiceIcons';
|
import { CloseModalD } from '../components/icons/FilesServiceIcons';
|
||||||
import { Breadcrumb } from '../components/PatientCaseBanner';
|
import { Breadcrumb } from '../components/PatientCaseBanner';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ثبت مراجعه جدید — پورت کامل tauri /files/create-service (حالت ایجاد):
|
* ثبت مراجعه جدید — پورت کامل tauri /files/create-service (حالت ایجاد):
|
||||||
@@ -53,11 +54,14 @@ export default function NewSessionPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in" style={{ width: '100%' }}>
|
<div className="fade-in" style={{ width: '100%' }}>
|
||||||
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
|
||||||
|
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
|
||||||
|
<TourButton tourId="session-new" ready />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* card — tauri width 748 centered */}
|
{/* card — tauri width 748 centered */}
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
|
<div className="bg-[var(--surface)]" data-tour="session-card" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import Modal from '../components/ui/Modal';
|
|||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||||
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
|
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
import { useTour } from '../hooks/useTour';
|
||||||
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
|
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
|
||||||
import { useIssueInvoice } from '../hooks/useIssueInvoice';
|
import { useIssueInvoice } from '../hooks/useIssueInvoice';
|
||||||
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
|
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
|
||||||
@@ -200,10 +202,16 @@ export default function PatientDetailPage() {
|
|||||||
.filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled'))
|
.filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled'))
|
||||||
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
|
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
|
||||||
|
|
||||||
|
useTour('patient-detail', { ready: !isLoading });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */}
|
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */}
|
||||||
<Breadcrumb name={record?.user_name || 'پرونده'} backTo="/admin/patients" />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
|
||||||
|
<Breadcrumb name={record?.user_name || 'پرونده'} backTo="/admin/patients" />
|
||||||
|
<TourButton tourId="patient-detail" />
|
||||||
|
</div>
|
||||||
|
<div data-tour="patient-banner">
|
||||||
<PatientCaseBanner
|
<PatientCaseBanner
|
||||||
name={record?.user_name || 'پرونده'}
|
name={record?.user_name || 'پرونده'}
|
||||||
recordNumber={record?.record_number}
|
recordNumber={record?.record_number}
|
||||||
@@ -215,9 +223,10 @@ export default function PatientDetailPage() {
|
|||||||
hasDebt={hasDebt}
|
hasDebt={hasDebt}
|
||||||
onAddNote={() => setTab('notes')}
|
onAddNote={() => setTab('notes')}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* tab bar */}
|
{/* tab bar */}
|
||||||
<div style={{ display: 'flex', gap: 4, overflowX: 'auto', borderBottom: '1px solid var(--border)', marginBottom: 18 }}>
|
<div data-tour="patient-tabs" style={{ display: 'flex', gap: 4, overflowX: 'auto', borderBottom: '1px solid var(--border)', marginBottom: 18 }}>
|
||||||
{TABS.map((t) => {
|
{TABS.map((t) => {
|
||||||
const on = t.key === tab;
|
const on = t.key === tab;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { numericField } from '../lib/forms';
|
|||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
|
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
|
||||||
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
|
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
|
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
|
||||||
|
|
||||||
@@ -131,10 +132,13 @@ export default function PatientRecordFormPage() {
|
|||||||
<div className="fade-in" style={{ maxWidth: 1000, margin: '0 auto' }}>
|
<div className="fade-in" style={{ maxWidth: 1000, margin: '0 auto' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
|
||||||
<BackButton fallback="/admin/patients" />
|
<BackButton fallback="/admin/patients" />
|
||||||
<div style={{ fontSize: 14, color: 'var(--text-3)' }}>پرونده › <b style={{ color: 'var(--text)' }}>{isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}</b></div>
|
<div style={{ fontSize: 14, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
|
||||||
|
پرونده › <b style={{ color: 'var(--text)' }}>{isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}</b>
|
||||||
|
<TourButton tourId="patient-record-form" ready />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit((d) => save.mutate(d))}
|
<form onSubmit={form.handleSubmit((d) => save.mutate(d))} data-tour="record-form"
|
||||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 24 }}>
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 24 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 18 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 18 }}>
|
||||||
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
|
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import Pagination from '../components/ui/Pagination';
|
|||||||
import PatientTagsCell from '../components/PatientTagsCell';
|
import PatientTagsCell from '../components/PatientTagsCell';
|
||||||
import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal';
|
import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { useTour } from '../hooks/useTour';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
import {
|
import {
|
||||||
SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn,
|
SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn,
|
||||||
} from '../components/icons/FilesToolbarIcons';
|
} from '../components/icons/FilesToolbarIcons';
|
||||||
@@ -161,6 +163,8 @@ export default function PatientsListPage() {
|
|||||||
queryFn: () => api.get(`/api/v1/patients?${qs.toString()}`),
|
queryFn: () => api.get(`/api/v1/patients?${qs.toString()}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useTour('patients', { ready: !isLoading });
|
||||||
|
|
||||||
const activeFilters = countFilters(filters);
|
const activeFilters = countFilters(filters);
|
||||||
const records = data?.data ?? EMPTY;
|
const records = data?.data ?? EMPTY;
|
||||||
const total = data?.meta?.totalRecords ?? 0;
|
const total = data?.meta?.totalRecords ?? 0;
|
||||||
@@ -183,14 +187,17 @@ export default function PatientsListPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
{/* Head — تیتر تنها (tauri files/head) */}
|
{/* Head — تیتر تنها (tauri files/head) */}
|
||||||
<p className="text-[var(--text)] text-[16px] md:text-[18px] lg:text-[20px] font-bold">پروندهها</p>
|
<div className="flex items-center gap-1" data-tour="page-title">
|
||||||
|
<p className="text-[var(--text)] text-[16px] md:text-[18px] lg:text-[20px] font-bold">پروندهها</p>
|
||||||
|
<TourButton tourId="patients" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Inputs — ردیف کنترلها (tauri files/inputs) */}
|
{/* Inputs — ردیف کنترلها (tauri files/inputs) */}
|
||||||
<div className="my-[16px] md:my-[20px] lg:my-[24px]">
|
<div className="my-[16px] md:my-[20px] lg:my-[24px]">
|
||||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-[16px] lg:gap-[20px]">
|
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-[16px] lg:gap-[20px]">
|
||||||
{/* جستجو + سوییچ نما */}
|
{/* جستجو + سوییچ نما */}
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 w-full">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 w-full">
|
||||||
<div className="w-full md:w-[442px]">
|
<div className="w-full md:w-[442px]" data-tour="patients-search">
|
||||||
<div className="flex items-center rounded-[6px] border border-[var(--border)] bg-[var(--bg)]" style={{ height: 48, paddingInline: 4 }}>
|
<div className="flex items-center rounded-[6px] border border-[var(--border)] bg-[var(--bg)]" style={{ height: 48, paddingInline: 4 }}>
|
||||||
<input
|
<input
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
@@ -211,7 +218,7 @@ export default function PatientsListPage() {
|
|||||||
{/* فیلتر + تشکیل پرونده */}
|
{/* فیلتر + تشکیل پرونده */}
|
||||||
<div className="flex items-center w-full lg:w-auto justify-between lg:justify-end" style={{ gap: 12 }}>
|
<div className="flex items-center w-full lg:w-auto justify-between lg:justify-end" style={{ gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
type="button" aria-label="فیلترها" onClick={() => setFilterOpen(true)}
|
type="button" aria-label="فیلترها" data-tour="patients-filters" onClick={() => setFilterOpen(true)}
|
||||||
className="flex items-center justify-center rounded-[4px] cursor-pointer"
|
className="flex items-center justify-center rounded-[4px] cursor-pointer"
|
||||||
style={{ width: 62, height: 48, border: '1px solid var(--primary)', background: 'transparent', position: 'relative' }}
|
style={{ width: 62, height: 48, border: '1px solid var(--primary)', background: 'transparent', position: 'relative' }}
|
||||||
>
|
>
|
||||||
@@ -222,7 +229,7 @@ export default function PatientsListPage() {
|
|||||||
</button>
|
</button>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button
|
<button
|
||||||
type="button" onClick={() => navigate('/admin/patients/new')}
|
type="button" data-tour="patients-new" onClick={() => navigate('/admin/patients/new')}
|
||||||
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer"
|
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer"
|
||||||
style={{ height: 48, minWidth: 137, background: 'var(--primary)', border: 'none', padding: '0 16px' }}
|
style={{ height: 48, minWidth: 137, background: 'var(--primary)', border: 'none', padding: '0 16px' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
|||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import type { PracticeDomain } from '../types';
|
import type { PracticeDomain } from '../types';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* حوزهٔ فعالیت کلینیک.
|
* حوزهٔ فعالیت کلینیک.
|
||||||
@@ -62,7 +63,7 @@ export default function PracticeDomainSettingsPage() {
|
|||||||
// منوی تنظیمات را نشان نمیداد و کاربر برای رفتن به بخش بعدی باید «بازگشت» میزد.
|
// منوی تنظیمات را نشان نمیداد و کاربر برای رفتن به بخش بعدی باید «بازگشت» میزد.
|
||||||
<SettingsLayout active="practice-domain">
|
<SettingsLayout active="practice-domain">
|
||||||
<div className="card card-pad" style={{ display: 'grid', gap: 14, maxWidth: 560 }}>
|
<div className="card card-pad" style={{ display: 'grid', gap: 14, maxWidth: 560 }}>
|
||||||
<h1 className="section-title" style={{ margin: 0 }}>حوزهٔ فعالیت</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ margin: 0 }}>حوزهٔ فعالیت</h1><TourButton tourId="practice-domain" ready /></div>
|
||||||
|
|
||||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||||
حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با
|
حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -9,6 +9,7 @@ import { formatDate } from '../lib/utils';
|
|||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
interface PreRegistration {
|
interface PreRegistration {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -49,6 +50,8 @@ export default function PreRegistrationsPage() {
|
|||||||
const [approveTarget, setApproveTarget] = useState<PreRegistration | null>(null);
|
const [approveTarget, setApproveTarget] = useState<PreRegistration | null>(null);
|
||||||
const [rejectTarget, setRejectTarget] = useState<PreRegistration | null>(null);
|
const [rejectTarget, setRejectTarget] = useState<PreRegistration | null>(null);
|
||||||
const [rejectNote, setRejectNote] = useState('');
|
const [rejectNote, setRejectNote] = useState('');
|
||||||
|
const closeReject = useCallback(() => setRejectTarget(null), []);
|
||||||
|
const rejectDismiss = useOverlayDismiss(closeReject);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<PaginatedResponse<PreRegistration>>({
|
const { data, isLoading } = useQuery<PaginatedResponse<PreRegistration>>({
|
||||||
@@ -206,8 +209,8 @@ export default function PreRegistrationsPage() {
|
|||||||
{/* Reject Dialog */}
|
{/* Reject Dialog */}
|
||||||
{rejectTarget && (
|
{rejectTarget && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={() => setRejectTarget(null)}>
|
<div className="overlay" {...rejectDismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 420 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<b>رد درخواست — {rejectTarget.name}</b>
|
<b>رد درخواست — {rejectTarget.name}</b>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
|||||||
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
|
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
|
||||||
import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings';
|
import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings';
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [
|
const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [
|
||||||
{ value: 'none', label: 'هرگز — شمارنده پیوسته جلو میرود' },
|
{ value: 'none', label: 'هرگز — شمارنده پیوسته جلو میرود' },
|
||||||
@@ -49,7 +50,7 @@ export default function RecordNumberSettingsPage() {
|
|||||||
style={{ background: 'var(--surface)', minWidth: 0 }}
|
style={{ background: 'var(--surface)', minWidth: 0 }}
|
||||||
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
||||||
>
|
>
|
||||||
<h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1><TourButton tourId="record-number-settings" ready /></div>
|
||||||
<p style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 18, lineHeight: 2 }}>
|
<p style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 18, lineHeight: 2 }}>
|
||||||
با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه — چه از فرم تشکیل پرونده و چه از
|
با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه — چه از فرم تشکیل پرونده و چه از
|
||||||
نوبتی که قطعی میشود — بر اساس همین الگو ساخته میشود.
|
نوبتی که قطعی میشود — بر اساس همین الگو ساخته میشود.
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export default function RepresentationBlogFormPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
backTo="/admin/representation-blogs"
|
backTo="/admin/representation-blogs"
|
||||||
title={isEdit ? 'ویرایش مقاله' : 'نوشتن مقالهٔ جدید'}
|
title={isEdit ? 'ویرایش مقاله' : 'نوشتن مقالهٔ جدید'}
|
||||||
|
tourId="representation-blog-form"
|
||||||
breadcrumbs={[{ label: 'وبلاگ من', to: '/admin/representation-blogs' }, { label: isEdit ? 'ویرایش' : 'جدید' }]}
|
breadcrumbs={[{ label: 'وبلاگ من', to: '/admin/representation-blogs' }, { label: isEdit ? 'ویرایش' : 'جدید' }]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export default function RepresentationBlogsPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="وبلاگ من"
|
title="وبلاگ من"
|
||||||
|
tourId="representation-blogs"
|
||||||
description="مقالات مخصوص دامنه و برند شما."
|
description="مقالات مخصوص دامنه و برند شما."
|
||||||
action={<button className="cp-btn-primary" onClick={() => navigate('/admin/representation-blogs/new')}>مقالهٔ جدید</button>}
|
action={<button className="cp-btn-primary" onClick={() => navigate('/admin/representation-blogs/new')}>مقالهٔ جدید</button>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { api } from '../lib/api';
|
|||||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||||
import { formatRial, formatNumber, formatDate } from '../lib/utils';
|
import { formatRial, formatNumber, formatDate } from '../lib/utils';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
interface FinanceRow {
|
interface FinanceRow {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -79,7 +80,7 @@ export default function RepresentationFinancePage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">گزارش مالی</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">گزارش مالی</h1><TourButton tourId="representation-finance" ready /></div>
|
||||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>
|
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>
|
||||||
درآمد ثبتشده از پورسانت نوبتها — تفکیک هر تراکنش
|
درآمد ثبتشده از پورسانت نوبتها — تفکیک هر تراکنش
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from '../lib/api';
|
|||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import PersianDatePicker from '../components/ui/PersianDatePicker';
|
import PersianDatePicker from '../components/ui/PersianDatePicker';
|
||||||
import { toEnglishDigits, digitsOnly } from '../lib/utils';
|
import { toEnglishDigits, digitsOnly } from '../lib/utils';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
|
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
|
||||||
function toJalali(iso: string): string {
|
function toJalali(iso: string): string {
|
||||||
@@ -126,7 +127,7 @@ export default function RepresentationProfilePage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">پروفایل نماینده</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">پروفایل نماینده</h1><TourButton tourId="representation-profile" ready /></div>
|
||||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>تأیید هویت و مدیریت شمارههای شبا</div>
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>تأیید هویت و مدیریت شمارههای شبا</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { formatRial, formatDate, tomanToRial } from '../lib/utils';
|
|||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { digitsOnly } from '../lib/utils';
|
import { digitsOnly } from '../lib/utils';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
interface WalletBalance { balance_rials: number }
|
interface WalletBalance { balance_rials: number }
|
||||||
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
|
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
|
||||||
@@ -101,7 +102,7 @@ export default function RepresentationSettlementPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">تسویه حساب</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">تسویه حساب</h1><TourButton tourId="representation-settlement" ready /></div>
|
||||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درخواست برداشت از کیفپول نماینده</div>
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درخواست برداشت از کیفپول نماینده</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
|||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
|
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
import { useTour } from '../hooks/useTour';
|
||||||
import { AppointmentInfoModal, TransferReserveModal } from '../components/AppointmentActions';
|
import { AppointmentInfoModal, TransferReserveModal } from '../components/AppointmentActions';
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
@@ -119,16 +121,21 @@ export default function ReserveAppointmentsPage() {
|
|||||||
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' };
|
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' };
|
||||||
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' };
|
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' };
|
||||||
|
|
||||||
|
useTour('appointments-reserve', { ready: !isLoading });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in" style={{ padding: '20px 24px' }}>
|
<div className="fade-in" style={{ padding: '20px 24px' }}>
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<BackButton fallback="/admin/appointments" />
|
<BackButton fallback="/admin/appointments" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, marginBottom: 16 }}>
|
||||||
<h1 style={{ fontSize: 17, fontWeight: 800 }}>نوبت های رزرو شده</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
|
||||||
|
<h1 style={{ fontSize: 17, fontWeight: 800 }}>نوبت های رزرو شده</h1>
|
||||||
|
<TourButton tourId="appointments-reserve" />
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{isClinic && (
|
{isClinic && (
|
||||||
<div style={{ minWidth: 180 }}>
|
<div style={{ minWidth: 180 }} data-tour="reserve-doctor">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={clinicDoctors.map(d => ({ value: d.uuid, label: d.name }))}
|
options={clinicDoctors.map(d => ({ value: d.uuid, label: d.name }))}
|
||||||
value={doctorUuid || null}
|
value={doctorUuid || null}
|
||||||
@@ -140,7 +147,7 @@ export default function ReserveAppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{primaryRole !== 'representation' && (
|
{primaryRole !== 'representation' && (
|
||||||
<button className="btn primary sm" disabled={!doctorUuid}
|
<button className="btn primary sm" data-tour="reserve-new" disabled={!doctorUuid}
|
||||||
onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
<PlusIcon style={{ width: 15 }} /> نوبت رزرو
|
<PlusIcon style={{ width: 15 }} /> نوبت رزرو
|
||||||
</button>
|
</button>
|
||||||
@@ -148,7 +155,7 @@ export default function ReserveAppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
|
<div data-tour="reserve-list" style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export default function ResourceDetailPage() {
|
|||||||
title={resource.name}
|
title={resource.name}
|
||||||
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`}
|
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`}
|
||||||
backTo="/admin/resources"
|
backTo="/admin/resources"
|
||||||
|
tourId="resource-detail"
|
||||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
|
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
|
||||||
action={
|
action={
|
||||||
canUpdate ? (
|
canUpdate ? (
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default function ResourcePoolsPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="استخر منابع"
|
title="استخر منابع"
|
||||||
|
tourId="resource-pools"
|
||||||
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید از یک نوع باشند."
|
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید از یک نوع باشند."
|
||||||
backTo="/admin/resources"
|
backTo="/admin/resources"
|
||||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
|
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function ResourceTypesPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="نوع منابع"
|
title="نوع منابع"
|
||||||
|
tourId="resource-types"
|
||||||
description="دستهبندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوعهای سیستمی حذف نمیشوند چون پل خودکار منابع به آنها تکیه دارد."
|
description="دستهبندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوعهای سیستمی حذف نمیشوند چون پل خودکار منابع به آنها تکیه دارد."
|
||||||
backTo="/admin/resources"
|
backTo="/admin/resources"
|
||||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
|
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export default function ResourcesPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="منابع"
|
title="منابع"
|
||||||
|
tourId="resources"
|
||||||
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
||||||
/* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
|
/* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
|
||||||
همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */
|
همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function SecretaryEarningsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader title="درآمد نوبتهای آنلاین" description="سهم شما از نوبتهایی که آنلاین ثبت و پرداخت شدهاند" />
|
<PageHeader title="درآمد نوبتهای آنلاین" description="سهم شما از نوبتهایی که آنلاین ثبت و پرداخت شدهاند"tourId="secretary-earnings" />
|
||||||
|
|
||||||
{summary && !summary.enabled ? (
|
{summary && !summary.enabled ? (
|
||||||
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text-2)', lineHeight: 1.9 }}>
|
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text-2)', lineHeight: 1.9 }}>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function SecretarySettlementPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader title="تسویه حساب" description="برداشت سهم نوبتهای آنلاین به شماره شبای شما" />
|
<PageHeader title="تسویه حساب" description="برداشت سهم نوبتهای آنلاین به شماره شبای شما"tourId="secretary-settlement" />
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
|
||||||
<StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" />
|
<StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" />
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ function ServiceDetailPageInner() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
backTo="/admin/clinic-services"
|
backTo="/admin/clinic-services"
|
||||||
|
tourId="service-detail"
|
||||||
title={item.name}
|
title={item.name}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: 'سرویسها', to: '/admin/clinic-services' },
|
{ label: 'سرویسها', to: '/admin/clinic-services' },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import PaymentStep from '../components/session/PaymentStep';
|
|||||||
import DetailsStep from '../components/session/DetailsStep';
|
import DetailsStep from '../components/session/DetailsStep';
|
||||||
import { CloseModalD } from '../components/icons/FilesServiceIcons';
|
import { CloseModalD } from '../components/icons/FilesServiceIcons';
|
||||||
import { Breadcrumb } from '../components/PatientCaseBanner';
|
import { Breadcrumb } from '../components/PatientCaseBanner';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تکمیل پرداخت مراجعه — پورت صفحهی tauri /files/create-service در حالت payment:
|
* تکمیل پرداخت مراجعه — پورت صفحهی tauri /files/create-service در حالت payment:
|
||||||
@@ -56,11 +57,14 @@ export default function SessionPaymentPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in" style={{ width: '100%' }}>
|
<div className="fade-in" style={{ width: '100%' }}>
|
||||||
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
|
||||||
|
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
|
||||||
|
<TourButton tourId="session-payment" ready />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* card — tauri width 748 centered */}
|
{/* card — tauri width 748 centered */}
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
|
<div className="bg-[var(--surface)]" data-tour="session-card" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ChevronLeftIcon } from '@heroicons/react/24/outline';
|
|||||||
import { groupedMenuForRole } from '../components/layout/settingsMenu';
|
import { groupedMenuForRole } from '../components/layout/settingsMenu';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SettingsMenuPage — the settings landing list for doctor/clinic users.
|
* SettingsMenuPage — the settings landing list for doctor/clinic users.
|
||||||
@@ -22,7 +23,7 @@ export default function SettingsMenuPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in" style={{ maxWidth: 720, margin: '0 auto' }}>
|
<div className="fade-in" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||||
<h1 className="section-title" style={{ marginBottom: 16 }}>تنظیمات</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 16 }}>تنظیمات</h1><TourButton tourId="settings-menu" ready /></div>
|
||||||
|
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<section key={group.key} aria-label={group.label} style={{ marginBottom: 20 }}>
|
<section key={group.key} aria-label={group.label} style={{ marginBottom: 20 }}>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function SkillsPage() {
|
|||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مهارتها"
|
title="مهارتها"
|
||||||
|
tourId="resource-skills"
|
||||||
description="مهارت روی منابع مینشیند و در انتخاب منبع مناسب استفاده میشود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمیشود."
|
description="مهارت روی منابع مینشیند و در انتخاب منبع مناسب استفاده میشود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمیشود."
|
||||||
backTo="/admin/resources"
|
backTo="/admin/resources"
|
||||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارتها' }]}
|
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارتها' }]}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
import { formatRial, tomanToRial } from '../lib/utils';
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import SmsWalletPage from './SmsWalletPage';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مبلغِ واردشده اعتبارِ کیف پول است — خالص. مالیات رویش اضافه میشود، پس عددِ
|
||||||
|
* دکمهٔ پرداخت باید بزرگتر از مبلغ واردشده باشد وگرنه کاربر سرِ درگاه غافلگیر
|
||||||
|
* میشود.
|
||||||
|
*/
|
||||||
|
function mockApi(taxPercent: number) {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/sms/wallet/balance')) {
|
||||||
|
return Promise.resolve({ success: true, data: { balance_rials: 0, sms_price_rials: 5000 } });
|
||||||
|
}
|
||||||
|
if (url.includes('/sms/wallet/logs')) {
|
||||||
|
return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||||
|
}
|
||||||
|
if (url.includes('/sms/settings')) return Promise.resolve({ success: true, data: null });
|
||||||
|
// FeatureGate صفحه را پشت قابلیت پنل پیامک نگه میدارد.
|
||||||
|
if (url.includes('/subscription/my')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
data: { subscription: null, used_trial: false, effective_plan: { features: { sms_panel: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/payment/config')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
data: { test_mode: false, appointment_fee_rials: 0, tax_percent: taxPercent, gateways: [{ name: 'mellat', label: 'بانک ملت' }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openChargeModalWith(amountToman: string) {
|
||||||
|
renderWithProviders(<SmsWalletPage />, { route: '/admin/sms-wallet' });
|
||||||
|
fireEvent.click(await screen.findByText('شارژ کیف پول'));
|
||||||
|
|
||||||
|
const input = await screen.findByLabelText('مبلغ شارژ (تومان)');
|
||||||
|
fireEvent.change(input, { target: { value: amountToman } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SmsWalletPage — tax', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
// useSubscription فقط برای این نقشها کوئری میزند؛ بدونش FeatureGate صفحه را میبندد.
|
||||||
|
useAuthStore.setState({ primaryRole: 'doctor', context: null } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds tax on top of the credit and shows the split', async () => {
|
||||||
|
mockApi(10);
|
||||||
|
await openChargeModalWith('100000');
|
||||||
|
|
||||||
|
const net = tomanToRial(100000);
|
||||||
|
expect(await screen.findByText('اعتباری که به کیف پول اضافه میشود')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('مالیات بر ارزش افزوده ۱۰٪')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(formatRial(net * 0.1))).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(formatRial(net * 1.1)).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no tax row and charges the plain amount when tax is off', async () => {
|
||||||
|
mockApi(0);
|
||||||
|
await openChargeModalWith('100000');
|
||||||
|
|
||||||
|
expect(await screen.findByText(/پرداخت .* از طریق بانک ملت/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('اعتباری که به کیف پول اضافه میشود')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(new RegExp(formatRial(tomanToRial(100000))))).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -66,7 +66,7 @@ function SmsWalletPageInner() {
|
|||||||
queryFn: () => api.get('/api/v1/sms/settings'),
|
queryFn: () => api.get('/api/v1/sms/settings'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isTestMode } = usePaymentConfig();
|
const { isTestMode, taxPercent } = usePaymentConfig();
|
||||||
|
|
||||||
const balance = balanceData?.data;
|
const balance = balanceData?.data;
|
||||||
const logs = logsData?.data ?? EMPTY_LOGS;
|
const logs = logsData?.data ?? EMPTY_LOGS;
|
||||||
@@ -76,6 +76,12 @@ function SmsWalletPageInner() {
|
|||||||
const chargeForm = useForm<ChargeForm>({ resolver: zodResolver(chargeSchema) });
|
const chargeForm = useForm<ChargeForm>({ resolver: zodResolver(chargeSchema) });
|
||||||
const watchAmount = chargeForm.watch('amount_rials');
|
const watchAmount = chargeForm.watch('amount_rials');
|
||||||
|
|
||||||
|
// مبلغِ واردشده خالص است — همان چیزی که به کیف پول مینشیند. مالیات رویش اضافه
|
||||||
|
// میشود، دقیقاً با همان فرمولِ بکاند، تا عددِ مودال با صفحهٔ بانک یکی باشد.
|
||||||
|
const chargeNet = tomanToRial(Number(watchAmount) || 0);
|
||||||
|
const chargeTax = Math.round(chargeNet * taxPercent / 100);
|
||||||
|
const chargePayable = chargeNet + chargeTax;
|
||||||
|
|
||||||
const chargeMutation = useMutation({
|
const chargeMutation = useMutation({
|
||||||
mutationFn: ({ amount_rials }: ChargeForm) =>
|
mutationFn: ({ amount_rials }: ChargeForm) =>
|
||||||
api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', {
|
api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', {
|
||||||
@@ -111,10 +117,10 @@ function SmsWalletPageInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" />
|
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" tourId="sms-wallet" />
|
||||||
|
|
||||||
{/* ردیف بالا: موجودی + آمار */}
|
{/* ردیف بالا: موجودی + آمار */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 }}>
|
<div data-tour="sms-balance" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||||
{/* کارت موجودی — gradient */}
|
{/* کارت موجودی — gradient */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
|
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
|
||||||
@@ -548,6 +554,31 @@ function SmsWalletPageInner() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{watchAmount && Number(watchAmount) >= 1000 && taxPercent > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 6,
|
||||||
|
background: 'var(--surface-2)', borderRadius: 8,
|
||||||
|
padding: '10px 14px', fontSize: 13, color: 'var(--text-2)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>اعتباری که به کیف پول اضافه میشود</span>
|
||||||
|
<span style={{ direction: 'ltr' }}>{formatRial(chargeNet)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>مالیات بر ارزش افزوده {formatNumber(taxPercent)}٪</span>
|
||||||
|
<span style={{ direction: 'ltr' }}>{formatRial(chargeTax)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between',
|
||||||
|
borderTop: '1px solid var(--border)', paddingTop: 6,
|
||||||
|
color: 'var(--text)', fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
<span>مبلغ قابل پرداخت</span>
|
||||||
|
<span style={{ direction: 'ltr' }}>{formatRial(chargePayable)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchAmount && Number(watchAmount) >= 1000 && (
|
{watchAmount && Number(watchAmount) >= 1000 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: isTestMode ? 'var(--warning-bg)' : 'var(--primary-soft)',
|
background: isTestMode ? 'var(--warning-bg)' : 'var(--primary-soft)',
|
||||||
@@ -556,8 +587,8 @@ function SmsWalletPageInner() {
|
|||||||
fontSize: 13, color: isTestMode ? 'var(--warning)' : 'var(--primary)', fontWeight: 500,
|
fontSize: 13, color: isTestMode ? 'var(--warning)' : 'var(--primary)', fontWeight: 500,
|
||||||
}}>
|
}}>
|
||||||
{isTestMode
|
{isTestMode
|
||||||
? `پرداخت آزمایشی ${formatRial(tomanToRial(Number(watchAmount)))}`
|
? `پرداخت آزمایشی ${formatRial(chargePayable)}`
|
||||||
: `پرداخت ${formatRial(tomanToRial(Number(watchAmount)))} از طریق ${gateway === 'mellat' ? 'بانک ملت' : 'سپ'}`
|
: `پرداخت ${formatRial(chargePayable)} از طریق ${gateway === 'mellat' ? 'بانک ملت' : 'سپ'}`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export default function StaffPage() {
|
|||||||
<SettingsLayout active="staff">
|
<SettingsLayout active="staff">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مدیریت پرسنل"
|
title="مدیریت پرسنل"
|
||||||
|
tourId="staff"
|
||||||
description="لیست پرسنل کلینیک / مطب"
|
description="لیست پرسنل کلینیک / مطب"
|
||||||
action={
|
action={
|
||||||
canCreate ? (
|
canCreate ? (
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export default function StaffSessionDetailPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`جلسهٔ ${formatNumber(session.session_number)} از ${formatNumber(session.total_sessions)}`}
|
title={`جلسهٔ ${formatNumber(session.session_number)} از ${formatNumber(session.total_sessions)}`}
|
||||||
|
tourId="my-session-detail"
|
||||||
backTo="/admin/my-sessions"
|
backTo="/admin/my-sessions"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default function StaffTreatmentSessionsPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="جلسات امروز من"
|
title="جلسات امروز من"
|
||||||
|
tourId="my-sessions"
|
||||||
action={
|
action={
|
||||||
<button type="button" className="btn secondary sm" onClick={() => refetch()} disabled={isFetching}>
|
<button type="button" className="btn secondary sm" onClick={() => refetch()} disabled={isFetching}>
|
||||||
{isFetching ? 'در حال بهروزرسانی…' : 'بهروزرسانی'}
|
{isFetching ? 'در حال بهروزرسانی…' : 'بهروزرسانی'}
|
||||||
|
|||||||
@@ -136,3 +136,49 @@ describe('SubscriptionPage', () => {
|
|||||||
expect(await screen.findByText('در حال حاضر پلنی برای نمایش وجود ندارد.')).toBeInTheDocument();
|
expect(await screen.findByText('در حال حاضر پلنی برای نمایش وجود ندارد.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Tax ────────────────────────────────────────────────────────────────────
|
||||||
|
// قیمت دوره خالص است و مالیات رویش مینشیند؛ کارت و مودال باید جمع کل را نشان
|
||||||
|
// دهند نه قیمت خالص را، وگرنه کاربر سرِ درگاه عدد دیگری میبیند.
|
||||||
|
|
||||||
|
const TAXED_PLANS = [
|
||||||
|
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3,
|
||||||
|
features: { patient_records: true, services: true, sms_panel: false }, active: true,
|
||||||
|
periods: [
|
||||||
|
{ uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000,
|
||||||
|
tax_percent: 10, tax_rials: 100000, payable_rials: 1100000, is_trial: false },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('SubscriptionPage — tax', () => {
|
||||||
|
beforeEach(() => { get.mockReset(); mockApi({ plans: TAXED_PLANS }); });
|
||||||
|
|
||||||
|
it('shows the payable amount on the plan card, not the net price', async () => {
|
||||||
|
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||||
|
const card = within(await screen.findByTestId('plan-card-basic'));
|
||||||
|
|
||||||
|
expect(card.getByText(formatRial(1100000))).toBeInTheDocument();
|
||||||
|
expect(card.queryByText(formatRial(1000000))).not.toBeInTheDocument();
|
||||||
|
expect(card.getByText(/۱۰٪ مالیات بر ارزش افزوده/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaks the price down inside the payment modal', async () => {
|
||||||
|
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||||
|
const card = within(await screen.findByTestId('plan-card-basic'));
|
||||||
|
fireEvent.click(card.getByText('تمدید اشتراک'));
|
||||||
|
|
||||||
|
await screen.findByText('پرداخت اشتراک');
|
||||||
|
expect(screen.getByText('قیمت دوره')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('مالیات بر ارزش افزوده ۱۰٪')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('جمع کل')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(formatRial(1100000)).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the net price when the backend sends no tax fields', async () => {
|
||||||
|
mockApi({ plans: PLANS });
|
||||||
|
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||||
|
const card = within(await screen.findByTestId('plan-card-basic'));
|
||||||
|
|
||||||
|
expect(card.getByText(formatRial(9000000))).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,10 +10,20 @@ import { usePaymentConfig } from '../hooks/usePaymentConfig';
|
|||||||
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
|
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
import {
|
import {
|
||||||
SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled,
|
SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled,
|
||||||
} from './subscriptionIcons';
|
} from './subscriptionIcons';
|
||||||
|
|
||||||
|
// ── Tax helpers ───────────────────────────────────────────────────────────
|
||||||
|
// `price_rials` خالص است و مالیات رویش مینشیند. فیلدهای مالیاتی را بکاند حساب
|
||||||
|
// میکند؛ fallback فقط برای پاسخِ کششدهٔ نسخهٔ قبلی است.
|
||||||
|
|
||||||
|
export const taxOf = (p: Pick<SubscriptionPeriod, 'tax_rials'>): number => p.tax_rials ?? 0;
|
||||||
|
|
||||||
|
export const payableOf = (p: Pick<SubscriptionPeriod, 'price_rials' | 'payable_rials'>): number =>
|
||||||
|
p.payable_rials ?? p.price_rials;
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Shared feature labels (also consumed by PaymentSuccessPage). */
|
/** Shared feature labels (also consumed by PaymentSuccessPage). */
|
||||||
@@ -92,9 +102,10 @@ export default function SubscriptionPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const purchaseMutation = useMutation({
|
const purchaseMutation = useMutation({
|
||||||
mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) =>
|
// مبلغ فرستاده نمیشود: بکاند خودش قیمت دوره + مالیات را حساب میکند.
|
||||||
|
mutationFn: ({ period_uuid, gateway }: { period_uuid: string; gateway: string }) =>
|
||||||
api.post<{ data: { pay_url?: string; redirect_url?: string; payment_url?: string } }>('/api/v1/subscription-payment', {
|
api.post<{ data: { pay_url?: string; redirect_url?: string; payment_url?: string } }>('/api/v1/subscription-payment', {
|
||||||
period_uuid, gateway, amount_rials,
|
period_uuid, gateway,
|
||||||
// Gateway returns here (with ?payment_uuid&status); the success page reads them.
|
// Gateway returns here (with ?payment_uuid&status); the success page reads them.
|
||||||
frontend_address: `${window.location.origin}/admin/subscription/success`,
|
frontend_address: `${window.location.origin}/admin/subscription/success`,
|
||||||
}),
|
}),
|
||||||
@@ -125,9 +136,12 @@ export default function SubscriptionPage() {
|
|||||||
<div dir="ltr" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 32, paddingTop: 8 }}>
|
<div dir="ltr" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 32, paddingTop: 8 }}>
|
||||||
{/* ── Header: current plan (left) + title (right) ── */}
|
{/* ── Header: current plan (left) + title (right) ── */}
|
||||||
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||||
{!myLoading && <CurrentPlanCard my={my} onRenew={renewCurrentPlan} />}
|
{!myLoading && <div data-tour="subscription-current"><CurrentPlanCard my={my} onRenew={renewCurrentPlan} /></div>}
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--text)' }}>انتخاب پلن اشتراک</div>
|
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--text)', display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
|
||||||
|
انتخاب پلن اشتراک
|
||||||
|
<TourButton tourId="subscription" ready={!plansLoading} />
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-3)', marginTop: 4, textAlign: 'left' }}>
|
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-3)', marginTop: 4, textAlign: 'left' }}>
|
||||||
پلن اشتراک مناسب خود را انتخاب نمایید:
|
پلن اشتراک مناسب خود را انتخاب نمایید:
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +160,7 @@ export default function SubscriptionPage() {
|
|||||||
در حال حاضر پلنی برای نمایش وجود ندارد.
|
در حال حاضر پلنی برای نمایش وجود ندارد.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 16, width: '100%' }}>
|
<div data-tour="subscription-plans" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 16, width: '100%' }}>
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<PlanCard
|
<PlanCard
|
||||||
key={plan.uuid}
|
key={plan.uuid}
|
||||||
@@ -191,11 +205,36 @@ export default function SubscriptionPage() {
|
|||||||
<div style={{ textAlign: 'left' }}>
|
<div style={{ textAlign: 'left' }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 2 }}>مبلغ قابل پرداخت</div>
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 2 }}>مبلغ قابل پرداخت</div>
|
||||||
<div style={{ fontWeight: 800, fontSize: 20, color: 'var(--primary)' }}>
|
<div style={{ fontWeight: 800, fontSize: 20, color: 'var(--primary)' }}>
|
||||||
{formatRial(purchaseTarget.period.price_rials)}
|
{formatRial(payableOf(purchaseTarget.period))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{taxOf(purchaseTarget.period) > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 6,
|
||||||
|
background: 'var(--surface-2)', borderRadius: 'var(--r-sm)',
|
||||||
|
padding: '10px 12px', fontSize: 13, color: 'var(--text-2)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>قیمت دوره</span>
|
||||||
|
<span style={{ direction: 'ltr' }}>{formatRial(purchaseTarget.period.price_rials)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>مالیات بر ارزش افزوده {formatNumber(purchaseTarget.period.tax_percent)}٪</span>
|
||||||
|
<span style={{ direction: 'ltr' }}>{formatRial(taxOf(purchaseTarget.period))}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between',
|
||||||
|
borderTop: '1px solid var(--border)', paddingTop: 6,
|
||||||
|
color: 'var(--text)', fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
<span>جمع کل</span>
|
||||||
|
<span style={{ direction: 'ltr' }}>{formatRial(payableOf(purchaseTarget.period))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isTestMode ? (
|
{isTestMode ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--warning-bg)', border: '1px solid var(--warning)',
|
background: 'var(--warning-bg)', border: '1px solid var(--warning)',
|
||||||
@@ -248,12 +287,12 @@ export default function SubscriptionPage() {
|
|||||||
style={{ flex: 1, height: 44 }}
|
style={{ flex: 1, height: 44 }}
|
||||||
disabled={purchaseMutation.isPending || (!isTestMode && (gateways.length === 0 || selectedGateway === ''))}
|
disabled={purchaseMutation.isPending || (!isTestMode && (gateways.length === 0 || selectedGateway === ''))}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
purchaseMutation.mutate({ period_uuid: purchaseTarget.period.uuid, gateway: selectedGateway, amount_rials: purchaseTarget.period.price_rials })
|
purchaseMutation.mutate({ period_uuid: purchaseTarget.period.uuid, gateway: selectedGateway })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{purchaseMutation.isPending
|
{purchaseMutation.isPending
|
||||||
? 'در حال انتقال...'
|
? 'در حال انتقال...'
|
||||||
: `پرداخت ${formatRial(purchaseTarget.period.price_rials)}`}
|
: `پرداخت ${formatRial(payableOf(purchaseTarget.period))}`}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn ghost" style={{ height: 44 }} onClick={() => setPurchaseTarget(null)}>
|
<button className="btn ghost" style={{ height: 44 }} onClick={() => setPurchaseTarget(null)}>
|
||||||
انصراف
|
انصراف
|
||||||
@@ -478,11 +517,18 @@ function PlanCard({
|
|||||||
justifyContent: 'flex-end', width: '100%',
|
justifyContent: 'flex-end', width: '100%',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 600, direction: 'ltr' }}>
|
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 600, direction: 'ltr' }}>
|
||||||
{formatRial(selectedPeriod.price_rials)}
|
{formatRial(payableOf(selectedPeriod))}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 500 }}>: قیمت</span>
|
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 500 }}>: قیمت</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isFree && selectedPeriod && taxOf(selectedPeriod) > 0 && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, color: 'var(--text-3)', textAlign: 'left', marginTop: -4,
|
||||||
|
}}>
|
||||||
|
شامل {formatNumber(selectedPeriod.tax_percent)}٪ مالیات بر ارزش افزوده
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|||||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
|
||||||
interface TenantTag { uuid: string; name: string; color: string; active: boolean }
|
interface TenantTag { uuid: string; name: string; color: string; active: boolean }
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ export default function TagsSettingsPage() {
|
|||||||
<SettingsLayout active="tags">
|
<SettingsLayout active="tags">
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
|
||||||
<h1 className="section-title">برچسبها</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">برچسبها</h1><TourButton tourId="tags-settings" ready /></div>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className="btn primary" onClick={openCreate}><PlusIcon style={{ width: 16 }} /> برچسب جدید</button>
|
<button className="btn primary" onClick={openCreate}><PlusIcon style={{ width: 16 }} /> برچسب جدید</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ export default function TreatmentCasesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="دورههای درمان" />
|
<PageHeader title="دورههای درمان" tourId="treatment-cases" />
|
||||||
|
|
||||||
<div className="tabs" style={{ marginBottom: 16 }}>
|
<div className="tabs" data-tour="treatment-tabs" style={{ marginBottom: 16 }}>
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -108,10 +109,12 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
|
|||||||
onSave: (role: string) => void; loading: boolean;
|
onSave: (role: string) => void; loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
|
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
|
||||||
|
const dismiss = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 400 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 400 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<h2 style={{ fontSize: 16 }}>تغییر نقش</h2>
|
<h2 style={{ fontSize: 16 }}>تغییر نقش</h2>
|
||||||
<button className="mini-btn" onClick={onClose}>
|
<button className="mini-btn" onClick={onClose}>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user