Compare commits

12 Commits
Author SHA1 Message Date
hamed 2459625c41 feat(tenant): implement tenant filter scope to manage cross-tenant data visibility 2026-08-10 16:22:00 +03:30
hamed ee2682e222 feat(landing): update meta titles for landing pages and enhance FAQ section layout 2026-08-10 15:01:40 +03:30
hamed 68b8b05630 feat: add landing page templates and registration modal
- Implemented header and hero section in _header.html.twig and _hero_art.html.twig.
- Created a pre-registration modal in _reg_modal.html.twig with form fields and validation.
- Added page scripts for dynamic behavior and interaction in _page_scripts.html.twig.
- Developed landing page structure in landing.html.twig, integrating header, footer, and modal.
- Introduced tests for landing page rendering and registry validation in LandingPageTest.php and LandingRegistryTest.php.
2026-08-10 14:38:27 +03:30
hamed 231ce793bc Add user guides for clinic management roles in Persian
- Create README.md for user guides covering roles: independent doctor, clinic owner, clinic manager, secretary, and staff.
- Add detailed step-by-step guides for setting up and managing clinics for clinic managers and clinic owners.
- Include instructions for independent doctors on setting up their practice.
- Provide a comprehensive guide for secretaries on daily operations and patient management.
- Introduce staff guide for nurses and technicians on session management.
2026-08-10 14:10:26 +03:30
hamed bb2dbc3371 fix(appointment): ensure appointment details are always fetched from the server to avoid incorrect pricing 2026-08-10 12:21:28 +03:30
hamed 7073377122 feat(billing): add command and tests for backfilling missing invoices for paid sessions 2026-08-10 12:08:58 +03:30
hamed 3365a0427e feat: implement useOverlayDismiss hook for consistent modal dismissal behavior 2026-08-10 11:27:16 +03:30
hamed 8a18457751 feat(billing): ensure finalized invoice for confirmed appointments with payments 2026-08-10 11:10:37 +03:30
hamed 505ab412a3 feat(appointment): add percentage shortcuts for patient share in payment rows 2026-08-10 10:38:56 +03:30
hamed a8699065b8 fix(tour): adjust close button positioning for driver popover in RTL layout 2026-08-10 10:30:46 +03:30
hamed ecdefa3c24 feat(tour): add onboarding tours for various admin pages
- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience.
- Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel.
- Updated documentation to reflect the addition of tours and their implementation details.
2026-08-10 10:10:20 +03:30
hamed 20c8eaaad9 feat: add TourProgressController and related entities for user tour progress tracking
- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users.
- Created UserTourProgress entity to store the highest version of tours seen by each user.
- Developed UserTourProgressRepository for database interactions related to user tour progress.
- Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps.
- Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
2026-08-10 09:34:14 +03:30
145 changed files with 21369 additions and 1215 deletions
+520
View File
@@ -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` که ۸۲ نقطهٔ استفاده دارد.
+325
View File
@@ -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` از این تغییر متأثر نیست؛ این صفحات روی دامنهٔ خود کلینیک‌پرو
هستند.
+283
View File
@@ -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();
+3
View File
@@ -59,3 +59,6 @@ clinicpro/*
# Database backups (never commit dumps to the repo)
*.sql
*.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 { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
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(''); }
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
const dismiss = useOverlayDismiss(onClose, open);
if (!open) return null;
@@ -103,8 +99,8 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
};
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
<div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 560 }}>
<div className="modal-head">
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
<button type="button" className="mini-btn" onClick={onClose}>
@@ -29,8 +29,12 @@ function render() {
beforeEach(() => {
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: {} });
});
@@ -45,6 +49,14 @@ function amountInputs() {
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
}
/**
* مودال تا رسیدنِ جزئیات نوبت از سرور، دکمه‌هایش قفل است — مبلغ نباید از ردیفِ
* ناقصِ فهرست خوانده شود. هر تست اول منتظر همین می‌ماند.
*/
async function settle() {
await waitFor(() => expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).not.toBeDisabled());
}
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» می‌رود و ردیف‌های مبلغ را برمی‌گرداند. */
function goToPayment() {
nextStep();
@@ -57,31 +69,35 @@ function goToReview() {
}
describe('ConfirmAppointmentModal', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', async () => {
render();
await settle();
expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
expect(screen.getByText('ویزیت')).toBeInTheDocument();
expect(screen.getByText('لیزر')).toBeInTheDocument();
expect(screen.getByText('جمع کل')).toBeInTheDocument();
});
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', () => {
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', async () => {
render();
await settle();
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
goToReview();
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
});
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', () => {
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
});
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', () => {
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', async () => {
render();
await settle();
const rows = goToPayment();
fireEvent.change(rows[0], { target: { value: '9000000' } });
@@ -96,6 +112,7 @@ describe('ConfirmAppointmentModal', () => {
it('پرداخت جزئی مجاز است و همان یک روش را ثبت می‌کند', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
@@ -108,6 +125,7 @@ describe('ConfirmAppointmentModal', () => {
it('تقسیم پرداخت بین دو روش: مجموع ردیف‌ها به‌صورت آرایه ثبت می‌شود', async () => {
render();
await settle();
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم می‌کنیم
fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
// افزودن روش دوم — پیش‌فرض با باقی‌ماندهٔ ۱۰۰٬۰۰۰ تومان پر می‌شود
@@ -131,8 +149,9 @@ describe('ConfirmAppointmentModal', () => {
}));
});
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', () => {
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', async () => {
render();
await settle();
goToPayment();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
expect(amountInputs()).toHaveLength(2);
@@ -162,12 +181,20 @@ const SUPP_CONTRACT = {
category_coverages: { outpatient: 90, inpatient: 60 },
};
/**
* @param detail نوبتی که اندپوینت جزئیات برمی‌گرداند — مودال همیشه آن را می‌خواند،
* چون ردیفِ فهرست مبلغ ویزیت و بیمه ندارد.
*/
function mockInsurance(
categories: { key: string; label: string; enabled: boolean }[],
freeVisitPriceRials = 0,
contracts: unknown[] = [CONTRACT],
detail: unknown = referenceAppointment,
) {
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/appointment/')) {
return Promise.resolve({ success: true, data: detail });
}
if (url === '/api/v1/insurance-pricing') {
return Promise.resolve({
success: true,
@@ -211,6 +238,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
@@ -222,6 +250,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
{ key: 'inpatient', label: 'خدمات بستری', enabled: false },
]);
renderReference();
await settle();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
@@ -230,6 +259,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
@@ -239,6 +269,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال می‌شوند (سرپایی ۷۰٪)', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -261,6 +292,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمی‌شود', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
@@ -269,6 +301,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('سهم پایه و تکمیلی جدا و زنجیره‌ای محاسبه می‌شوند و هر دو ارسال می‌گردند', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -296,6 +329,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال می‌شود', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -308,15 +342,17 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
});
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(
<ConfirmAppointmentModal
open
appointmentUuid="a1"
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }}
appointment={priceless}
onClose={() => {}}
/>,
);
await settle();
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد.
@@ -336,6 +372,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('نوع بستری درصد خودش را می‌گیرد (۳۰٪)', async () => {
mockInsurance(BOTH);
renderReference();
await settle();
await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات بستری');
@@ -353,8 +390,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li'))
.map(li => li.textContent?.replace(/^\d+/, '').trim());
it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', () => {
it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', async () => {
render();
await settle();
expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']);
// جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه.
@@ -362,16 +400,18 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
expect(screen.queryByPlaceholderText('0')).toBeNull();
});
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', () => {
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', async () => {
render();
await settle();
expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
});
it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', () => {
it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' }));
@@ -382,8 +422,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰');
});
it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', () => {
it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', async () => {
render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
@@ -392,3 +433,119 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
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 type { ApiResponse } from '../../lib/api';
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 { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
import Modal from '../ui/Modal';
@@ -13,6 +13,9 @@ import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect';
import Stepper from '../ui/Stepper';
/** میان‌بُرهای مبلغ روی هر ردیف پرداخت — درصدی از سهم بیمار. */
const PAYMENT_PERCENTS = [20, 50, 70, 100];
/** همان چهار روشِ SessionPayment::METHODS در بک‌اند. */
const METHOD_OPTIONS = [
{ value: 'cash', label: 'پرداخت نقدی' },
@@ -116,12 +119,14 @@ export default function ConfirmAppointmentModal({
const [touched, setTouched] = useState(false);
const [stepIdx, setStepIdx] = useState(0);
// وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم:
// مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند.
// جزئیات همیشه گرفته می‌شود، حتی وقتی صفحهٔ میزبان نوبتی پاس داده است: ردیفِ
// فهرست نوبت‌ها `visit_price_rials` و بیمه را ندارد، و مودال با اعتماد به همان
// ردیف، هزینهٔ ویزیتِ ثبت‌شده را صفر نشان می‌داد و همان صفر را هم ثبت می‌کرد.
// مبلغ چیزی نیست که از یک payload ناقص حدس زده شود.
const detailQuery = useQuery({
queryKey: ['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],
);
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);
@@ -254,6 +261,11 @@ export default function ConfirmAppointmentModal({
patchRow(id, { method, methodUuid: '', reference: '' });
}
/** درصدِ سهم بیمار روی همان ردیف می‌نشیند؛ ردیف‌های دیگر دست‌نخورده می‌مانند. */
function applyPercent(id: number, percent: number) {
patchRow(id, { amountToman: rialToToman(Math.round((payable * percent) / 100)) });
}
function addRow() {
setTouched(true);
// ردیفِ جدید پیش‌فرض با باقی‌مانده پر می‌شود تا تسویه سریع‌تر باشد.
@@ -270,7 +282,9 @@ export default function ConfirmAppointmentModal({
onClose();
}
const loading = detailQuery.isLoading && !appointment;
// تا رسیدنِ جزئیات، دکمه‌ها قفل‌اند: کاربر نباید روی مبلغی که هنوز از سرور نیامده
// «مرحلهٔ بعد» بزند و پرداختِ صفر ثبت کند.
const loading = detailQuery.isLoading;
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
const isLastStep = currentStep === 'review';
@@ -492,6 +506,39 @@ export default function ConfirmAppointmentModal({
)}
</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' && (
<div style={{ display: 'flex', gap: 10 }}>
+5 -2
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import Portal from './Portal';
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
interface Props {
open: boolean;
@@ -28,12 +29,14 @@ export default function ConfirmDialog({
onCancel,
children,
}: Props) {
const dismiss = useOverlayDismiss(onCancel, open);
if (!open) return null;
return (
<Portal>
<div className="overlay" onClick={onCancel}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
<div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 420 }}>
<div className="modal-head">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{
+5 -3
View File
@@ -63,9 +63,11 @@ export default function DataTable<T extends object>({
<div>
{/* Toolbar row */}
{(onSearchChange !== undefined || headerExtra) && (
<div className="toolbar">
// لنگرهای data-tour اینجا تعریف می‌شوند تا هر صفحه‌ای که DataTable دارد
// بدون تغییر کد خودش، در تور راهنما قابل اشاره باشد.
<div className="toolbar" data-tour="page-toolbar">
{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 }} />
<input
type="text"
@@ -79,7 +81,7 @@ export default function DataTable<T extends object>({
</div>
)}
<div className="table-wrap">
<div className="table-wrap" data-tour="page-table">
<table className="t">
<thead>
<tr>
@@ -9,6 +9,7 @@ import type { ApiResponse } from '../../lib/api';
import MobileInput from './MobileInput';
import Portal from './Portal';
import { iranMobileSchema } from '../../lib/utils';
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
const inviteSchema = z.object({
mobile: iranMobileSchema,
@@ -39,10 +40,12 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
onError: (e: Error) => toast.error(e.message),
});
const dismiss = useOverlayDismiss(onClose);
return (
<Portal>
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 420 }}>
<div className="modal-head">
<b>دعوت پزشک به کلینیک</b>
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
+5 -13
View File
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import React from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -21,22 +22,13 @@ interface Props {
}
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
const dismiss = useOverlayDismiss(onClose, open);
if (!open) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div
className="modal"
style={{ maxWidth: sizeMap[size] }}
onClick={(e) => e.stopPropagation()}
>
<div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: sizeMap[size] }}>
<div className="modal-head">
<h2>{title}</h2>
<button type="button" className="mini-btn" onClick={onClose} aria-label="بستن">
+14 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router';
import { ChevronLeftIcon } from '@heroicons/react/24/outline';
import BackButton from './BackButton';
import TourButton from './TourButton';
interface Crumb {
label: string;
@@ -18,9 +19,16 @@ interface Props {
* مقدار، مقصدِ fallback است وقتی تاریخچه‌ای برای برگشتن نیست.
*/
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 (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
<div style={{ minWidth: 0 }}>
@@ -48,12 +56,15 @@ export default function PageHeader({ title, breadcrumbs, action, description, ba
))}
</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 && (
<p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p>
)}
</div>
{action && <div style={{ flexShrink: 0 }}>{action}</div>}
{action && <div style={{ flexShrink: 0 }} data-tour="page-action">{action}</div>}
</div>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Pagination({ page, total, limit, onPageChange }: Props)
}
return (
<div className="pagination">
<div className="pagination" data-tour="page-pagination">
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
نمایش {formatNumber(from)}{formatNumber(to)} از {formatNumber(total)} مورد
</span>
@@ -120,7 +120,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
while (cells.length % 7 !== 0) cells.push(null);
return (
<div ref={ref} style={{
<div ref={ref} className="cp-calendar" style={{
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
@@ -123,6 +123,10 @@ export default function SearchableSelect({
return (
<Select<SelectOption>
// پیشوند پایدار برای کلاس‌ها: کلاس‌های emotion در build تولیدی برچسب ندارند و
// «منوی باز است یا نه» با آن‌ها قابل تشخیص نیست. useOverlayDismiss به همین
// نشانه نگاه می‌کند تا Esc اول منو را ببندد نه مودال را.
classNamePrefix="cp-select"
options={options}
value={selected}
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();
});
});
+45
View File
@@ -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>
);
}
+200
View File
@@ -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();
});
});
+52
View File
@@ -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 };
}
+76
View File
@@ -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 };
}
+48
View File
@@ -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 }),
};
}
+98
View File
@@ -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;
}
+14
View File
@@ -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);
}
+108
View File
@@ -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',
},
],
};
+105
View File
@@ -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' },
],
};
+85
View File
@@ -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' },
],
};
+63
View File
@@ -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',
},
],
};
+38
View File
@@ -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',
},
],
};
+91
View File
@@ -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' },
],
};
+75
View File
@@ -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' },
],
};
+15
View File
@@ -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[];
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
import TourButton from '../components/ui/TourButton';
const ROLE_LABELS: Record<string, string> = {
admin: 'مدیر', clinic: 'کلینیک', doctor: 'پزشک',
@@ -48,7 +49,7 @@ export default function AccountSettingsPage() {
return (
<SettingsLayout active="account">
<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 */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}>
+10 -5
View File
@@ -19,6 +19,8 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
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 Switch from '../components/ui/Switch';
@@ -266,6 +268,8 @@ export default function AppointmentCreatePage() {
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
});
useTour('appointment-create', { ready: true });
return (
<div style={{ padding: '20px 24px', maxWidth: 1080, margin: '0 auto' }}>
{/* بردکرامب */}
@@ -273,7 +277,8 @@ export default function AppointmentCreatePage() {
<BackButton fallback="/admin/appointments" />
<span style={{ fontSize: 13, 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>
{/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددوره‌ای بدون این،
@@ -313,7 +318,7 @@ export default function AppointmentCreatePage() {
{!isDoctor && (
<>
<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 }}>
<SearchableSelect
options={doctorOptions}
@@ -386,7 +391,7 @@ export default function AppointmentCreatePage() {
{/* حالت جستجوی مراجعه‌کنندهٔ موجود */}
{!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 className="field" style={{ width: 400, maxWidth: '100%' }}>
<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>
<label style={label}>بخش</label>
<label style={label} data-tour="create-service">بخش</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
inputId="appt-section-select"
@@ -702,7 +707,7 @@ export default function AppointmentCreatePage() {
</div>
<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 ? '...' : 'ثبت اطلاعات'}
</button>
</div>
+5 -3
View File
@@ -106,6 +106,8 @@ export default function AppointmentDetailPage() {
<PageHeader
backTo="/admin/appointments"
title="جزئیات نوبت"
tourId="appointment-detail"
tourReady={!isLoading}
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نوبت‌ها', to: backTo },
@@ -122,7 +124,7 @@ export default function AppointmentDetailPage() {
) : appt ? (
<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">
<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={<span dir="ltr">{appt.patient_mobile}</span>} />
<InfoRow label="پزشک" value={appt.doctor?.name ?? null} />
@@ -139,7 +141,7 @@ export default function AppointmentDetailPage() {
<AppointmentSegmentsCard appointmentUuid={appt.uuid} />
<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">
<p className="text-sm text-[var(--text-2)] mb-2">وضعیت فعلی:</p>
<StatusBadge type="appointment" value={appt.status} />
@@ -198,7 +200,7 @@ export default function AppointmentDetailPage() {
</div>
<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 ? (
<div className="h-6 w-40 rounded skeleton" />
) : events.length === 0 ? (
+4 -2
View File
@@ -10,6 +10,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
import BackButton from '../components/ui/BackButton';
import TourButton from '../components/ui/TourButton';
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
@@ -194,12 +195,13 @@ export default function AppointmentEditPage() {
return (
<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}`} />
</div>
<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>
<label style={label}>بخش</label>
@@ -9,6 +9,7 @@ import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import SearchableSelect from '../components/ui/SearchableSelect';
import { usePermissions } from '../hooks/usePermissions';
import TourButton from '../components/ui/TourButton';
const PERSONAL = 'personal';
@@ -75,7 +76,7 @@ export default function AppointmentSettingsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }}
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} />
@@ -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);
});
});
+33 -16
View File
@@ -28,6 +28,8 @@ import { useUrlState } from '../hooks/useUrlState';
import TurnsTimeline from '../components/appointments/TurnsTimeline';
import TurnsTable from '../components/appointments/TurnsTable';
import { usePermissions } from '../hooks/usePermissions';
import { useTour } from '../hooks/useTour';
import TourButton from '../components/ui/TourButton';
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
@@ -68,7 +70,7 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
}
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)}>
<ChevronRightIcon style={{ width: 15, height: 15 }} />
</button>
@@ -417,14 +419,23 @@ export default function AppointmentsPage() {
navigate(`/admin/appointments/${a.uuid}`);
}
// تور بار اول فقط وقتی راه می‌افتد که نوبت‌ها آمده باشند؛ قبل از آن نیمی از
// المان‌های هدف هنوز روی صفحه نیستند.
useTour('appointments', { ready: !apptQuery.isLoading });
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>
<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={{
@@ -454,13 +465,16 @@ export default function AppointmentsPage() {
</div>
)}
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
<div data-tour="appointments-view" style={{ display: 'flex' }}>
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
</div>
<div style={{ flex: 1 }} />
{/* سمت چپ: فیلتر + افزودن نوبت */}
<button
aria-label="فیلترها"
data-tour="appointments-filters"
className="btn sm"
onClick={() => setFiltersOpen(true)}
style={{
@@ -476,6 +490,7 @@ export default function AppointmentsPage() {
{!isRepresentation && canCreateAppt && (
<button
className="btn primary sm"
data-tour="appointments-new"
onClick={() => {
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویس‌های خودش.
if (activeResource) { setBookingResource(activeResource); return; }
@@ -491,21 +506,23 @@ export default function AppointmentsPage() {
</div>
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
<div style={{
<div data-tour="appointments-list" style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', overflow: 'hidden',
}}>
{showDoctorTabs && (
<DoctorTabs
doctors={doctors.map(d => ({
uuid: d.uuid,
name: d.name,
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
}))}
selected={selectedDoctorUuid}
onSelect={selectDoctor}
showAll={isAdmin}
/>
<div data-tour="appointments-doctors">
<DoctorTabs
doctors={doctors.map(d => ({
uuid: d.uuid,
name: d.name,
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
}))}
selected={selectedDoctorUuid}
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;
}) {
return (
<div style={{ minWidth: 280 }}>
<div data-tour="appointments-service" style={{ minWidth: 280 }}>
<SearchableSelect
options={options.map(s => ({ value: s.uuid, label: s.name }))}
value={value || null}
@@ -68,6 +68,7 @@ export default function CatalogCategoriesPage() {
<div className="fade-in">
<PageHeader
title="دسته‌بندی‌ها"
tourId="service-categories"
description="دسته‌بندی سراسری کلینیک؛ یک بار تعریف می‌شود و سرویس‌ها و منابع از همین‌ها انتخاب می‌کنند."
backTo="/admin/settings-menu"
action={
@@ -158,6 +158,7 @@ export default function ClaimPatientDetailPage() {
<FeatureGate feature="insurance">
<PageHeader
backTo="/admin/claims"
tourId="claim-detail"
title={patient?.full_name ?? 'پرونده بیمه بیمار'}
description={[patient?.mobile, patient?.national_code].filter(Boolean).join(' · ') || undefined}
breadcrumbs={[
@@ -202,7 +203,8 @@ export default function ClaimPatientDetailPage() {
/>
</div>
<Modal open={!!selected} title="جزئیات مطالبه" size="lg" onClose={() => setSelected(null)}>
<Modal open={!!selected} title="جزئیات مطالبه"
size="lg" onClose={() => setSelected(null)}>
{selected && (
<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 }}>
+1
View File
@@ -183,6 +183,7 @@ export default function ClaimsPage() {
<FeatureGate feature="insurance">
<PageHeader
title="پرونده‌های بیمه"
tourId="claims"
description="پیگیری مطالبات بیمه به تفکیک بیمار"
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'پرونده‌های بیمه' }]}
/>
@@ -87,6 +87,7 @@ function ClinicAppointmentSettingsContent() {
در دید، بدون اسکرول. پایین‌تر از هدر، کاربر باید دنبالش می‌گشت. */}
<PageHeader
title="مدیریت نوبت‌دهی"
tourId="clinic-appointment-settings"
description={description}
backTo="/admin/settings-menu"
action={(
+6 -2
View File
@@ -27,6 +27,7 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
import { latinDigitsField } from '../lib/forms';
import Switch from '../components/ui/Switch';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
// Fix leaflet icons
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 dismiss = useOverlayDismiss(onClose);
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }}>
<div className="modal-head">
<b>ویرایش کلینیک</b>
<button className="mini-btn" onClick={onClose}>
@@ -587,6 +590,7 @@ export default function ClinicDetailPage() {
<PageHeader
backTo="/admin/clinics"
title={clinicName}
tourId="clinic-detail"
breadcrumbs={[{ label: 'کلینیک‌ها', to: '/admin/clinics' }, { label: clinicName }]}
action={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
+1
View File
@@ -48,6 +48,7 @@ function ClinicDoctorsContent() {
{/* عنوان و بازگشت و اکشن هر سه در PageHeader — همان قاعدهٔ بقیهٔ صفحات پنل. */}
<PageHeader
title="پزشکان کلینیک"
tourId="clinic-doctors"
description="مدیریت پزشکان و دعوت‌نامه‌های کلینیک"
backTo="/admin/settings-menu"
action={
@@ -133,6 +133,7 @@ function ClinicServicesPageInner() {
که نه دکمهٔ بازگشت دارد و نه می‌گوید چیست. */}
<PageHeader
title="سرویس‌ها"
tourId="clinic-services"
description="بخش‌ها و سرویس‌های قابل رزرو. مدت و قیمت هر سرویس از همین‌جا می‌آید."
backTo="/admin/settings-menu"
action={
+8 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import {
@@ -25,6 +25,8 @@ import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
import { latinDigitsField } from '../lib/forms';
import TourButton from '../components/ui/TourButton';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
const HUES_LIST = [256, 205, 162, 295, 272];
@@ -42,6 +44,8 @@ export default function ClinicsPage() {
const isRepresentation = primaryRole === 'representation';
// وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
const closeAdd = useCallback(() => setAddOpen(false), []);
const addDismiss = useOverlayDismiss(closeAdd);
const page = pageOf(urlState.page);
const search = urlState.search;
const statusFilter = urlState.status;
@@ -108,7 +112,7 @@ export default function ClinicsPage() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<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>
<button className="btn primary sm" onClick={() => setAddOpen(true)}>
@@ -263,8 +267,8 @@ export default function ClinicsPage() {
{/* Add Modal */}
{addOpen && (
<Portal>
<div className="overlay" onClick={() => setAddOpen(false)}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
<div className="overlay" {...addDismiss}>
<div className="modal" style={{ maxWidth: 420 }}>
<div className="modal-head">
<b>افزودن کلینیک</b>
</div>
+2 -1
View File
@@ -22,6 +22,7 @@ import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsP
import { usePermissions } from '../hooks/usePermissions';
import { STATUS_META } from '../components/ui/AppointmentStatusDropdown';
import StatusBadge from '../components/ui/StatusBadge';
import TourButton from '../components/ui/TourButton';
// ── Chart period (Jalali) ─────────────────────────────────────────────────
@@ -437,7 +438,7 @@ function AdminDashboard() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<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>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
+6 -2
View File
@@ -1,5 +1,6 @@
import SettingsLayout from '../components/layout/SettingsLayout';
import DiscountTab from '../components/DiscountTab';
import TourButton from '../components/ui/TourButton';
/** مدیریت تخفیف‌ها — بخش تنظیمات برای تعریف قوانین تخفیف عمومی (owner-scoped). */
export default function DiscountsPage() {
@@ -9,8 +10,11 @@ export default function DiscountsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
>
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت تخفیفها</h1>
<DiscountTab />
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }} data-tour="page-title">
<h1 className="section-title">مدیریت تخفیفها</h1>
<TourButton tourId="discounts" ready />
</div>
<div data-tour="discounts-rules"><DiscountTab /></div>
</div>
</SettingsLayout>
);
+5 -1
View File
@@ -38,6 +38,7 @@ import { latinDigitsField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -1432,7 +1433,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div>
<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">
{/* Active status badge */}
{doctor.active
+4 -2
View File
@@ -18,6 +18,7 @@ import PersianDatePicker from '../components/ui/PersianDatePicker';
import { iranMobileSchema } from '../lib/utils';
import BackButton from '../components/ui/BackButton';
import { useAuthStore } from '../stores/authStore';
import TourButton from '../components/ui/TourButton';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -319,10 +320,11 @@ export default function DoctorFormPage() {
پزشکان
</button>
<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>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onSubmit)} data-tour="doctor-form">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── Section: اطلاعات حساب ──────────────────────────────── */}
+2 -1
View File
@@ -16,6 +16,7 @@ import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore';
import TourButton from '../components/ui/TourButton';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -239,7 +240,7 @@ export default function DoctorsPage() {
{/* Header */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<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>
<div style={{ display: 'flex', gap: 10 }}>
+5 -1
View File
@@ -9,6 +9,7 @@ import CreateStep from '../components/session/CreateStep';
import PaymentStep from '../components/session/PaymentStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/** ویرایش مراجعه‌ی ثبت‌شده — دو تب: ویرایش سرویس‌ها + مدیریت پرداخت‌ها. */
export default function EditSessionPage() {
@@ -47,7 +48,10 @@ export default function EditSessionPage() {
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<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)}
style={{ minWidth: 40, height: 40, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CloseModalD />
@@ -56,6 +56,7 @@ export default function HolidaysSettingsPage() {
<div className="fade-in">
<PageHeader
title="تعطیلات رسمی"
tourId="holidays"
description="تعطیلات کشوری برای همهٔ محیط‌ها اعمال می‌شود. اگر این محیط روزی را باز است، همین‌جا استثنا بزنید."
backTo="/admin/settings-menu"
/>
@@ -11,6 +11,7 @@ export default function InsurancePricingPage() {
<div className="fade-in">
<PageHeader
title="مدیریت بیمه"
tourId="insurance-pricing"
description="قراردادهای بیمه پایه و تکمیلی"
/>
<InsuranceServiceCategoriesCard />
+7 -3
View File
@@ -5,6 +5,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useInventory } from '../hooks/useInventory';
import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory';
import InventoryStatCards from '../components/inventory/InventoryStatCards';
import TourButton from '../components/ui/TourButton';
import InventoryItemsTable from '../components/inventory/InventoryItemsTable';
import PackagesView from '../components/inventory/PackagesView';
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 }}>
{/* Header */}
<div>
<h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)', marginBottom: 12 }}>انبارداری</h1>
<div className="inv-tabs">
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 12 }} data-tour="page-title">
<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 === 'packages' ? ' active' : ''}`} onClick={() => setTab('packages')}>پکیج</button>
</div>
@@ -105,7 +109,7 @@ export default function InventoryPage() {
{/* Body */}
{tab === 'stock' ? (
<>
<InventoryStatCards stats={stats} />
<div data-tour="inventory-stats"><InventoryStatCards stats={stats} /></div>
{itemsLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : filteredItems.length === 0 ? (
+1
View File
@@ -59,6 +59,7 @@ function MyFinancialPageContent() {
<div className="page">
<PageHeader
title="مدیریت پرداخت"
tourId="my-financial"
description={`روش‌های پرداخت «${environmentName}» (حساب بانکی و کارت‌خوان)`}
/>
+7
View File
@@ -606,6 +606,8 @@ function MyPatientsPageInner() {
<PageHeader
title="پرونده بیماران"
description="مراجعه‌کنندگان ثبت‌شده شما"
tourId="my-patients"
tourReady={!isLoading}
action={
canCreate ? (
<button
@@ -621,6 +623,7 @@ function MyPatientsPageInner() {
<div style={{ marginBottom: 16 }}>
<div
data-tour="patients-search"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
@@ -709,6 +712,7 @@ function MyPatientsPageInner() {
) : (
<>
<div
data-tour="patients-records"
style={
viewMode === "grid"
? { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 14 }
@@ -1025,6 +1029,7 @@ function MyPatientsPageInner() {
<PageHeader
title={selectedPatientName}
description={`تلفن: ${selectedPatientPhone} — پرونده از ${formatDate(selectedRecord.created_at)}`}
tourId="my-patient-record"
action={
<div style={{ display: "flex", gap: 8 }}>
<button
@@ -1047,6 +1052,7 @@ function MyPatientsPageInner() {
{/* بنر اطلاعات بیمار — مطابق فیگما */}
<div
data-tour="record-banner"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
@@ -1130,6 +1136,7 @@ function MyPatientsPageInner() {
<div
className="cp-tabs"
data-tour="record-tabs"
style={{
display: "flex",
gap: 8,
@@ -70,7 +70,9 @@ describe('MyPaymentDetailPage (پرداخت‌های ثبت‌شده)', () => {
it('fetches invoices for the patient uuid from the route', async () => {
renderPage();
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 () => {
@@ -128,6 +128,7 @@ export default function MyPaymentDetailPage() {
<>
<PageHeader
backTo="/admin/my-payments"
tourId="my-payment-detail"
title="پرداخت‌های ثبت‌شده"
description="صورتحساب‌های ثبت‌شده‌ی این بیمار"
breadcrumbs={[
+1
View File
@@ -129,6 +129,7 @@ export default function MyPaymentsPage() {
<>
<PageHeader
title="لیست پرداخت‌ها"
tourId="my-payments"
description="پرداخت‌های ثبت‌شده‌ی بیماران شما"
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداخت‌ها' }]}
/>
+7 -2
View File
@@ -13,6 +13,7 @@ import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
import SecretaryPermissionsModal from "../components/SecretaryPermissionsModal";
import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
@@ -815,12 +816,15 @@ function MySecretariesPageContent() {
return (
<div dir="rtl">
<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 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) => (
<button
key={idx}
@@ -837,6 +841,7 @@ function MySecretariesPageContent() {
))}
</div>
<button
data-tour="secretaries-add"
onClick={handleAddClick}
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
+6 -2
View File
@@ -11,6 +11,7 @@ import PaymentStep from '../components/session/PaymentStep';
import DetailsStep from '../components/session/DetailsStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/**
* ثبت مراجعه جدید پورت کامل tauri /files/create-service (حالت ایجاد):
@@ -53,11 +54,14 @@ export default function NewSessionPage() {
return (
<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 */}
<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 }}>
<button
type="button"
+11 -2
View File
@@ -22,6 +22,8 @@ import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PersianDateInput from '../components/ui/PersianDateInput';
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 { useIssueInvoice } from '../hooks/useIssueInvoice';
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'))
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
useTour('patient-detail', { ready: !isLoading });
return (
<div className="fade-in">
{/* 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
name={record?.user_name || 'پرونده'}
recordNumber={record?.record_number}
@@ -215,9 +223,10 @@ export default function PatientDetailPage() {
hasDebt={hasDebt}
onAddNote={() => setTab('notes')}
/>
</div>
{/* 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) => {
const on = t.key === tab;
return (
+6 -2
View File
@@ -15,6 +15,7 @@ import { numericField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
import TourButton from '../components/ui/TourButton';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
@@ -131,10 +132,13 @@ export default function PatientRecordFormPage() {
<div className="fade-in" style={{ maxWidth: 1000, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
<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>
<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 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 18 }}>
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
+11 -4
View File
@@ -14,6 +14,8 @@ import Pagination from '../components/ui/Pagination';
import PatientTagsCell from '../components/PatientTagsCell';
import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal';
import { usePermissions } from '../hooks/usePermissions';
import { useTour } from '../hooks/useTour';
import TourButton from '../components/ui/TourButton';
import {
SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn,
} from '../components/icons/FilesToolbarIcons';
@@ -161,6 +163,8 @@ export default function PatientsListPage() {
queryFn: () => api.get(`/api/v1/patients?${qs.toString()}`),
});
useTour('patients', { ready: !isLoading });
const activeFilters = countFilters(filters);
const records = data?.data ?? EMPTY;
const total = data?.meta?.totalRecords ?? 0;
@@ -183,14 +187,17 @@ export default function PatientsListPage() {
return (
<div className="fade-in">
{/* 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) */}
<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 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 }}>
<input
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 }}>
<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"
style={{ width: 62, height: 48, border: '1px solid var(--primary)', background: 'transparent', position: 'relative' }}
>
@@ -222,7 +229,7 @@ export default function PatientsListPage() {
</button>
{canCreate && (
<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"
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 { usePermissions } from '../hooks/usePermissions';
import type { PracticeDomain } from '../types';
import TourButton from '../components/ui/TourButton';
/**
* حوزهٔ فعالیت کلینیک.
@@ -62,7 +63,7 @@ export default function PracticeDomainSettingsPage() {
// منوی تنظیمات را نشان نمی‌داد و کاربر برای رفتن به بخش بعدی باید «بازگشت» می‌زد.
<SettingsLayout active="practice-domain">
<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 }}>
حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با
+6 -3
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
@@ -9,6 +9,7 @@ import { formatDate } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
interface PreRegistration {
uuid: string;
@@ -49,6 +50,8 @@ export default function PreRegistrationsPage() {
const [approveTarget, setApproveTarget] = useState<PreRegistration | null>(null);
const [rejectTarget, setRejectTarget] = useState<PreRegistration | null>(null);
const [rejectNote, setRejectNote] = useState('');
const closeReject = useCallback(() => setRejectTarget(null), []);
const rejectDismiss = useOverlayDismiss(closeReject);
const limit = 20;
const { data, isLoading } = useQuery<PaginatedResponse<PreRegistration>>({
@@ -206,8 +209,8 @@ export default function PreRegistrationsPage() {
{/* Reject Dialog */}
{rejectTarget && (
<Portal>
<div className="overlay" onClick={() => setRejectTarget(null)}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
<div className="overlay" {...rejectDismiss}>
<div className="modal" style={{ maxWidth: 420 }}>
<div className="modal-head">
<b>رد درخواست {rejectTarget.name}</b>
</div>
@@ -4,6 +4,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings';
import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [
{ value: 'none', label: 'هرگز — شمارنده پیوسته جلو می‌رود' },
@@ -49,7 +50,7 @@ export default function RecordNumberSettingsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }}
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 }}>
با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه چه از فرم تشکیل پرونده و چه از
نوبتی که قطعی میشود بر اساس همین الگو ساخته میشود.
@@ -94,6 +94,7 @@ export default function RepresentationBlogFormPage() {
<PageHeader
backTo="/admin/representation-blogs"
title={isEdit ? 'ویرایش مقاله' : 'نوشتن مقالهٔ جدید'}
tourId="representation-blog-form"
breadcrumbs={[{ label: 'وبلاگ من', to: '/admin/representation-blogs' }, { label: isEdit ? 'ویرایش' : 'جدید' }]}
/>
@@ -53,6 +53,7 @@ export default function RepresentationBlogsPage() {
<div className="fade-in">
<PageHeader
title="وبلاگ من"
tourId="representation-blogs"
description="مقالات مخصوص دامنه و برند شما."
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 { formatRial, formatNumber, formatDate } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import TourButton from '../components/ui/TourButton';
interface FinanceRow {
uuid: string;
@@ -79,7 +80,7 @@ export default function RepresentationFinancePage() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<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>
@@ -6,6 +6,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import { toEnglishDigits, digitsOnly } from '../lib/utils';
import TourButton from '../components/ui/TourButton';
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
function toJalali(iso: string): string {
@@ -126,7 +127,7 @@ export default function RepresentationProfilePage() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<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>
</div>
@@ -7,6 +7,7 @@ import { formatRial, formatDate, tomanToRial } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
import TourButton from '../components/ui/TourButton';
interface WalletBalance { balance_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="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<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>
</div>
+11 -4
View File
@@ -17,6 +17,8 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import Pagination from '../components/ui/Pagination';
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
import BackButton from '../components/ui/BackButton';
import TourButton from '../components/ui/TourButton';
import { useTour } from '../hooks/useTour';
import { AppointmentInfoModal, TransferReserveModal } from '../components/AppointmentActions';
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 td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' };
useTour('appointments-reserve', { ready: !isLoading });
return (
<div className="fade-in" style={{ padding: '20px 24px' }}>
<div style={{ marginBottom: 14 }}>
<BackButton fallback="/admin/appointments" />
</div>
<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 }}>
{isClinic && (
<div style={{ minWidth: 180 }}>
<div style={{ minWidth: 180 }} data-tour="reserve-doctor">
<SearchableSelect
options={clinicDoctors.map(d => ({ value: d.uuid, label: d.name }))}
value={doctorUuid || null}
@@ -140,7 +147,7 @@ export default function ReserveAppointmentsPage() {
</div>
)}
{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 }}>
<PlusIcon style={{ width: 15 }} /> نوبت رزرو
</button>
@@ -148,7 +155,7 @@ export default function ReserveAppointmentsPage() {
</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 ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
@@ -72,6 +72,7 @@ export default function ResourceDetailPage() {
title={resource.name}
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`}
backTo="/admin/resources"
tourId="resource-detail"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
action={
canUpdate ? (
+1
View File
@@ -66,6 +66,7 @@ export default function ResourcePoolsPage() {
<div className="fade-in">
<PageHeader
title="استخر منابع"
tourId="resource-pools"
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید از یک نوع باشند."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
+1
View File
@@ -50,6 +50,7 @@ export default function ResourceTypesPage() {
<div className="fade-in">
<PageHeader
title="نوع منابع"
tourId="resource-types"
description="دسته‌بندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوع‌های سیستمی حذف نمی‌شوند چون پل خودکار منابع به آن‌ها تکیه دارد."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
+1
View File
@@ -104,6 +104,7 @@ export default function ResourcesPage() {
<div className="fade-in">
<PageHeader
title="منابع"
tourId="resources"
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان."
/* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */
+1 -1
View File
@@ -77,7 +77,7 @@ export default function SecretaryEarningsPage() {
return (
<div className="fade-in">
<PageHeader title="درآمد نوبت‌های آنلاین" description="سهم شما از نوبت‌هایی که آنلاین ثبت و پرداخت شده‌اند" />
<PageHeader title="درآمد نوبت‌های آنلاین" description="سهم شما از نوبت‌هایی که آنلاین ثبت و پرداخت شده‌اند"tourId="secretary-earnings" />
{summary && !summary.enabled ? (
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text-2)', lineHeight: 1.9 }}>
@@ -109,7 +109,7 @@ export default function SecretarySettlementPage() {
return (
<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)' }}>
<StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" />
+1
View File
@@ -436,6 +436,7 @@ function ServiceDetailPageInner() {
<div className="fade-in">
<PageHeader
backTo="/admin/clinic-services"
tourId="service-detail"
title={item.name}
breadcrumbs={[
{ label: 'سرویس‌ها', to: '/admin/clinic-services' },
+6 -2
View File
@@ -10,6 +10,7 @@ import PaymentStep from '../components/session/PaymentStep';
import DetailsStep from '../components/session/DetailsStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/**
* تکمیل پرداخت مراجعه پورت صفحهی tauri /files/create-service در حالت payment:
@@ -56,11 +57,14 @@ export default function SessionPaymentPage() {
return (
<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 */}
<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 }}>
<button
type="button"
+2 -1
View File
@@ -4,6 +4,7 @@ import { ChevronLeftIcon } from '@heroicons/react/24/outline';
import { groupedMenuForRole } from '../components/layout/settingsMenu';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import TourButton from '../components/ui/TourButton';
/**
* SettingsMenuPage the settings landing list for doctor/clinic users.
@@ -22,7 +23,7 @@ export default function SettingsMenuPage() {
return (
<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) => (
<section key={group.key} aria-label={group.label} style={{ marginBottom: 20 }}>
+1
View File
@@ -46,6 +46,7 @@ export default function SkillsPage() {
<div className="fade-in">
<PageHeader
title="مهارت‌ها"
tourId="resource-skills"
description="مهارت روی منابع می‌نشیند و در انتخاب منبع مناسب استفاده می‌شود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمی‌شود."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارت‌ها' }]}
+2 -2
View File
@@ -117,10 +117,10 @@ function SmsWalletPageInner() {
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 */}
<div style={{
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
+1
View File
@@ -189,6 +189,7 @@ export default function StaffPage() {
<SettingsLayout active="staff">
<PageHeader
title="مدیریت پرسنل"
tourId="staff"
description="لیست پرسنل کلینیک / مطب"
action={
canCreate ? (
@@ -102,6 +102,7 @@ export default function StaffSessionDetailPage() {
<>
<PageHeader
title={`جلسهٔ ${formatNumber(session.session_number)} از ${formatNumber(session.total_sessions)}`}
tourId="my-session-detail"
backTo="/admin/my-sessions"
/>
@@ -37,6 +37,7 @@ export default function StaffTreatmentSessionsPage() {
<>
<PageHeader
title="جلسات امروز من"
tourId="my-sessions"
action={
<button type="button" className="btn secondary sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'در حال به‌روزرسانی…' : 'به‌روزرسانی'}
+7 -3
View File
@@ -10,6 +10,7 @@ import { usePaymentConfig } from '../hooks/usePaymentConfig';
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
import Modal from '../components/ui/Modal';
import SettingsLayout from '../components/layout/SettingsLayout';
import TourButton from '../components/ui/TourButton';
import {
SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled,
} from './subscriptionIcons';
@@ -135,9 +136,12 @@ export default function SubscriptionPage() {
<div dir="ltr" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 32, paddingTop: 8 }}>
{/* ── Header: current plan (left) + title (right) ── */}
<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 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>
@@ -156,7 +160,7 @@ export default function SubscriptionPage() {
در حال حاضر پلنی برای نمایش وجود ندارد.
</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) => (
<PlanCard
key={plan.uuid}
+2 -1
View File
@@ -12,6 +12,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import SettingsLayout from '../components/layout/SettingsLayout';
import { usePermissions } from '../hooks/usePermissions';
import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
interface TenantTag { uuid: string; name: string; color: string; active: boolean }
@@ -72,7 +73,7 @@ export default function TagsSettingsPage() {
<SettingsLayout active="tags">
<div className="fade-in">
<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 && (
<button className="btn primary" onClick={openCreate}><PlusIcon style={{ width: 16 }} /> برچسب جدید</button>
)}
+2 -2
View File
@@ -58,9 +58,9 @@ export default function TreatmentCasesPage() {
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) => (
<button
key={t.id}
+5 -2
View File
@@ -16,6 +16,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -108,10 +109,12 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
onSave: (role: string) => void; loading: boolean;
}) {
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
const dismiss = useOverlayDismiss(onClose);
return (
<Portal>
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 400 }} onClick={(e) => e.stopPropagation()}>
<div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 400 }}>
<div className="modal-head">
<h2 style={{ fontSize: 16 }}>تغییر نقش</h2>
<button className="mini-btn" onClick={onClose}>
+52
View File
@@ -1096,3 +1096,55 @@ html, body { max-width: 100%; overflow-x: hidden; }
[data-theme="dark"] .ck.ck-icon,
[data-theme="dark"] .ck.ck-icon :is(path, polygon, rect, circle, ellipse) { fill: currentColor; }
[data-theme="dark"] .ck.ck-button.ck-on .ck-icon :is(path, polygon, rect, circle, ellipse) { fill: var(--primary); }
/* تور راهنما
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 دکمهٔ بستن را با right:0 می‌چسباند؛ در چیدمان راست‌چین جایش سمت چپ است. */
.driver-popover.cp-tour .driver-popover-close-btn {
color: var(--text-3);
right: auto;
left: 0;
}
.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;
padding: 5px 12px;
}
.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); }
+4
View File
@@ -1,5 +1,9 @@
twig:
file_name_pattern: '*.twig'
globals:
# فوتر مشترک است و در هر دو صفحهٔ اصلی و لندینگ رندر می‌شود؛ global بودن
# رجیستری از پاس‌دادن دستی همان متغیر در هر کنترلر جلوگیری می‌کند.
landing_pages: '@App\Shared\Landing\LandingRegistry'
when@test:
twig:
+6
View File
@@ -547,6 +547,12 @@ Get all appointments for the authenticated user.
**Permission:** `AUTH` — عمداً بدون مجوزِ رجیستری.
> **فیلتر محیط اینجا اعمال نمی‌شود.** رکورد در محیطِ پزشکِ مقصد ثبت می‌شود، ولی
> مالکش از راه `user_id` تعیین می‌شود. کاربری که خودش صاحب محیط دیگری است — پزشک،
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمی‌دید و ۴۰۴ می‌گرفت.
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام می‌شود و
> مجوز دست‌نخورده باقی می‌ماند.
> این اندپوینت `a.user = خودِ کاربر` را می‌دهد، یعنی نوبت‌های خودِ فرد **به‌عنوان
> بیمار**، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است.
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپ‌ها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص

Some files were not shown because too many files have changed in this diff Show More