# راهنمای قدم‌به‌قدم صفحات پنل ادمین — فاز ۱ (زیرساخت + صفحهٔ نوبت‌ها) ## پروژه `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) { ...

{title}

``` `AppointmentsPage` از `PageHeader` استفاده نمی‌کند و عنوان دست‌ساز دارد. کد فعلی از خط ۴۲۰: ```tsx return (
{/* عنوان */}

نوبت‌ ها

{/* نوار آمار */} {/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
{/* سمت چپ: فیلتر + افزودن نوبت */} {!isRepresentation && canCreateAppt && ( )}
``` الگوی persist موجود در `stores/uiStore.ts` مرجع است: ```ts export const useUiStore = create()( 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 = { [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; markSeen: (id: string, version: number) => void; isSeen: (id: string, version: number) => boolean; /** بدون آرگومان یعنی پاک کردن همهٔ تورها */ reset: (id?: string) => void; } export const useTourStore = create()( 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 ( ); } ``` مهم: در `TourButton` مقدار `ready: false` داده می‌شود تا **دکمه** مسئول اجرای خودکار نباشد. اجرای خودکار وظیفهٔ خودِ صفحه است که می‌داند داده‌اش کی آماده است. `PageHeader` یک پراپ اختیاری می‌گیرد و دکمه را کنار عنوان می‌گذارد: ```tsx interface Props { title: string; breadcrumbs?: Crumb[]; action?: React.ReactNode; description?: string; backTo?: string; /** شناسهٔ تور راهنمای این صفحه؛ اگر ثبت نشده باشد دکمه‌ای نمی‌آید */ tourId?: string; } // ...

{title}

``` `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

نوبت‌ ها

``` اتریبیوت‌ها روی همان hostهای موجود، بدون تغییر در کامپوننت‌های فرزند: ```tsx