From 158dcb58aa3cb0525880fb6dcf760d66b6750b8c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 11:56:08 +0330 Subject: [PATCH] feat: implement service mode completion for nobat724_front - Add task for completing service mode in clinicpro with detailed objectives and acceptance criteria. - Create architecture documentation for task 00b, outlining involved components and necessary changes. - Develop checklist for task 00b to ensure all requirements are met. - Document implementation notes for task 00b, emphasizing API contract checks and design system adherence. - Update task documentation for task 00b, specifying goals and current issues with service mode. --- .../taskes/_shared/definition-of-done.md | 85 +++++ docs/new_feture/taskes/_shared/red-lines.md | 96 +++++ .../taskes/_shared/ui-conventions.md | 126 +++++++ .../architecture.md | 332 ++++++++++++++++++ .../checklist.md | 118 +++++++ .../database.md | 123 +++++++ .../implementation_notes.md | 204 +++++++++++ .../task-00-service-mode-completion/task.md | 143 ++++++++ .../architecture.md | 203 +++++++++++ .../checklist.md | 123 +++++++ .../implementation_notes.md | 157 +++++++++ .../task-00b-nobat724-service-mode/task.md | 136 +++++++ 12 files changed, 1846 insertions(+) create mode 100644 docs/new_feture/taskes/_shared/definition-of-done.md create mode 100644 docs/new_feture/taskes/_shared/red-lines.md create mode 100644 docs/new_feture/taskes/_shared/ui-conventions.md create mode 100644 docs/new_feture/taskes/task-00-service-mode-completion/architecture.md create mode 100644 docs/new_feture/taskes/task-00-service-mode-completion/checklist.md create mode 100644 docs/new_feture/taskes/task-00-service-mode-completion/database.md create mode 100644 docs/new_feture/taskes/task-00-service-mode-completion/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-00-service-mode-completion/task.md create mode 100644 docs/new_feture/taskes/task-00b-nobat724-service-mode/architecture.md create mode 100644 docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md create mode 100644 docs/new_feture/taskes/task-00b-nobat724-service-mode/implementation_notes.md create mode 100644 docs/new_feture/taskes/task-00b-nobat724-service-mode/task.md diff --git a/docs/new_feture/taskes/_shared/definition-of-done.md b/docs/new_feture/taskes/_shared/definition-of-done.md new file mode 100644 index 00000000..0a86776d --- /dev/null +++ b/docs/new_feture/taskes/_shared/definition-of-done.md @@ -0,0 +1,85 @@ +# تعریف «تمام‌شده» و قالب چک‌لیست + +هر تسک یک `checklist.md` دارد. **پیش از اعلام پایان تسک، همهٔ ردیف‌ها بازبینی می‌شوند و +هیچ ردیفی در `🔄` یا `⏳` نمی‌ماند.** + +--- + +## نمادها + +| نماد | معنی | اجازهٔ باقی‌ماندن در پایان تسک | +|---|---|---| +| ✅ | انجام‌شده و تأییدشده | بله | +| 🔄 | در حال انجام | **نه** — یا ✅ شود یا با دلیل صریح به ⏳ منتقل شود | +| ⏳ | انجام‌نشده | **نه** — یا ✅ شود یا با دلیل مکتوب و تسک مقصد به تعویق برود | +| ⚠️ | نیازمند بررسی یا تست | **نه** — باید تعیین تکلیف شود | + +`⏳` تنها وقتی در پایان مجاز است که کنارش نوشته شده باشد: **چرا** به تعویق افتاد و +**کدام تسک** آن را برمی‌دارد. `⏳ بدون دلیل = تسک تمام نشده.` + +--- + +## قالب `checklist.md` + +```markdown +# چک‌لیست — تسک XX + +وضعیت کلی: ⏳ شروع نشده | 🔄 در حال انجام | ✅ تمام‌شده +آخرین بازبینی: — + +## ۰. خط سرخ‌ها (رجوع: _shared/red-lines.md) +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۰.۱ | منطق اسلاتی دست‌کاری نشد · `--group=slot-mode-frozen` سبز | ⏳ | | +| ۰.۲ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | ⏳ | | +| ۰.۳ | قرارداد `appointment-slots` و `month-availability` دست‌نخورده | ⏳ | | + +## ۱. بک‌اند +## ۲. دیتابیس و مهاجرت +## ۳. UI (رجوع: _shared/ui-conventions.md) +## ۴. تست +## ۵. مستندات +## ۶. بازبینی پایانی +``` + +--- + +## بخش ۶ — بازبینی پایانی، یکسان در همهٔ تسک‌ها + +```markdown +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۶.۱ | همهٔ ردیف‌های بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بی‌دلیل) | ⏳ | | +| ۶.۲ | `ddev exec php bin/phpunit` کامل سبز | ⏳ | | +| ۶.۳ | `ddev exec php bin/phpunit --group=slot-mode-frozen` سبز | ⏳ | | +| ۶.۴ | `ddev exec php vendor/bin/phpstan analyse` بدون خطای جدید | ⏳ | | +| ۶.۵ | `npx tsc --noEmit` بدون خطا | ⏳ | | +| ۶.۶ | `yarn test` سبز | ⏳ | | +| ۶.۷ | `TenantSchemaCoverageTest` و `TenantLookupInventoryTest` سبز | ⏳ | | +| ۶.۸ | `docs/api/*` به‌روز شد (قاعدهٔ ثابت پروژه) | ⏳ | | +| ۶.۹ | چک‌لیست UI کامل شد (اگر تسک صفحه/کامپوننت دارد) | ⏳ | | +| ۶.۱۰ | مصرف‌کنندگان دیگر دستی بررسی شدند: `nobat724_front` · `clinic-pro-tauri` | ⏳ | | +| ۶.۱۱ | تغییرات commit شد، سپس `graphify update .` اجرا شد | ⏳ | | +| ۶.۱۲ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | ⏳ | | +``` + +ردیف ۶.۱۰ در build هیچ‌کدام از آن دو ریپو خطا نمی‌دهد — بررسی فقط دستی ممکن است. +ردیف ۶.۱۱ ترتیبش مهم است: اول commit، بعد `graphify update`. + +--- + +## قواعد ثابت پروژه که در هر تسک اعمال می‌شوند + +از `CLAUDE.md` و `docs/architecture/tenancy.md`: + +1. entity جدید یا `TenantOwnedTrait` می‌گیرد یا با دلیل در `GlobalTables` ثبت می‌شود +2. `entity_type, entity_id` ستون‌های **اول** هر ایندکس ترکیبیِ لیست +3. هر uuid از request با `TenantOwnershipChecker` سنجیده می‌شود +4. timestamp ها `int` یونیکس، نه `DateTime` · نمایش شمسی فقط در UI +5. کنترلر نازک · `extends BaseController` · `success()/paginated()/error()` +6. منطق در Service، کوئری در Repository، وابستگی با constructor injection +7. لیست‌های ادمین با `getArrayResult()` +8. API جدید فقط وقتی هیچ endpoint موجودی — حتی با توسعه — کافی نباشد؛ **دلیلش نوشته شود** +9. تست موفق + خطا + مرزی · بدون اجرای موفق تست، تسک تمام نیست +10. کد و کامیت و مستندات انگلیسی · رشته‌های UI فارسی از i18n +11. SOLID · کلاس/کامپوننت چندمسئولیتی ننویس diff --git a/docs/new_feture/taskes/_shared/red-lines.md b/docs/new_feture/taskes/_shared/red-lines.md new file mode 100644 index 00000000..c274b934 --- /dev/null +++ b/docs/new_feture/taskes/_shared/red-lines.md @@ -0,0 +1,96 @@ +# خط سرخ‌ها — قواعدی که هیچ تسکی نمی‌تواند نقض کند + +این فایل بالای هر تسک حاکم است. اگر تسکی با این‌ها تناقض داشت، **این فایل برنده است** و +تسک باید اصلاح شود، نه این فایل. + +--- + +## ۱. ⛔ نوبت‌دهی اسلاتی به هیچ عنوان دست‌کاری نمی‌شود + +`booking_mode = 'slot'` منطق تولیدیِ زنده است. در **هیچ تسکی از این فاز** نه رفتارش، +نه امضایش، نه خروجی‌اش تغییر نمی‌کند. + +### فایل‌ها و مسیرهای قفل‌شده + +| فایل / مسیر | چه چیزی قفل است | +|---|---| +| `src/Appointment/Service/SlotCalculatorService.php` | متدهای `getAvailableSlots`، `getAllSlotsWithAvailability`، `hasAnyAvailability`، `findNextAvailableStart`، `buildSessionSlots`، `buildAllSessions`، `filterBookedSlots`، `isWithinBookingWindow` — **هیچ‌کدام** ویرایش نمی‌شوند | +| `GET /api/v1/appointment-slots` | قرارداد request/response | +| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | قرارداد | +| `Appointment::active_slot_key` و `refreshActiveSlotKey()` | مکانیزم یکتایی موجود | +| `AppointmentRepository::isSlotTaken` | امضا و معنا | +| `WeeklySchedule::MODE_SLOT` و `DEFAULT_META['booking_mode']` | مقدار پیش‌فرض `slot` می‌ماند | + +### چه چیزی مجاز است + +- **افزودن** متد جدید به `SlotCalculatorService` — بدون تغییر متدهای موجود +- **افزودن** کلاس/سرویس موازی (مثل `AvailabilityEngine` تسک ۰۶) +- **افزودن** کلید جدید به `WeeklySchedule.meta` — با حفظ پیش‌فرض‌های موجود +- **افزودن** ستون تهی‌پذیر به `appointments` + +### چه چیزی ممنوع است + +- تغییر امضای هر متد موجود در `SlotCalculatorService` +- تغییر شکل خروجی `appointment-slots` (حتی افزودن فیلد، اگر ترتیب/نوع فیلدهای موجود عوض شود) +- «یکدست‌سازی» یا refactor مسیر اسلاتی +- حذف `active_slot_key` یا `is_reserve` +- اعمال قوانین جدید (تسک ۰۹) روی حالت `slot` — حتی اگر منطقی به نظر برسد + +### اجبار خودکار + +هر تسک باید این تست را سبز نگه دارد: + +```bash +ddev exec php bin/phpunit --group=slot-mode-frozen +``` + +و در `tests/Appointment/SlotModeFrozenTest.php`: + +```php +/** @group slot-mode-frozen */ +public function testSlotModeContractUnchanged(): void +{ + // snapshot خروجی appointment-slots برای یک برنامهٔ ثابت + // هر تغییری در شکل پاسخ، این تست را قرمز می‌کند + self::assertJsonStringEqualsJsonFile( + __DIR__ . '/fixtures/slot-mode-contract.json', + $this->client->getResponse()->getContent() + ); +} +``` + +fixture در تسک ۰۰ ساخته می‌شود و **هیچ تسکی اجازهٔ به‌روزرسانی‌اش را ندارد**. + +--- + +## ۲. ✅ نوبت‌دهی سرویسی در همین فاز کامل می‌شود + +`booking_mode = 'service'` نیمه‌کاره است: در مسیر رزرو (سایت و درawer پنل) کار می‌کند، ولی +در ویرایش نوبت، نوبت رزرو، و بخشی از پنل بیمار غایب است. + +تکمیلش **پیش‌نیاز** بقیهٔ فاز است، نه موازی با آن: + +``` +تسک ۰۰ تکمیل نوبت‌دهی سرویسی در clinicpro +تسک ۰۰ب سازگارسازی nobat724_front با وضعیت فعلی + ↓ +تسک ۰۱ به بعد (موتور چندمنبعی) +``` + +دلیل ترتیب: اگر حالت `resource` (تسک ۰۶) روی حالت `service` نیمه‌کاره ساخته شود، هر باگ +موجود سرویسی به موتور جدید ارث می‌رسد و تشخیص منبعش غیرممکن می‌شود. + +--- + +## ۳. 🎨 هر صفحه یا بخش جدید، عیناً با دیزاین‌سیستم موجود + +هیچ طراحی جدید، هیچ کامپوننت موازی، هیچ رنگ hard-code. +جزئیات کامل و چک‌لیست: [ui-conventions.md](ui-conventions.md) + +--- + +## ۴. ☑️ هیچ تسکی بدون تکمیل چک‌لیستش تمام نیست + +هر تسک یک `checklist.md` دارد. پیش از اعلام پایان، **همهٔ** ردیف‌ها باید وضعیت نهایی +داشته باشند و هیچ ردیفی در `🔄` یا `⏳` نماند. +قالب و قواعد: [definition-of-done.md](definition-of-done.md) diff --git a/docs/new_feture/taskes/_shared/ui-conventions.md b/docs/new_feture/taskes/_shared/ui-conventions.md new file mode 100644 index 00000000..b9a24ae3 --- /dev/null +++ b/docs/new_feture/taskes/_shared/ui-conventions.md @@ -0,0 +1,126 @@ +# قواعد UI — هر صفحه و بخش جدید عیناً مطابق سیستم موجود + +**الزامی برای همهٔ تسک‌ها.** هیچ طراحی جدید، هیچ تم جدید، هیچ کامپوننت موازی. +منبع حقیقت: کدِ موجود، نه سلیقه و نه `docs/admin-ui/ui-design-spec.md` (که draft قدیمی +با پالت بنفش است و با کد شیپ‌شده نمی‌خواند). + +--- + +## پنل ادمین `clinicpro` — React 19 + Webpack Encore + Tailwind v4 + +### توکن‌ها — هرگز مقدار hard-code + +منبع: `assets/admin/styles.css`، بلوک `:root`. + +```tsx +// ❌ +
+ +// ✅ +
+``` + +| گروه | توکن | +|---|---| +| برند | `--primary` `#5559CE` · `--primary-600/700` · `--primary-soft/soft2` · `--on-primary` | +| اکسنت | `--accent` `#f0682a` · `--accent-600` · `--accent-bg` | +| سطوح | `--bg` `--bg-2` `--surface` `--surface-2/3` `--border` `--border-2` | +| متن | `--text` `--text-2` `--text-3` | +| وضعیت | `--success/-bg` `--warning/-bg` `--danger/-bg` `--info/-bg` `--violet/-bg` | +| کارت آمار | `--stat-{amber,violet,green,pink}-{bg,fg}` | +| شعاع | `--r-xs:7` `--r-sm:8` `--r:14` `--r-lg:18` `--r-xl:24` `--r-pill:999` | +| سایه | `--shadow-sm` `--shadow` `--shadow-lg` | +| چیدمان | `--sidebar-w:243` `--collapsed-w:90` `--topbar-h:64` `--gap:20` `--card-pad:22` `--row-h:56` | +| حرکت | `--ease: cubic-bezier(.22,.61,.36,1)` | + +دارک‌مود با `[data-theme="dark"]` و حالت فشرده با `[data-density="compact"]` خودکار +اعمال می‌شوند — **اگر** از توکن استفاده کرده باشی. مقدار hard-code در دارک‌مود می‌شکند. + +### کامپوننت‌ها — اول جست‌وجو، بعد ساخت + +`assets/admin/components/ui/` این‌ها را دارد. ساختن نسخهٔ موازی از هر کدام **رد** می‌شود: + +``` +DataTable (مرتب‌سازی، جستجو، skeleton، empty state، bulk) · Modal · ConfirmDialog +PageHeader (عنوان + breadcrumb + action + backTo) · BackButton · StatCard · StatusBadge +Pagination · SearchableSelect · AppointmentStatusDropdown +PersianDateInput / PersianDatePicker / PersianCalendar +MobileInput · PriceInput · Portal · FeatureGate · Altcha +``` + +### پنج قاعدهٔ غیرقابل‌مذاکره + +1. **`SearchableSelect`، هرگز ` بومی نیست +□ زیرصفحه‌ها backTo یا دارند +□ وضعیت لیست (جستجو/فیلتر/صفحه) در URL است با useUrlState +□ لیست‌ها از DataTable استفاده می‌کنند با skeleton و empty state فارسی +□ هیچ کامپوننت موازیِ چیزی که در components/ui/ هست ساخته نشد +□ همهٔ رشته‌ها فارسی و از i18n +□ تاریخ‌ها شمسی با formatDate · مبالغ با formatRial +□ RTL بررسی شد (ms/me نه ml/mr) +□ موبایل بررسی شد (بدون اسکرول افقی) +□ فرم‌ها با React Hook Form + Zod +□ داده با TanStack Query و استخراج envelope درست +□ خطاها با پیام فارسی از ErrorCodes نمایش داده می‌شوند +``` diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/architecture.md b/docs/new_feture/taskes/task-00-service-mode-completion/architecture.md new file mode 100644 index 00000000..7d0d4085 --- /dev/null +++ b/docs/new_feture/taskes/task-00-service-mode-completion/architecture.md @@ -0,0 +1,332 @@ +# معماری — تسک ۰۰ + +## ساختار فایل + +``` +src/Appointment/ +├── Service/ +│ ├── ServiceBookingCalculator.php # جدید — تنها مرجع «مدت مجاز یک ترکیب سرویس» +│ ├── ServiceRescheduleService.php # جدید — جابه‌جایی سرویس‌آگاه +│ ├── ReserveConversionService.php # جدید — تبدیل رزرو به نوبت +│ └── SlotCalculatorService.php # ⛔ فقط افزودن، بدون تغییر متدهای موجود +└── Controller/AppointmentController.php # توسعهٔ PATCH + دو route جدید + +assets/admin/ +├── pages/AppointmentEditPage.tsx # توسعه: حالت سرویسی +├── pages/ReserveAppointmentsPage.tsx # توسعه: سرویس‌ها + تبدیل +├── components/appointments/ServiceSlotPicker.tsx # موجود — استفادهٔ دوباره، بدون تغییر رفتار +└── hooks/useDoctorBookingServices.ts # موجود — استفادهٔ دوباره +``` + +## `ServiceBookingCalculator` — استخراج منطق تکرارشده + +منطق «چند سرویس → مدت کل» امروز **داخل کنترلر** است +([AppointmentController::serviceSlots](../../../src/Appointment/Controller/AppointmentController.php#L184)): + +```php +// وضعیت فعلی — درون کنترلر، تکرارشدنی +$totalMinutes = 0; +foreach ($uuids as $u) { + $item = $this->itemRepo->findByUuid($u); + if ($item === null) { return $this->error(…, 'سرویس یافت نشد', 422, …); } + if (!$item->isBookable()) { return $this->error(…, 'این سرویس برای نوبت‌دهی فعال نیست', 422, …); } + $duration = isset($overrides[$u]) && (int)$overrides[$u] > 0 + ? (int) $overrides[$u] + : (int) ($item->getDurationMinutes() ?? 0); + if ($duration <= 0) { return $this->error(…, 'مدت سرویس تعریف نشده است', 422, …); } + $totalMinutes += $duration; +} +``` + +سه مصرف‌کنندهٔ جدید (PATCH، reschedule، convert-reserve) به همین محاسبه نیاز دارند. +کپی‌کردنش یعنی چهار نسخه با چهار رفتار مرزی متفاوت. + +```php +final class ServiceBookingCalculator +{ + public function __construct( + private readonly ServiceItemRepository $items, + private readonly WeeklyScheduleRepository $schedules, + private readonly TenantOwnershipChecker $ownership, + ) {} + + /** + * مدت و بافرِ یک ترکیب سرویس. ترتیب بررسی عمداً: مالکیت محیط اول، بعد بقیه — + * وگرنه پیام خطا وجود و مدت سرویسِ محیط دیگر را لو می‌دهد. + * + * @param string[] $serviceUuids + * @param array $durationOverrides override منشی، فقط برای همین محاسبه + */ + public function calculate( + Doctor $doctor, + ?Clinic $clinic, + array $serviceUuids, + array $durationOverrides = [], + bool $allowInactive = false, + ): ServiceBookingDuration; + + /** آیا این محیط در حالت سرویسی است. */ + public function isServiceMode(Doctor $doctor, ?Clinic $clinic): bool; +} + +final readonly class ServiceBookingDuration +{ + public function __construct( + public int $totalMinutes, + public int $bufferMinutes, + public array $serviceItems, // ServiceItem[] — به ترتیب ورودی + public array $warnings = [], // مثلاً سرویس غیرفعال در نوبت موجود + ) {} + + public function endFor(int $start): int { return $start + $this->totalMinutes * 60; } +} +``` + +`serviceSlots()` موجود هم باید از همین سرویس استفاده کند — ولی **خروجی‌اش بیت‌به‌بیت +همان بماند**. این refactor بی‌خطر است چون رفتار جمع ساده حفظ می‌شود؛ تست موجود +`ServiceModeSectionDurationTest` تضمینش است. + +> ⚠️ جمعِ سادهٔ `+=` اشتباه است (مستند بند ۵) ولی **در این تسک اصلاح نمی‌شود**. +> اصلاحش تسک ۰۴ است (`DurationCalculator` با «زمان تنها / زمان اضافه»). اینجا فقط +> جای منطق عوض می‌شود، نه خودش. `ServiceBookingCalculator` نقطهٔ واحدی است که تسک ۰۴ +> بعداً یک خط در آن عوض می‌کند. + +## `PATCH /appointment/{uuid}` — توسعه، نه بازنویسی + +```php +// وضعیت فعلی حفظ می‌شود؛ فقط یک شاخه اضافه می‌شود +if ($hasStart || $hasEnd) { + if (!($hasStart && $hasEnd)) { /* 422 موجود */ } + + $newStart = …; $newEnd = …; + if ($newEnd <= $newStart) { /* 422 موجود */ } + + // ── جدید: فقط در حالت سرویسی ── + if ($this->serviceCalc->isServiceMode($doctor, $clinic)) { + $uuids = $data['service_item_uuids'] ?? $appointment->currentServiceUuids(); + $duration = $this->serviceCalc->calculate($doctor, $clinic, $uuids, allowInactive: true); + + if ($newEnd !== $duration->endFor($newStart)) { + return $this->error( + ErrorCodes::ERR_SERVICE_DURATION_MISMATCH, + sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes), + 422, 'slot_end' + ); + } + $appointment->replaceServiceItems($duration->serviceItems); + } + // ── پایان بخش جدید ── + + if ($this->appointmentRepo->isSlotTaken(…)) { /* 409 موجود */ } +} +``` + +شرط `isServiceMode` تضمین می‌کند مسیر اسلاتی **یک بایت هم** رفتارش عوض نشود: در حالت +`slot` هیچ‌کدام از خطوط جدید اجرا نمی‌شوند. + +`allowInactive: true` عمدی است: نوبت موجودی که سرویسش غیرفعال شده باید قابل جابه‌جایی +بماند. غیرفعال بودن با `warnings[]` برگردانده می‌شود، نه با `422`. + +## `POST /appointment/{uuid}/service-reschedule` — مسیر ترجیحی + +`PATCH` برای سازگاری توسعه یافت، ولی مسیر درست این است: کلاینت **مدت نمی‌فرستد**. + +``` +درخواست: +{ + "start": 1754…, // فقط زمان شروع + "service_item_uuids": ["…", "…"], // اختیاری؛ نبود = همان سرویس‌های فعلی + "durations": { "uuid": 25 } // اختیاری، override منشی +} + +پاسخ: +{ + "success": true, + "data": { + "uuid": "…", + "slot_start": 1754…, "slot_end": 1754…, + "total_duration_minutes": 35, + "buffer_minutes": 10, + "warnings": [] + } +} +``` + +```php +final class ServiceRescheduleService +{ + public function reschedule(Appointment $appt, ServiceRescheduleRequest $req): Appointment + { + return $this->em->wrapInTransaction(function () use ($appt, $req) { + $doctor = $appt->getDoctor(); + $clinic = $appt->getClinic(); + + if (!$this->serviceCalc->isServiceMode($doctor, $clinic)) { + throw new AppException(ErrorCodes::ERR_WRONG_BOOKING_MODE, + 'این نوبت در حالت نوبت‌دهی سرویسی نیست', 422); + } + + $duration = $this->serviceCalc->calculate($doctor, $clinic, + $req->serviceUuids ?? $appt->currentServiceUuids(), $req->overrides, allowInactive: true); + + // زمان باید واقعاً در فهرست زمان‌های ممکن باشد — نه فقط «اشغال نیست» + $starts = $this->slotCalculator->getServiceStartTimes( + $doctor, date('Y-m-d', $req->start), $duration->totalMinutes, + $clinic, forManagement: $req->forManagement, + excludeAppointmentId: $appt->getId(), // ← پارامتر جدید، پیش‌فرض null + ); + + if (!in_array($req->start, array_column($starts, 'start'), true)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, + 'این زمان برای مدت انتخابی در دسترس نیست', 422, 'start'); + } + + $appt->reschedule($req->start, $duration->endFor($req->start)); + $appt->replaceServiceItems($duration->serviceItems); + $appt->setServiceTotalMinutes($duration->totalMinutes); + $appt->setServiceBufferMinutes($duration->bufferMinutes); + $this->events->recordReschedule($appt); // AppointmentEvent موجود + + return $appt; + }); + } +} +``` + +### پارامتر `excludeAppointmentId` — تنها تغییر مجاز در `SlotCalculatorService` + +```php +public function getServiceStartTimes( + Doctor $doctor, string $date, int $durationMinutes, + ?Clinic $clinic = null, bool $forManagement = false, + ?int $excludeAppointmentId = null, // ← جدید، پیش‌فرض null +): array +``` + +**چرا مجاز است:** پارامتر اختیاری با پیش‌فرض `null` است، و متد `getServiceStartTimes` +فقط در مسیر **سرویسی** استفاده می‌شود — نه در اسلاتی. هیچ فراخوانی موجودی رفتارش عوض +نمی‌شود. + +**چرا لازم است:** بدون آن، نوبت در حال جابه‌جایی خودش را اشغال می‌بیند و زمان فعلی‌اش +هرگز در فهرست نمی‌آید. کاربر نمی‌تواند «همان ساعت، سرویس متفاوت» را ثبت کند. + +پیاده‌سازی: `AppointmentRepository::findBusyIntervals()` هم همان پارامتر را می‌گیرد — +دقیقاً همان الگویی که `isSlotTaken($doctor, $start, $end, $excludeId)` از قبل دارد. +پس این الگو در کدبیس ثابت‌شده است، نه تازه. + +## نوبت رزرو در حالت سرویسی + +امروز: `NewAppointmentDrawer.tsx:72` → `serviceMode = bookingMode === 'service' && !isReserve` + +تغییر: نوبت رزرو **هم** سرویس می‌پذیرد، ولی زمان نمی‌گیرد. + +``` +نوبت رزرو در حالت سرویسی: + slot_start = slot_end = نیمه‌شب روز (رفتار موجود، دست‌نخورده) + is_reserve = true (رفتار موجود) + service_items = سرویس‌های انتخابی ← جدید + service_total_minutes = مدت محاسبه‌شده ← جدید، برای تبدیل بعدی + active_slot_key = NULL (رفتار موجود — رزرو اسلات نمی‌گیرد) +``` + +`POST /appointment/{uuid}/convert-reserve`: + +``` +{ "start": 1754…, "service_item_uuids": [...] } ← سرویس‌ها اختیاری، پیش‌فرض همان‌های رزرو + ▼ + ├─ حالت اسلاتی: زمان باید در getAvailableSlots باشد + └─ حالت سرویسی: زمان باید در getServiceStartTimes(مدت) باشد + ▼ + is_reserve = false · slot_start/end واقعی · active_slot_key بازتولید می‌شود +``` + +`Appointment::refreshActiveSlotKey()` موجود این را خودکار انجام می‌دهد چون +`isReserve` را می‌خواند — **بدون تغییر آن متد**. فقط `setIsReserve(false)` باید +`refreshActiveSlotKey()` را صدا بزند (اگر نمی‌زند، این تنها یک خط اضافه است). + +## `AppointmentEditPage` — دو حالت، یک صفحه + +```tsx +const { bookingMode, services } = useDoctorBookingServices(doctorUuid, clinicUuid); +const isServiceMode = bookingMode === 'service' && !appointment?.is_reserve; + +// حالت اسلاتی: دقیقاً همان سه فیلد امروز — بدون هیچ تغییر +{!isServiceMode && ( + <> + + + + +)} + +// حالت سرویسی: انتخاب چند سرویس + picker زمان +{isServiceMode && ( + +)} +``` + +`ServiceSlotPicker` موجود فقط یک prop اختیاری می‌گیرد. رفتار فعلی‌اش (در +`AppointmentCreatePage` و `AppointmentsPage`) با `excludeAppointmentUuid = undefined` +دست‌نخورده می‌ماند. + +ورودی دستی ساعت در حالت سرویسی **پنهان** می‌شود، نه غیرفعال — فیلد disabled یعنی کاربر +فکر می‌کند باید کاری بکند. + +## تست قرارداد اسلاتی — قلب خط سرخ + +```php +// tests/Appointment/SlotModeFrozenTest.php +/** @group slot-mode-frozen */ +final class SlotModeFrozenTest extends WebTestCase +{ + public function testAppointmentSlotsContractIsFrozen(): void + { + $this->seedFixedSlotSchedule(); // برنامهٔ ثابت، تاریخ ثابت (از args، نه time()) + $this->client->request('GET', '/api/v1/appointment-slots?doctor_uuid=…&date=…'); + + self::assertJsonStringEqualsJsonFile( + __DIR__ . '/fixtures/slot-mode-contract.json', + $this->client->getResponse()->getContent(), + ); + } + + public function testMonthAvailabilityContractIsFrozen(): void { /* همان الگو */ } + + /** هیچ متد عمومیِ SlotCalculatorService امضایش عوض نشده. */ + public function testSlotCalculatorPublicApiIsFrozen(): void + { + $expected = require __DIR__ . '/fixtures/slot-calculator-signatures.php'; + $actual = $this->reflectPublicSignatures(SlotCalculatorService::class); + self::assertSame($expected, $actual); + } +} +``` + +متد سوم مهم‌ترین است: پارامتر اختیاری جدید `excludeAppointmentId` **یک بار** در fixture +ثبت می‌شود (در همین تسک) و بعد از آن هیچ تسکی اجازهٔ تغییرش را ندارد. + +fixture ها با تاریخ ثابت ساخته می‌شوند، نه `time()` — وگرنه تست فردا قرمز می‌شود. + +## UI — قواعد اجباری + +رجوع: [_shared/ui-conventions.md](../_shared/ui-conventions.md) + +- `AppointmentEditPage` از قبل `PageHeader` با `backTo` دارد → حفظ شود +- `ServiceSlotPicker` موجود بازاستفاده می‌شود؛ نسخهٔ موازی ساخته نمی‌شود +- انتخاب چند سرویس با `SearchableSelect` چندانتخابی — نه `` بومی | ⏳ | | +| ۳.۱۲ | `backTo`/`BackButton` روی هر دو صفحه | ⏳ | | +| ۳.۱۳ | وضعیت لیست رزروها در URL با `useUrlState` | ⏳ | | +| ۳.۱۴ | تاریخ با `PersianDateInput` · مبلغ با `formatRial` | ⏳ | | +| ۳.۱۵ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ⏳ | | +| ۳.۱۶ | موبایل بررسی شد — بدون اسکرول افقی | ⏳ | | +| ۳.۱۷ | همهٔ رشته‌ها فارسی و از i18n | ⏳ | | +| ۳.۱۸ | داده با TanStack Query و استخراج envelope درست | ⏳ | | + +## ۴. تست + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ⏳ | | +| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ⏳ | | +| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ⏳ | | +| ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچ‌کدام اجرا نمی‌شود» | ⏳ | | +| ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | | +| ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | ⏳ | | +| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ⏳ | | +| ۴.۸ | `AppointmentEditPage.test.tsx` — دو حالت | ⏳ | | + +## ۵. مستندات + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۵.۱ | `docs/api/appointment.md` — دو endpoint جدید + توسعهٔ PATCH | ⏳ | | +| ۵.۲ | ماتریس «کدام endpoint در کدام حالت» | ⏳ | | +| ۵.۳ | `docs/architecture/booking-modes.md` ساخته شد | ⏳ | تسک ۰۶ حالت سوم را اضافه می‌کند | +| ۵.۴ | دو کد خطای جدید مستند شد | ⏳ | | + +## ۶. بازبینی پایانی + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۶.۱ | همهٔ ردیف‌های بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بی‌دلیل) | ⏳ | | +| ۶.۲ | `ddev exec php bin/phpunit` کامل سبز | ⏳ | | +| ۶.۳ | `ddev exec php bin/phpunit --group=slot-mode-frozen` سبز | ⏳ | | +| ۶.۴ | `phpstan analyse` بدون خطای جدید | ⏳ | | +| ۶.۵ | `npx tsc --noEmit` بدون خطا | ⏳ | | +| ۶.۶ | `yarn test` سبز | ⏳ | | +| ۶.۷ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | | +| ۶.۸ | `docs/api/*` به‌روز شد | ⏳ | | +| ۶.۹ | چک‌لیست UI (بخش ۳) کامل شد | ⏳ | | +| ۶.۱۰ | `nobat724_front` و `clinic-pro-tauri` دستی بررسی شدند | ⏳ | `service_item` تکی هم‌گام است؟ | +| ۶.۱۱ | commit شد، سپس `graphify update .` | ⏳ | | +| ۶.۱۲ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | ⏳ | | diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/database.md b/docs/new_feture/taskes/task-00-service-mode-completion/database.md new file mode 100644 index 00000000..c96bab1e --- /dev/null +++ b/docs/new_feture/taskes/task-00-service-mode-completion/database.md @@ -0,0 +1,123 @@ +# دیتابیس — تسک ۰۰ + +## تغییر `appointments` — دو ستون تهی‌پذیر + +```sql +ALTER TABLE appointments + ADD COLUMN service_total_minutes SMALLINT NULL, + ADD COLUMN service_buffer_minutes SMALLINT NULL; +``` + +| ستون | معنی | چرا لازم است | +|---|---|---| +| `service_total_minutes` | مدت محاسبه‌شدهٔ ترکیب سرویس‌ها در لحظهٔ ثبت | `slot_end - slot_start` عدد را دارد ولی نمی‌گوید عمدی بود یا دستی؛ و برای نوبت رزرو (که `slot_start = slot_end`) هیچ‌جا مدت را نگه نمی‌داریم | +| `service_buffer_minutes` | `buffer_minutes` مؤثر در لحظهٔ ثبت | تغییر بافر در تنظیمات نباید معنای نوبت‌های ثبت‌شده را عوض کند | + +هر دو **تهی‌پذیر** و هر دو در حالت اسلاتی `NULL` می‌مانند. هیچ ستون موجودی حذف، تغییر +نوع یا تغییر معنا نمی‌دهد. + +⛔ `slot_start` و `slot_end` و `active_slot_key` و `is_reserve` دست‌نخورده. خط سرخ. + +### چرا نه یک ستون JSON + +وسوسه: یک `service_meta JSON` با همه‌چیز. رد شد چون تسک ۱۴ (گزارش دقت برنامه) روی +`plan_total_minutes` تجمعی می‌زند و JSON را نمی‌تواند `AVG` کند. دو ستون `SMALLINT` +ارزان‌ترند و تسک ۰۷ ستون `plan_total_minutes` را کنارشان اضافه می‌کند +(اسم متفاوت، معنی متفاوت: آن یکی مدت برنامهٔ چندبخشی است). + +## ایندکس + +هیچ ایندکس جدیدی. `idx_appointments_doctor_slot` و `idx_appointments_tenant_slot` موجود +همهٔ کوئری‌های این تسک را پوشش می‌دهند. + +## `appointment_service_items` — بدون تغییر schema + +جدول واسط ManyToMany موجود. تنها تغییر، **رفتاری** است: + +```php +// Appointment — متد جدید، بدون دست زدن به متدهای موجود +/** جایگزینی کامل سرویس‌ها؛ serviceItem تکی هم با اولی هم‌گام می‌شود. */ +public function replaceServiceItems(array $items): self +{ + $this->serviceItems->clear(); + foreach ($items as $item) { + if (!$this->serviceItems->contains($item)) { $this->serviceItems->add($item); } + } + $this->serviceItem = $items[0] ?? null; // ← سازگاری با مصرف‌کنندهٔ تکی + $this->updatedAt = time(); + return $this; +} + +/** @return string[] uuid سرویس‌های فعلی، به ترتیب */ +public function currentServiceUuids(): array +{ + $uuids = array_map(fn($i) => $i->getUuid(), $this->serviceItems->toArray()); + if ($uuids === [] && $this->serviceItem !== null) { $uuids = [$this->serviceItem->getUuid()]; } + return $uuids; +} +``` + +هم‌گام‌سازی `serviceItem` تکی اجباری است: `AppointmentsPage`، `ReserveAppointmentsPage`، +`nobat724_front` و `clinic-pro-tauri` هر چهار روی `service_item` تکی خوانده‌اند. رهاکردنش +یعنی نوبت با سرویس‌های جدید ولی نام سرویس قدیمی در لیست. + +## کدهای خطای جدید + +در `src/Shared/Constant/ErrorCodes.php`: + +```php +public const ERR_SERVICE_DURATION_MISMATCH = 'ERR_APPOINTMENT_010'; +// پیام: مدت این نوبت با مجموع مدت سرویس‌های انتخابی نمی‌خواند + +public const ERR_WRONG_BOOKING_MODE = 'ERR_APPOINTMENT_011'; +// پیام: این عملیات با روش نوبت‌دهی این محیط سازگار نیست +``` + +شمارهٔ بعدی دامنهٔ `APPOINTMENT` را از خود فایل بگیر، این دو عدد حدسی‌اند. +هر دو کد در تسک‌های ۰۶ و ۰۷ هم استفاده می‌شوند، پس نامشان عمومی است نه مخصوص این تسک. + +## Migration + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +## backfill + +```bash +ddev exec php bin/console app:appointment:backfill-service-duration # dry-run +ddev exec php bin/console app:appointment:backfill-service-duration --force +``` + +برای هر نوبت `pending`/`confirmed` **آیندهٔ** یک محیط سرویسی که `service_total_minutes` +ندارد: + +``` +service_total_minutes = (slot_end - slot_start) / 60 +service_buffer_minutes = buffer_minutes فعلیِ همان برنامه +``` + +مقدار از خودِ نوبت گرفته می‌شود، **نه از مدت سرویس‌ها** — چون نوبت موجود ممکن است با +مدت دستی ثبت شده باشد و بازمحاسبه یعنی تغییر گذشته. + +نوبت‌های اسلاتی و نوبت‌های گذشته رد می‌شوند. idempotent. + +## fixture های تست خط سرخ + +``` +tests/Appointment/fixtures/slot-mode-contract.json # پاسخ appointment-slots +tests/Appointment/fixtures/month-availability-contract.json # پاسخ month-availability +tests/Appointment/fixtures/slot-calculator-signatures.php # امضای متدهای عمومی +``` + +⛔ این سه فایل بعد از این تسک **read-only** اند. هیچ تسکی اجازهٔ به‌روزرسانی‌شان را ندارد. +اگر تستی قرمز شد، کد باید برگردد نه fixture. این جمله را در بالای هر سه فایل به‌عنوان +کامنت بنویس. + +fixture ها با تاریخ ثابت ساخته می‌شوند (`2026-01-05` مثلاً)، نه `time()` — وگرنه فردا قرمز. + +## طبقه‌بندی tenant + +هیچ entity جدیدی. `appointments` از قبل جفت tenant دارد. +`TenantSchemaCoverageTest` باید بدون تغییر سبز بماند. diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/implementation_notes.md b/docs/new_feture/taskes/task-00-service-mode-completion/implementation_notes.md new file mode 100644 index 00000000..e63f5c0a --- /dev/null +++ b/docs/new_feture/taskes/task-00-service-mode-completion/implementation_notes.md @@ -0,0 +1,204 @@ +# نکات پیاده‌سازی — تسک ۰۰ + +## ۱. اول تست خط سرخ، بعد هر چیز دیگر + +ترتیب کار: + +``` +۱. tests/Appointment/SlotModeFrozenTest.php + سه fixture ← اول این +۲. ddev exec php bin/phpunit --group=slot-mode-frozen ← باید سبز باشد قبل از هر تغییری +۳. بقیهٔ تسک +۴. دوباره گام ۲ — باید همچنان سبز باشد +``` + +اگر fixture را بعد از تغییرات بسازی، هیچ چیزی را تضمین نکرده‌ای — snapshot وضعیت +تغییریافته را گرفته‌ای. + +## ۲. `isServiceMode` گِیت همه‌چیز است + +هر خط کد جدید در مسیر مشترک باید داخل این شرط باشد: + +```php +if ($this->serviceCalc->isServiceMode($doctor, $clinic)) { + // … منطق جدید +} +``` + +نه بیرونش، نه با `??`، نه با «اگر سرویس دارد». معیار **فقط** `booking_mode` است. +نوبت اسلاتی هم می‌تواند `service_item_id` داشته باشد (فیلدهای Figma نوبت‌ها) — آن دلیل +سرویسی بودن نیست. + +اشتباه رایج: + +```php +// ❌ نوبت اسلاتیِ دارای سرویس را وارد مسیر جدید می‌کند +if ($appointment->getServiceItems()->count() > 0) { … } +``` + +## ۳. جمع سادهٔ مدت را همین‌جا اصلاح نکن + +```php +$totalMinutes += $duration; // ← اشتباه است، ولی دست نزن +``` + +مستند بند ۵ می‌گوید این فرمول ظرفیت را الکی پر می‌کند و راه‌حلش «زمان تنها / زمان اضافه» +است — که تسک ۰۴ می‌سازد. اصلاحش اینجا یعنی: + +- مدت همهٔ نوبت‌های چندسرویسیِ در حال رزرو یک‌شبه کم می‌شود +- سایت و اپ دسکتاپ عدد متفاوت می‌بینند بدون اینکه چیزی در build بشکند +- و هیچ داده‌ای برای «زمان اضافه» وجود ندارد، پس اصلاح بی‌ورودی غیرممکن است + +کاری که این تسک می‌کند: محاسبه را به **یک نقطه** منتقل می‌کند تا تسک ۰۴ یک خط عوض کند. + +## ۴. `excludeAppointmentId` — الگوی موجود را تکرار کن + +`AppointmentRepository::isSlotTaken($doctor, $start, $end, $excludeId)` از قبل این پارامتر +را دارد. `findBusyIntervals` هم همان را بگیرد، با همان نام و همان جای پارامتر و همان +پیش‌فرض `null`. + +دو الگوی متفاوت برای یک کار (مثلاً یکی `?int $excludeId`، دیگری `array $excludeIds`) +یعنی اولین کسی که هر دو را می‌بیند یکی را اشتباه صدا می‌زند. + +## ۵. اعتبارسنجی زمان: عضویت در فهرست، نه «اشغال نبودن» + +```php +// ❌ ناکافی +if ($this->appointmentRepo->isSlotTaken($doctor, $start, $end, $excludeId)) { /* 409 */ } + +// ✅ +$starts = $this->slotCalculator->getServiceStartTimes(…); +if (!in_array($req->start, array_column($starts, 'start'), true)) { /* 422 */ } +``` + +`isSlotTaken` فقط تداخل با نوبت دیگر را می‌گوید. `getServiceStartTimes` علاوه بر آن +شیفت، تعطیلی، `date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند. با شرط اول، +منشی می‌تواند نوبت را ساعت ۳ بامداد بگذارد. + +## ۶. `warnings[]` به‌جای `422` برای سرویس غیرفعال در نوبت موجود + +```php +$duration = $this->serviceCalc->calculate(…, allowInactive: true); +// $duration->warnings === ['سرویس «لیزر صورت» دیگر برای نوبت‌دهی فعال نیست'] +``` + +نوبت موجود با سرویسی که کلینیک غیرفعالش کرده، باید قابل جابه‌جایی و لغو بماند. `422` +یعنی آن نوبت برای همیشه قفل می‌شود و منشی هیچ کاری نمی‌تواند بکند. + +ولی **افزودن** سرویس غیرفعال به نوبت → `422`. تفاوتش `allowInactive` است که فقط برای +uuid های موجودِ نوبت `true` می‌شود، نه برای uuid های تازه‌ی درخواست. + +## ۷. `replaceServiceItems` باید `serviceItem` تکی را هم‌گام کند + +```php +$this->serviceItem = $items[0] ?? null; +``` + +چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند (`AppointmentsPage`، +`ReserveAppointmentsPage`، `nobat724_front/services/response.js`، +`clinic-pro-tauri/src/service/response.js`). این دقیقاً همان الگویی است که +`ServiceItem::setStaffMembers()` برای `staff` تکی دارد — تکرارش کن. + +## ۸. `refreshActiveSlotKey` پس از `setIsReserve(false)` + +```php +public function setIsReserve(bool $v): self +{ + $this->isReserve = $v; + $this->refreshActiveSlotKey(); // ← اگر نیست، اضافه کن + $this->updatedAt = time(); + return $this; +} +``` + +بدون آن، رزروِ تبدیل‌شده `active_slot_key = NULL` می‌ماند و دو نفر می‌توانند همان ساعت +را بگیرند. **این تنها تغییر مجاز در مکانیزم `active_slot_key` است** و فقط چون یک شرط +موجود را اعمال می‌کند، نه عوضش می‌کند. تست: `ConvertReserveSlotKeyTest`. + +## ۹. تبدیل رزرو، اتمی + +```php +$this->em->wrapInTransaction(function () use ($reserve, $req) { + $duration = …; // یا اسلات اسلاتی + $reserve->setIsReserve(false); + $reserve->reschedule($req->start, $end); + $reserve->replaceServiceItems($duration->serviceItems); + // UniqueConstraintViolationException روی active_slot_key → 409 +}); +``` + +`try/catch` روی `UniqueConstraintViolationException` و ترجمه به `409 ERR_SLOT_TAKEN` — +همان چیزی که `SlotTakenException` موجود در `src/Appointment/Repository/` انجام می‌دهد. +از همان استفاده کن. + +## ۱۰. `ReserveAppointmentsPage` → `DataTable` + +صفحه امروز جدول خام با `` دارد. چون در این تسک دستش می‌زنیم، همان‌جا به +`DataTable` مهاجرت کند: توکن inline خلاف [ui-conventions](../_shared/ui-conventions.md) +است و در دارک‌مود می‌شکند. + +این «scope creep» نیست — قاعدهٔ پروژه است که صفحهٔ دست‌خورده باید با دیزاین‌سیستم بخواند. + +## ۱۱. edge case ها + +| حالت | رفتار درست | +|---|---| +| نوبت سرویسی بدون هیچ سرویس (داده قدیمی) | مدت موجود حفظ · `warnings[]` · **رد نمی‌شود** | +| `PATCH` فقط یادداشت روی نوبت سرویسی | بدون اعتبارسنجی مدت — مثل امروز | +| `service-reschedule` روی نوبت اسلاتی | `422 ERR_WRONG_BOOKING_MODE` | +| `service-reschedule` روی نوبت رزرو | `422` — مسیرش `convert-reserve` است | +| زمان فعلی نوبت با سرویس جدید | با `excludeAppointmentId` در فهرست می‌آید | +| بافر عوض شد بعد از ثبت | نوبت موجود سالم؛ فقط جابه‌جایی جدید بافر جدید می‌گیرد | +| سرویس محیط دیگر در `service_item_uuids[]` | `404` — `TenantOwnershipChecker` **پیش از** هر بررسی دیگر | +| `durations` override با مقدار ۰ یا منفی | نادیده گرفته شود (رفتار موجود `serviceSlots`) | +| نوبت گذشته | `service-reschedule` → `422` | +| دو درخواست جابه‌جایی هم‌زمان به یک زمان | `active_slot_key` → یکی `409` | +| نوبت در محیطی که وسط کار به اسلاتی برگشت | `booking_mode` قفل است پس رخ نمی‌دهد؛ ولی اگر داده دستی عوض شد → `422` روشن | + +## ۱۲. تست + +``` +tests/Appointment/SlotModeFrozenTest.php ← ⭐ اول از همه + - قرارداد appointment-slots بیت‌به‌بیت + - قرارداد month-availability + - امضای متدهای عمومی SlotCalculatorService +tests/Appointment/ServiceBookingCalculatorTest.php + - جمع مدت چند سرویس (رفتار فعلی حفظ شود) + - override منشی + - سرویس بدون مدت → 422 + - سرویس غیرفعال با allowInactive → warning نه خطا + - سرویس محیط دیگر → استثنا، بدون افشای وجود +tests/Appointment/ServiceRescheduleTest.php ← ⭐ + - جابه‌جایی با همان سرویس‌ها → مدت یکسان + - حذف یک سرویس → مدت خودکار کم می‌شود، کلاینت عددی نفرستاده + - زمان بیرون getServiceStartTimes → 422 + - زمان فعلی خود نوبت با excludeAppointmentId در فهرست است + - روی نوبت اسلاتی → 422 ERR_WRONG_BOOKING_MODE +tests/Appointment/PatchServiceDurationTest.php + - slot_end ناسازگار → 422 ERR_SERVICE_DURATION_MISMATCH با مدت درست در پیام + - service_item_uuids[] جایگزینی کامل می‌کند و serviceItem تکی هم‌گام می‌شود + - در حالت اسلاتی هیچ‌کدام از این‌ها اجرا نمی‌شود (رفتار امروز) +tests/Appointment/ConvertReserveTest.php + - رزرو سرویسی → نوبت زمان‌دار با مدت درست + - رزرو اسلاتی → مسیر اسلاتی، بدون تغییر + - active_slot_key بعد از تبدیل پر می‌شود + - دو تبدیل هم‌زمان → یکی 409 +tests/Appointment/ServiceModeSectionDurationTest.php ← موجود، باید سبز بماند +tests/Appointment/BookingTenantTest.php ← موجود، باید سبز بماند +assets/admin/pages/AppointmentEditPage.test.tsx + - حالت اسلاتی: سه فیلد ساعت هست، ServiceSlotPicker نیست + - حالت سرویسی: ServiceSlotPicker هست، فیلد ساعت پنهان است + - تغییر سرویس‌ها، slot انتخابی را باطل می‌کند +``` + +## ۱۳. مستندات + +`docs/api/appointment.md`: +- بخش «روش‌های نوبت‌دهی» با ماتریس «کدام endpoint در کدام حالت» +- دو endpoint جدید +- توسعهٔ `PATCH` و پارامتر `exclude_appointment_uuid` +- دو کد خطای جدید + +`docs/api/appointment-settings.md`: یادآوری قفل بودن `booking_mode` پس از اولین ثبت. + +و یک سند کوتاه `docs/architecture/booking-modes.md` که ماتریس کامل را ثبت کند — +تسک ۰۶ حالت سومی به همین ماتریس اضافه می‌کند. diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/task.md b/docs/new_feture/taskes/task-00-service-mode-completion/task.md new file mode 100644 index 00000000..1eb552f1 --- /dev/null +++ b/docs/new_feture/taskes/task-00-service-mode-completion/task.md @@ -0,0 +1,143 @@ +# تسک ۰۰ — تکمیل نوبت‌دهی سرویسی در clinicpro + +**فاز:** ۰ (تثبیت وضعیت فعلی) · **وابستگی:** — · **زمان:** ۱۴-۱۸ ساعت +**پیش‌نیاز همهٔ تسک‌های ۰۱ به بعد** + +--- + +## ⛔ خط سرخ + +منطق اسلاتی (`booking_mode = 'slot'`) در این تسک **به هیچ عنوان** دست‌کاری نمی‌شود. +فهرست کامل قفل‌شده‌ها: [_shared/red-lines.md](../_shared/red-lines.md). + +این تسک fixture و تست `--group=slot-mode-frozen` را **می‌سازد** — همان تستی که همهٔ +تسک‌های بعدی باید سبز نگهش دارند. + +--- + +## هدف + +حالت `booking_mode = 'service'` در مسیر **رزرو** کار می‌کند، ولی در بقیهٔ چرخهٔ عمر نوبت +غایب است. این تسک آن را کامل می‌کند تا موتور چندمنبعی (تسک ۰۶) روی پایهٔ سالم ساخته شود. + +## وضعیت فعلی — چه کار می‌کند و چه نمی‌کند + +### ✅ کار می‌کند + +| مسیر | فایل | +|---|---| +| انتخاب سرویس و اسلات در رزرو عمومی | `GET /api/v1/appointment-booking-services/{doctorUuid}` · `GET /api/v1/appointment-service-slots` | +| محاسبهٔ زمان‌های شروع بر اساس مدت سرویس | `SlotCalculatorService::getServiceStartTimes()` | +| ثبت نوبت با چند سرویس | `POST /api/v1/appointment` + `appointment_service_items` | +| توگل روش نوبت‌دهی در تنظیمات | `assets/admin/components/schedule/ScheduleSection.tsx` | +| ساخت نوبت از پنل | `assets/admin/pages/AppointmentCreatePage.tsx` + `components/appointments/ServiceSlotPicker.tsx` + `hooks/useDoctorBookingServices.ts` | +| ساخت سریع از drawer | `assets/admin/components/NewAppointmentDrawer.tsx` | +| رزرو از سایت | `nobat724_front/components/appointment/*` | + +### ❌ کار نمی‌کند — شکاف‌های این تسک + +**۱. ویرایش و جابه‌جایی نوبت، حالت سرویسی را نمی‌شناسد.** + +`PATCH /api/v1/appointment/{uuid}` ([AppointmentController.php:1077](../../../src/Appointment/Controller/AppointmentController.php#L1077)): + +```php +$hasStart = array_key_exists('slot_start', $data); +$hasEnd = array_key_exists('slot_end', $data); +// … فقط این دو بررسی می‌شوند: +if ($newEnd <= $newStart) { /* 422 */ } +if ($this->appointmentRepo->isSlotTaken($doctor, $newStart, $newEnd, $id)) { /* 409 */ } +``` + +سه مشکل: +- مدت دلخواه پذیرفته می‌شود؛ هیچ بررسی‌ای که `slot_end - slot_start` با مجموع مدت + سرویس‌های نوبت بخواند وجود ندارد +- `buffer_minutes` نادیده گرفته می‌شود — نوبت جدید می‌تواند چسبیده به نوبت بعدی بنشیند +- فقط `service_item_uuid` تکی به‌روز می‌شود؛ `service_items` (ManyToMany) دست‌نخورده + می‌ماند → نوبت با سرویس‌های قبلی و مدت جدید ناسازگار می‌شود + +**۲. `AppointmentEditPage.tsx` ورودی دستی ساعت دارد.** + +سه فیلد `date`/`start`/`end` آزاد + یک `SearchableSelect` تکی برای سرویس +([AppointmentEditPage.tsx:74-76](../../../assets/admin/pages/AppointmentEditPage.tsx#L74)). +هیچ `ServiceSlotPicker` ای نیست، هیچ چند-سرویسی نیست. + +نتیجه: منشی نوبت سرویسیِ ۴۵ دقیقه‌ای را ویرایش می‌کند، ۲۰ دقیقه می‌گذارد، سیستم قبول +می‌کند، و بیمار بعدی روی نوبت اول می‌نشیند. + +**۳. نوبت رزرو (`is_reserve`) در حالت سرویسی معنا ندارد.** + +`NewAppointmentDrawer.tsx:72` صریح: `$serviceMode = bookingMode === 'service' && !isReserve`. +پس نوبت رزرو همیشه اسلاتی رفتار می‌کند و `ReserveAppointmentsPage.tsx` فقط +`service_item?.name` تکی نشان می‌دهد. تبدیل رزرو به نوبت واقعی هم مسیر سرویسی ندارد. + +**۴. `patient_facing` بودن مدت جایی نمایش داده نمی‌شود.** + +پاسخ `appointment-service-slots` مدت کل را می‌دهد ولی نوبت ثبت‌شده هیچ‌جا نگه نمی‌دارد +که این مدت از کدام سرویس‌ها و چه بافری آمده. لیست نوبت‌ها فقط `slot_start/slot_end` دارد. + +## دامنه + +**هست:** +- `ServiceBookingCalculator` — یک سرویس واحد که «مدت مجاز یک ترکیب سرویس» را حساب می‌کند + (استخراج منطق تکرارشدهٔ `serviceSlots()` از کنترلر) +- اعتبارسنجی حالت سرویسی در `PATCH /appointment/{uuid}` +- endpoint جابه‌جایی سرویس‌آگاه: `POST /api/v1/appointment/{uuid}/service-reschedule` +- `ServiceSlotPicker` در `AppointmentEditPage` +- حالت سرویسی برای نوبت رزرو + تبدیل رزرو به نوبت +- ستون‌های `service_total_minutes` و `service_buffer_minutes` روی `appointments` +- fixture و تست `--group=slot-mode-frozen` + +**نیست:** بخش‌های نوبت، چند منبع، قوانین (تسک ۰۵ به بعد). `nobat724_front` (تسک ۰۰ب). + +## Endpoint ها + +| متد | مسیر | توضیح | +|---|---|---| +| POST | `/api/v1/appointment/{uuid}/service-reschedule` | جابه‌جایی سرویس‌آگاه: سرویس‌ها + زمان شروع؛ مدت را خودش حساب می‌کند | +| PATCH | `/api/v1/appointment/{uuid}` | **توسعه** — در حالت سرویسی مدت را اعتبارسنجی می‌کند و `service_item_uuids[]` می‌پذیرد | +| POST | `/api/v1/appointment/{uuid}/convert-reserve` | تبدیل نوبت رزرو به نوبت زمان‌دار (هر دو حالت) | +| GET | `/api/v1/appointment-service-slots` | **توسعه** — پارامتر `exclude_appointment_uuid` برای جابه‌جایی | + +هیچ endpoint اسلاتی‌ای تغییر نمی‌کند. `GET /appointment-slots` دست‌نخورده. + +## معیار پذیرش + +- ✅ موفق: نوبت سرویسیِ «لیزر صورت (۲۰) + بیکینی (۱۵)» با مدت ۳۵ دقیقه. + `POST /appointment/{uuid}/service-reschedule` با زمان جدید و همان سرویس‌ها → + `200` و `slot_end - slot_start = 35 * 60` دقیقاً. +- ✅ موفق: همان endpoint با حذف بیکینی → مدت خودکار ۲۰ دقیقه می‌شود، بدون اینکه کلاینت + عددی بفرستد. +- ✅ موفق: `GET /appointment-service-slots?…&exclude_appointment_uuid={uuid}` بازهٔ خودِ + نوبت را اشغال حساب نمی‌کند، پس زمان فعلی‌اش در فهرست می‌آید. +- ✅ موفق: `AppointmentEditPage` برای نوبت سرویسی، `ServiceSlotPicker` نشان می‌دهد و + ورودی دستی ساعت را **پنهان** می‌کند؛ برای نوبت اسلاتی، دقیقاً رفتار امروز. +- ✅ موفق: نوبت رزرو در حالت سرویسی سرویس‌هایش را ذخیره می‌کند و + `POST /convert-reserve` با زمان انتخابی، نوبت زمان‌دار با مدت درست می‌سازد. +- ✅ موفق (**خط سرخ**): `ddev exec php bin/phpunit --group=slot-mode-frozen` سبز است و + fixture قرارداد اسلاتی بیت‌به‌بیت تغییر نکرده. +- ❌ خطا: `PATCH` با `slot_end - slot_start` ناسازگار با مدت سرویس‌ها → + `422` `ERR_SERVICE_DURATION_MISMATCH` با پیام فارسی شامل مدت درست. +- ❌ خطا: `service-reschedule` روی نوبت **اسلاتی** → `422` `ERR_WRONG_BOOKING_MODE`. +- ❌ خطا: `service-reschedule` با زمان شروعی که در `getServiceStartTimes` نیست → + `422` با پیام «این زمان برای مدت انتخابی در دسترس نیست». +- ❌ خطا: سرویس محیط دیگر در `service_item_uuids[]` → `404` (بدون لو دادن وجودش). +- ⚠️ مرزی: نوبتی که سرویس‌هایش غیرفعال (`bookable=false`) شده‌اند → جابه‌جایی مجاز است + با `warnings[]`؛ افزودن سرویس غیرفعال ممنوع. +- ⚠️ مرزی: `buffer_minutes` تغییر کرد بعد از ثبت نوبت → نوبت موجود سالم می‌ماند؛ + فقط جابه‌جایی جدید بافر جدید را می‌گیرد. +- ⚠️ مرزی: جابه‌جایی به روزی که برنامهٔ هفتگی آن محیط عوض شده → همان اعتبارسنجی + `getServiceStartTimes`، پس خودکار پوشش داده می‌شود. +- ⚠️ مرزی: نوبت سرویسی بدون هیچ سرویس (داده قدیمی) → مدت موجود حفظ می‌شود و + `warnings[]` می‌گوید سرویس ثبت نشده. **رد نمی‌شود.** +- ⚠️ مرزی: `PATCH` بدون `slot_start` روی نوبت سرویسی (فقط تغییر یادداشت) → بدون + اعتبارسنجی مدت، مثل امروز. + +## خروجی + +- `src/Appointment/Service/ServiceBookingCalculator.php` + توسعهٔ کنترلر +- `assets/admin/pages/AppointmentEditPage.tsx` توسعه‌یافته +- `assets/admin/pages/ReserveAppointmentsPage.tsx` توسعه‌یافته +- migration دو ستون تهی‌پذیر +- `tests/Appointment/SlotModeFrozenTest.php` + fixture +- `docs/api/appointment.md` به‌روزرسانی +- [checklist.md](checklist.md) کامل‌شده diff --git a/docs/new_feture/taskes/task-00b-nobat724-service-mode/architecture.md b/docs/new_feture/taskes/task-00b-nobat724-service-mode/architecture.md new file mode 100644 index 00000000..ca889241 --- /dev/null +++ b/docs/new_feture/taskes/task-00b-nobat724-service-mode/architecture.md @@ -0,0 +1,203 @@ +# معماری — تسک ۰۰ب + +پروژه: `nobat724_front` · Next.js 15 App Router · MUI v5 + Tailwind · RTL · Vazir + +## فایل‌های درگیر + +``` +components/appointment/ +├── index.js # ارکستراتور مراحل — تغییر جزئی +├── service/index.js # ⚠️ بازنویسی با توکن تم +├── date/index.js # مصرف adaptServiceSlots +└── detail/SubmitData.js # تغییر جزئی + +lib/appointmentSlots.js # adaptServiceSlots شیفت‌آگاه +services/response.js # endpoint های جدید تسک ۰۰ + +components/dashboard/userAccount/sidebars/turns/ +├── Card.js # + سرویس و مدت +├── isTurnsDetails/DetailLg.js +├── isTurnsDetails/DetailSm.js +└── isTurnsDetails/ButtonData.js # + جابه‌جایی سرویس‌آگاه +``` + +## ۱. بازنویسی `service/index.js` — توکن، نه hex + +وضعیت فعلی چهار رنگ hard-code دارد و در دارک‌مود می‌شکند: + +```jsx +// وضعیت فعلی +

