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.
This commit is contained in:
hamed
2026-08-10 10:10:20 +03:30
parent 20c8eaaad9
commit ecdefa3c24
73 changed files with 1008 additions and 76 deletions
+5 -3
View File
@@ -63,9 +63,11 @@ export default function DataTable<T extends object>({
<div>
{/* Toolbar row */}
{(onSearchChange !== undefined || headerExtra) && (
<div className="toolbar">
// لنگرهای data-tour اینجا تعریف می‌شوند تا هر صفحه‌ای که DataTable دارد
// بدون تغییر کد خودش، در تور راهنما قابل اشاره باشد.
<div className="toolbar" data-tour="page-toolbar">
{onSearchChange !== undefined && (
<div className="field" style={{ flex: '0 0 auto', minWidth: 240 }}>
<div className="field" data-tour="page-search" style={{ flex: '0 0 auto', minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0 }} />
<input
type="text"
@@ -79,7 +81,7 @@ export default function DataTable<T extends object>({
</div>
)}
<div className="table-wrap">
<div className="table-wrap" data-tour="page-table">
<table className="t">
<thead>
<tr>
+9 -4
View File
@@ -21,9 +21,14 @@ interface Props {
backTo?: string;
/** شناسهٔ تور راهنمای این صفحه؛ اگر در registry ثبت نشده باشد دکمه‌ای نمی‌آید. */
tourId?: string;
/**
* محتوای صفحه نشسته است و تورِ بار اول می‌تواند خودکار اجرا شود.
* صفحه‌ای که داده‌اش دیر می‌آید، `!isLoading` بدهد تا تور روی صفحهٔ نیمه‌ساخته نیفتد.
*/
tourReady?: boolean;
}
export default function PageHeader({ title, breadcrumbs, action, description, backTo, tourId }: Props) {
export default function PageHeader({ title, breadcrumbs, action, description, backTo, tourId, tourReady = true }: Props) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
<div style={{ minWidth: 0 }}>
@@ -51,15 +56,15 @@ export default function PageHeader({ title, breadcrumbs, action, description, ba
))}
</nav>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<h1 className="section-title">{title}</h1>
<TourButton tourId={tourId} />
<TourButton tourId={tourId} ready={tourReady} />
</div>
{description && (
<p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p>
)}
</div>
{action && <div style={{ flexShrink: 0 }}>{action}</div>}
{action && <div style={{ flexShrink: 0 }} data-tour="page-action">{action}</div>}
</div>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Pagination({ page, total, limit, onPageChange }: Props)
}
return (
<div className="pagination">
<div className="pagination" data-tour="page-pagination">
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
نمایش {formatNumber(from)}{formatNumber(to)} از {formatNumber(total)} مورد
</span>
+9 -5
View File
@@ -8,17 +8,21 @@ import { getTour } from '../../lib/tour/registry';
* وجود تور قبل از هر هوکی بررسی می‌شود تا صفحه‌ای که راهنما ندارد — یعنی بیشتر
* صفحات پنل — هیچ درخواستی برای وضعیت تورها نفرستد.
*/
export default function TourButton({ tourId }: { tourId?: string }) {
export default function TourButton({ tourId, ready = false }: { tourId?: string; ready?: boolean }) {
const tour = getTour(tourId);
if (!tour) return null;
return <TourLauncher tourId={tour.id} />;
return <TourLauncher tourId={tour.id} ready={ready} />;
}
/** اجرای خودکار وظیفهٔ خود صفحه است؛ این دکمه فقط دستی اجرا می‌کند. */
function TourLauncher({ tourId }: { tourId: string }) {
const { start } = useTour(tourId);
/**
* `ready` یعنی محتوای صفحه نشسته است و اجرای خودکارِ بار اول مجاز است.
* صفحه‌ای که دکمه را مستقیم می‌گذارد و آماده‌بودنش را نمی‌داند، آن را false می‌گذارد
* و تور فقط با کلیک اجرا می‌شود.
*/
function TourLauncher({ tourId, ready }: { tourId: string; ready: boolean }) {
const { start } = useTour(tourId, { ready });
return (
<button
+90 -3
View File
@@ -1,10 +1,97 @@
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/ دارد و فقط اینجا ثبت می‌شود. */
export const TOURS: Record<string, TourDefinition> = {
[appointmentsTour.id]: appointmentsTour,
};
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;
+46
View File
@@ -59,4 +59,50 @@ describe('registry', () => {
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,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' },
],
};
+2 -1
View File
@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
import TourButton from '../components/ui/TourButton';
const ROLE_LABELS: Record<string, string> = {
admin: 'مدیر', clinic: 'کلینیک', doctor: 'پزشک',
@@ -48,7 +49,7 @@ export default function AccountSettingsPage() {
return (
<SettingsLayout active="account">
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 620 }}>
<h1 className="section-title">حساب کاربری</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">حساب کاربری</h1><TourButton tourId="account-settings" ready /></div>
{/* Profile summary */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}>
+10 -5
View File
@@ -19,6 +19,8 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
import BackButton from '../components/ui/BackButton';
import TourButton from '../components/ui/TourButton';
import { useTour } from '../hooks/useTour';
import { digitsOnly, todayIso, formatNumber } from '../lib/utils';
import Switch from '../components/ui/Switch';
@@ -266,6 +268,8 @@ export default function AppointmentCreatePage() {
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
});
useTour('appointment-create', { ready: true });
return (
<div style={{ padding: '20px 24px', maxWidth: 1080, margin: '0 auto' }}>
{/* بردکرامب */}
@@ -273,7 +277,8 @@ export default function AppointmentCreatePage() {
<BackButton fallback="/admin/appointments" />
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span>
<span style={{ color: 'var(--text-3)' }}></span>
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span>
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }} data-tour="page-title">ثبت نوبت جدید</span>
<TourButton tourId="appointment-create" />
</div>
{/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددوره‌ای بدون این،
@@ -313,7 +318,7 @@ export default function AppointmentCreatePage() {
{!isDoctor && (
<>
<div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div>
<label style={label}>انتخاب پزشک</label>
<label style={label} data-tour="create-doctor">انتخاب پزشک</label>
<div style={{ margin: '6px 0 4px', maxWidth: 400 }}>
<SearchableSelect
options={doctorOptions}
@@ -386,7 +391,7 @@ export default function AppointmentCreatePage() {
{/* حالت جستجوی مراجعه‌کنندهٔ موجود */}
{!newPatient && (
<>
<label style={label}>انتخاب مراجعه کننده</label>
<label style={label} data-tour="create-patient">انتخاب مراجعه کننده</label>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12, margin: '6px 0 8px' }}>
<div className="field" style={{ width: 400, maxWidth: '100%' }}>
<MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} />
@@ -487,7 +492,7 @@ export default function AppointmentCreatePage() {
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<label style={label} data-tour="create-service">بخش</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
inputId="appt-section-select"
@@ -702,7 +707,7 @@ export default function AppointmentCreatePage() {
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn primary lg" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
<button className="btn primary lg" data-tour="create-submit" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
{create.isPending ? '...' : 'ثبت اطلاعات'}
</button>
</div>
+5 -3
View File
@@ -106,6 +106,8 @@ export default function AppointmentDetailPage() {
<PageHeader
backTo="/admin/appointments"
title="جزئیات نوبت"
tourId="appointment-detail"
tourReady={!isLoading}
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نوبت‌ها', to: backTo },
@@ -122,7 +124,7 @@ export default function AppointmentDetailPage() {
) : appt ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<h3 className="font-semibold text-[var(--text)] mb-4">اطلاعات بیمار</h3>
<h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-patient">اطلاعات بیمار</h3>
<InfoRow label="نام بیمار" value={appt.patient_name} />
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
<InfoRow label="پزشک" value={appt.doctor?.name ?? null} />
@@ -139,7 +141,7 @@ export default function AppointmentDetailPage() {
<AppointmentSegmentsCard appointmentUuid={appt.uuid} />
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<h3 className="font-semibold text-[var(--text)] mb-4">وضعیت و اقدامات</h3>
<h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-status">وضعیت و اقدامات</h3>
<div className="mb-4">
<p className="text-sm text-[var(--text-2)] mb-2">وضعیت فعلی:</p>
<StatusBadge type="appointment" value={appt.status} />
@@ -198,7 +200,7 @@ export default function AppointmentDetailPage() {
</div>
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6 lg:col-span-2">
<h3 className="font-semibold text-[var(--text)] mb-4">تاریخچه رویدادها</h3>
<h3 className="font-semibold text-[var(--text)] mb-4" data-tour="appt-events">تاریخچه رویدادها</h3>
{eventsQuery.isLoading ? (
<div className="h-6 w-40 rounded skeleton" />
) : events.length === 0 ? (
+4 -2
View File
@@ -10,6 +10,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
import BackButton from '../components/ui/BackButton';
import TourButton from '../components/ui/TourButton';
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
@@ -194,12 +195,13 @@ export default function AppointmentEditPage() {
return (
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div data-tour="page-title"><TourButton tourId="appointment-edit" ready /></div>
<BackButton fallback={`/admin/appointments?date=${date}`} />
</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>مشخصات سرویس:</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }} data-tour="edit-service">مشخصات سرویس:</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
<div>
<label style={label}>بخش</label>
@@ -9,6 +9,7 @@ import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import SearchableSelect from '../components/ui/SearchableSelect';
import { usePermissions } from '../hooks/usePermissions';
import TourButton from '../components/ui/TourButton';
const PERSONAL = 'personal';
@@ -75,7 +76,7 @@ export default function AppointmentSettingsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
>
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1><TourButton tourId="appointment-settings" ready /></div>
<FreeVisitPrice readOnly={apptReadOnly} />
@@ -68,6 +68,7 @@ export default function CatalogCategoriesPage() {
<div className="fade-in">
<PageHeader
title="دسته‌بندی‌ها"
tourId="service-categories"
description="دسته‌بندی سراسری کلینیک؛ یک بار تعریف می‌شود و سرویس‌ها و منابع از همین‌ها انتخاب می‌کنند."
backTo="/admin/settings-menu"
action={
@@ -158,6 +158,7 @@ export default function ClaimPatientDetailPage() {
<FeatureGate feature="insurance">
<PageHeader
backTo="/admin/claims"
tourId="claim-detail"
title={patient?.full_name ?? 'پرونده بیمه بیمار'}
description={[patient?.mobile, patient?.national_code].filter(Boolean).join(' · ') || undefined}
breadcrumbs={[
@@ -202,7 +203,8 @@ export default function ClaimPatientDetailPage() {
/>
</div>
<Modal open={!!selected} title="جزئیات مطالبه" size="lg" onClose={() => setSelected(null)}>
<Modal open={!!selected} title="جزئیات مطالبه"
size="lg" onClose={() => setSelected(null)}>
{selected && (
<div dir="rtl" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, fontSize: 13 }}>
+1
View File
@@ -183,6 +183,7 @@ export default function ClaimsPage() {
<FeatureGate feature="insurance">
<PageHeader
title="پرونده‌های بیمه"
tourId="claims"
description="پیگیری مطالبات بیمه به تفکیک بیمار"
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'پرونده‌های بیمه' }]}
/>
@@ -87,6 +87,7 @@ function ClinicAppointmentSettingsContent() {
در دید، بدون اسکرول. پایین‌تر از هدر، کاربر باید دنبالش می‌گشت. */}
<PageHeader
title="مدیریت نوبت‌دهی"
tourId="clinic-appointment-settings"
description={description}
backTo="/admin/settings-menu"
action={(
+1
View File
@@ -587,6 +587,7 @@ export default function ClinicDetailPage() {
<PageHeader
backTo="/admin/clinics"
title={clinicName}
tourId="clinic-detail"
breadcrumbs={[{ label: 'کلینیک‌ها', to: '/admin/clinics' }, { label: clinicName }]}
action={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
+1
View File
@@ -48,6 +48,7 @@ function ClinicDoctorsContent() {
{/* عنوان و بازگشت و اکشن هر سه در PageHeader — همان قاعدهٔ بقیهٔ صفحات پنل. */}
<PageHeader
title="پزشکان کلینیک"
tourId="clinic-doctors"
description="مدیریت پزشکان و دعوت‌نامه‌های کلینیک"
backTo="/admin/settings-menu"
action={
@@ -133,6 +133,7 @@ function ClinicServicesPageInner() {
که نه دکمهٔ بازگشت دارد و نه می‌گوید چیست. */}
<PageHeader
title="سرویس‌ها"
tourId="clinic-services"
description="بخش‌ها و سرویس‌های قابل رزرو. مدت و قیمت هر سرویس از همین‌جا می‌آید."
backTo="/admin/settings-menu"
action={
+2 -1
View File
@@ -25,6 +25,7 @@ import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
import { latinDigitsField } from '../lib/forms';
import TourButton from '../components/ui/TourButton';
const HUES_LIST = [256, 205, 162, 295, 272];
@@ -108,7 +109,7 @@ export default function ClinicsPage() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">کلینیکها</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">کلینیکها</h1><TourButton tourId="clinics" ready /></div>
<div className="muted">{formatNumber(total)} کلینیک ثبتشده</div>
</div>
<button className="btn primary sm" onClick={() => setAddOpen(true)}>
+2 -1
View File
@@ -22,6 +22,7 @@ import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsP
import { usePermissions } from '../hooks/usePermissions';
import { STATUS_META } from '../components/ui/AppointmentStatusDropdown';
import StatusBadge from '../components/ui/StatusBadge';
import TourButton from '../components/ui/TourButton';
// ── Chart period (Jalali) ─────────────────────────────────────────────────
@@ -437,7 +438,7 @@ function AdminDashboard() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">داشبورد مدیریت</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">داشبورد مدیریت</h1><TourButton tourId="dashboard" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
+6 -2
View File
@@ -1,5 +1,6 @@
import SettingsLayout from '../components/layout/SettingsLayout';
import DiscountTab from '../components/DiscountTab';
import TourButton from '../components/ui/TourButton';
/** مدیریت تخفیف‌ها — بخش تنظیمات برای تعریف قوانین تخفیف عمومی (owner-scoped). */
export default function DiscountsPage() {
@@ -9,8 +10,11 @@ export default function DiscountsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
>
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت تخفیفها</h1>
<DiscountTab />
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }} data-tour="page-title">
<h1 className="section-title">مدیریت تخفیفها</h1>
<TourButton tourId="discounts" ready />
</div>
<div data-tour="discounts-rules"><DiscountTab /></div>
</div>
</SettingsLayout>
);
+5 -1
View File
@@ -38,6 +38,7 @@ import { latinDigitsField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -1432,7 +1433,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div>
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-[var(--text)]">{displayDoctorName(doctor.name)}</h1>
<div className="flex items-center gap-1" data-tour="page-title">
<h1 className="text-xl font-bold text-[var(--text)]">{displayDoctorName(doctor.name)}</h1>
<TourButton tourId="doctor-detail" ready />
</div>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Active status badge */}
{doctor.active
+4 -2
View File
@@ -18,6 +18,7 @@ import PersianDatePicker from '../components/ui/PersianDatePicker';
import { iranMobileSchema } from '../lib/utils';
import BackButton from '../components/ui/BackButton';
import { useAuthStore } from '../stores/authStore';
import TourButton from '../components/ui/TourButton';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -319,10 +320,11 @@ export default function DoctorFormPage() {
پزشکان
</button>
<span>/</span>
<span style={{ color: 'var(--text-2)', fontWeight: 600 }}>افزودن پزشک جدید</span>
<span style={{ color: 'var(--text-2)', fontWeight: 600 }} data-tour="page-title">افزودن پزشک جدید</span>
<TourButton tourId="doctor-new" ready />
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onSubmit)} data-tour="doctor-form">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── Section: اطلاعات حساب ──────────────────────────────── */}
+2 -1
View File
@@ -16,6 +16,7 @@ import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore';
import TourButton from '../components/ui/TourButton';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -239,7 +240,7 @@ export default function DoctorsPage() {
{/* Header */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">پزشکان</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">پزشکان</h1><TourButton tourId="doctors" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت پزشکان، تخصصها و وضعیت همکاری</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
+5 -1
View File
@@ -9,6 +9,7 @@ import CreateStep from '../components/session/CreateStep';
import PaymentStep from '../components/session/PaymentStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/** ویرایش مراجعه‌ی ثبت‌شده — دو تب: ویرایش سرویس‌ها + مدیریت پرداخت‌ها. */
export default function EditSessionPage() {
@@ -47,7 +48,10 @@ export default function EditSessionPage() {
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ fontSize: 16, fontWeight: 700 }}>ویرایش مراجعه</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<h2 style={{ fontSize: 16, fontWeight: 700 }}>ویرایش مراجعه</h2>
<TourButton tourId="session-edit" ready />
</div>
<button type="button" aria-label="بستن" onClick={() => nav(-1)}
style={{ minWidth: 40, height: 40, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CloseModalD />
@@ -56,6 +56,7 @@ export default function HolidaysSettingsPage() {
<div className="fade-in">
<PageHeader
title="تعطیلات رسمی"
tourId="holidays"
description="تعطیلات کشوری برای همهٔ محیط‌ها اعمال می‌شود. اگر این محیط روزی را باز است، همین‌جا استثنا بزنید."
backTo="/admin/settings-menu"
/>
@@ -11,6 +11,7 @@ export default function InsurancePricingPage() {
<div className="fade-in">
<PageHeader
title="مدیریت بیمه"
tourId="insurance-pricing"
description="قراردادهای بیمه پایه و تکمیلی"
/>
<InsuranceServiceCategoriesCard />
+7 -3
View File
@@ -5,6 +5,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useInventory } from '../hooks/useInventory';
import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory';
import InventoryStatCards from '../components/inventory/InventoryStatCards';
import TourButton from '../components/ui/TourButton';
import InventoryItemsTable from '../components/inventory/InventoryItemsTable';
import PackagesView from '../components/inventory/PackagesView';
import AddItemModal from '../components/inventory/AddItemModal';
@@ -56,8 +57,11 @@ export default function InventoryPage() {
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div>
<h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)', marginBottom: 12 }}>انبارداری</h1>
<div className="inv-tabs">
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 12 }} data-tour="page-title">
<h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)' }}>انبارداری</h1>
<TourButton tourId="inventory" ready />
</div>
<div className="inv-tabs" data-tour="inventory-tabs">
<button className={`inv-tab${tab === 'stock' ? ' active' : ''}`} onClick={() => setTab('stock')}>کالاهای مصرفی</button>
<button className={`inv-tab${tab === 'packages' ? ' active' : ''}`} onClick={() => setTab('packages')}>پکیج</button>
</div>
@@ -105,7 +109,7 @@ export default function InventoryPage() {
{/* Body */}
{tab === 'stock' ? (
<>
<InventoryStatCards stats={stats} />
<div data-tour="inventory-stats"><InventoryStatCards stats={stats} /></div>
{itemsLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : filteredItems.length === 0 ? (
+1
View File
@@ -59,6 +59,7 @@ function MyFinancialPageContent() {
<div className="page">
<PageHeader
title="مدیریت پرداخت"
tourId="my-financial"
description={`روش‌های پرداخت «${environmentName}» (حساب بانکی و کارت‌خوان)`}
/>
+7
View File
@@ -606,6 +606,8 @@ function MyPatientsPageInner() {
<PageHeader
title="پرونده بیماران"
description="مراجعه‌کنندگان ثبت‌شده شما"
tourId="my-patients"
tourReady={!isLoading}
action={
canCreate ? (
<button
@@ -621,6 +623,7 @@ function MyPatientsPageInner() {
<div style={{ marginBottom: 16 }}>
<div
data-tour="patients-search"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
@@ -709,6 +712,7 @@ function MyPatientsPageInner() {
) : (
<>
<div
data-tour="patients-records"
style={
viewMode === "grid"
? { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 14 }
@@ -1025,6 +1029,7 @@ function MyPatientsPageInner() {
<PageHeader
title={selectedPatientName}
description={`تلفن: ${selectedPatientPhone} — پرونده از ${formatDate(selectedRecord.created_at)}`}
tourId="my-patient-record"
action={
<div style={{ display: "flex", gap: 8 }}>
<button
@@ -1047,6 +1052,7 @@ function MyPatientsPageInner() {
{/* بنر اطلاعات بیمار — مطابق فیگما */}
<div
data-tour="record-banner"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
@@ -1130,6 +1136,7 @@ function MyPatientsPageInner() {
<div
className="cp-tabs"
data-tour="record-tabs"
style={{
display: "flex",
gap: 8,
@@ -70,7 +70,9 @@ describe('MyPaymentDetailPage (پرداخت‌های ثبت‌شده)', () => {
it('fetches invoices for the patient uuid from the route', async () => {
renderPage();
await screen.findAllByText('دنیا خلیلی');
expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices');
// ترتیب درخواست‌ها قرارداد نیست — صفحه کوئری‌های جانبی هم دارد (مثل وضعیت تور راهنما).
const urls = get.mock.calls.map((c: unknown[]) => String(c[0]));
expect(urls.some((u) => u.includes('/api/v1/my/billing/patients/abc/invoices'))).toBe(true);
});
it('expands a row to show its line items and payment methods on بیشتر', async () => {
@@ -128,6 +128,7 @@ export default function MyPaymentDetailPage() {
<>
<PageHeader
backTo="/admin/my-payments"
tourId="my-payment-detail"
title="پرداخت‌های ثبت‌شده"
description="صورتحساب‌های ثبت‌شده‌ی این بیمار"
breadcrumbs={[
+1
View File
@@ -129,6 +129,7 @@ export default function MyPaymentsPage() {
<>
<PageHeader
title="لیست پرداخت‌ها"
tourId="my-payments"
description="پرداخت‌های ثبت‌شده‌ی بیماران شما"
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداخت‌ها' }]}
/>
+7 -2
View File
@@ -13,6 +13,7 @@ import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
import SecretaryPermissionsModal from "../components/SecretaryPermissionsModal";
import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
@@ -815,12 +816,15 @@ function MySecretariesPageContent() {
return (
<div dir="rtl">
<div className="flex items-center justify-between">
<p className="text-[var(--text)] text-[20px] font-bold">لیست منشی ها</p>
<div className="flex items-center gap-1" data-tour="page-title">
<p className="text-[var(--text)] text-[20px] font-bold">لیست منشی ها</p>
<TourButton tourId="my-secretaries" ready />
</div>
</div>
{/* تب‌ها + دکمه افزودن */}
<div className="w-full flex items-end justify-between mt-[20px]">
<div className="flex items-center gap-[8px] border-b border-[var(--border)]">
<div className="flex items-center gap-[8px] border-b border-[var(--border)]" data-tour="secretaries-tabs">
{["منشی های فعلی", "منشی های قبلی"].map((label, idx) => (
<button
key={idx}
@@ -837,6 +841,7 @@ function MySecretariesPageContent() {
))}
</div>
<button
data-tour="secretaries-add"
onClick={handleAddClick}
className="shadow-none gap-[8px] bg-[var(--primary)] text-[var(--on-primary)] text-[14px] md:text-[15px] lg:text-[16px]
font-medium py-[10px] px-[16px] h-[43px] md:h-[45px] lg:h-[48px] rounded-[4px] cursor-pointer
+6 -2
View File
@@ -11,6 +11,7 @@ import PaymentStep from '../components/session/PaymentStep';
import DetailsStep from '../components/session/DetailsStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/**
* ثبت مراجعه جدید پورت کامل tauri /files/create-service (حالت ایجاد):
@@ -53,11 +54,14 @@ export default function NewSessionPage() {
return (
<div className="fade-in" style={{ width: '100%' }}>
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
<TourButton tourId="session-new" ready />
</div>
{/* card — tauri width 748 centered */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div className="bg-[var(--surface)]" data-tour="session-card" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<button
type="button"
+11 -2
View File
@@ -22,6 +22,8 @@ import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PersianDateInput from '../components/ui/PersianDateInput';
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
import { useTour } from '../hooks/useTour';
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
import { useIssueInvoice } from '../hooks/useIssueInvoice';
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
@@ -200,10 +202,16 @@ export default function PatientDetailPage() {
.filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled'))
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
useTour('patient-detail', { ready: !isLoading });
return (
<div className="fade-in">
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */}
<Breadcrumb name={record?.user_name || 'پرونده'} backTo="/admin/patients" />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<Breadcrumb name={record?.user_name || 'پرونده'} backTo="/admin/patients" />
<TourButton tourId="patient-detail" />
</div>
<div data-tour="patient-banner">
<PatientCaseBanner
name={record?.user_name || 'پرونده'}
recordNumber={record?.record_number}
@@ -215,9 +223,10 @@ export default function PatientDetailPage() {
hasDebt={hasDebt}
onAddNote={() => setTab('notes')}
/>
</div>
{/* tab bar */}
<div style={{ display: 'flex', gap: 4, overflowX: 'auto', borderBottom: '1px solid var(--border)', marginBottom: 18 }}>
<div data-tour="patient-tabs" style={{ display: 'flex', gap: 4, overflowX: 'auto', borderBottom: '1px solid var(--border)', marginBottom: 18 }}>
{TABS.map((t) => {
const on = t.key === tab;
return (
+6 -2
View File
@@ -15,6 +15,7 @@ import { numericField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
import TourButton from '../components/ui/TourButton';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
@@ -131,10 +132,13 @@ export default function PatientRecordFormPage() {
<div className="fade-in" style={{ maxWidth: 1000, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
<BackButton fallback="/admin/patients" />
<div style={{ fontSize: 14, color: 'var(--text-3)' }}>پرونده <b style={{ color: 'var(--text)' }}>{isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}</b></div>
<div style={{ fontSize: 14, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
پرونده <b style={{ color: 'var(--text)' }}>{isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}</b>
<TourButton tourId="patient-record-form" ready />
</div>
</div>
<form onSubmit={form.handleSubmit((d) => save.mutate(d))}
<form onSubmit={form.handleSubmit((d) => save.mutate(d))} data-tour="record-form"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 18 }}>
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
+11 -4
View File
@@ -14,6 +14,8 @@ import Pagination from '../components/ui/Pagination';
import PatientTagsCell from '../components/PatientTagsCell';
import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal';
import { usePermissions } from '../hooks/usePermissions';
import { useTour } from '../hooks/useTour';
import TourButton from '../components/ui/TourButton';
import {
SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn,
} from '../components/icons/FilesToolbarIcons';
@@ -161,6 +163,8 @@ export default function PatientsListPage() {
queryFn: () => api.get(`/api/v1/patients?${qs.toString()}`),
});
useTour('patients', { ready: !isLoading });
const activeFilters = countFilters(filters);
const records = data?.data ?? EMPTY;
const total = data?.meta?.totalRecords ?? 0;
@@ -183,14 +187,17 @@ export default function PatientsListPage() {
return (
<div className="fade-in">
{/* Head — تیتر تنها (tauri files/head) */}
<p className="text-[var(--text)] text-[16px] md:text-[18px] lg:text-[20px] font-bold">پروندهها</p>
<div className="flex items-center gap-1" data-tour="page-title">
<p className="text-[var(--text)] text-[16px] md:text-[18px] lg:text-[20px] font-bold">پروندهها</p>
<TourButton tourId="patients" />
</div>
{/* Inputs — ردیف کنترل‌ها (tauri files/inputs) */}
<div className="my-[16px] md:my-[20px] lg:my-[24px]">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-[16px] lg:gap-[20px]">
{/* جستجو + سوییچ نما */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 w-full">
<div className="w-full md:w-[442px]">
<div className="w-full md:w-[442px]" data-tour="patients-search">
<div className="flex items-center rounded-[6px] border border-[var(--border)] bg-[var(--bg)]" style={{ height: 48, paddingInline: 4 }}>
<input
dir="rtl"
@@ -211,7 +218,7 @@ export default function PatientsListPage() {
{/* فیلتر + تشکیل پرونده */}
<div className="flex items-center w-full lg:w-auto justify-between lg:justify-end" style={{ gap: 12 }}>
<button
type="button" aria-label="فیلترها" onClick={() => setFilterOpen(true)}
type="button" aria-label="فیلترها" data-tour="patients-filters" onClick={() => setFilterOpen(true)}
className="flex items-center justify-center rounded-[4px] cursor-pointer"
style={{ width: 62, height: 48, border: '1px solid var(--primary)', background: 'transparent', position: 'relative' }}
>
@@ -222,7 +229,7 @@ export default function PatientsListPage() {
</button>
{canCreate && (
<button
type="button" onClick={() => navigate('/admin/patients/new')}
type="button" data-tour="patients-new" onClick={() => navigate('/admin/patients/new')}
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer"
style={{ height: 48, minWidth: 137, background: 'var(--primary)', border: 'none', padding: '0 16px' }}
>
@@ -7,6 +7,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import type { PracticeDomain } from '../types';
import TourButton from '../components/ui/TourButton';
/**
* حوزهٔ فعالیت کلینیک.
@@ -62,7 +63,7 @@ export default function PracticeDomainSettingsPage() {
// منوی تنظیمات را نشان نمی‌داد و کاربر برای رفتن به بخش بعدی باید «بازگشت» می‌زد.
<SettingsLayout active="practice-domain">
<div className="card card-pad" style={{ display: 'grid', gap: 14, maxWidth: 560 }}>
<h1 className="section-title" style={{ margin: 0 }}>حوزهٔ فعالیت</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ margin: 0 }}>حوزهٔ فعالیت</h1><TourButton tourId="practice-domain" ready /></div>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با
@@ -4,6 +4,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings';
import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [
{ value: 'none', label: 'هرگز — شمارنده پیوسته جلو می‌رود' },
@@ -49,7 +50,7 @@ export default function RecordNumberSettingsPage() {
style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
>
<h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1><TourButton tourId="record-number-settings" ready /></div>
<p style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 18, lineHeight: 2 }}>
با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه چه از فرم تشکیل پرونده و چه از
نوبتی که قطعی میشود بر اساس همین الگو ساخته میشود.
@@ -94,6 +94,7 @@ export default function RepresentationBlogFormPage() {
<PageHeader
backTo="/admin/representation-blogs"
title={isEdit ? 'ویرایش مقاله' : 'نوشتن مقالهٔ جدید'}
tourId="representation-blog-form"
breadcrumbs={[{ label: 'وبلاگ من', to: '/admin/representation-blogs' }, { label: isEdit ? 'ویرایش' : 'جدید' }]}
/>
@@ -53,6 +53,7 @@ export default function RepresentationBlogsPage() {
<div className="fade-in">
<PageHeader
title="وبلاگ من"
tourId="representation-blogs"
description="مقالات مخصوص دامنه و برند شما."
action={<button className="cp-btn-primary" onClick={() => navigate('/admin/representation-blogs/new')}>مقالهٔ جدید</button>}
/>
@@ -8,6 +8,7 @@ import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import TourButton from '../components/ui/TourButton';
interface FinanceRow {
uuid: string;
@@ -79,7 +80,7 @@ export default function RepresentationFinancePage() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">گزارش مالی</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">گزارش مالی</h1><TourButton tourId="representation-finance" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>
درآمد ثبتشده از پورسانت نوبتها تفکیک هر تراکنش
</div>
@@ -6,6 +6,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import { toEnglishDigits, digitsOnly } from '../lib/utils';
import TourButton from '../components/ui/TourButton';
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
function toJalali(iso: string): string {
@@ -126,7 +127,7 @@ export default function RepresentationProfilePage() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">پروفایل نماینده</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">پروفایل نماینده</h1><TourButton tourId="representation-profile" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>تأیید هویت و مدیریت شمارههای شبا</div>
</div>
</div>
@@ -7,6 +7,7 @@ import { formatRial, formatDate, tomanToRial } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
import TourButton from '../components/ui/TourButton';
interface WalletBalance { balance_rials: number }
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
@@ -101,7 +102,7 @@ export default function RepresentationSettlementPage() {
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">تسویه حساب</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">تسویه حساب</h1><TourButton tourId="representation-settlement" ready /></div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درخواست برداشت از کیفپول نماینده</div>
</div>
</div>
+11 -4
View File
@@ -17,6 +17,8 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import Pagination from '../components/ui/Pagination';
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
import BackButton from '../components/ui/BackButton';
import TourButton from '../components/ui/TourButton';
import { useTour } from '../hooks/useTour';
import { AppointmentInfoModal, TransferReserveModal } from '../components/AppointmentActions';
const LIMIT = 20;
@@ -119,16 +121,21 @@ export default function ReserveAppointmentsPage() {
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' };
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' };
useTour('appointments-reserve', { ready: !isLoading });
return (
<div className="fade-in" style={{ padding: '20px 24px' }}>
<div style={{ marginBottom: 14 }}>
<BackButton fallback="/admin/appointments" />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10, marginBottom: 16 }}>
<h1 style={{ fontSize: 17, fontWeight: 800 }}>نوبت های رزرو شده</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<h1 style={{ fontSize: 17, fontWeight: 800 }}>نوبت های رزرو شده</h1>
<TourButton tourId="appointments-reserve" />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isClinic && (
<div style={{ minWidth: 180 }}>
<div style={{ minWidth: 180 }} data-tour="reserve-doctor">
<SearchableSelect
options={clinicDoctors.map(d => ({ value: d.uuid, label: d.name }))}
value={doctorUuid || null}
@@ -140,7 +147,7 @@ export default function ReserveAppointmentsPage() {
</div>
)}
{primaryRole !== 'representation' && (
<button className="btn primary sm" disabled={!doctorUuid}
<button className="btn primary sm" data-tour="reserve-new" disabled={!doctorUuid}
onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<PlusIcon style={{ width: 15 }} /> نوبت رزرو
</button>
@@ -148,7 +155,7 @@ export default function ReserveAppointmentsPage() {
</div>
</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
<div data-tour="reserve-list" style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
{isLoading ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
@@ -72,6 +72,7 @@ export default function ResourceDetailPage() {
title={resource.name}
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`}
backTo="/admin/resources"
tourId="resource-detail"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
action={
canUpdate ? (
+1
View File
@@ -66,6 +66,7 @@ export default function ResourcePoolsPage() {
<div className="fade-in">
<PageHeader
title="استخر منابع"
tourId="resource-pools"
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید از یک نوع باشند."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
+1
View File
@@ -50,6 +50,7 @@ export default function ResourceTypesPage() {
<div className="fade-in">
<PageHeader
title="نوع منابع"
tourId="resource-types"
description="دسته‌بندی منابع: پزشک، پرسنل، اتاق، دستگاه لیزر، یونیت. نوع‌های سیستمی حذف نمی‌شوند چون پل خودکار منابع به آن‌ها تکیه دارد."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'نوع منابع' }]}
+1
View File
@@ -104,6 +104,7 @@ export default function ResourcesPage() {
<div className="fade-in">
<PageHeader
title="منابع"
tourId="resources"
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان."
/* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */
+1 -1
View File
@@ -77,7 +77,7 @@ export default function SecretaryEarningsPage() {
return (
<div className="fade-in">
<PageHeader title="درآمد نوبت‌های آنلاین" description="سهم شما از نوبت‌هایی که آنلاین ثبت و پرداخت شده‌اند" />
<PageHeader title="درآمد نوبت‌های آنلاین" description="سهم شما از نوبت‌هایی که آنلاین ثبت و پرداخت شده‌اند"tourId="secretary-earnings" />
{summary && !summary.enabled ? (
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text-2)', lineHeight: 1.9 }}>
@@ -109,7 +109,7 @@ export default function SecretarySettlementPage() {
return (
<div className="fade-in">
<PageHeader title="تسویه حساب" description="برداشت سهم نوبت‌های آنلاین به شماره شبای شما" />
<PageHeader title="تسویه حساب" description="برداشت سهم نوبت‌های آنلاین به شماره شبای شما"tourId="secretary-settlement" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" />
+1
View File
@@ -436,6 +436,7 @@ function ServiceDetailPageInner() {
<div className="fade-in">
<PageHeader
backTo="/admin/clinic-services"
tourId="service-detail"
title={item.name}
breadcrumbs={[
{ label: 'سرویس‌ها', to: '/admin/clinic-services' },
+6 -2
View File
@@ -10,6 +10,7 @@ import PaymentStep from '../components/session/PaymentStep';
import DetailsStep from '../components/session/DetailsStep';
import { CloseModalD } from '../components/icons/FilesServiceIcons';
import { Breadcrumb } from '../components/PatientCaseBanner';
import TourButton from '../components/ui/TourButton';
/**
* تکمیل پرداخت مراجعه پورت صفحهی tauri /files/create-service در حالت payment:
@@ -56,11 +57,14 @@ export default function SessionPaymentPage() {
return (
<div className="fade-in" style={{ width: '100%' }}>
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
<Breadcrumb name={patientName} backTo={`/admin/patients/${recordUuid}`} />
<TourButton tourId="session-payment" ready />
</div>
{/* card — tauri width 748 centered */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="bg-[var(--surface)]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div className="bg-[var(--surface)]" data-tour="session-card" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<button
type="button"
+2 -1
View File
@@ -4,6 +4,7 @@ import { ChevronLeftIcon } from '@heroicons/react/24/outline';
import { groupedMenuForRole } from '../components/layout/settingsMenu';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import TourButton from '../components/ui/TourButton';
/**
* SettingsMenuPage the settings landing list for doctor/clinic users.
@@ -22,7 +23,7 @@ export default function SettingsMenuPage() {
return (
<div className="fade-in" style={{ maxWidth: 720, margin: '0 auto' }}>
<h1 className="section-title" style={{ marginBottom: 16 }}>تنظیمات</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title" style={{ marginBottom: 16 }}>تنظیمات</h1><TourButton tourId="settings-menu" ready /></div>
{groups.map((group) => (
<section key={group.key} aria-label={group.label} style={{ marginBottom: 20 }}>
+1
View File
@@ -46,6 +46,7 @@ export default function SkillsPage() {
<div className="fade-in">
<PageHeader
title="مهارت‌ها"
tourId="resource-skills"
description="مهارت روی منابع می‌نشیند و در انتخاب منبع مناسب استفاده می‌شود. مهارتی که به منبعی داده شده، تا برداشته نشود حذف نمی‌شود."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'مهارت‌ها' }]}
+2 -2
View File
@@ -117,10 +117,10 @@ function SmsWalletPageInner() {
return (
<>
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" />
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" tourId="sms-wallet" />
{/* ردیف بالا: موجودی + آمار */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 }}>
<div data-tour="sms-balance" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 }}>
{/* کارت موجودی — gradient */}
<div style={{
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
+1
View File
@@ -189,6 +189,7 @@ export default function StaffPage() {
<SettingsLayout active="staff">
<PageHeader
title="مدیریت پرسنل"
tourId="staff"
description="لیست پرسنل کلینیک / مطب"
action={
canCreate ? (
@@ -102,6 +102,7 @@ export default function StaffSessionDetailPage() {
<>
<PageHeader
title={`جلسهٔ ${formatNumber(session.session_number)} از ${formatNumber(session.total_sessions)}`}
tourId="my-session-detail"
backTo="/admin/my-sessions"
/>
@@ -37,6 +37,7 @@ export default function StaffTreatmentSessionsPage() {
<>
<PageHeader
title="جلسات امروز من"
tourId="my-sessions"
action={
<button type="button" className="btn secondary sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'در حال به‌روزرسانی…' : 'به‌روزرسانی'}
+7 -3
View File
@@ -10,6 +10,7 @@ import { usePaymentConfig } from '../hooks/usePaymentConfig';
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
import Modal from '../components/ui/Modal';
import SettingsLayout from '../components/layout/SettingsLayout';
import TourButton from '../components/ui/TourButton';
import {
SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled,
} from './subscriptionIcons';
@@ -135,9 +136,12 @@ export default function SubscriptionPage() {
<div dir="ltr" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 32, paddingTop: 8 }}>
{/* ── Header: current plan (left) + title (right) ── */}
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
{!myLoading && <CurrentPlanCard my={my} onRenew={renewCurrentPlan} />}
{!myLoading && <div data-tour="subscription-current"><CurrentPlanCard my={my} onRenew={renewCurrentPlan} /></div>}
<div>
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--text)' }}>انتخاب پلن اشتراک</div>
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--text)', display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
انتخاب پلن اشتراک
<TourButton tourId="subscription" ready={!plansLoading} />
</div>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-3)', marginTop: 4, textAlign: 'left' }}>
پلن اشتراک مناسب خود را انتخاب نمایید:
</div>
@@ -156,7 +160,7 @@ export default function SubscriptionPage() {
در حال حاضر پلنی برای نمایش وجود ندارد.
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 16, width: '100%' }}>
<div data-tour="subscription-plans" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 16, width: '100%' }}>
{plans.map((plan) => (
<PlanCard
key={plan.uuid}
+2 -1
View File
@@ -12,6 +12,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import SettingsLayout from '../components/layout/SettingsLayout';
import { usePermissions } from '../hooks/usePermissions';
import Switch from '../components/ui/Switch';
import TourButton from '../components/ui/TourButton';
interface TenantTag { uuid: string; name: string; color: string; active: boolean }
@@ -72,7 +73,7 @@ export default function TagsSettingsPage() {
<SettingsLayout active="tags">
<div className="fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
<h1 className="section-title">برچسبها</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">برچسبها</h1><TourButton tourId="tags-settings" ready /></div>
{canCreate && (
<button className="btn primary" onClick={openCreate}><PlusIcon style={{ width: 16 }} /> برچسب جدید</button>
)}
+2 -2
View File
@@ -58,9 +58,9 @@ export default function TreatmentCasesPage() {
return (
<>
<PageHeader title="دوره‌های درمان" />
<PageHeader title="دوره‌های درمان" tourId="treatment-cases" />
<div className="tabs" style={{ marginBottom: 16 }}>
<div className="tabs" data-tour="treatment-tabs" style={{ marginBottom: 16 }}>
{TABS.map((t) => (
<button
key={t.id}
+8
View File
@@ -200,6 +200,14 @@ Each tour carries a `version` in the frontend registry (`assets/admin/lib/tour/t
The tour auto-runs again when its version is higher than the stored one, which is how a rewritten
tour reaches users who already saw the old text.
Every non-admin page of the panel ships a tour. Adding one to a new page means: a file under
`assets/admin/lib/tour/tours/`, one line in `registry.ts`, and `tourId` on the page's `PageHeader`
— the shared `data-tour` anchors (`page-title`, `page-action`, `page-search`, `page-table`,
`page-pagination`) already come from `PageHeader`, `DataTable` and `Pagination`. A step whose
anchor is not on screen is dropped silently, so role-specific pages need no branching.
`PageHeader` auto-runs the tour once the page is ready; pass `tourReady={!isLoading}` when the
data arrives late.
## GET `/api/v1/my/tours`
Tours the current user has already been through.