Compare commits

24 Commits
Author SHA1 Message Date
hamedandClaude Opus 5 40b1da64be fix(seo): shorten the footer and center the related-solutions cards
Every landing page was listed in the footer, so adding keyword clusters made
that one column taller than the rest of the footer put together. It now lists
six — the head terms and the two biggest specialties — and the rest stay
reachable through the "other solutions" block each page already renders.

That trade only holds if nothing falls out of the link graph, so the old test
asserting "the footer links to every page" is replaced by one that walks the
related-page graph from the footer links and fails when any page is not
reachable. Physiotherapy was the one page that would have been orphaned; the
polyclinic page now links to it.

The related-solutions heading said "for your trade", which reads oddly for the
clinics it is addressing, and its cards were left-aligned in a centered
section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:26:59 +03:30
hamedandClaude Opus 5 8e4bb3ca7b feat(seo): add eight landing pages for the clusters we had nothing for
Keyword research put the head demand on four phrases — clinic management
software, doctor's office software, online booking, and the price/comparison
questions that precede a purchase — and we only had a page for one of them.
The new pages cover the head terms (clinic management, online booking), the
buying intent (price, how to choose, download), the positioning that separates
a hosted product from the installed ones that dominate these results (web
based, no install), and two long-tails where competition is thin (clinic
accounting, psychology practice).

The download page deliberately answers the intent rather than the wording:
someone searching for an installer wants to try something without paying, and
the page gives them that while explaining why there is nothing to install.

Copy is written per page rather than templated — eight pages saying the same
thing in different words is duplicate content, and every claim on them maps to
something the product actually does. Internal links from the older pages were
repointed at the new head-term pages, and the registry tests no longer hardcode
the page count: they now assert that every declared slug has a page, and that
titles, headings and descriptions stay unique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:16:58 +03:30
hamedandClaude Opus 5 6e3d574e38 fix(session): show and keep the doctor's insurance when editing an encounter
The encounter edit form asked for contracts in the panel's own environment, so
inside a clinic it got an empty list: no insurance to choose, and the one
already recorded on the encounter had no matching option to display. It now
scopes the request by the encounter's doctor, the same way the confirm modal
does, which is why the session payload's existing doctor_uuid is threaded
through.

Saving the form had the matching defect on the server. Recalculating the totals
looked up the coverage percentage against the record's tenant rather than the
contract's, so a single save on a correctly-billed encounter zeroed the
insurance share and moved the whole amount back onto the patient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:56:57 +03:30
hamedandClaude Opus 5 4f76cff503 fix(billing): bill the insurance share on clinic appointments
The invoice and the encounter belong to the clinic, but the contract sits on
the doctor, so every coverage lookup asked the clinic, got nothing, and
resolved to zero percent. Three failures followed from that one wrong tenant:
the encounter charged the patient the full amount, the payment page showed a
balance the insurer owed, and — because no line carried an insurance share —
no claim was ever built, leaving the claims page empty.

Coverage now resolves through the same doctor-first-then-clinic rule the rest
of the insurance settings use, both when the encounter is created and when its
invoice lines are built.