۱. انتخاب سرویس

+className={active ? "border-[#5559CE] bg-[#5559CE]/5" + : "border-gray-200 bg-white hover:border-[#5559CE]"} +``` + +```jsx +// هدف — همان ساختار DOM، رنگ از تم +

۱. انتخاب سرویس

+className={active + ? "border-primary bg-primary/5" + : "border-border bg-surface hover:border-primary"} +``` + +⚠️ **نام دقیق کلاس‌ها را از `tailwind.config.js` و `mui/index.js` همین پروژه بردار.** +اسم‌های بالا نمونه‌اند. قاعده: هر رنگی که در بقیهٔ مراحل رزرو (`location/`، `date/`، +`information/`) استفاده می‌شود، اینجا هم همان — نه یک پالت جدید. + +**رفتار عوض نمی‌شود:** همان toggle، همان ساختار، همان متن‌ها. فقط منبع رنگ. + +اگر پروژه توکن معادل ندارد (مثلاً `bg-surface` تعریف نشده)، از همان الگویی استفاده کن +که مرحلهٔ قبلی (`location/index.js`) دارد — نه ساختن توکن جدید در این تسک. + +## ۲. حذف محاسبهٔ موازی مدت + +```js +// ❌ وضعیت فعلی — منبع دوم حقیقت +const totalMinutes = services + .filter((s) => draft.includes(s.uuid)) + .reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0); +``` + +مدت باید از پاسخ `appointment-service-slots` بیاید که از قبل `total_duration_minutes` و +`buffer_minutes` دارد. ولی یک مسئلهٔ ترتیبی هست: مرحلهٔ انتخاب سرویس **پیش از** انتخاب +روز است، و آن endpoint تاریخ می‌خواهد. + +دو گزینه: + +| گزینه | ارزیابی | +|---|---| +| فراخوانی `appointment-service-slots` با تاریخ امروز فقط برای گرفتن مدت | یک درخواست اضافه، و اگر امروز تعطیل باشد پاسخ خالی است ولی `total_duration_minutes` همچنان می‌آید ✅ | +| نگه‌داشتن محاسبهٔ فرانت به‌عنوان تخمین + اصلاح در مرحلهٔ بعد | بیمار دو عدد متفاوت می‌بیند ❌ | + +**انتخاب: گزینهٔ اول**، با یک تفاوت مهم — مدت **تخمینی** برچسب می‌گیرد تا وقتی روز +انتخاب نشده: + +```js +// مرحلهٔ انتخاب سرویس +const { data } = useServiceDuration(doctorUuid, clinicUuid, draft); // hook جدید +const minutes = data?.total_duration_minutes ?? fallbackSum(draft); // fallback با console.warn + +مدت تقریبی: {minutes} دقیقه // پیش از انتخاب روز +مدت نوبت: {minutes} دقیقه // پس از انتخاب روز، از همان پاسخ +``` + +`fallbackSum` فقط برای بک‌اند قدیمی است و `console.warn` می‌زند. حذفش پس از deploy تسک ۰۰. + +## ۳. `adaptServiceSlots` شیفت‌آگاه + +مشکل: همهٔ زمان‌ها در یک تب با برچسب ثابت «زمان‌های خالی» جمع می‌شوند و `end_time` +اشتباه است (پایانِ آخرین **شروع**، نه پایان نوبت). + +```js +// هدف — گروه‌بندی بر اساس شکاف زمانی، با برچسب واقعی +export function adaptServiceSlots(slotsResponse) { + const payload = slotsResponse?.data ?? slotsResponse ?? {}; + const starts = payload.start_times ?? []; + if (!starts.length) return []; + + const durationMin = Number(payload.total_duration_minutes) || 0; + const GAP_THRESHOLD_MIN = 60; // شکاف بیشتر از یک ساعت = شیفت جدا + + const groups = []; + let current = null; + + for (const s of starts) { + const gapMin = current + ? (s.start - current.slots[current.slots.length - 1].start) / 60 + : Infinity; + + if (!current || gapMin > GAP_THRESHOLD_MIN) { + current = { slots: [] }; + groups.push(current); + } + current.slots.push({ ...s, is_available: true }); + } + + return groups.map((g) => { + const first = g.slots[0]; + const last = g.slots[g.slots.length - 1]; + const endTime = last.end_time ?? addMinutes(last.start_time, durationMin); + return { + start_time: first.start_time, + end_time: endTime, + label: `${first.start_time} - ${endTime}`, // ← همان قالب حالت اسلاتی + slots: g.slots, + }; + }); +} +``` + +`end_time` هر start از قبل در پاسخ بک‌اند هست (`getServiceStartTimes` هر آیتم را با +`end_time` می‌دهد) — پس `addMinutes` فقط fallback است. + +**چرا آستانهٔ شکاف و نه اطلاعات شیفت از بک‌اند؟** پاسخ `appointment-service-slots` +امروز فقط `start_times` مسطح می‌دهد و شیفت را نمی‌گوید. دو راه بود: + +| راه | ارزیابی | +|---|---| +| افزودن گروه‌بندی شیفت به پاسخ بک‌اند | درست‌تر، ولی تغییر قرارداد endpoint که سه کلاینت مصرفش می‌کنند — و تسک ۰۰ آن را قفل نکرده ولی بازش هم نکرده | +| **گروه‌بندی هیوریستیک در فرانت** ✅ | بدون تغییر قرارداد؛ برای شیفت صبح/عصر (شکاف معمولاً ۲-۳ ساعت) دقیق است | + +انتخاب دوم برای این تسک. اگر بعداً دقت کافی نبود، تسک ۰۶ که `AvailabilityEngine` را +می‌سازد می‌تواند گروه‌بندی واقعی را در پاسخِ **endpoint جدید** بدهد — بدون دست زدن به +این یکی. این تصمیم را در `docs/api/appointment.md` سمت بک‌اند هم یادداشت کن. + +⛔ `adaptSlots()` (حالت اسلاتی) **یک خط هم** عوض نمی‌شود. + +## ۴. سرویس و مدت در پنل کاربر + +`GET /api/v1/appointments/user` از قبل چه می‌دهد؟ **پیش از کدنویسی بررسی کن.** اگر +`service_items` و `service_total_minutes` در پاسخ نیست: + +- ستون `service_total_minutes` در تسک ۰۰ اضافه شد ✅ +- افزودنش به سریالایزر پاسخ، **بخشی از تسک ۰۰** است (ردیف ۱.۱۶ چک‌لیستش) +- اگر جا افتاده، اینجا به‌عنوان یک ردیف ⚠️ ثبت و به تسک ۰۰ برگردان + +```jsx +// Card.js — دو خط جدید، فقط وقتی داده هست +{turn.service_items?.length > 0 && ( + {turn.service_items.map((s) => s.name).join("، ")} +)} +{turn.service_total_minutes && ( + {turn.service_total_minutes} دقیقه +)} +``` + +شرط `&&` اجباری است: نوبت اسلاتی این دو را ندارد و کارتش باید **دقیقاً** مثل امروز +بماند. نوبت سرویسیِ قدیمی هم ممکن است `service_items` خالی داشته باشد → نام «—». + +## ۵. جابه‌جایی سرویس‌آگاه از پنل + +`services/response.js` سه متد جدید می‌گیرد: + +```js +serviceReschedule: (uuid, body) => + request.post(`api/v1/appointment/${uuid}/service-reschedule`, body, { requireAuth: true }), + +getServiceSlotsForReschedule: (doctor_uuid, date, service_uuids, exclude_uuid, clinic_uuid) => + request.get( + `api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` + + service_uuids.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`).join("") + + `&exclude_appointment_uuid=${exclude_uuid}` + clinicQuery(clinic_uuid), + { requireAuth: true } + ), +``` + +`ButtonData.js` یک دکمهٔ «جابه‌جایی» می‌گیرد که مودال موجود +(`isTurnsDetails/modal/index.js`) را با کامپوننت انتخاب زمان باز می‌کند — همان +`components/appointment/date/` بازاستفاده می‌شود، نه یک انتخابگر جدید. + +بیمار **مدت را وارد نمی‌کند**: `service-reschedule` فقط `start` می‌گیرد و مدت را از +سرویس‌های موجود نوبت حساب می‌کند (تسک ۰۰، بخش `ServiceRescheduleService`). + +## UI — قواعد اجباری + +رجوع: [_shared/ui-conventions.md](../_shared/ui-conventions.md)، بخش `nobat724_front` + +- تم MUI از `mui/index.js` — تم جدید نساز +- فونت فقط Vazir از `app/globals.css` +- `darkMode: "class"`؛ صفحات عمومی `data-theme`، پنل `class` — هر دو بررسی شوند +- کامپوننت‌های موجود `components/appointment/*` توسعه داده شوند، مسیر موازی نه +- تاریخ شمسی با `jalali-moment` +- RTL — `ms-*`/`me-*` +- هر صفحه‌ای که دست خورد، `generateMetadata` و `await params` سالم بماند diff --git a/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md b/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md new file mode 100644 index 00000000..9ebb3afc --- /dev/null +++ b/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md @@ -0,0 +1,123 @@ +# چک‌لیست — تسک ۰۰ب (سازگارسازی nobat724_front) + +**وضعیت کلی:** ⏳ شروع نشده +**آخرین بازبینی:** — + +قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · +خط سرخ‌ها: [_shared/red-lines.md](../_shared/red-lines.md) · +UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) + +--- + +## ۰. خط سرخ — مسیر اسلاتی سایت + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۰.۱ | `adaptSlots()` یک خط هم عوض نشد | ⏳ | | +| ۰.۲ | رندر تب‌های شیفت در حالت اسلاتی دست‌نخورده | ⏳ | | +| ۰.۳ | مسیر رزرو اسلاتی سرتاسر دستی تست شد — بیت‌به‌بیت مثل قبل | ⏳ | سناریو ۳ | +| ۰.۴ | کارت نوبت اسلاتی در پنل بدون تغییر | ⏳ | سناریو ۵ | + +## ۱. پیش‌بررسی قرارداد API + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۱.۱ | `appointments/user` فیلد `service_items` دارد | ⏳ | اگر نه → به تسک ۰۰ برگردان | +| ۱.۲ | `appointments/user` فیلد `service_total_minutes` دارد | ⏳ | همان | +| ۱.۳ | هر `start_times[i]` فیلد `end_time` دارد | ⏳ | | +| ۱.۴ | `total_duration_minutes` و `buffer_minutes` در پاسخ هستند | ⏳ | | +| ۱.۵ | `exclude_appointment_uuid` روی `appointment-service-slots` کار می‌کند | ⏳ | تسک ۰۰ ساخته | + +## ۲. انتخاب سرویس — دیزاین و منطق + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۲.۱ | چهار رنگ hard-code (`#3B3B3B` `#7A7A7A` `#5559CE` `bg-white`) حذف شد | ⏳ | | +| ۲.۲ | کلاس‌ها از همان الگوی `location/` و `date/` کپی شد، توکن جدید ساخته نشد | ⏳ | | +| ۲.۳ | ساختار DOM و رفتار toggle عوض نشد | ⏳ | فقط منبع رنگ | +| ۲.۴ | محاسبهٔ `reduce` مدت از فرانت حذف شد | ⏳ | | +| ۲.۵ | مدت از `total_duration_minutes` بک‌اند می‌آید | ⏳ | | +| ۲.۶ | `fallbackSum` با `console.warn` — موقت، تسک مقصد حذفش ثبت شد | ⏳ | | +| ۲.۷ | برچسب «مدت تقریبی» پیش از انتخاب روز، «مدت نوبت» پس از آن | ⏳ | | +| ۲.۸ | انتخاب صفر سرویس → دکمهٔ ادامه غیرفعال با راهنمای فارسی | ⏳ | | +| ۲.۹ | محل سرویسی بدون سرویس `bookable` → پیام روشن + پیشنهاد محل دیگر | ⏳ | | + +## ۳. `adaptServiceSlots` شیفت‌آگاه + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۳.۱ | گروه‌بندی بر اساس شکاف زمانی پیاده شد | ⏳ | | +| ۳.۲ | آستانه = `max(60, durationMin)` | ⏳ | وگرنه نوبت بلند به تب‌های تک‌عضوی می‌شکند | +| ۳.۳ | برچسب واقعی `"HH:MM - HH:MM"` — نه «زمان‌های خالی» ثابت | ⏳ | | +| ۳.۴ | `end_time` از پاسخ بک‌اند، `addMinutes` فقط fallback | ⏳ | | +| ۳.۵ | کامنت: هیوریستیک است، راه دقیق endpoint تسک ۰۶ | ⏳ | | +| ۳.۶ | `start_times` خالی → `[]` و پیام دلیل‌دار در UI | ⏳ | | + +## ۴. پنل کاربر — سرویس، مدت، جابه‌جایی + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۴.۱ | `Card.js` نام سرویس‌ها را نشان می‌دهد (با شرط `&&`) | ⏳ | | +| ۴.۲ | `Card.js` مدت را نشان می‌دهد (با شرط `&&`) | ⏳ | | +| ۴.۳ | `DetailLg.js` و `DetailSm.js` هر دو | ⏳ | | +| ۴.۴ | نوبت رزرو: فقط سرویس، بدون مدت | ⏳ | زمان ندارد | +| ۴.۵ | نوبت سرویسی بدون `service_items` → «—»، بدون کرش | ⏳ | | +| ۴.۶ | `services/response.js`: `serviceReschedule` اضافه شد | ⏳ | | +| ۴.۷ | `services/response.js`: `getServiceSlotsForReschedule` با `exclude_appointment_uuid` | ⏳ | | +| ۴.۸ | `ButtonData.js` دکمهٔ جابه‌جایی + مودال موجود | ⏳ | | +| ۴.۹ | انتخابگر زمان: `components/appointment/date/` بازاستفاده شد، نه ساخت جدید | ⏳ | | +| ۴.۱۰ | بیمار مدت وارد نمی‌کند — بک‌اند حساب می‌کند | ⏳ | | +| ۴.۱۱ | خطای بک‌اند با پیام فارسی خودش نمایش داده می‌شود | ⏳ | نه «خطای نامشخص» | +| ۴.۱۲ | پس از خطای تداخل، `refetchSlots()` اجرا می‌شود | ⏳ | | + +## ۵. UI — قواعد سایت + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۵.۱ | تم MUI از `mui/index.js` — تم جدید ساخته نشد | ⏳ | | +| ۵.۲ | فونت فقط Vazir — فونت جدید اضافه نشد | ⏳ | | +| ۵.۳ | دارک‌مود صفحات عمومی (`data-theme`) بررسی شد | ⏳ | سناریو ۲ | +| ۵.۴ | دارک‌مود پنل (`class`) بررسی شد | ⏳ | سناریو ۷ — مکانیزم متفاوت | +| ۵.۵ | کامپوننت موازی ساخته نشد؛ `components/appointment/*` توسعه یافت | ⏳ | | +| ۵.۶ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ⏳ | | +| ۵.۷ | موبایل بررسی شد — بدون اسکرول افقی | ⏳ | سناریو ۱۰ | +| ۵.۸ | تاریخ‌ها شمسی با `jalali-moment` | ⏳ | | +| ۵.۹ | همهٔ رشته‌ها فارسی | ⏳ | | +| ۵.۱۰ | صفحاتی که دست خوردند `generateMetadata` و `await params` سالم دارند | ⏳ | | +| ۵.۱۱ | دامنه گسترش نیافت — صفحهٔ رزرو بازطراحی نشد | ⏳ | انحراف بقیهٔ مراحل، اگر بود، ⚠️ ثبت شود | + +## ۶. تست دستی — ده سناریو + +| # | سناریو | وضعیت | یادداشت | +|---|---|---|---| +| ۶.۱ | رزرو سرویسی کامل تا پیامک | ⏳ | | +| ۶.۲ | همان در دارک‌مود عمومی | ⏳ | | +| ۶.۳ | رزرو اسلاتی کامل — بدون تغییر | ⏳ | ⛔ خط سرخ | +| ۶.۴ | پزشک دو-شیفته سرویسی → دو تب با برچسب واقعی | ⏳ | | +| ۶.۵ | پنل با نوبت اسلاتی تنها → بدون تغییر | ⏳ | | +| ۶.۶ | پنل با نوبت سرویسی → سرویس و مدت | ⏳ | | +| ۶.۷ | پنل در دارک‌مود | ⏳ | | +| ۶.۸ | جابه‌جایی سرویسی → مدت حفظ | ⏳ | | +| ۶.۹ | جابه‌جایی به زمان اشغال → پیام فارسی + refetch | ⏳ | | +| ۶.۱۰ | همهٔ موارد بالا روی موبایل | ⏳ | | +| ۶.۱۱ | تست واحد `adaptServiceSlots` (پنج حالت) | ⏳ | تابع خالص، بهترین کاندید | + +## ۷. مستندات + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۷.۱ | `nobat724_front/CLAUDE.md` بخش «حالت‌های نوبت‌دهی» | ⏳ | | +| ۷.۲ | یادداشت هیوریستیک شیفت در `clinicpro/docs/api/appointment.md` | ⏳ | | + +## ۸. بازبینی پایانی + +| # | مورد | وضعیت | یادداشت | +|---|---|---|---| +| ۸.۱ | همهٔ ردیف‌های بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بی‌دلیل) | ⏳ | | +| ۸.۲ | `npm run build` بدون خطا | ⏳ | | +| ۸.۳ | `npm run lint` بدون خطای جدید | ⏳ | | +| ۸.۴ | ده سناریوی دستی بخش ۶ اجرا شد | ⏳ | | +| ۸.۵ | چک‌لیست UI (بخش ۵) کامل شد | ⏳ | | +| ۸.۶ | `clinic-pro-tauri` دستی بررسی شد — قرارداد مشترک نشکسته | ⏳ | همان `service_item` تکی | +| ۸.۷ | commit شد، سپس `graphify update .` | ⏳ | | +| ۸.۸ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | ⏳ | `fallbackSum` | diff --git a/docs/new_feture/taskes/task-00b-nobat724-service-mode/implementation_notes.md b/docs/new_feture/taskes/task-00b-nobat724-service-mode/implementation_notes.md new file mode 100644 index 00000000..c110ca14 --- /dev/null +++ b/docs/new_feture/taskes/task-00b-nobat724-service-mode/implementation_notes.md @@ -0,0 +1,157 @@ +# نکات پیاده‌سازی — تسک ۰۰ب + +## ۱. اول قرارداد پاسخ را بررسی کن، بعد کد بزن + +سه چیز را پیش از شروع تأیید کن: + +```bash +# ۱. پاسخ appointments/user چه فیلدهایی دارد؟ +curl -s -H "Authorization: Bearer $TOKEN" \ + https://clinic-pro.ddev.site/api/v1/appointments/user | jq '.data[0] | keys' + +# ۲. start_times هر آیتم end_time دارد؟ +curl -s "https://clinic-pro.ddev.site/api/v1/appointment-service-slots?doctor_uuid=…&date=…&service_item_uuids[]=…" \ + | jq '.data.start_times[0]' + +# ۳. total_duration_minutes و buffer_minutes در پاسخ هستند؟ +``` + +اگر `service_items` یا `service_total_minutes` در پاسخ `appointments/user` نیست، +**به تسک ۰۰ برگردان** — سریالایزر آنجا اصلاح می‌شود، نه اینکه اینجا از endpoint دیگری +دور بزنیم. + +## ۲. رنگ‌ها را از همسایه کپی کن، نه از حافظه + +```bash +# ببین مرحلهٔ قبلی رزرو چه کلاسی می‌زند +grep -n "className" components/appointment/location/index.js | head -30 +grep -n "className" components/appointment/date/index.js | head -30 +``` + +هدف: کامپوننت انتخاب سرویس **از بقیهٔ مراحل قابل تشخیص نباشد**. اگر بقیه `bg-white` +می‌زنند و توکن ندارند، تو هم توکن جدید نساز — همان کاری را بکن که آن‌ها می‌کنند، +و اگر دارک‌مود در آن‌ها هم شکسته است، این یک مسئلهٔ جدا است که در چک‌لیست ⚠️ ثبت +می‌شود، نه اینکه در این تسک کل صفحهٔ رزرو بازطراحی شود. + +**دامنه را گسترش نده.** فقط `service/index.js` که خودمان اضافه کردیم و از بقیه منحرف است. + +## ۳. `fallbackSum` موقت است و باید هشدار بدهد + +```js +const minutes = data?.total_duration_minutes ?? (() => { + console.warn('[booking] total_duration_minutes missing — falling back to client sum'); + return fallbackSum(draft, services); +})(); +``` + +بدون `console.warn`، بک‌اندی که فیلد را نمی‌دهد بی‌صدا کار می‌کند و شش ماه بعد کسی +نمی‌فهمد چرا مدت با نوبت نمی‌خواند. حذف `fallbackSum` پس از deploy تسک ۰۰ یک ردیف +⏳ در چک‌لیست است با تسک مقصد مشخص. + +## ۴. آستانهٔ شکاف: ۶۰ دقیقه، با دلیل + +```js +const GAP_THRESHOLD_MIN = 60; +``` + +شیفت صبح/عصر معمولاً ۲-۳ ساعت فاصله دارد. یک نوبت ۹۰ دقیقه‌ای هم می‌تواند شکاف ۹۰ +دقیقه‌ای بسازد بدون اینکه شیفت جدا باشد — پس آستانه نباید کمتر از مدت نوبت باشد: + +```js +const threshold = Math.max(GAP_THRESHOLD_MIN, durationMin); +``` + +این خط را فراموش نکن، وگرنه نوبت‌های بلند به تب‌های تک‌عضوی تقسیم می‌شوند. + +هیوریستیک است و در کامنت باید بنویسی: راه دقیق، گروه‌بندی از سمت بک‌اند است که تسک ۰۶ +در endpoint جدید می‌دهد. + +## ۵. شرط `&&` روی فیلدهای سرویسی در پنل + +```jsx +{turn.service_items?.length > 0 && ( … )} +``` + +نه `turn.service_items.map(...)` خالی. نوبت اسلاتی این فیلد را ندارد و بدون شرط، کارت +همهٔ نوبت‌های اسلاتی کرش می‌کند — یعنی کل پنل کاربر می‌شکند، نه فقط یک خط. + +تست: پنل کاربری که **فقط** نوبت اسلاتی دارد باید بدون هیچ تغییری رندر شود. + +## ۶. جابه‌جایی: مودال موجود، انتخابگر موجود + +``` +ButtonData.js → دکمهٔ «جابه‌جایی» → isTurnsDetails/modal/index.js + └─ components/appointment/date/ بازاستفاده +``` + +انتخابگر تاریخ/ساعت جدید نساز. کامپوننت `date/` از قبل هر دو حالت را می‌شناسد +(`adaptSlots` و `adaptServiceSlots`) و همان را با props متفاوت صدا بزن. + +## ۷. خطای بک‌اند را نمایش بده، نه پیام عمومی + +```js +// ❌ +catch { toast.error('خطایی رخ داد'); } + +// ✅ +catch (err) { + const msg = err?.response?.data?.errors?.[0]?.message ?? 'خطایی رخ داد'; + toast.error(msg); + refetchSlots(); // ← فهرست زمان‌ها به‌روز شود +} +``` + +`ERR_SLOT_TAKEN` پیام فارسی دقیق دارد («این بازه زمانی قبلاً رزرو شده است»). نشان دادن +«خطای نامشخص» یعنی بیمار همان دکمه را ده بار می‌زند. `refetchSlots()` بعد از خطای تداخل +اجباری است. + +## ۸. edge case ها + +| حالت | رفتار درست | +|---|---| +| محل سرویسی بدون سرویس `bookable` | پیام روشن + پیشنهاد محل دیگر اگر باشد | +| پزشک اسلاتی در مطب، سرویسی در کلینیک | تعویض محل، مرحلهٔ سرویس را ظاهر/پنهان می‌کند و انتخاب‌ها باطل می‌شوند (رفتار موجود `changeLocation`) | +| `start_times` خالی | پیام دلیل‌دار، نه فهرست خالی | +| `total_duration_minutes` غایب | `fallbackSum` + `console.warn` | +| نوبت سرویسی قدیمی بدون `service_items` | نام «—»، مدت اگر هست نمایش، بدون کرش | +| پنل کاربری فقط با نوبت اسلاتی | بیت‌به‌بیت مثل امروز | +| نوبت رزرو (`is_reserve`) در پنل | مدت نمایش داده نشود (زمان ندارد)، فقط سرویس‌ها | +| جابه‌جایی به زمان اشغال‌شده | پیام فارسی بک‌اند + `refetch` | +| دارک‌مود در پنل (`class`) و صفحات عمومی (`data-theme`) | هر دو بررسی شوند — دو مکانیزم متفاوت‌اند | +| یک سرویس با `duration_minutes = null` | بک‌اند `422` می‌دهد؛ UI پیامش را نشان دهد و آن سرویس را برجسته کند | + +## ۹. تست + +پروژه تست خودکار محدودی دارد. سناریوهای دستی اجباری (در چک‌لیست ثبت شوند): + +``` +۱. رزرو سرویسی کامل: انتخاب محل سرویسی → سرویس → روز → ساعت → ثبت → پیامک +۲. همان مسیر در دارک‌مود (صفحات عمومی، data-theme) +۳. رزرو اسلاتی کامل — باید بیت‌به‌بیت مثل قبل باشد +۴. پزشک با دو شیفت در حالت سرویسی → دو تب زمانی با برچسب واقعی +۵. پنل کاربر با نوبت اسلاتی تنها → بدون تغییر +۶. پنل کاربر با نوبت سرویسی → سرویس‌ها و مدت دیده می‌شود +۷. پنل کاربر در دارک‌مود (class) +۸. جابه‌جایی نوبت سرویسی → مدت حفظ می‌شود +۹. جابه‌جایی به زمان اشغال‌شده → پیام فارسی + refetch +۱۰. موبایل: هر ده مورد بالا، بدون اسکرول افقی +``` + +اگر تست خودکار اضافه می‌کنی، `adaptServiceSlots` تابع خالص است و بهترین کاندید: + +``` +tests/appointmentSlots.test.js + - یک شیفت → یک گروه + - دو شیفت با شکاف ۳ ساعت → دو گروه با برچسب درست + - نوبت ۹۰ دقیقه‌ای با شکاف ۹۰ دقیقه → یک گروه (آستانه = max(60, duration)) + - start_times خالی → [] + - end_time از پاسخ می‌آید، نه محاسبه +``` + +## ۱۰. مستندات + +`nobat724_front/CLAUDE.md` یک بخش کوتاه «حالت‌های نوبت‌دهی» بگیرد: `slot` و `service`، +اینکه per محل تعیین می‌شوند، و اینکه `adaptSlots`/`adaptServiceSlots` نقطهٔ تفکیک‌اند. + +در `clinicpro/docs/api/appointment.md` یادداشت کن که گروه‌بندی شیفت در حالت سرویسی +هیوریستیک سمت فرانت است و راه دقیقش endpoint تسک ۰۶ است. diff --git a/docs/new_feture/taskes/task-00b-nobat724-service-mode/task.md b/docs/new_feture/taskes/task-00b-nobat724-service-mode/task.md new file mode 100644 index 00000000..d8498f5d --- /dev/null +++ b/docs/new_feture/taskes/task-00b-nobat724-service-mode/task.md @@ -0,0 +1,136 @@ +# تسک ۰۰ب — سازگارسازی nobat724_front با وضعیت فعلی نوبت‌دهی سرویسی + +**پروژه:** `nobat724_front` (سایت عمومی) · **فاز:** ۰ · **وابستگی:** ۰۰ · **زمان:** ۱۰-۱۴ ساعت +**پیش‌نیاز همهٔ تسک‌های ۰۱ به بعد** + +--- + +## ⛔ خط سرخ + +مسیر اسلاتی سایت دست‌کاری نمی‌شود: `adaptSlots()`، رندر تب‌های شیفت، و همهٔ رفتار +`booking_mode === 'slot'` عیناً می‌ماند. +رجوع: [_shared/red-lines.md](../_shared/red-lines.md) + +--- + +## هدف + +سایت حالت سرویسی را **می‌شناسد** ولی سه دسته مشکل دارد: انحراف از دیزاین‌سیستم، +محاسبهٔ موازی مدت در فرانت، و نبود سرویس/مدت در پنل کاربر. این تسک همه را می‌بندد و +سایت را با endpoint های جدید تسک ۰۰ هم‌گام می‌کند. + +## وضعیت فعلی + +### ✅ کار می‌کند + +| مورد | فایل | +|---|---| +| تشخیص حالت per محل | `components/appointment/index.js:125` — `selectedLocation?.booking_mode === "service"` | +| مرحلهٔ انتخاب سرویس | `components/appointment/service/index.js` | +| فراخوانی endpoint ها | `services/response.js:78,83` | +| تبدیل پاسخ به قالب اسلات | `lib/appointmentSlots.js` → `adaptServiceSlots()` | +| ارسال سرویس‌ها در ثبت | `components/appointment/detail/SubmitData.js:152` | +| JSON-LD `availableService` با `estimatedDuration` | `app/doctor/[slug]/page.js:212` | +| باطل‌کردن انتخاب‌ها با تعویض محل | `changeLocation()` در `index.js` | + +### ❌ مشکلات این تسک + +**۱. انحراف از دیزاین‌سیستم — رنگ‌های hard-code.** + +`components/appointment/service/index.js`: + +```jsx +