Changing the insurance on an appointment also re-runs the encounter's shares.
Reception routinely confirms first and corrects the insurance afterwards, and
until now those shares stayed frozen at whatever the first calculation said.
Recorded payments are untouched; only the payable amount moves, so an
overpayment simply clamps the remainder at zero. The PATCH response also
returns the appointment's real venue now, matching GET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:37:44 +03:30
hamedandClaude Opus 5 b24f45cc83 fix(insurance): read a doctor's own settings first, then their clinic's
A clinic owner configures insurance on the doctor (`doctor_uuid`), but an
appointment booked at the clinic belongs to the clinic — so at confirm time
the engine looked for contracts under the clinic, found none, and the operator
had no insurance to pick and no way to save one ("this insurance has no active
contract"). The two sides were writing and reading different tenants.

Contracts, service kinds and the visit price now resolve doctor-first with the
appointment's clinic as fallback, each judged separately: a doctor who holds
their own contracts but leaves the visit price to the clinic gets each from the
right place. The confirm modal asks the same question the engine answers, via
`inherit=1` on the two read endpoints; the settings pages deliberately do not
send it, since editing must target the doctor's own row.

Two further things came out of the same sweep. The service-kind settings
repository had the tenant-filter blindness already fixed for contracts and
pricing — reads pinned to the caller's environment while the target is another
tenant — so it is now exempted the same way. And a coverage percentage of zero
is accepted as a real choice meaning "this contract does not cover that service
kind"; what is still rejected is leaving an enabled kind with no percentage at
all, inheriting a central default of zero included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:18:35 +03:30
hamedandClaude Opus 5 1f64b516d2 fix(insurance): let a clinic owner save the visit price of a member doctor
The pricing rows are keyed by the *target* tenant, but the tenant filter pins
every read to the environment of whoever is asking. A clinic owner setting the
free-visit price for one of their doctors was therefore blind to the row that
already existed: each save inserted another one — the unique key does not stop
it, because insurance_id is NULL for the free-visit row and MySQL does not treat
NULLs as equal — and the following read was blind in the same way, so the panel
kept showing the old value. From the outside it simply looked like the field
would not save.

Reads now run outside the filter, the same exception the tenant-insurance
repository already makes for the same reason, with authorization still coming
from resolveTargetEntity(). findOneForInsurance() takes the newest row so a
tenant that already accumulated duplicates converges on the last value the user
entered, and a migration collapses those leftovers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:52:04 +03:30
hamedandClaude Opus 5 5d2594ff87 feat(privacy): keep venue phone numbers out of every public response
A doctor's office number sat next to the address on the public profile and in
the anonymous API payload, so harvesting the phone number of every practice in
the country was one unauthenticated request away. Street address and map
coordinates stay public — a patient needs those to find the place — but the
phone is now opt-in per caller: DoctorAddress::toArray() and the clinic
serializers only emit it when told to, and the public doctor/clinic endpoints
tell them to only when the caller may edit that profile (the same can_edit they
already compute). Owner-facing address CRUD keeps returning it unchanged.

The patient still gets the number where it is actually useful — their own
appointment. That payload also stops guessing: it used to serialise the
doctor's *first* address, so a booking made at the clinic or at a second office
showed the wrong street entirely. It now resolves the address recorded on the
appointment itself, which works the same for a personal office and a clinic
branch, and falls back to the clinic's own number when the address has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:31:52 +03:30
hamedandClaude Opus 5 8a43297e24 test: remove the two real sources of full-run flakiness, and the mock notices
createUser() drew a random mobile and recovered from a collision by catching
the unique-constraint violation and calling resetManager(). That hands back a
brand-new EntityManager, which detaches every entity the running test had
built so far; its next flush died with "Multiple non-persisted new entities
were found", always in a different test and never reproducible in isolation.
The number is now checked before the insert, so the collision never reaches
the database and the manager stays open.

testParentIdAddsNoQueryPerSpecialty counted queries on the first request of
each size, so one-shot per-process caches — site config, subscription plan,
Doctrine metadata — landed inside the count or not depending on which tests
had run before it. Both requests are now warmed first; the assertion measures
steady-state growth, which is what it was always about.

The 23 PHPUnit notices were all one complaint: doubles created with
createMock() that never had an expectation. The ones that only stub return
values became createStub(); in SmsServiceLookupOnlyTest the provider and the
bus got the expectations they were missing, since "dispatch does not touch
the provider" and "sendNow does not enqueue" are exactly what that suite is
there to prove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 09:57:29 +03:30
hamedandClaude Opus 5 ed2d2ec74a fix(blog): order every blog list by id as well as created_at
created_at is a second-resolution integer, so dozens of posts routinely share
one value and MySQL is free to return tied rows in any order. Two consecutive
pages of the same list could hand back one post twice and never show another,
and the admin-list tests failed at random on whichever post got shuffled past
the page boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:43:11 +03:30
hamedandClaude Opus 5 5986fad5a1 fix(logs): resolve the five real defects surfaced by app_log
TenantInsurance reads ran through the tenant filter, which pins every query
to the *requesting* user's environment. A clinic owner managing a doctor's
contracts therefore read an empty set, recomputed version 1, and hit
`uniq_tenant_insurance_version` on insert. The reads now bypass the filter —
authorization is already established by resolveTargetEntity(), and the
uuid-based paths re-assert ownership after loading.

UserActiveContext::upsert() raced with itself: the panel fires several
/oauth/userinfo requests at once, all saw no row, all inserted, and the
losers died on a duplicate PRIMARY (closing the EntityManager with them).
Replaced with INSERT ... ON DUPLICATE KEY UPDATE.

A service that carries a treatment protocol but no catalog category is bad
catalog data, not a system failure; it was logged at error level on every
confirm and buried the real errors. Now a warning carrying the service id.

Kavenegar's HTTP 431 says only "malformed request". The provider's own
message and the token slot names are now logged so the template can actually
be fixed in the panel; token values stay out of the log.

Redis DSNs gained timeout/retry_interval/tcp_keepalive so a brief connection
loss reconnects quietly instead of logging a warning each time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:31:15 +03:30
hamed 57ea7b8c59 Refactor code structure for improved readability and maintainability 2026-08-18 08:03:40 +03:30
hamed 1d13212d47 Add AST cache for ApiLeastPrivilegeTest.php with nodes and edges 2026-08-11 09:40:37 +03:30
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
261 changed files with 46610 additions and 5582 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 -1
View File
@@ -17,7 +17,9 @@
# Replace mariadb-XXXXXXXX / redis-XXXXXXXX with the real hostname shown on each # Replace mariadb-XXXXXXXX / redis-XXXXXXXX with the real hostname shown on each
# resource's page (Internal URL). serverVersion MUST match the MariaDB resource (11.8). # resource's page (Internal URL). serverVersion MUST match the MariaDB resource (11.8).
DATABASE_URL="mysql://clinic:DB_PASSWORD@mariadb-XXXXXXXX:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4" DATABASE_URL="mysql://clinic:DB_PASSWORD@mariadb-XXXXXXXX:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4"
REDIS_URL="redis://redis-XXXXXXXX:6379" # retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بی‌سروصدا دوباره برقرار می‌شود
# و «Connection lost» به‌صورت warning در app_log نمی‌نشیند.
REDIS_URL="redis://redis-XXXXXXXX:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60"
# stream_max_entries caps the Redis stream so the queue cannot grow without bound # stream_max_entries caps the Redis stream so the queue cannot grow without bound
MESSENGER_TRANSPORT_DSN="redis://redis-XXXXXXXX:6379/messages?stream_max_entries=20000" MESSENGER_TRANSPORT_DSN="redis://redis-XXXXXXXX:6379/messages?stream_max_entries=20000"
# If the Redis resource has a password: redis://:PASSWORD@redis-XXXXXXXX:6379 # If the Redis resource has a password: redis://:PASSWORD@redis-XXXXXXXX:6379
+3 -1
View File
@@ -31,7 +31,9 @@ MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
###< symfony/messenger ### ###< symfony/messenger ###
###> Redis ### ###> Redis ###
REDIS_URL=redis://redis:6379 # retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بی‌سروصدا دوباره برقرار می‌شود
# و «Connection lost» به‌صورت warning در app_log نمی‌نشیند.
REDIS_URL=redis://redis:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60
###< Redis ### ###< Redis ###
###> Auth ### ###> Auth ###
+3 -1
View File
@@ -22,7 +22,9 @@ JWT_PASSPHRASE= # openssl rand -hex 32 (JWT keypair is generated with i
# put both + this app on the SAME private network, then copy their private hosts here. # put both + this app on the SAME private network, then copy their private hosts here.
# serverVersion MUST match the MariaDB service (11.8). # serverVersion MUST match the MariaDB service (11.8).
DATABASE_URL="mysql://<user>:<pass>@<db-private-host>:3306/<db>?serverVersion=mariadb-11.8.0&charset=utf8mb4" DATABASE_URL="mysql://<user>:<pass>@<db-private-host>:3306/<db>?serverVersion=mariadb-11.8.0&charset=utf8mb4"
REDIS_URL="redis://<redis-private-host>:6379" # retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بی‌سروصدا دوباره برقرار می‌شود
# و «Connection lost» به‌صورت warning در app_log نمی‌نشیند.
REDIS_URL="redis://<redis-private-host>:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60"
MESSENGER_TRANSPORT_DSN="redis://<redis-private-host>:6379/messages" MESSENGER_TRANSPORT_DSN="redis://<redis-private-host>:6379/messages"
# If the Redis service has a password: redis://:<pass>@<redis-private-host>:6379 # If the Redis service has a password: redis://:<pass>@<redis-private-host>:6379
+3
View File
@@ -59,3 +59,6 @@ clinicpro/*
# Database backups (never commit dumps to the repo) # Database backups (never commit dumps to the repo)
*.sql *.sql
*.sql.gz *.sql.gz
# اسکرین‌شات‌های راهنمای کاربری — با guide-shots.mjs بازتولید می‌شوند و ۱۷ مگابایت‌اند
docs/guide/images/
+7 -2
View File
@@ -74,10 +74,15 @@ function percentsToStrings(map?: Record<string, number>): Record<string, string>
return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)])); return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)]));
} }
/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمی‌دهد. */ /**
* درصد پوششِ قابل ثبت: عددی بین ۰ تا ۱۰۰.
*
* صفر مقدار معتبری است و یعنی «این قرارداد آن نوع خدمت را پوشش نمی‌دهد» — سهم بیمار
* صددرصد. آنچه رد می‌شود خالی‌ماندنِ فیلد است، نه صفر بودنش.
*/
export function isValidPercent(raw?: string): boolean { export function isValidPercent(raw?: string): boolean {
const n = Number(raw); const n = Number(raw);
return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 && n <= 100; return raw !== undefined && raw !== '' && Number.isFinite(n) && n >= 0 && n <= 100;
} }
/** /**
@@ -28,6 +28,8 @@ export interface SessionCardData {
base_insurance_discount_percent?: number; base_insurance_discount_percent?: number;
supplementary_discount_percent?: number; supplementary_discount_percent?: number;
doctor_name?: string | null; doctor_name?: string | null;
/** پزشکِ نوبتِ این مراجعه — محدودهٔ قرارداد بیمه از روی او حل می‌شود. */
doctor_uuid?: string | null;
final_price_rials?: number; final_price_rials?: number;
// تفکیک بیمه — سرور محاسبه می‌کند (PatientSession::applyShares) // تفکیک بیمه — سرور محاسبه می‌کند (PatientSession::applyShares)
gross_total_rials?: number; gross_total_rials?: number;
@@ -4,6 +4,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline';
import SearchableSelect from './ui/SearchableSelect'; import SearchableSelect from './ui/SearchableSelect';
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods'; import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils'; import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
export type WalletMode = 'charge' | 'withdraw'; export type WalletMode = 'charge' | 'withdraw';
@@ -59,12 +60,7 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); } if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); }
}, [open]); }, [open]);
useEffect(() => { const dismiss = useOverlayDismiss(onClose, open);
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null; if (!open) return null;
@@ -103,8 +99,8 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
}; };
return createPortal( return createPortal(
<div className="overlay" onClick={onClose}> <div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}> <div className="modal" style={{ maxWidth: 560 }}>
<div className="modal-head"> <div className="modal-head">
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2> <h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
<button type="button" className="mini-btn" onClick={onClose}> <button type="button" className="mini-btn" onClick={onClose}>
@@ -29,8 +29,12 @@ function render() {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// payment-methods (pos / bank-accounts) و بقیهٔ GETها // مودال همیشه جزئیات نوبت را از سرور می‌گیرد — ردیفِ فهرست مبلغ ویزیت ندارد.
get.mockResolvedValue({ success: true, data: [] }); get.mockImplementation((url: string) =>
url.startsWith('/api/v1/appointment/')
? Promise.resolve({ success: true, data: appointment })
// payment-methods (pos / bank-accounts) و بقیهٔ GETها
: Promise.resolve({ success: true, data: [] }));
post.mockResolvedValue({ success: true, data: {} }); post.mockResolvedValue({ success: true, data: {} });
}); });
@@ -45,6 +49,14 @@ function amountInputs() {
return screen.getAllByPlaceholderText('0') as HTMLInputElement[]; return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
} }
/**
* مودال تا رسیدنِ جزئیات نوبت از سرور، دکمه‌هایش قفل است — مبلغ نباید از ردیفِ
* ناقصِ فهرست خوانده شود. هر تست اول منتظر همین می‌ماند.
*/
async function settle() {
await waitFor(() => expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).not.toBeDisabled());
}
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» می‌رود و ردیف‌های مبلغ را برمی‌گرداند. */ /** از مرحلهٔ «بیمه و هزینه» به «پرداخت» می‌رود و ردیف‌های مبلغ را برمی‌گرداند. */
function goToPayment() { function goToPayment() {
nextStep(); nextStep();
@@ -57,31 +69,35 @@ function goToReview() {
} }
describe('ConfirmAppointmentModal', () => { describe('ConfirmAppointmentModal', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => { it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', async () => {
render(); render();
await settle();
expect(screen.getByText('محمد رضایی')).toBeInTheDocument(); expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
expect(screen.getByText('ویزیت')).toBeInTheDocument(); expect(screen.getByText('ویزیت')).toBeInTheDocument();
expect(screen.getByText('لیزر')).toBeInTheDocument(); expect(screen.getByText('لیزر')).toBeInTheDocument();
expect(screen.getByText('جمع کل')).toBeInTheDocument(); expect(screen.getByText('جمع کل')).toBeInTheDocument();
}); });
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', () => { it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', async () => {
render(); render();
await settle();
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان // ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰'); expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
goToReview(); goToReview();
expect(screen.getByText('تسویه کامل')).toBeInTheDocument(); expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
}); });
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', () => { it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', async () => {
render(); render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } }); fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview(); goToReview();
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument(); expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
}); });
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', () => { it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', async () => {
render(); render();
await settle();
const rows = goToPayment(); const rows = goToPayment();
fireEvent.change(rows[0], { target: { value: '9000000' } }); fireEvent.change(rows[0], { target: { value: '9000000' } });
@@ -96,6 +112,7 @@ describe('ConfirmAppointmentModal', () => {
it('پرداخت جزئی مجاز است و همان یک روش را ثبت می‌کند', async () => { it('پرداخت جزئی مجاز است و همان یک روش را ثبت می‌کند', async () => {
render(); render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } }); fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview(); goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })); fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
@@ -108,6 +125,7 @@ describe('ConfirmAppointmentModal', () => {
it('تقسیم پرداخت بین دو روش: مجموع ردیف‌ها به‌صورت آرایه ثبت می‌شود', async () => { it('تقسیم پرداخت بین دو روش: مجموع ردیف‌ها به‌صورت آرایه ثبت می‌شود', async () => {
render(); render();
await settle();
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم می‌کنیم // ردیف اول را به ۲۰۰٬۰۰۰ تومان کم می‌کنیم
fireEvent.change(goToPayment()[0], { target: { value: '200000' } }); fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
// افزودن روش دوم — پیش‌فرض با باقی‌ماندهٔ ۱۰۰٬۰۰۰ تومان پر می‌شود // افزودن روش دوم — پیش‌فرض با باقی‌ماندهٔ ۱۰۰٬۰۰۰ تومان پر می‌شود
@@ -131,8 +149,9 @@ describe('ConfirmAppointmentModal', () => {
})); }));
}); });
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', () => { it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', async () => {
render(); render();
await settle();
goToPayment(); goToPayment();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ })); fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
expect(amountInputs()).toHaveLength(2); expect(amountInputs()).toHaveLength(2);
@@ -162,12 +181,20 @@ const SUPP_CONTRACT = {
category_coverages: { outpatient: 90, inpatient: 60 }, category_coverages: { outpatient: 90, inpatient: 60 },
}; };
/**
* @param detail نوبتی که اندپوینت جزئیات برمی‌گرداند — مودال همیشه آن را می‌خواند،
* چون ردیفِ فهرست مبلغ ویزیت و بیمه ندارد.
*/
function mockInsurance( function mockInsurance(
categories: { key: string; label: string; enabled: boolean }[], categories: { key: string; label: string; enabled: boolean }[],
freeVisitPriceRials = 0, freeVisitPriceRials = 0,
contracts: unknown[] = [CONTRACT], contracts: unknown[] = [CONTRACT],
detail: unknown = referenceAppointment,
) { ) {
get.mockImplementation((url: string) => { get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/appointment/')) {
return Promise.resolve({ success: true, data: detail });
}
if (url === '/api/v1/insurance-pricing') { if (url === '/api/v1/insurance-pricing') {
return Promise.resolve({ return Promise.resolve({
success: true, success: true,
@@ -211,6 +238,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => { it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => {
mockInsurance(BOTH); mockInsurance(BOTH);
renderReference(); renderReference();
await settle();
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument(); expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
expect(screen.getByText('بیمه پایه')).toBeInTheDocument(); expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
@@ -222,6 +250,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
{ key: 'inpatient', label: 'خدمات بستری', enabled: false }, { key: 'inpatient', label: 'خدمات بستری', enabled: false },
]); ]);
renderReference(); renderReference();
await settle();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument(); expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument(); expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
@@ -230,6 +259,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => { it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
mockInsurance(BOTH); mockInsurance(BOTH);
renderReference(); renderReference();
await settle();
await screen.findByText('نوع خدمت'); await screen.findByText('نوع خدمت');
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument(); expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
@@ -239,6 +269,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال می‌شوند (سرپایی ۷۰٪)', async () => { it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال می‌شوند (سرپایی ۷۰٪)', async () => {
mockInsurance(BOTH); mockInsurance(BOTH);
renderReference(); renderReference();
await settle();
await screen.findByText('نوع خدمت'); await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -261,6 +292,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمی‌شود', async () => { it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمی‌شود', async () => {
mockInsurance(BOTH); mockInsurance(BOTH);
renderReference(); renderReference();
await settle();
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument(); expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument(); expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
@@ -269,6 +301,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('سهم پایه و تکمیلی جدا و زنجیره‌ای محاسبه می‌شوند و هر دو ارسال می‌گردند', async () => { it('سهم پایه و تکمیلی جدا و زنجیره‌ای محاسبه می‌شوند و هر دو ارسال می‌گردند', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]); mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference(); renderReference();
await settle();
await screen.findByText('نوع خدمت'); await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -296,6 +329,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال می‌شود', async () => { it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال می‌شود', async () => {
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]); mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
renderReference(); renderReference();
await settle();
await screen.findByText('نوع خدمت'); await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
@@ -308,15 +342,17 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
}); });
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => { it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => {
mockInsurance(BOTH, 5_952_000); const priceless = { uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] };
mockInsurance(BOTH, 5_952_000, [CONTRACT], priceless);
renderWithProviders( renderWithProviders(
<ConfirmAppointmentModal <ConfirmAppointmentModal
open open
appointmentUuid="a1" appointmentUuid="a1"
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }} appointment={priceless}
onClose={() => {}} onClose={() => {}}
/>, />,
); );
await settle();
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument(); expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد. // ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد.
@@ -336,6 +372,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
it('نوع بستری درصد خودش را می‌گیرد (۳۰٪)', async () => { it('نوع بستری درصد خودش را می‌گیرد (۳۰٪)', async () => {
mockInsurance(BOTH); mockInsurance(BOTH);
renderReference(); renderReference();
await settle();
await screen.findByText('نوع خدمت'); await screen.findByText('نوع خدمت');
await pick('انتخاب نوع خدمت', 'خدمات بستری'); await pick('انتخاب نوع خدمت', 'خدمات بستری');
@@ -353,8 +390,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li')) Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li'))
.map(li => li.textContent?.replace(/^\d+/, '').trim()); .map(li => li.textContent?.replace(/^\d+/, '').trim());
it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', () => { it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', async () => {
render(); render();
await settle();
expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']); expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']);
// جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه. // جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه.
@@ -362,16 +400,18 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
expect(screen.queryByPlaceholderText('0')).toBeNull(); expect(screen.queryByPlaceholderText('0')).toBeNull();
}); });
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', () => { it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', async () => {
render(); render();
await settle();
expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
}); });
it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', () => { it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', async () => {
render(); render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } }); fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' })); fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' }));
@@ -382,8 +422,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰'); expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰');
}); });
it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', () => { it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', async () => {
render(); render();
await settle();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } }); fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview(); goToReview();
@@ -392,3 +433,119 @@ describe('ConfirmAppointmentModal — ویزارد', () => {
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).toBeInTheDocument();
}); });
}); });
/** جمع نوبتِ تست: ۲۱۲۰۰۰۰ + ۸۸۰۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان. */
describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () => {
const percentButton = (percent: string) =>
screen.getAllByRole('button', { name: `${percent}٪` })[0];
it('کلیک روی هر درصد، همان کسر از سهم بیمار را در مبلغ می‌گذارد', async () => {
render();
await settle();
const [amount] = goToPayment();
fireEvent.click(percentButton('۲۰'));
expect(amount).toHaveValue('۶۰٬۰۰۰');
fireEvent.click(percentButton('۵۰'));
expect(amount).toHaveValue('۱۵۰٬۰۰۰');
fireEvent.click(percentButton('۷۰'));
expect(amount).toHaveValue('۲۱۰٬۰۰۰');
fireEvent.click(percentButton('۱۰۰'));
expect(amount).toHaveValue('۳۰۰٬۰۰۰');
});
it('درصدِ اعمال‌شده تا مرحلهٔ ثبت می‌ماند و به سرور می‌رسد', async () => {
render();
await settle();
goToPayment();
fireEvent.click(percentButton('۵۰'));
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1].payments).toEqual([
{ method: 'cash', amount_rials: 1_500_000 },
]);
});
it('هر ردیف درصد خودش را دارد؛ ردیف دیگر دست نمی‌خورد', async () => {
render();
await settle();
goToPayment();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
const rows = amountInputs();
expect(rows).toHaveLength(2);
// ۲۰٪ روی ردیف دوم، بدون اثر روی ردیف اول که پیش‌فرضِ کامل دارد.
const secondRowPercent = screen.getAllByRole('button', { name: '۲۰٪' })[1];
fireEvent.click(secondRowPercent);
expect(rows[0]).toHaveValue('۳۰۰٬۰۰۰');
expect(rows[1]).toHaveValue('۶۰٬۰۰۰');
});
it('نوبتِ بدون هزینه اصلاً دکمهٔ درصدی ندارد', async () => {
const free = { uuid: 'a2', version: 1, visit_price_rials: 0, service_items: [] };
// جزئیات هم باید همان نوبتِ بی‌هزینه باشد؛ مودال مبلغ را از سرور می‌خواند.
get.mockImplementation((url: string) =>
Promise.resolve({ success: true, data: url.startsWith('/api/v1/appointment/') ? free : [] }));
renderWithProviders(
<ConfirmAppointmentModal
open
appointmentUuid="a2"
appointment={free}
onClose={() => {}}
/>,
);
await settle();
nextStep();
expect(screen.queryByRole('button', { name: '۵۰٪' })).not.toBeInTheDocument();
});
});
/**
* باگ واقعی: تایم‌لاین ردیفِ فهرست نوبت‌ها را پاس می‌داد و آن ردیف اصلاً
* `visit_price_rials` ندارد. مودال به همان اعتماد می‌کرد، «۰ تومان» نشان می‌داد و
* همان صفر را ثبت می‌کرد — هزینهٔ ویزیتِ ثبت‌شده در نوبت نادیده می‌ماند.
*/
describe('ConfirmAppointmentModal — ردیفِ ناقصِ فهرست', () => {
/** دقیقاً شکل ردیفِ `/api/v1/my/appointments`: بدون مبلغ و بدون بیمه. */
const listRow = {
uuid: 'a1',
version: 1,
patient_name: 'محمد رضایی',
service_items: [],
};
it('مبلغ را از جزئیات سرور می‌گیرد، نه از ردیفِ پاس‌داده‌شده', async () => {
renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={listRow} onClose={() => {}} />,
);
await settle();
// ۳٬۰۰۰٬۰۰۰ ریالِ جزئیات = ۳۰۰٬۰۰۰ تومان، نه صفرِ ردیفِ فهرست.
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
});
it('تا نیامدنِ جزئیات، رفتن به مرحلهٔ پرداخت قفل است', () => {
let resolveDetail: (v: unknown) => void = () => {};
get.mockImplementation((url: string) =>
url.startsWith('/api/v1/appointment/')
? new Promise((resolve) => { resolveDetail = resolve; })
: Promise.resolve({ success: true, data: [] }));
renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={listRow} onClose={() => {}} />,
);
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
resolveDetail({ success: true, data: appointment });
});
});
@@ -5,7 +5,7 @@ import { UserCircleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api'; import type { ApiResponse } from '../../lib/api';
import type { BankAccount, Pos } from '../../hooks/usePaymentMethods'; import type { BankAccount, Pos } from '../../hooks/usePaymentMethods';
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils'; import { formatNumber, formatRial, rialToToman, tomanToRial } from '../../lib/utils';
import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares'; import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares';
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance'; import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
import Modal from '../ui/Modal'; import Modal from '../ui/Modal';
@@ -13,6 +13,9 @@ import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect'; import SearchableSelect from '../ui/SearchableSelect';
import Stepper from '../ui/Stepper'; import Stepper from '../ui/Stepper';
/** میان‌بُرهای مبلغ روی هر ردیف پرداخت — درصدی از سهم بیمار. */
const PAYMENT_PERCENTS = [20, 50, 70, 100];
/** همان چهار روشِ SessionPayment::METHODS در بک‌اند. */ /** همان چهار روشِ SessionPayment::METHODS در بک‌اند. */
const METHOD_OPTIONS = [ const METHOD_OPTIONS = [
{ value: 'cash', label: 'پرداخت نقدی' }, { value: 'cash', label: 'پرداخت نقدی' },
@@ -38,6 +41,7 @@ interface AppointmentLike {
insurance_service_category?: string | null; insurance_service_category?: string | null;
insurance_base_id?: number | null; insurance_base_id?: number | null;
insurance_supplementary_id?: number | null; insurance_supplementary_id?: number | null;
doctor?: { uuid?: string | null } | null;
} }
interface Props { interface Props {
@@ -116,12 +120,14 @@ export default function ConfirmAppointmentModal({
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
const [stepIdx, setStepIdx] = useState(0); const [stepIdx, setStepIdx] = useState(0);
// وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم: // جزئیات همیشه گرفته می‌شود، حتی وقتی صفحهٔ میزبان نوبتی پاس داده است: ردیفِ
// مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند. // فهرست نوبت‌ها `visit_price_rials` و بیمه را ندارد، و مودال با اعتماد به همان
// ردیف، هزینهٔ ویزیتِ ثبت‌شده را صفر نشان می‌داد و همان صفر را هم ثبت می‌کرد.
// مبلغ چیزی نیست که از یک payload ناقص حدس زده شود.
const detailQuery = useQuery({ const detailQuery = useQuery({
queryKey: ['appointment', appointmentUuid], queryKey: ['appointment', appointmentUuid],
queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`), queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`),
enabled: open && !appointment, enabled: open,
}); });
// روش‌های پرداختِ ثبت‌شده — فقط وقتی مودال باز است. // روش‌های پرداختِ ثبت‌شده — فقط وقتی مودال باز است.
@@ -148,11 +154,13 @@ export default function ConfirmAppointmentModal({
[bankQuery.data], [bankQuery.data],
); );
const appt: AppointmentLike | null = appointment // پاسخ سرور مرجع است؛ نوبتِ پاس‌داده‌شده فقط تا رسیدنِ آن، صفحه را خالی نگه نمی‌دارد.
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null); const detail: AppointmentLike | null =
(detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null;
const appt: AppointmentLike | null = detail ?? appointment ?? null;
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ────────────────────────────────────── // ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
const insurance = useAppointmentInsurance(open); const insurance = useAppointmentInsurance(open, appt?.doctor?.uuid ?? null);
// نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را می‌گیرد؛ // نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را می‌گیرد؛
// مودال هم باید همان را نشان دهد، وگرنه صفر نشان می‌دهد و مبلغ ثبت‌شده فرق می‌کند. // مودال هم باید همان را نشان دهد، وگرنه صفر نشان می‌دهد و مبلغ ثبت‌شده فرق می‌کند.
@@ -254,6 +262,11 @@ export default function ConfirmAppointmentModal({
patchRow(id, { method, methodUuid: '', reference: '' }); patchRow(id, { method, methodUuid: '', reference: '' });
} }
/** درصدِ سهم بیمار روی همان ردیف می‌نشیند؛ ردیف‌های دیگر دست‌نخورده می‌مانند. */
function applyPercent(id: number, percent: number) {
patchRow(id, { amountToman: rialToToman(Math.round((payable * percent) / 100)) });
}
function addRow() { function addRow() {
setTouched(true); setTouched(true);
// ردیفِ جدید پیش‌فرض با باقی‌مانده پر می‌شود تا تسویه سریع‌تر باشد. // ردیفِ جدید پیش‌فرض با باقی‌مانده پر می‌شود تا تسویه سریع‌تر باشد.
@@ -270,7 +283,9 @@ export default function ConfirmAppointmentModal({
onClose(); onClose();
} }
const loading = detailQuery.isLoading && !appointment; // تا رسیدنِ جزئیات، دکمه‌ها قفل‌اند: کاربر نباید روی مبلغی که هنوز از سرور نیامده
// «مرحلهٔ بعد» بزند و پرداختِ صفر ثبت کند.
const loading = detailQuery.isLoading;
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key; const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
const isLastStep = currentStep === 'review'; const isLastStep = currentStep === 'review';
@@ -492,6 +507,39 @@ export default function ConfirmAppointmentModal({
)} )}
</div> </div>
{/* میان‌بُرهای درصدی — پرداخت جزئی رایج است و تایپ دستیِ مبلغ خطا می‌آورد. */}
{payable > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{PAYMENT_PERCENTS.map((percent) => {
const active = tomanToRial(r.amountToman) === Math.round((payable * percent) / 100);
return (
<button
key={percent}
type="button"
className="btn sm"
onClick={() => applyPercent(r.id, percent)}
title={formatRial(Math.round((payable * percent) / 100))}
style={{
height: 30,
padding: '0 12px',
fontSize: 12,
borderRadius: 'var(--r-pill)',
border: `1px solid ${active ? 'var(--primary)' : 'var(--border)'}`,
background: active ? 'var(--primary-soft)' : 'var(--surface)',
color: active ? 'var(--primary)' : 'var(--text-2)',
fontWeight: active ? 700 : 500,
}}
>
{formatNumber(percent)}٪
</button>
);
})}
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
از {formatRial(payable)}
</span>
</div>
)}
{/* جزئیاتِ کارت‌خوان: انتخاب دستگاهِ ثبت‌شده + شناسه تراکنش */} {/* جزئیاتِ کارت‌خوان: انتخاب دستگاهِ ثبت‌شده + شناسه تراکنش */}
{r.method === 'pos' && ( {r.method === 'pos' && (
<div style={{ display: 'flex', gap: 10 }}> <div style={{ display: 'flex', gap: 10 }}>
@@ -72,7 +72,8 @@ describe('TurnsTimeline', () => {
it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان می‌دهد', async () => { it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان می‌دهد', async () => {
(api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => (api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) =>
url === '/api/v1/billing/tenant-insurances' // با پزشکِ نوبت، آدرس `?doctor_uuid=…&inherit=1` هم می‌گیرد.
url.startsWith('/api/v1/billing/tenant-insurances')
? Promise.resolve({ success: true, data: { data: [{ ? Promise.resolve({ success: true, data: { data: [{
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true, insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
@@ -108,7 +108,7 @@ function OccupiedCard({
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
// نام بیمه فقط با نگاشت از قراردادهای کش‌شده به دست می‌آید؛ payload نوبت نامی ندارد // نام بیمه فقط با نگاشت از قراردادهای کش‌شده به دست می‌آید؛ payload نوبت نامی ندارد
// تا لیست‌های نوبت به N+1 نیفتند. // تا لیست‌های نوبت به N+1 نیفتند.
const insurance = useAppointmentInsurance(!!a.insurance_base_id); const insurance = useAppointmentInsurance(!!a.insurance_base_id, a.doctor_uuid ?? null);
const insuranceChip = [ const insuranceChip = [
a.insurance_service_category_label, a.insurance_service_category_label,
insurance.insuranceNameOf(a.insurance_base_id), insurance.insuranceNameOf(a.insurance_base_id),
+11 -2
View File
@@ -105,11 +105,20 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({ const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({
queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'), queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'),
}); });
// مراجعهٔ برخاسته از نوبت، قراردادِ پزشکِ همان نوبت را دارد نه قراردادِ محیط پنل:
// در کلینیک، قرارداد روی خودِ پزشک ثبت می‌شود و بدون این محدوده، فهرست بیمه‌ها
// خالی می‌آمد و بیمهٔ ثبت‌شدهٔ همان مراجعه هم در فرم پیدا نمی‌شد.
const insuranceScope = editSession?.doctor_uuid
? `?doctor_uuid=${encodeURIComponent(editSession.doctor_uuid)}&inherit=1`
: '';
const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({ const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({
queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'), queryKey: ['tenant-insurances', editSession?.doctor_uuid ?? null],
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${insuranceScope}`),
}); });
const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({ const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), queryKey: ['insurance-pricing', editSession?.doctor_uuid ?? null],
queryFn: () => api.get(`/api/v1/insurance-pricing${insuranceScope}`),
}); });
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0; const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false; const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
+5 -2
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline'; import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import Portal from './Portal'; import Portal from './Portal';
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -28,12 +29,14 @@ export default function ConfirmDialog({
onCancel, onCancel,
children, children,
}: Props) { }: Props) {
const dismiss = useOverlayDismiss(onCancel, open);
if (!open) return null; if (!open) return null;
return ( return (
<Portal> <Portal>
<div className="overlay" onClick={onCancel}> <div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}> <div className="modal" style={{ maxWidth: 420 }}>
<div className="modal-head"> <div className="modal-head">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ <span style={{
+5 -3
View File
@@ -63,9 +63,11 @@ export default function DataTable<T extends object>({
<div> <div>
{/* Toolbar row */} {/* Toolbar row */}
{(onSearchChange !== undefined || headerExtra) && ( {(onSearchChange !== undefined || headerExtra) && (
<div className="toolbar"> // لنگرهای data-tour اینجا تعریف می‌شوند تا هر صفحه‌ای که DataTable دارد
// بدون تغییر کد خودش، در تور راهنما قابل اشاره باشد.
<div className="toolbar" data-tour="page-toolbar">
{onSearchChange !== undefined && ( {onSearchChange !== undefined && (
<div className="field" style={{ flex: '0 0 auto', minWidth: 240 }}> <div className="field" data-tour="page-search" style={{ flex: '0 0 auto', minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0 }} /> <MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0 }} />
<input <input
type="text" type="text"
@@ -79,7 +81,7 @@ export default function DataTable<T extends object>({
</div> </div>
)} )}
<div className="table-wrap"> <div className="table-wrap" data-tour="page-table">
<table className="t"> <table className="t">
<thead> <thead>
<tr> <tr>
@@ -9,6 +9,7 @@ import type { ApiResponse } from '../../lib/api';
import MobileInput from './MobileInput'; import MobileInput from './MobileInput';
import Portal from './Portal'; import Portal from './Portal';
import { iranMobileSchema } from '../../lib/utils'; import { iranMobileSchema } from '../../lib/utils';
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
const inviteSchema = z.object({ const inviteSchema = z.object({
mobile: iranMobileSchema, mobile: iranMobileSchema,
@@ -39,10 +40,12 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
const dismiss = useOverlayDismiss(onClose);
return ( return (
<Portal> <Portal>
<div className="overlay" onClick={onClose}> <div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ maxWidth: 420 }}>
<div className="modal-head"> <div className="modal-head">
<b>دعوت پزشک به کلینیک</b> <b>دعوت پزشک به کلینیک</b>
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button> <button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
+5 -13
View File
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react'; import React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { XMarkIcon } from '@heroicons/react/24/outline'; import { XMarkIcon } from '@heroicons/react/24/outline';
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'; type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -21,22 +22,13 @@ interface Props {
} }
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) { export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
useEffect(() => { const dismiss = useOverlayDismiss(onClose, open);
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null; if (!open) return null;
return createPortal( return createPortal(
<div className="overlay" onClick={onClose}> <div className="overlay" {...dismiss}>
<div <div className="modal" style={{ maxWidth: sizeMap[size] }}>
className="modal"
style={{ maxWidth: sizeMap[size] }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-head"> <div className="modal-head">
<h2>{title}</h2> <h2>{title}</h2>
<button type="button" className="mini-btn" onClick={onClose} aria-label="بستن"> <button type="button" className="mini-btn" onClick={onClose} aria-label="بستن">
+14 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { ChevronLeftIcon } from '@heroicons/react/24/outline'; import { ChevronLeftIcon } from '@heroicons/react/24/outline';
import BackButton from './BackButton'; import BackButton from './BackButton';
import TourButton from './TourButton';
interface Crumb { interface Crumb {
label: string; label: string;
@@ -18,9 +19,16 @@ interface Props {
* مقدار، مقصدِ fallback است وقتی تاریخچه‌ای برای برگشتن نیست. * مقدار، مقصدِ fallback است وقتی تاریخچه‌ای برای برگشتن نیست.
*/ */
backTo?: string; backTo?: string;
/** شناسهٔ تور راهنمای این صفحه؛ اگر در registry ثبت نشده باشد دکمه‌ای نمی‌آید. */
tourId?: string;
/**
* محتوای صفحه نشسته است و تورِ بار اول می‌تواند خودکار اجرا شود.
* صفحه‌ای که داده‌اش دیر می‌آید، `!isLoading` بدهد تا تور روی صفحهٔ نیمه‌ساخته نیفتد.
*/
tourReady?: boolean;
} }
export default function PageHeader({ title, breadcrumbs, action, description, backTo }: Props) { export default function PageHeader({ title, breadcrumbs, action, description, backTo, tourId, tourReady = true }: Props) {
return ( return (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
@@ -48,12 +56,15 @@ export default function PageHeader({ title, breadcrumbs, action, description, ba
))} ))}
</nav> </nav>
)} )}
<h1 className="section-title">{title}</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<h1 className="section-title">{title}</h1>
<TourButton tourId={tourId} ready={tourReady} />
</div>
{description && ( {description && (
<p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p> <p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p>
)} )}
</div> </div>
{action && <div style={{ flexShrink: 0 }}>{action}</div>} {action && <div style={{ flexShrink: 0 }} data-tour="page-action">{action}</div>}
</div> </div>
); );
} }
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Pagination({ page, total, limit, onPageChange }: Props)
} }
return ( return (
<div className="pagination"> <div className="pagination" data-tour="page-pagination">
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}> <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
نمایش {formatNumber(from)}{formatNumber(to)} از {formatNumber(total)} مورد نمایش {formatNumber(from)}{formatNumber(to)} از {formatNumber(total)} مورد
</span> </span>
@@ -120,7 +120,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
while (cells.length % 7 !== 0) cells.push(null); while (cells.length % 7 !== 0) cells.push(null);
return ( return (
<div ref={ref} style={{ <div ref={ref} className="cp-calendar" style={{
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4, position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
background: 'var(--surface)', border: '1px solid var(--border)', background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)', borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
@@ -123,6 +123,10 @@ export default function SearchableSelect({
return ( return (
<Select<SelectOption> <Select<SelectOption>
// پیشوند پایدار برای کلاس‌ها: کلاس‌های emotion در build تولیدی برچسب ندارند و
// «منوی باز است یا نه» با آن‌ها قابل تشخیص نیست. useOverlayDismiss به همین
// نشانه نگاه می‌کند تا Esc اول منو را ببندد نه مودال را.
classNamePrefix="cp-select"
options={options} options={options}
value={selected} value={selected}
onChange={(opt) => onChange?.(opt ? opt.value : null)} onChange={(opt) => onChange?.(opt ? opt.value : null)}
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/utils';
vi.mock('@/lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
const drive = vi.fn();
vi.mock('driver.js', () => ({ driver: vi.fn(() => ({ drive })) }));
vi.mock('driver.js/dist/driver.css', () => ({}));
import { api } from '@/lib/api';
import TourButton from './TourButton';
import PageHeader from './PageHeader';
import { appointmentsTour } from '@/lib/tour/tours/appointments';
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
get.mockResolvedValue({ data: { seen: {} } });
drive.mockReset();
document.body.innerHTML = '';
});
describe('TourButton', () => {
it('برای تور ثبت‌شده دکمهٔ راهنما می‌آورد', () => {
renderWithProviders(<TourButton tourId="appointments" />);
expect(screen.getByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
});
it('برای شناسهٔ ناشناس یا بدون شناسه چیزی رندر نمی‌کند', () => {
const { container } = renderWithProviders(<TourButton tourId="does-not-exist" />);
expect(container).toBeEmptyDOMElement();
const { container: bare } = renderWithProviders(<TourButton />);
expect(bare).toBeEmptyDOMElement();
});
it('کلیک، تور را روی المان‌های موجود اجرا می‌کند', async () => {
const user = userEvent.setup();
renderWithProviders(
<>
<TourButton tourId="appointments" />
{appointmentsTour.steps.map((s) => (
<div key={s.anchor} data-tour={s.anchor} />
))}
</>,
);
await user.click(screen.getByRole('button', { name: 'راهنمای این صفحه' }));
expect(drive).toHaveBeenCalledTimes(1);
});
});
describe('PageHeader', () => {
it('با tourId دکمهٔ راهنما را کنار عنوان می‌گذارد', () => {
renderWithProviders(<PageHeader title="نوبت‌ها" tourId="appointments" />);
expect(screen.getByRole('heading', { name: 'نوبت‌ها' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
});
it('بدون tourId هیچ دکمهٔ راهنمایی ندارد', () => {
renderWithProviders(<PageHeader title="نوبت‌ها" />);
expect(screen.queryByRole('button', { name: 'راهنمای این صفحه' })).toBeNull();
});
it('صفحهٔ بدون تور هیچ درخواستی برای وضعیت تورها نمی‌فرستد', () => {
renderWithProviders(<PageHeader title="نوبت‌ها" />);
expect(get).not.toHaveBeenCalled();
});
});
+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,
}),
);
});
});
+12 -5
View File
@@ -22,16 +22,23 @@ interface PricingPayload {
* به‌همراه محاسبهٔ سهم — مشترک بین مودال «قطعی کردن نوبت» و صفحهٔ ویرایش نوبت تا * به‌همراه محاسبهٔ سهم — مشترک بین مودال «قطعی کردن نوبت» و صفحهٔ ویرایش نوبت تا
* هر دو یک قاعده را نشان دهند. * هر دو یک قاعده را نشان دهند.
*/ */
export function useAppointmentInsurance(enabled: boolean) { export function useAppointmentInsurance(enabled: boolean, doctorUuid?: string | null) {
// بدون پزشک، محیطِ خودِ کاربر پرسیده می‌شود — همان رفتار قبلی برای مطب شخصی.
//
// با پزشک، `inherit=1` هم می‌رود: نوبتِ ثبت‌شده در کلینیک محیطش «کلینیک» است ولی
// قرارداد بیمه معمولاً روی خودِ پزشک ذخیره شده. سرور اول تنظیم پزشک را می‌دهد و
// در نبودش تنظیم کلینیک را — دقیقاً همان چیزی که سرِ قطعی‌کردن اعمال می‌شود.
const scopeQuery = doctorUuid ? `?doctor_uuid=${encodeURIComponent(doctorUuid)}&inherit=1` : '';
const pricingQuery = useQuery<ApiResponse<PricingPayload>>({ const pricingQuery = useQuery<ApiResponse<PricingPayload>>({
queryKey: ['insurance-pricing'], queryKey: ['insurance-pricing', doctorUuid ?? null],
queryFn: () => api.get('/api/v1/insurance-pricing'), queryFn: () => api.get(`/api/v1/insurance-pricing${scopeQuery}`),
enabled, enabled,
}); });
const contractsQuery = useQuery<ApiResponse<{ data: TenantContract[] }>>({ const contractsQuery = useQuery<ApiResponse<{ data: TenantContract[] }>>({
queryKey: ['tenant-insurances'], queryKey: ['tenant-insurances', doctorUuid ?? null],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'), queryFn: () => api.get(`/api/v1/billing/tenant-insurances${scopeQuery}`),
enabled, enabled,
}); });
@@ -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 { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout'; import SettingsLayout from '../components/layout/SettingsLayout';
import TourButton from '../components/ui/TourButton';
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
admin: 'مدیر', clinic: 'کلینیک', doctor: 'پزشک', admin: 'مدیر', clinic: 'کلینیک', doctor: 'پزشک',
@@ -48,7 +49,7 @@ export default function AccountSettingsPage() {
return ( return (
<SettingsLayout active="account"> <SettingsLayout active="account">
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 620 }}> <div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 620 }}>
<h1 className="section-title">حساب کاربری</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">حساب کاربری</h1><TourButton tourId="account-settings" ready /></div>
{/* Profile summary */} {/* Profile summary */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}> <div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}>
+10 -5
View File
@@ -19,6 +19,8 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker'; import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils'; import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
import BackButton from '../components/ui/BackButton'; import BackButton from '../components/ui/BackButton';
import TourButton from '../components/ui/TourButton';
import { useTour } from '../hooks/useTour';
import { digitsOnly, todayIso, formatNumber } from '../lib/utils'; import { digitsOnly, todayIso, formatNumber } from '../lib/utils';
import Switch from '../components/ui/Switch'; import Switch from '../components/ui/Switch';
@@ -266,6 +268,8 @@ export default function AppointmentCreatePage() {
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'), onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
}); });
useTour('appointment-create', { ready: true });
return ( return (
<div style={{ padding: '20px 24px', maxWidth: 1080, margin: '0 auto' }}> <div style={{ padding: '20px 24px', maxWidth: 1080, margin: '0 auto' }}>
{/* بردکرامب */} {/* بردکرامب */}
@@ -273,7 +277,8 @@ export default function AppointmentCreatePage() {
<BackButton fallback="/admin/appointments" /> <BackButton fallback="/admin/appointments" />
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span> <span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span>
<span style={{ color: 'var(--text-3)' }}></span> <span style={{ color: 'var(--text-3)' }}></span>
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span> <span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }} data-tour="page-title">ثبت نوبت جدید</span>
<TourButton tourId="appointment-create" />
</div> </div>
{/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددوره‌ای بدون این، {/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددوره‌ای بدون این،
@@ -313,7 +318,7 @@ export default function AppointmentCreatePage() {
{!isDoctor && ( {!isDoctor && (
<> <>
<div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div> <div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div>
<label style={label}>انتخاب پزشک</label> <label style={label} data-tour="create-doctor">انتخاب پزشک</label>
<div style={{ margin: '6px 0 4px', maxWidth: 400 }}> <div style={{ margin: '6px 0 4px', maxWidth: 400 }}>
<SearchableSelect <SearchableSelect
options={doctorOptions} options={doctorOptions}
@@ -386,7 +391,7 @@ export default function AppointmentCreatePage() {
{/* حالت جستجوی مراجعه‌کنندهٔ موجود */} {/* حالت جستجوی مراجعه‌کنندهٔ موجود */}
{!newPatient && ( {!newPatient && (
<> <>
<label style={label}>انتخاب مراجعه کننده</label> <label style={label} data-tour="create-patient">انتخاب مراجعه کننده</label>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12, margin: '6px 0 8px' }}> <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12, margin: '6px 0 8px' }}>
<div className="field" style={{ width: 400, maxWidth: '100%' }}> <div className="field" style={{ width: 400, maxWidth: '100%' }}>
<MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} /> <MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} />
@@ -487,7 +492,7 @@ export default function AppointmentCreatePage() {
<> <>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}>
<div> <div>
<label style={label}>بخش</label> <label style={label} data-tour="create-service">بخش</label>
<div style={{ marginTop: 6 }}> <div style={{ marginTop: 6 }}>
<SearchableSelect <SearchableSelect
inputId="appt-section-select" inputId="appt-section-select"
@@ -702,7 +707,7 @@ export default function AppointmentCreatePage() {
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn primary lg" disabled={!valid || create.isPending} onClick={() => create.mutate()}> <button className="btn primary lg" data-tour="create-submit" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
{create.isPending ? '...' : 'ثبت اطلاعات'} {create.isPending ? '...' : 'ثبت اطلاعات'}
</button> </button>
</div> </div>
+5 -3
View File
@@ -106,6 +106,8 @@ export default function AppointmentDetailPage() {
<PageHeader <PageHeader
backTo="/admin/appointments" backTo="/admin/appointments"
title="جزئیات نوبت" title="جزئیات نوبت"
tourId="appointment-detail"
tourReady={!isLoading}
breadcrumbs={[ breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نوبت‌ها', to: backTo }, { label: 'نوبت‌ها', to: backTo },
@@ -122,7 +124,7 @@ export default function AppointmentDetailPage() {
) : appt ? ( ) : appt ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6"> <div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<h3 className="font-semibold text-[var(--text)] mb-4">اطلاعات بیمار</h3> <h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-patient">اطلاعات بیمار</h3>
<InfoRow label="نام بیمار" value={appt.patient_name} /> <InfoRow label="نام بیمار" value={appt.patient_name} />
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} /> <InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
<InfoRow label="پزشک" value={appt.doctor?.name ?? null} /> <InfoRow label="پزشک" value={appt.doctor?.name ?? null} />
@@ -139,7 +141,7 @@ export default function AppointmentDetailPage() {
<AppointmentSegmentsCard appointmentUuid={appt.uuid} /> <AppointmentSegmentsCard appointmentUuid={appt.uuid} />
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6"> <div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<h3 className="font-semibold text-[var(--text)] mb-4">وضعیت و اقدامات</h3> <h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-status">وضعیت و اقدامات</h3>
<div className="mb-4"> <div className="mb-4">
<p className="text-sm text-[var(--text-2)] mb-2">وضعیت فعلی:</p> <p className="text-sm text-[var(--text-2)] mb-2">وضعیت فعلی:</p>
<StatusBadge type="appointment" value={appt.status} /> <StatusBadge type="appointment" value={appt.status} />
@@ -198,7 +200,7 @@ export default function AppointmentDetailPage() {
</div> </div>
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6 lg:col-span-2"> <div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6 lg:col-span-2">
<h3 className="font-semibold text-[var(--text)] mb-4">تاریخچه رویدادها</h3> <h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-events">تاریخچه رویدادها</h3>
{eventsQuery.isLoading ? ( {eventsQuery.isLoading ? (
<div className="h-6 w-40 rounded skeleton" /> <div className="h-6 w-40 rounded skeleton" />
) : events.length === 0 ? ( ) : events.length === 0 ? (
+4 -2
View File
@@ -10,6 +10,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares'; import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
import BackButton from '../components/ui/BackButton'; import BackButton from '../components/ui/BackButton';
import TourButton from '../components/ui/TourButton';
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance'; import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
@@ -194,12 +195,13 @@ export default function AppointmentEditPage() {
return ( return (
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}> <div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div data-tour="page-title"><TourButton tourId="appointment-edit" ready /></div>
<BackButton fallback={`/admin/appointments?date=${date}`} /> <BackButton fallback={`/admin/appointments?date=${date}`} />
</div> </div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}> <div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>مشخصات سرویس:</div> <div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }} data-tour="edit-service">مشخصات سرویس:</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
<div> <div>
<label style={label}>بخش</label> <label style={label}>بخش</label>
@@ -9,6 +9,7 @@ import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection'; import type { AddressData } from '../components/schedule/ScheduleSection';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import TourButton from '../components/ui/TourButton';
const PERSONAL = 'personal'; const PERSONAL = 'personal';
@@ -75,7 +76,7 @@ export default function AppointmentSettingsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }} style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]" className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
> >
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1><TourButton tourId="appointment-settings" ready /></div>
<FreeVisitPrice readOnly={apptReadOnly} /> <FreeVisitPrice readOnly={apptReadOnly} />
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
const drive = vi.fn();
vi.mock('driver.js', () => ({ driver: vi.fn(() => ({ drive })) }));
vi.mock('driver.js/dist/driver.css', () => ({}));
import { driver } from 'driver.js';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import AppointmentsPage from './AppointmentsPage';
import { appointmentsTour } from '../lib/tour/tours/appointments';
import { resolveSteps } from '../lib/tour/resolveSteps';
const get = api.get as ReturnType<typeof vi.fn>;
const driverMock = driver as unknown as ReturnType<typeof vi.fn>;
/** پاسخ‌های ثابت صفحه؛ فقط وضعیت تور بین تست‌ها فرق می‌کند. */
function mockApi(seen: Record<string, number>) {
get.mockImplementation((url: string) => {
if (url.includes('/my/tours')) return Promise.resolve({ success: true, data: { seen } });
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 7, completed: 3, waiting: 2, cancelled: 1 } });
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => {
get.mockReset();
drive.mockReset();
driverMock.mockClear();
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1' } as any);
});
describe('AppointmentsPage — راهنمای صفحه', () => {
it('دکمهٔ راهنما کنار عنوان است', async () => {
mockApi({});
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
});
it('برای کاربری که تور را ندیده، خودکار اجرا می‌شود', async () => {
mockApi({});
renderWithProviders(<AppointmentsPage />);
await waitFor(() => expect(drive).toHaveBeenCalledTimes(1), { timeout: 3000 });
});
it('برای کاربری که تور را دیده، خودکار اجرا نمی‌شود', async () => {
mockApi({ appointments: appointmentsTour.version });
renderWithProviders(<AppointmentsPage />);
await screen.findByText('این روز تعطیل است');
await new Promise((r) => setTimeout(r, 500));
expect(drive).not.toHaveBeenCalled();
});
it('پزشک مستقل تب پزشکان ندارد، پس آن استپ از تور حذف می‌شود', async () => {
mockApi({});
renderWithProviders(<AppointmentsPage />);
await waitFor(() => expect(drive).toHaveBeenCalled(), { timeout: 3000 });
const anchors = driverMock.mock.calls[0][0].steps.map((s: { element: string }) => s.element);
expect(anchors).not.toContain('[data-tour="appointments-doctors"]');
expect(anchors).toContain('[data-tour="appointments-stats"]');
// شمارندهٔ تور همان تعداد استپِ واقعاً موجود است، نه کل تعریف
expect(anchors).toHaveLength(resolveSteps(appointmentsTour.steps).length);
expect(anchors.length).toBeLessThan(appointmentsTour.steps.length);
});
});
+33 -16
View File
@@ -28,6 +28,8 @@ import { useUrlState } from '../hooks/useUrlState';
import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTimeline from '../components/appointments/TurnsTimeline';
import TurnsTable from '../components/appointments/TurnsTable'; import TurnsTable from '../components/appointments/TurnsTable';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useTour } from '../hooks/useTour';
import TourButton from '../components/ui/TourButton';
import NewAppointmentModal from '../components/appointments/NewAppointmentModal'; import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
import type { BookingSlot } from '../components/appointments/NewAppointmentModal'; import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
@@ -68,7 +70,7 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
} }
return ( return (
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}> <div ref={ref} data-tour="appointments-date" style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}> <button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
<ChevronRightIcon style={{ width: 15, height: 15 }} /> <ChevronRightIcon style={{ width: 15, height: 15 }} />
</button> </button>
@@ -417,14 +419,23 @@ export default function AppointmentsPage() {
navigate(`/admin/appointments/${a.uuid}`); navigate(`/admin/appointments/${a.uuid}`);
} }
// تور بار اول فقط وقتی راه می‌افتد که نوبت‌ها آمده باشند؛ قبل از آن نیمی از
// المان‌های هدف هنوز روی صفحه نیستند.
useTour('appointments', { ready: !apptQuery.isLoading });
return ( return (
<div style={{ padding: '20px 24px' }}> <div style={{ padding: '20px 24px' }}>
<div style={{ maxWidth: 1050, margin: '0 auto' }}> <div style={{ maxWidth: 1050, margin: '0 auto' }}>
{/* عنوان */} {/* عنوان */}
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }}>
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>نوبت ها</h1>
<TourButton tourId="appointments" />
</div>
{/* نوار آمار */} {/* نوار آمار */}
<TurnsStatInfo stats={stats} /> <div data-tour="appointments-stats">
<TurnsStatInfo stats={stats} />
</div>
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */} {/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
<div style={{ <div style={{
@@ -454,13 +465,16 @@ export default function AppointmentsPage() {
</div> </div>
)} )}
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} /> <div data-tour="appointments-view" style={{ display: 'flex' }}>
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
</div>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
{/* سمت چپ: فیلتر + افزودن نوبت */} {/* سمت چپ: فیلتر + افزودن نوبت */}
<button <button
aria-label="فیلترها" aria-label="فیلترها"
data-tour="appointments-filters"
className="btn sm" className="btn sm"
onClick={() => setFiltersOpen(true)} onClick={() => setFiltersOpen(true)}
style={{ style={{
@@ -476,6 +490,7 @@ export default function AppointmentsPage() {
{!isRepresentation && canCreateAppt && ( {!isRepresentation && canCreateAppt && (
<button <button
className="btn primary sm" className="btn primary sm"
data-tour="appointments-new"
onClick={() => { onClick={() => {
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویس‌های خودش. // تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویس‌های خودش.
if (activeResource) { setBookingResource(activeResource); return; } if (activeResource) { setBookingResource(activeResource); return; }
@@ -491,21 +506,23 @@ export default function AppointmentsPage() {
</div> </div>
{/* کارت اصلی — تب دکترها (هدر) + محتوا */} {/* کارت اصلی — تب دکترها (هدر) + محتوا */}
<div style={{ <div data-tour="appointments-list" style={{
background: 'var(--surface)', border: '1px solid var(--border)', background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', overflow: 'hidden', borderRadius: 'var(--r)', overflow: 'hidden',
}}> }}>
{showDoctorTabs && ( {showDoctorTabs && (
<DoctorTabs <div data-tour="appointments-doctors">
doctors={doctors.map(d => ({ <DoctorTabs
uuid: d.uuid, doctors={doctors.map(d => ({
name: d.name, uuid: d.uuid,
note: d.hasSchedule ? undefined : 'بدون ساعت کاری', name: d.name,
}))} note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
selected={selectedDoctorUuid} }))}
onSelect={selectDoctor} selected={selectedDoctorUuid}
showAll={isAdmin} onSelect={selectDoctor}
/> showAll={isAdmin}
/>
</div>
)} )}
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده {/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
@@ -735,7 +752,7 @@ function ServiceFilterSelect({ value, options, onChange }: {
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void; value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
}) { }) {
return ( return (
<div style={{ minWidth: 280 }}> <div data-tour="appointments-service" style={{ minWidth: 280 }}>
<SearchableSelect <SearchableSelect
options={options.map(s => ({ value: s.uuid, label: s.name }))} options={options.map(s => ({ value: s.uuid, label: s.name }))}
value={value || null} value={value || null}
@@ -68,6 +68,7 @@ export default function CatalogCategoriesPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="دسته‌بندی‌ها" title="دسته‌بندی‌ها"
tourId="service-categories"
description="دسته‌بندی سراسری کلینیک؛ یک بار تعریف می‌شود و سرویس‌ها و منابع از همین‌ها انتخاب می‌کنند." description="دسته‌بندی سراسری کلینیک؛ یک بار تعریف می‌شود و سرویس‌ها و منابع از همین‌ها انتخاب می‌کنند."
backTo="/admin/settings-menu" backTo="/admin/settings-menu"
action={ action={
@@ -158,6 +158,7 @@ export default function ClaimPatientDetailPage() {
<FeatureGate feature="insurance"> <FeatureGate feature="insurance">
<PageHeader <PageHeader
backTo="/admin/claims" backTo="/admin/claims"
tourId="claim-detail"
title={patient?.full_name ?? 'پرونده بیمه بیمار'} title={patient?.full_name ?? 'پرونده بیمه بیمار'}
description={[patient?.mobile, patient?.national_code].filter(Boolean).join(' · ') || undefined} description={[patient?.mobile, patient?.national_code].filter(Boolean).join(' · ') || undefined}
breadcrumbs={[ breadcrumbs={[
@@ -202,7 +203,8 @@ export default function ClaimPatientDetailPage() {
/> />
</div> </div>
<Modal open={!!selected} title="جزئیات مطالبه" size="lg" onClose={() => setSelected(null)}> <Modal open={!!selected} title="جزئیات مطالبه"
size="lg" onClose={() => setSelected(null)}>
{selected && ( {selected && (
<div dir="rtl" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}> <div dir="rtl" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, fontSize: 13 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, fontSize: 13 }}>
+1
View File
@@ -183,6 +183,7 @@ export default function ClaimsPage() {
<FeatureGate feature="insurance"> <FeatureGate feature="insurance">
<PageHeader <PageHeader
title="پرونده‌های بیمه" title="پرونده‌های بیمه"
tourId="claims"
description="پیگیری مطالبات بیمه به تفکیک بیمار" description="پیگیری مطالبات بیمه به تفکیک بیمار"
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'پرونده‌های بیمه' }]} breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'پرونده‌های بیمه' }]}
/> />
@@ -87,6 +87,7 @@ function ClinicAppointmentSettingsContent() {
در دید، بدون اسکرول. پایین‌تر از هدر، کاربر باید دنبالش می‌گشت. */} در دید، بدون اسکرول. پایین‌تر از هدر، کاربر باید دنبالش می‌گشت. */}
<PageHeader <PageHeader
title="مدیریت نوبت‌دهی" title="مدیریت نوبت‌دهی"
tourId="clinic-appointment-settings"
description={description} description={description}
backTo="/admin/settings-menu" backTo="/admin/settings-menu"
action={( action={(
+6 -2
View File
@@ -27,6 +27,7 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { latinDigitsField } from '../lib/forms'; import { latinDigitsField } from '../lib/forms';
import Switch from '../components/ui/Switch'; import Switch from '../components/ui/Switch';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
// Fix leaflet icons // Fix leaflet icons
delete (L.Icon.Default.prototype as any)._getIconUrl; delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -238,9 +239,11 @@ function EditModal({ clinic, onClose, onSaved }: {
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic'); const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
const dismiss = useOverlayDismiss(onClose);
return createPortal( return createPortal(
<div className="overlay" onClick={onClose}> <div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }}>
<div className="modal-head"> <div className="modal-head">
<b>ویرایش کلینیک</b> <b>ویرایش کلینیک</b>
<button className="mini-btn" onClick={onClose}> <button className="mini-btn" onClick={onClose}>
@@ -587,6 +590,7 @@ export default function ClinicDetailPage() {
<PageHeader <PageHeader
backTo="/admin/clinics" backTo="/admin/clinics"
title={clinicName} title={clinicName}
tourId="clinic-detail"
breadcrumbs={[{ label: 'کلینیک‌ها', to: '/admin/clinics' }, { label: clinicName }]} breadcrumbs={[{ label: 'کلینیک‌ها', to: '/admin/clinics' }, { label: clinicName }]}
action={ action={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
+1
View File
@@ -48,6 +48,7 @@ function ClinicDoctorsContent() {
{/* عنوان و بازگشت و اکشن هر سه در PageHeader — همان قاعدهٔ بقیهٔ صفحات پنل. */} {/* عنوان و بازگشت و اکشن هر سه در PageHeader — همان قاعدهٔ بقیهٔ صفحات پنل. */}
<PageHeader <PageHeader
title="پزشکان کلینیک" title="پزشکان کلینیک"
tourId="clinic-doctors"
description="مدیریت پزشکان و دعوت‌نامه‌های کلینیک" description="مدیریت پزشکان و دعوت‌نامه‌های کلینیک"
backTo="/admin/settings-menu" backTo="/admin/settings-menu"
action={ action={
@@ -133,6 +133,7 @@ function ClinicServicesPageInner() {
که نه دکمهٔ بازگشت دارد و نه می‌گوید چیست. */} که نه دکمهٔ بازگشت دارد و نه می‌گوید چیست. */}
<PageHeader <PageHeader
title="سرویس‌ها" title="سرویس‌ها"
tourId="clinic-services"
description="بخش‌ها و سرویس‌های قابل رزرو. مدت و قیمت هر سرویس از همین‌جا می‌آید." description="بخش‌ها و سرویس‌های قابل رزرو. مدت و قیمت هر سرویس از همین‌جا می‌آید."
backTo="/admin/settings-menu" backTo="/admin/settings-menu"
action={ action={
+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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { import {
@@ -25,6 +25,8 @@ import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal'; import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
import { latinDigitsField } from '../lib/forms'; import { latinDigitsField } from '../lib/forms';
import TourButton from '../components/ui/TourButton';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
const HUES_LIST = [256, 205, 162, 295, 272]; const HUES_LIST = [256, 205, 162, 295, 272];
@@ -42,6 +44,8 @@ export default function ClinicsPage() {
const isRepresentation = primaryRole === 'representation'; const isRepresentation = primaryRole === 'representation';
// وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند. // وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' }); const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
const closeAdd = useCallback(() => setAddOpen(false), []);
const addDismiss = useOverlayDismiss(closeAdd);
const page = pageOf(urlState.page); const page = pageOf(urlState.page);
const search = urlState.search; const search = urlState.search;
const statusFilter = urlState.status; const statusFilter = urlState.status;
@@ -108,7 +112,7 @@ export default function ClinicsPage() {
<div className="fade-in"> <div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}> <div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div> <div>
<h1 className="section-title">کلینیکها</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">کلینیکها</h1><TourButton tourId="clinics" ready /></div>
<div className="muted">{formatNumber(total)} کلینیک ثبتشده</div> <div className="muted">{formatNumber(total)} کلینیک ثبتشده</div>
</div> </div>
<button className="btn primary sm" onClick={() => setAddOpen(true)}> <button className="btn primary sm" onClick={() => setAddOpen(true)}>
@@ -263,8 +267,8 @@ export default function ClinicsPage() {
{/* Add Modal */} {/* Add Modal */}
{addOpen && ( {addOpen && (
<Portal> <Portal>
<div className="overlay" onClick={() => setAddOpen(false)}> <div className="overlay" {...addDismiss}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}> <div className="modal" style={{ maxWidth: 420 }}>
<div className="modal-head"> <div className="modal-head">
<b>افزودن کلینیک</b> <b>افزودن کلینیک</b>
</div> </div>
+2 -1
View File
@@ -22,6 +22,7 @@ import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsP
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { STATUS_META } from '../components/ui/AppointmentStatusDropdown'; import { STATUS_META } from '../components/ui/AppointmentStatusDropdown';
import StatusBadge from '../components/ui/StatusBadge'; import StatusBadge from '../components/ui/StatusBadge';
import TourButton from '../components/ui/TourButton';
// ── Chart period (Jalali) ───────────────────────────────────────────────── // ── Chart period (Jalali) ─────────────────────────────────────────────────
@@ -437,7 +438,7 @@ function AdminDashboard() {
<div className="fade-in"> <div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}> <div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div> <div>
<h1 className="section-title">داشبورد مدیریت</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">داشبورد مدیریت</h1><TourButton tourId="dashboard" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div> <div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
+6 -2
View File
@@ -1,5 +1,6 @@
import SettingsLayout from '../components/layout/SettingsLayout'; import SettingsLayout from '../components/layout/SettingsLayout';
import DiscountTab from '../components/DiscountTab'; import DiscountTab from '../components/DiscountTab';
import TourButton from '../components/ui/TourButton';
/** مدیریت تخفیف‌ها — بخش تنظیمات برای تعریف قوانین تخفیف عمومی (owner-scoped). */ /** مدیریت تخفیف‌ها — بخش تنظیمات برای تعریف قوانین تخفیف عمومی (owner-scoped). */
export default function DiscountsPage() { export default function DiscountsPage() {
@@ -9,8 +10,11 @@ export default function DiscountsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }} style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]" className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
> >
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت تخفیفها</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }} data-tour="page-title">
<DiscountTab /> <h1 className="section-title">مدیریت تخفیفها</h1>
<TourButton tourId="discounts" ready />
</div>
<div data-tour="discounts-rules"><DiscountTab /></div>
</div> </div>
</SettingsLayout> </SettingsLayout>
); );
+5 -1
View File
@@ -38,6 +38,7 @@ import { latinDigitsField } from '../lib/forms';
import BackButton from '../components/ui/BackButton'; import BackButton from '../components/ui/BackButton';
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection'; import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
import Switch from '../components/ui/Switch'; import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
// Fix leaflet default marker icons // Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl; delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -1432,7 +1433,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div> </div>
<div className="flex-1 min-w-0 sm:mb-1"> <div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-[var(--text)]">{displayDoctorName(doctor.name)}</h1> <div className="flex items-center gap-1" data-tour="page-title">
<h1 className="text-xl font-bold text-[var(--text)]">{displayDoctorName(doctor.name)}</h1>
<TourButton tourId="doctor-detail" ready />
</div>
<div className="flex items-center gap-2 mt-1.5 flex-wrap"> <div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Active status badge */} {/* Active status badge */}
{doctor.active {doctor.active
+4 -2
View File
@@ -18,6 +18,7 @@ import PersianDatePicker from '../components/ui/PersianDatePicker';
import { iranMobileSchema } from '../lib/utils'; import { iranMobileSchema } from '../lib/utils';
import BackButton from '../components/ui/BackButton'; import BackButton from '../components/ui/BackButton';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import TourButton from '../components/ui/TourButton';
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -319,10 +320,11 @@ export default function DoctorFormPage() {
پزشکان پزشکان
</button> </button>
<span>/</span> <span>/</span>
<span style={{ color: 'var(--text-2)', fontWeight: 600 }}>افزودن پزشک جدید</span> <span style={{ color: 'var(--text-2)', fontWeight: 600 }} data-tour="page-title">افزودن پزشک جدید</span>
<TourButton tourId="doctor-new" ready />
</div> </div>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)} data-tour="doctor-form">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── Section: اطلاعات حساب ──────────────────────────────── */} {/* ── Section: اطلاعات حساب ──────────────────────────────── */}
+2 -1
View File
@@ -16,6 +16,7 @@ import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import TourButton from '../components/ui/TourButton';
// ── Types ───────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────
@@ -239,7 +240,7 @@ export default function DoctorsPage() {
{/* Header */} {/* Header */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}> <div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div> <div>
<h1 className="section-title">پزشکان</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">پزشکان</h1><TourButton tourId="doctors" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت پزشکان، تخصصها و وضعیت همکاری</div> <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت پزشکان، تخصصها و وضعیت همکاری</div>
</div> </div>
<div style={{ display: 'flex', gap: 10 }}> <div style={{ display: 'flex', gap: 10 }}>
+5 -1
View File
@@ -9,6 +9,7 @@ import CreateStep from '../components/session/CreateStep';
import PaymentStep from '../components/session/PaymentStep'; import PaymentStep from '../components/session/PaymentStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons'; import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner'; import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/** ویرایش مراجعه‌ی ثبت‌شده — دو تب: ویرایش سرویس‌ها + مدیریت پرداخت‌ها. */ /** ویرایش مراجعه‌ی ثبت‌شده — دو تب: ویرایش سرویس‌ها + مدیریت پرداخت‌ها. */
export default function EditSessionPage() { export default function EditSessionPage() {
@@ -47,7 +48,10 @@ export default function EditSessionPage() {
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}> <div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}> <div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ fontSize: 16, fontWeight: 700 }}>ویرایش مراجعه</h2> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<h2 style={{ fontSize: 16, fontWeight: 700 }}>ویرایش مراجعه</h2>
<TourButton tourId="session-edit" ready />
</div>
<button type="button" aria-label="بستن" onClick={() => nav(-1)} <button type="button" aria-label="بستن" onClick={() => nav(-1)}
style={{ minWidth: 40, height: 40, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> style={{ minWidth: 40, height: 40, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CloseModalD /> <CloseModalD />
@@ -56,6 +56,7 @@ export default function HolidaysSettingsPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="تعطیلات رسمی" title="تعطیلات رسمی"
tourId="holidays"
description="تعطیلات کشوری برای همهٔ محیط‌ها اعمال می‌شود. اگر این محیط روزی را باز است، همین‌جا استثنا بزنید." description="تعطیلات کشوری برای همهٔ محیط‌ها اعمال می‌شود. اگر این محیط روزی را باز است، همین‌جا استثنا بزنید."
backTo="/admin/settings-menu" backTo="/admin/settings-menu"
/> />
@@ -11,6 +11,7 @@ export default function InsurancePricingPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="مدیریت بیمه" title="مدیریت بیمه"
tourId="insurance-pricing"
description="قراردادهای بیمه پایه و تکمیلی" description="قراردادهای بیمه پایه و تکمیلی"
/> />
<InsuranceServiceCategoriesCard /> <InsuranceServiceCategoriesCard />
+7 -3
View File
@@ -5,6 +5,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useInventory } from '../hooks/useInventory'; import { useInventory } from '../hooks/useInventory';
import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory'; import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory';
import InventoryStatCards from '../components/inventory/InventoryStatCards'; import InventoryStatCards from '../components/inventory/InventoryStatCards';
import TourButton from '../components/ui/TourButton';
import InventoryItemsTable from '../components/inventory/InventoryItemsTable'; import InventoryItemsTable from '../components/inventory/InventoryItemsTable';
import PackagesView from '../components/inventory/PackagesView'; import PackagesView from '../components/inventory/PackagesView';
import AddItemModal from '../components/inventory/AddItemModal'; import AddItemModal from '../components/inventory/AddItemModal';
@@ -56,8 +57,11 @@ export default function InventoryPage() {
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */} {/* Header */}
<div> <div>
<h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)', marginBottom: 12 }}>انبارداری</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 12 }} data-tour="page-title">
<div className="inv-tabs"> <h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)' }}>انبارداری</h1>
<TourButton tourId="inventory" ready />
</div>
<div className="inv-tabs" data-tour="inventory-tabs">
<button className={`inv-tab${tab === 'stock' ? ' active' : ''}`} onClick={() => setTab('stock')}>کالاهای مصرفی</button> <button className={`inv-tab${tab === 'stock' ? ' active' : ''}`} onClick={() => setTab('stock')}>کالاهای مصرفی</button>
<button className={`inv-tab${tab === 'packages' ? ' active' : ''}`} onClick={() => setTab('packages')}>پکیج</button> <button className={`inv-tab${tab === 'packages' ? ' active' : ''}`} onClick={() => setTab('packages')}>پکیج</button>
</div> </div>
@@ -105,7 +109,7 @@ export default function InventoryPage() {
{/* Body */} {/* Body */}
{tab === 'stock' ? ( {tab === 'stock' ? (
<> <>
<InventoryStatCards stats={stats} /> <div data-tour="inventory-stats"><InventoryStatCards stats={stats} /></div>
{itemsLoading ? ( {itemsLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div> <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : filteredItems.length === 0 ? ( ) : filteredItems.length === 0 ? (
+1
View File
@@ -59,6 +59,7 @@ function MyFinancialPageContent() {
<div className="page"> <div className="page">
<PageHeader <PageHeader
title="مدیریت پرداخت" title="مدیریت پرداخت"
tourId="my-financial"
description={`روش‌های پرداخت «${environmentName}» (حساب بانکی و کارت‌خوان)`} description={`روش‌های پرداخت «${environmentName}» (حساب بانکی و کارت‌خوان)`}
/> />
+7
View File
@@ -606,6 +606,8 @@ function MyPatientsPageInner() {
<PageHeader <PageHeader
title="پرونده بیماران" title="پرونده بیماران"
description="مراجعه‌کنندگان ثبت‌شده شما" description="مراجعه‌کنندگان ثبت‌شده شما"
tourId="my-patients"
tourReady={!isLoading}
action={ action={
canCreate ? ( canCreate ? (
<button <button
@@ -621,6 +623,7 @@ function MyPatientsPageInner() {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div <div
data-tour="patients-search"
style={{ style={{
background: "var(--surface)", background: "var(--surface)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
@@ -709,6 +712,7 @@ function MyPatientsPageInner() {
) : ( ) : (
<> <>
<div <div
data-tour="patients-records"
style={ style={
viewMode === "grid" viewMode === "grid"
? { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 14 } ? { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 14 }
@@ -1025,6 +1029,7 @@ function MyPatientsPageInner() {
<PageHeader <PageHeader
title={selectedPatientName} title={selectedPatientName}
description={`تلفن: ${selectedPatientPhone} — پرونده از ${formatDate(selectedRecord.created_at)}`} description={`تلفن: ${selectedPatientPhone} — پرونده از ${formatDate(selectedRecord.created_at)}`}
tourId="my-patient-record"
action={ action={
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button <button
@@ -1047,6 +1052,7 @@ function MyPatientsPageInner() {
{/* بنر اطلاعات بیمار — مطابق فیگما */} {/* بنر اطلاعات بیمار — مطابق فیگما */}
<div <div
data-tour="record-banner"
style={{ style={{
background: "var(--surface)", background: "var(--surface)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
@@ -1130,6 +1136,7 @@ function MyPatientsPageInner() {
<div <div
className="cp-tabs" className="cp-tabs"
data-tour="record-tabs"
style={{ style={{
display: "flex", display: "flex",
gap: 8, gap: 8,
@@ -70,7 +70,9 @@ describe('MyPaymentDetailPage (پرداخت‌های ثبت‌شده)', () => {
it('fetches invoices for the patient uuid from the route', async () => { it('fetches invoices for the patient uuid from the route', async () => {
renderPage(); renderPage();
await screen.findAllByText('دنیا خلیلی'); await screen.findAllByText('دنیا خلیلی');
expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices'); // ترتیب درخواست‌ها قرارداد نیست — صفحه کوئری‌های جانبی هم دارد (مثل وضعیت تور راهنما).
const urls = get.mock.calls.map((c: unknown[]) => String(c[0]));
expect(urls.some((u) => u.includes('/api/v1/my/billing/patients/abc/invoices'))).toBe(true);
}); });
it('expands a row to show its line items and payment methods on بیشتر', async () => { it('expands a row to show its line items and payment methods on بیشتر', async () => {
@@ -128,6 +128,7 @@ export default function MyPaymentDetailPage() {
<> <>
<PageHeader <PageHeader
backTo="/admin/my-payments" backTo="/admin/my-payments"
tourId="my-payment-detail"
title="پرداخت‌های ثبت‌شده" title="پرداخت‌های ثبت‌شده"
description="صورتحساب‌های ثبت‌شده‌ی این بیمار" description="صورتحساب‌های ثبت‌شده‌ی این بیمار"
breadcrumbs={[ breadcrumbs={[
+1
View File
@@ -129,6 +129,7 @@ export default function MyPaymentsPage() {
<> <>
<PageHeader <PageHeader
title="لیست پرداخت‌ها" title="لیست پرداخت‌ها"
tourId="my-payments"
description="پرداخت‌های ثبت‌شده‌ی بیماران شما" description="پرداخت‌های ثبت‌شده‌ی بیماران شما"
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداخت‌ها' }]} breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداخت‌ها' }]}
/> />
+7 -2
View File
@@ -13,6 +13,7 @@ import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types"; import type { Secretary, SecretaryPermissions } from "../types";
import SecretaryPermissionsModal from "../components/SecretaryPermissionsModal"; import SecretaryPermissionsModal from "../components/SecretaryPermissionsModal";
import Switch from '../components/ui/Switch'; import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
// ── SVG icons (copied verbatim from clinic-pro-tauri) ─────────────────────── // ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
@@ -815,12 +816,15 @@ function MySecretariesPageContent() {
return ( return (
<div dir="rtl"> <div dir="rtl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-[var(--text)] text-[20px] font-bold">لیست منشی ها</p> <div className="flex items-center gap-1" data-tour="page-title">
<p className="text-[var(--text)] text-[20px] font-bold">لیست منشی ها</p>
<TourButton tourId="my-secretaries" ready />
</div>
</div> </div>
{/* تب‌ها + دکمه افزودن */} {/* تب‌ها + دکمه افزودن */}
<div className="w-full flex items-end justify-between mt-[20px]"> <div className="w-full flex items-end justify-between mt-[20px]">
<div className="flex items-center gap-[8px] border-b border-[var(--border)]"> <div className="flex items-center gap-[8px] border-b border-[var(--border)]" data-tour="secretaries-tabs">
{["منشی های فعلی", "منشی های قبلی"].map((label, idx) => ( {["منشی های فعلی", "منشی های قبلی"].map((label, idx) => (
<button <button
key={idx} key={idx}
@@ -837,6 +841,7 @@ function MySecretariesPageContent() {
))} ))}
</div> </div>
<button <button
data-tour="secretaries-add"
onClick={handleAddClick} onClick={handleAddClick}
className="shadow-none gap-[8px] bg-[var(--primary)] text-[var(--on-primary)] text-[14px] md:text-[15px] lg:text-[16px] className="shadow-none gap-[8px] bg-[var(--primary)] text-[var(--on-primary)] text-[14px] md:text-[15px] lg:text-[16px]
font-medium py-[10px] px-[16px] h-[43px] md:h-[45px] lg:h-[48px] rounded-[4px] cursor-pointer font-medium py-[10px] px-[16px] h-[43px] md:h-[45px] lg:h-[48px] rounded-[4px] cursor-pointer
+6 -2
View File
@@ -11,6 +11,7 @@ import PaymentStep from '../components/session/PaymentStep';
import DetailsStep from '../components/session/DetailsStep'; import DetailsStep from '../components/session/DetailsStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons'; import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner'; import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/** /**
* ثبت مراجعه جدید پورت کامل tauri /files/create-service (حالت ایجاد): * ثبت مراجعه جدید پورت کامل tauri /files/create-service (حالت ایجاد):
@@ -53,11 +54,14 @@ export default function NewSessionPage() {
return ( return (
<div className="fade-in" style={{ width: '100%' }}> <div className="fade-in" style={{ width: '100%' }}>
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} /> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
<TourButton tourId="session-new" ready />
</div>
{/* card — tauri width 748 centered */} {/* card — tauri width 748 centered */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}> <div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}> <div className="bg-[var(--surface)]" data-tour="session-card" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<button <button
type="button" type="button"
+11 -2
View File
@@ -22,6 +22,8 @@ import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import PersianDateInput from '../components/ui/PersianDateInput'; import PersianDateInput from '../components/ui/PersianDateInput';
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner'; import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
import { useTour } from '../hooks/useTour';
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard'; import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
import { useIssueInvoice } from '../hooks/useIssueInvoice'; import { useIssueInvoice } from '../hooks/useIssueInvoice';
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard'; import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
@@ -200,10 +202,16 @@ export default function PatientDetailPage() {
.filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled')) .filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled'))
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null; .sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
useTour('patient-detail', { ready: !isLoading });
return ( return (
<div className="fade-in"> <div className="fade-in">
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */} {/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */}
<Breadcrumb name={record?.user_name || 'پرونده'} backTo="/admin/patients" /> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<Breadcrumb name={record?.user_name || 'پرونده'} backTo="/admin/patients" />
<TourButton tourId="patient-detail" />
</div>
<div data-tour="patient-banner">
<PatientCaseBanner <PatientCaseBanner
name={record?.user_name || 'پرونده'} name={record?.user_name || 'پرونده'}
recordNumber={record?.record_number} recordNumber={record?.record_number}
@@ -215,9 +223,10 @@ export default function PatientDetailPage() {
hasDebt={hasDebt} hasDebt={hasDebt}
onAddNote={() => setTab('notes')} onAddNote={() => setTab('notes')}
/> />
</div>
{/* tab bar */} {/* tab bar */}
<div style={{ display: 'flex', gap: 4, overflowX: 'auto', borderBottom: '1px solid var(--border)', marginBottom: 18 }}> <div data-tour="patient-tabs" style={{ display: 'flex', gap: 4, overflowX: 'auto', borderBottom: '1px solid var(--border)', marginBottom: 18 }}>
{TABS.map((t) => { {TABS.map((t) => {
const on = t.key === tab; const on = t.key === tab;
return ( return (
+6 -2
View File
@@ -15,6 +15,7 @@ import { numericField } from '../lib/forms';
import BackButton from '../components/ui/BackButton'; import BackButton from '../components/ui/BackButton';
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils'; import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings'; import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
import TourButton from '../components/ui/TourButton';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر']; const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
@@ -131,10 +132,13 @@ export default function PatientRecordFormPage() {
<div className="fade-in" style={{ maxWidth: 1000, margin: '0 auto' }}> <div className="fade-in" style={{ maxWidth: 1000, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
<BackButton fallback="/admin/patients" /> <BackButton fallback="/admin/patients" />
<div style={{ fontSize: 14, color: 'var(--text-3)' }}>پرونده <b style={{ color: 'var(--text)' }}>{isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}</b></div> <div style={{ fontSize: 14, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
پرونده <b style={{ color: 'var(--text)' }}>{isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}</b>
<TourButton tourId="patient-record-form" ready />
</div>
</div> </div>
<form onSubmit={form.handleSubmit((d) => save.mutate(d))} <form onSubmit={form.handleSubmit((d) => save.mutate(d))} data-tour="record-form"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 24 }}> style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 18 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 18 }}>
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}> <Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
+11 -4
View File
@@ -14,6 +14,8 @@ import Pagination from '../components/ui/Pagination';
import PatientTagsCell from '../components/PatientTagsCell'; import PatientTagsCell from '../components/PatientTagsCell';
import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal'; import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useTour } from '../hooks/useTour';
import TourButton from '../components/ui/TourButton';
import { import {
SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn, SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn,
} from '../components/icons/FilesToolbarIcons'; } from '../components/icons/FilesToolbarIcons';
@@ -161,6 +163,8 @@ export default function PatientsListPage() {
queryFn: () => api.get(`/api/v1/patients?${qs.toString()}`), queryFn: () => api.get(`/api/v1/patients?${qs.toString()}`),
}); });
useTour('patients', { ready: !isLoading });
const activeFilters = countFilters(filters); const activeFilters = countFilters(filters);
const records = data?.data ?? EMPTY; const records = data?.data ?? EMPTY;
const total = data?.meta?.totalRecords ?? 0; const total = data?.meta?.totalRecords ?? 0;
@@ -183,14 +187,17 @@ export default function PatientsListPage() {
return ( return (
<div className="fade-in"> <div className="fade-in">
{/* Head — تیتر تنها (tauri files/head) */} {/* Head — تیتر تنها (tauri files/head) */}
<p className="text-[var(--text)] text-[16px] md:text-[18px] lg:text-[20px] font-bold">پروندهها</p> <div className="flex items-center gap-1" data-tour="page-title">
<p className="text-[var(--text)] text-[16px] md:text-[18px] lg:text-[20px] font-bold">پروندهها</p>
<TourButton tourId="patients" />
</div>
{/* Inputs — ردیف کنترل‌ها (tauri files/inputs) */} {/* Inputs — ردیف کنترل‌ها (tauri files/inputs) */}
<div className="my-[16px] md:my-[20px] lg:my-[24px]"> <div className="my-[16px] md:my-[20px] lg:my-[24px]">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-[16px] lg:gap-[20px]"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-[16px] lg:gap-[20px]">
{/* جستجو + سوییچ نما */} {/* جستجو + سوییچ نما */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 w-full"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 w-full">
<div className="w-full md:w-[442px]"> <div className="w-full md:w-[442px]" data-tour="patients-search">
<div className="flex items-center rounded-[6px] border border-[var(--border)] bg-[var(--bg)]" style={{ height: 48, paddingInline: 4 }}> <div className="flex items-center rounded-[6px] border border-[var(--border)] bg-[var(--bg)]" style={{ height: 48, paddingInline: 4 }}>
<input <input
dir="rtl" dir="rtl"
@@ -211,7 +218,7 @@ export default function PatientsListPage() {
{/* فیلتر + تشکیل پرونده */} {/* فیلتر + تشکیل پرونده */}
<div className="flex items-center w-full lg:w-auto justify-between lg:justify-end" style={{ gap: 12 }}> <div className="flex items-center w-full lg:w-auto justify-between lg:justify-end" style={{ gap: 12 }}>
<button <button
type="button" aria-label="فیلترها" onClick={() => setFilterOpen(true)} type="button" aria-label="فیلترها" data-tour="patients-filters" onClick={() => setFilterOpen(true)}
className="flex items-center justify-center rounded-[4px] cursor-pointer" className="flex items-center justify-center rounded-[4px] cursor-pointer"
style={{ width: 62, height: 48, border: '1px solid var(--primary)', background: 'transparent', position: 'relative' }} style={{ width: 62, height: 48, border: '1px solid var(--primary)', background: 'transparent', position: 'relative' }}
> >
@@ -222,7 +229,7 @@ export default function PatientsListPage() {
</button> </button>
{canCreate && ( {canCreate && (
<button <button
type="button" onClick={() => navigate('/admin/patients/new')} type="button" data-tour="patients-new" onClick={() => navigate('/admin/patients/new')}
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer" className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer"
style={{ height: 48, minWidth: 137, background: 'var(--primary)', border: 'none', padding: '0 16px' }} style={{ height: 48, minWidth: 137, background: 'var(--primary)', border: 'none', padding: '0 16px' }}
> >
@@ -7,6 +7,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import type { PracticeDomain } from '../types'; import type { PracticeDomain } from '../types';
import TourButton from '../components/ui/TourButton';
/** /**
* حوزهٔ فعالیت کلینیک. * حوزهٔ فعالیت کلینیک.
@@ -62,7 +63,7 @@ export default function PracticeDomainSettingsPage() {
// منوی تنظیمات را نشان نمی‌داد و کاربر برای رفتن به بخش بعدی باید «بازگشت» می‌زد. // منوی تنظیمات را نشان نمی‌داد و کاربر برای رفتن به بخش بعدی باید «بازگشت» می‌زد.
<SettingsLayout active="practice-domain"> <SettingsLayout active="practice-domain">
<div className="card card-pad" style={{ display: 'grid', gap: 14, maxWidth: 560 }}> <div className="card card-pad" style={{ display: 'grid', gap: 14, maxWidth: 560 }}>
<h1 className="section-title" style={{ margin: 0 }}>حوزهٔ فعالیت</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ margin: 0 }}>حوزهٔ فعالیت</h1><TourButton tourId="practice-domain" ready /></div>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}> <p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با
+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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline'; import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -9,6 +9,7 @@ import { formatDate } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
interface PreRegistration { interface PreRegistration {
uuid: string; uuid: string;
@@ -49,6 +50,8 @@ export default function PreRegistrationsPage() {
const [approveTarget, setApproveTarget] = useState<PreRegistration | null>(null); const [approveTarget, setApproveTarget] = useState<PreRegistration | null>(null);
const [rejectTarget, setRejectTarget] = useState<PreRegistration | null>(null); const [rejectTarget, setRejectTarget] = useState<PreRegistration | null>(null);
const [rejectNote, setRejectNote] = useState(''); const [rejectNote, setRejectNote] = useState('');
const closeReject = useCallback(() => setRejectTarget(null), []);
const rejectDismiss = useOverlayDismiss(closeReject);
const limit = 20; const limit = 20;
const { data, isLoading } = useQuery<PaginatedResponse<PreRegistration>>({ const { data, isLoading } = useQuery<PaginatedResponse<PreRegistration>>({
@@ -206,8 +209,8 @@ export default function PreRegistrationsPage() {
{/* Reject Dialog */} {/* Reject Dialog */}
{rejectTarget && ( {rejectTarget && (
<Portal> <Portal>
<div className="overlay" onClick={() => setRejectTarget(null)}> <div className="overlay" {...rejectDismiss}>
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}> <div className="modal" style={{ maxWidth: 420 }}>
<div className="modal-head"> <div className="modal-head">
<b>رد درخواست {rejectTarget.name}</b> <b>رد درخواست {rejectTarget.name}</b>
</div> </div>
@@ -4,6 +4,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings'; import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings'; import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings';
import Switch from '../components/ui/Switch'; import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [ const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [
{ value: 'none', label: 'هرگز — شمارنده پیوسته جلو می‌رود' }, { value: 'none', label: 'هرگز — شمارنده پیوسته جلو می‌رود' },
@@ -49,7 +50,7 @@ export default function RecordNumberSettingsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }} style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]" className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
> >
<h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1><TourButton tourId="record-number-settings" ready /></div>
<p style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 18, lineHeight: 2 }}> <p style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 18, lineHeight: 2 }}>
با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه چه از فرم تشکیل پرونده و چه از با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه چه از فرم تشکیل پرونده و چه از
نوبتی که قطعی میشود بر اساس همین الگو ساخته میشود. نوبتی که قطعی میشود بر اساس همین الگو ساخته میشود.
@@ -94,6 +94,7 @@ export default function RepresentationBlogFormPage() {
<PageHeader <PageHeader
backTo="/admin/representation-blogs" backTo="/admin/representation-blogs"
title={isEdit ? 'ویرایش مقاله' : 'نوشتن مقالهٔ جدید'} title={isEdit ? 'ویرایش مقاله' : 'نوشتن مقالهٔ جدید'}
tourId="representation-blog-form"
breadcrumbs={[{ label: 'وبلاگ من', to: '/admin/representation-blogs' }, { label: isEdit ? 'ویرایش' : 'جدید' }]} breadcrumbs={[{ label: 'وبلاگ من', to: '/admin/representation-blogs' }, { label: isEdit ? 'ویرایش' : 'جدید' }]}
/> />
@@ -53,6 +53,7 @@ export default function RepresentationBlogsPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="وبلاگ من" title="وبلاگ من"
tourId="representation-blogs"
description="مقالات مخصوص دامنه و برند شما." description="مقالات مخصوص دامنه و برند شما."
action={<button className="cp-btn-primary" onClick={() => navigate('/admin/representation-blogs/new')}>مقالهٔ جدید</button>} action={<button className="cp-btn-primary" onClick={() => navigate('/admin/representation-blogs/new')}>مقالهٔ جدید</button>}
/> />
@@ -8,6 +8,7 @@ import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils'; import { formatRial, formatNumber, formatDate } from '../lib/utils';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
import TourButton from '../components/ui/TourButton';
interface FinanceRow { interface FinanceRow {
uuid: string; uuid: string;
@@ -79,7 +80,7 @@ export default function RepresentationFinancePage() {
<div className="fade-in"> <div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}> <div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div> <div>
<h1 className="section-title">گزارش مالی</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">گزارش مالی</h1><TourButton tourId="representation-finance" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}> <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>
درآمد ثبتشده از پورسانت نوبتها تفکیک هر تراکنش درآمد ثبتشده از پورسانت نوبتها تفکیک هر تراکنش
</div> </div>
@@ -6,6 +6,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import PersianDatePicker from '../components/ui/PersianDatePicker'; import PersianDatePicker from '../components/ui/PersianDatePicker';
import { toEnglishDigits, digitsOnly } from '../lib/utils'; import { toEnglishDigits, digitsOnly } from '../lib/utils';
import TourButton from '../components/ui/TourButton';
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir // تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
function toJalali(iso: string): string { function toJalali(iso: string): string {
@@ -126,7 +127,7 @@ export default function RepresentationProfilePage() {
<div className="fade-in"> <div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}> <div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div> <div>
<h1 className="section-title">پروفایل نماینده</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">پروفایل نماینده</h1><TourButton tourId="representation-profile" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>تأیید هویت و مدیریت شمارههای شبا</div> <div className="muted" style={{ fontSize: 13, marginTop: 2 }}>تأیید هویت و مدیریت شمارههای شبا</div>
</div> </div>
</div> </div>
@@ -7,6 +7,7 @@ import { formatRial, formatDate, tomanToRial } from '../lib/utils';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils'; import { digitsOnly } from '../lib/utils';
import TourButton from '../components/ui/TourButton';
interface WalletBalance { balance_rials: number } interface WalletBalance { balance_rials: number }
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } } interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
@@ -101,7 +102,7 @@ export default function RepresentationSettlementPage() {
<div className="fade-in"> <div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}> <div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div> <div>
<h1 className="section-title">تسویه حساب</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">تسویه حساب</h1><TourButton tourId="representation-settlement" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درخواست برداشت از کیفپول نماینده</div> <div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درخواست برداشت از کیفپول نماینده</div>
</div> </div>
</div> </div>
+11 -4
View File
@@ -17,6 +17,8 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
import NewAppointmentDrawer from '../components/NewAppointmentDrawer'; import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
import BackButton from '../components/ui/BackButton'; import BackButton from '../components/ui/BackButton';
import TourButton from '../components/ui/TourButton';
import { useTour } from '../hooks/useTour';
import { AppointmentInfoModal, TransferReserveModal } from '../components/AppointmentActions'; import { AppointmentInfoModal, TransferReserveModal } from '../components/AppointmentActions';
const LIMIT = 20; const LIMIT = 20;
@@ -119,16 +121,21 @@ export default function ReserveAppointmentsPage() {
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' }; const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' };
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' }; const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' };
useTour('appointments-reserve', { ready: !isLoading });
return ( return (
<div className="fade-in" style={{ padding: '20px 24px' }}> <div className="fade-in" style={{ padding: '20px 24px' }}>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<BackButton fallback="/admin/appointments" /> <BackButton fallback="/admin/appointments" />
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, marginBottom: 16 }}>
<h1 style={{ fontSize: 17, fontWeight: 800 }}>نوبت های رزرو شده</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<h1 style={{ fontSize: 17, fontWeight: 800 }}>نوبت های رزرو شده</h1>
<TourButton tourId="appointments-reserve" />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isClinic && ( {isClinic && (
<div style={{ minWidth: 180 }}> <div style={{ minWidth: 180 }} data-tour="reserve-doctor">
<SearchableSelect <SearchableSelect
options={clinicDoctors.map(d => ({ value: d.uuid, label: d.name }))} options={clinicDoctors.map(d => ({ value: d.uuid, label: d.name }))}
value={doctorUuid || null} value={doctorUuid || null}
@@ -140,7 +147,7 @@ export default function ReserveAppointmentsPage() {
</div> </div>
)} )}
{primaryRole !== 'representation' && ( {primaryRole !== 'representation' && (
<button className="btn primary sm" disabled={!doctorUuid} <button className="btn primary sm" data-tour="reserve-new" disabled={!doctorUuid}
onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 5 }}> onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<PlusIcon style={{ width: 15 }} /> نوبت رزرو <PlusIcon style={{ width: 15 }} /> نوبت رزرو
</button> </button>
@@ -148,7 +155,7 @@ export default function ReserveAppointmentsPage() {
</div> </div>
</div> </div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}> <div data-tour="reserve-list" style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
{isLoading ? ( {isLoading ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div> <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
@@ -72,6 +72,7 @@ export default function ResourceDetailPage() {
title={resource.name} title={resource.name}
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`} description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`}
backTo="/admin/resources" backTo="/admin/resources"
tourId="resource-detail"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]} breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
action={ action={
canUpdate ? ( canUpdate ? (
+1
View File
@@ -66,6 +66,7 @@ export default function ResourcePoolsPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="استخر منابع" title="استخر منابع"
tourId="resource-pools"
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید از یک نوع باشند." description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید از یک نوع باشند."
backTo="/admin/resources" backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]} breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
+1
View File
@@ -50,6 +50,7 @@ export default function ResourceTypesPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="نوع منابع" title="نوع منابع"
tourId="resource-types"
description="دسته‌بندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوع‌های سیستمی حذف نمی‌شوند چون پل خودکار منابع به آن‌ها تکیه دارد." description="دسته‌بندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوع‌های سیستمی حذف نمی‌شوند چون پل خودکار منابع به آن‌ها تکیه دارد."
backTo="/admin/resources" backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]} breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
+1
View File
@@ -104,6 +104,7 @@ export default function ResourcesPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="منابع" title="منابع"
tourId="resources"
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان." description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان."
/* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار /* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */ همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */
+1 -1
View File
@@ -77,7 +77,7 @@ export default function SecretaryEarningsPage() {
return ( return (
<div className="fade-in"> <div className="fade-in">
<PageHeader title="درآمد نوبت‌های آنلاین" description="سهم شما از نوبت‌هایی که آنلاین ثبت و پرداخت شده‌اند" /> <PageHeader title="درآمد نوبت‌های آنلاین" description="سهم شما از نوبت‌هایی که آنلاین ثبت و پرداخت شده‌اند"tourId="secretary-earnings" />
{summary && !summary.enabled ? ( {summary && !summary.enabled ? (
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text-2)', lineHeight: 1.9 }}> <div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text-2)', lineHeight: 1.9 }}>
@@ -109,7 +109,7 @@ export default function SecretarySettlementPage() {
return ( return (
<div className="fade-in"> <div className="fade-in">
<PageHeader title="تسویه حساب" description="برداشت سهم نوبت‌های آنلاین به شماره شبای شما" /> <PageHeader title="تسویه حساب" description="برداشت سهم نوبت‌های آنلاین به شماره شبای شما"tourId="secretary-settlement" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" /> <StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" />
+1
View File
@@ -436,6 +436,7 @@ function ServiceDetailPageInner() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
backTo="/admin/clinic-services" backTo="/admin/clinic-services"
tourId="service-detail"
title={item.name} title={item.name}
breadcrumbs={[ breadcrumbs={[
{ label: 'سرویس‌ها', to: '/admin/clinic-services' }, { label: 'سرویس‌ها', to: '/admin/clinic-services' },
+6 -2
View File
@@ -10,6 +10,7 @@ import PaymentStep from '../components/session/PaymentStep';
import DetailsStep from '../components/session/DetailsStep'; import DetailsStep from '../components/session/DetailsStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons'; import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner'; import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/** /**
* تکمیل پرداخت مراجعه پورت صفحهی tauri /files/create-service در حالت payment: * تکمیل پرداخت مراجعه پورت صفحهی tauri /files/create-service در حالت payment:
@@ -56,11 +57,14 @@ export default function SessionPaymentPage() {
return ( return (
<div className="fade-in" style={{ width: '100%' }}> <div className="fade-in" style={{ width: '100%' }}>
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} /> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
<TourButton tourId="session-payment" ready />
</div>
{/* card — tauri width 748 centered */} {/* card — tauri width 748 centered */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}> <div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}> <div className="bg-[var(--surface)]" data-tour="session-card" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<button <button
type="button" type="button"
+2 -1
View File
@@ -4,6 +4,7 @@ import { ChevronLeftIcon } from '@heroicons/react/24/outline';
import { groupedMenuForRole } from '../components/layout/settingsMenu'; import { groupedMenuForRole } from '../components/layout/settingsMenu';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import TourButton from '../components/ui/TourButton';
/** /**
* SettingsMenuPage the settings landing list for doctor/clinic users. * SettingsMenuPage the settings landing list for doctor/clinic users.
@@ -22,7 +23,7 @@ export default function SettingsMenuPage() {
return ( return (
<div className="fade-in" style={{ maxWidth: 720, margin: '0 auto' }}> <div className="fade-in" style={{ maxWidth: 720, margin: '0 auto' }}>
<h1 className="section-title" style={{ marginBottom: 16 }}>تنظیمات</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 16 }}>تنظیمات</h1><TourButton tourId="settings-menu" ready /></div>
{groups.map((group) => ( {groups.map((group) => (
<section key={group.key} aria-label={group.label} style={{ marginBottom: 20 }}> <section key={group.key} aria-label={group.label} style={{ marginBottom: 20 }}>
+1
View File
@@ -46,6 +46,7 @@ export default function SkillsPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="مهارت‌ها" title="مهارت‌ها"
tourId="resource-skills"
description="مهارت روی منابع می‌نشیند و در انتخاب منبع مناسب استفاده می‌شود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمی‌شود." description="مهارت روی منابع می‌نشیند و در انتخاب منبع مناسب استفاده می‌شود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمی‌شود."
backTo="/admin/resources" backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارت‌ها' }]} breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارت‌ها' }]}
+2 -2
View File
@@ -117,10 +117,10 @@ function SmsWalletPageInner() {
return ( return (
<> <>
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" /> <PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" tourId="sms-wallet" />
{/* ردیف بالا: موجودی + آمار */} {/* ردیف بالا: موجودی + آمار */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 }}> <div data-tour="sms-balance" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 }}>
{/* کارت موجودی — gradient */} {/* کارت موجودی — gradient */}
<div style={{ <div style={{
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))', background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
+1
View File
@@ -189,6 +189,7 @@ export default function StaffPage() {
<SettingsLayout active="staff"> <SettingsLayout active="staff">
<PageHeader <PageHeader
title="مدیریت پرسنل" title="مدیریت پرسنل"
tourId="staff"
description="لیست پرسنل کلینیک / مطب" description="لیست پرسنل کلینیک / مطب"
action={ action={
canCreate ? ( canCreate ? (

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