۱. انتخاب سرویس

+

+className={active + ? "border-[#5559CE] bg-[#5559CE]/5" + : "border-gray-200 bg-white hover:border-[#5559CE]"} +``` + +چهار رنگ hard-code. سایت `darkMode: "class"` دارد و صفحات عمومی با `data-theme` تم +عوض می‌کنند — این کامپوننت در دارک‌مود می‌شکند. بقیهٔ مراحل رزرو از تم MUI/Tailwind +استفاده می‌کنند و این یکی نمی‌کند. + +**۲. محاسبهٔ موازی مدت در فرانت.** + +```js +// components/appointment/service/index.js +const totalMinutes = services + .filter((s) => draft.includes(s.uuid)) + .reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0); +``` + +بک‌اند همان عدد را در `total_duration_minutes` پاسخ `appointment-service-slots` +برمی‌گرداند. دو محاسبه یعنی: وقتی تسک ۰۴ فرمول را به «زمان تنها / زمان اضافه» عوض کند، +سایت عدد قدیمی نشان می‌دهد و بیمار مدتی می‌بیند که با مدت واقعی نوبتش نمی‌خواند. + +**۳. `adaptServiceSlots` برچسب گمراه‌کننده می‌سازد.** + +```js +return [{ + start_time: starts[0].start_time, + end_time: starts[starts.length - 1].start_time, // ← پایانِ آخرین شروع، نه پایان نوبت + label: "زمان‌های خالی", + slots: …, +}]; +``` + +همهٔ زمان‌ها در یک تب جمع می‌شوند و مرز شیفت‌ها (صبح/عصر) از بین می‌رود — در حالی که +حالت اسلاتی همان اطلاعات را از بک‌اند دارد و نشان می‌دهد. برای پزشکی با شیفت صبح و عصر، +بیمار یک فهرست بلند بی‌ساختار می‌بیند. + +**۴. پنل کاربر سرویس و مدت نوبت را نشان نمی‌دهد.** + +`components/dashboard/userAccount/sidebars/turns/Card.js` و `isTurnsDetails/*` هیچ ارجاعی +به `service` یا مدت ندارند. بیمار نوبت سرویسی گرفته و در پنلش نمی‌بیند چه سرویسی رزرو +کرده یا نوبتش چند دقیقه است. + +**۵. جابه‌جایی نوبت در پنل کاربر، سرویس‌آگاه نیست.** + +پس از تسک ۰۰، endpoint `POST /appointment/{uuid}/service-reschedule` وجود دارد. +`ButtonData.js` هیچ مسیری برای جابه‌جایی ندارد. + +## دامنه + +**هست:** +- بازنویسی `components/appointment/service/index.js` با توکن‌های تم (بدون تغییر رفتار) +- حذف محاسبهٔ مدت از فرانت — مصرف `total_duration_minutes` بک‌اند +- `adaptServiceSlots` گروه‌بندی per شیفت +- نمایش سرویس‌ها و مدت در کارت و جزئیات نوبت پنل کاربر +- جابه‌جایی سرویس‌آگاه از پنل کاربر +- به‌روزرسانی `services/response.js` برای endpoint های جدید تسک ۰۰ + +**نیست:** تغییری در مسیر اسلاتی · حالت `resource` (تسک ۰۶ و پس از آن، یک تسک frontend جدا) + +## معیار پذیرش + +- ✅ موفق: مرحلهٔ انتخاب سرویس در دارک‌مود درست رندر می‌شود (هیچ متن سیاه روی زمینهٔ + تیره، هیچ کارت سفید). +- ✅ موفق: مدت نمایش‌داده‌شده در مرحلهٔ انتخاب سرویس **از پاسخ بک‌اند** می‌آید؛ اگر + بک‌اند عدد متفاوتی بدهد، UI همان را نشان می‌دهد. +- ✅ موفق: پزشکی با دو شیفت (صبح ۹-۱۳، عصر ۱۶-۲۰) در حالت سرویسی → دو تب زمانی، + با برچسب واقعی هر شیفت. +- ✅ موفق: کارت نوبت در پنل کاربر نام سرویس‌ها و مدت را نشان می‌دهد؛ نوبت اسلاتی + دقیقاً مثل امروز (بدون این دو خط). +- ✅ موفق: بیمار از پنل نوبت سرویسی‌اش را جابه‌جا می‌کند → مدت خودکار حفظ می‌شود، + بیمار عددی وارد نمی‌کند. +- ❌ خطا: جابه‌جایی به زمان اشغال‌شده → پیام فارسی از بک‌اند نمایش داده می‌شود + (نه «خطای نامشخص»)، و فهرست زمان‌ها خودکار به‌روز می‌شود. +- ❌ خطا: انتخاب صفر سرویس → دکمهٔ ادامه غیرفعال با راهنمای فارسی. +- ⚠️ مرزی: محلی که `booking_mode = 'service'` است ولی هیچ سرویس `bookable` ندارد → + پیام روشن («سرویسی برای نوبت‌دهی آنلاین تعریف نشده است») + پیشنهاد محل دیگر اگر باشد. +- ⚠️ مرزی: پزشک در مطب شخصی اسلاتی و در کلینیک سرویسی → تعویض محل، مرحلهٔ سرویس را + ظاهر/پنهان می‌کند و انتخاب‌های قبلی باطل می‌شوند (رفتار موجود، حفظ شود). +- ⚠️ مرزی: پاسخ `appointment-service-slots` خالی → پیام دلیل‌دار، نه فهرست خالی بی‌توضیح. +- ⚠️ مرزی: نوبت قدیمی سرویسی بدون `service_items` → کارت مدت را نشان می‌دهد و نام + سرویس را «—»؛ کرش نمی‌کند. +- ⚠️ مرزی: `total_duration_minutes` در پاسخ نبود (بک‌اند قدیمی) → fallback به محاسبهٔ + فرانت با یک `console.warn`، نه صفحهٔ خالی. + +## خروجی + +- `components/appointment/service/index.js` بازنویسی‌شده با توکن تم +- `lib/appointmentSlots.js` — `adaptServiceSlots` شیفت‌آگاه +- `components/dashboard/userAccount/sidebars/turns/*` — سرویس و مدت +- `services/response.js` — endpoint های جدید +- [checklist.md](checklist.md) کامل‌شده