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.
This commit is contained in:
hamed
2026-07-30 11:56:08 +03:30
parent 021d0eb6b2
commit 158dcb58aa
12 changed files with 1846 additions and 0 deletions
@@ -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 · کلاس/کامپوننت چندمسئولیتی ننویس
@@ -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)
@@ -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
// ❌
<div style={{ background: '#5559CE', borderRadius: 14 }}>
// ✅
<div className="bg-[var(--primary)] rounded-[var(--r)]">
```
| گروه | توکن |
|---|---|
| برند | `--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`، هرگز `<select>` بومی.** بی‌استثنا.
2. **دکمهٔ بازگشت در هر زیرصفحه.** صفحه‌ای که از دل صفحهٔ دیگر باز می‌شود:
- با `PageHeader` → فقط `backTo="/admin/…"` بده
- بی `PageHeader``<BackButton fallback="/admin/…" />` بالای هدر
- دکمهٔ دست‌ساز نساز. رفتار در `hooks/useGoBack.ts` متمرکز است.
3. **وضعیت لیست در URL، نه در `useState`.** جستجو، فیلتر، شمارهٔ صفحه، نما — همه با
`hooks/useUrlState.ts`:
```tsx
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
```
دلیلش «بازگشت» است: `navigate(-1)` همان URL را برمی‌گرداند. فیلد جستجوی debounce‌شده
local می‌ماند، فقط مقدار نهایی به URL می‌رود.
4. **داده فقط با TanStack Query.** `useQuery`/`useMutation`، کلید `['resource', page, filters]
همهٔ HTTP از `lib/api.ts`. استخراج: paginated → `data?.data` + `data?.meta?.totalRecords
single → `data?.data`؛ Category → `data?.data?.data ?? []`.
5. **فرم با React Hook Form + Zod.** `z.object({...})` و تایپ با `z.infer`.
### فارسی، RTL، شمسی
- همهٔ رشته‌های UI فارسی — از فایل i18n، نه inline
- تاریخ‌ها شمسی با `formatDate`/`formatDateTime` از `lib/utils.ts` (پشت‌صحنه `jalaali-js`)
- مبالغ با `formatRial`/`formatNumber`
- جهت RTL — `ms-*`/`me-*` به‌جای `ml-*`/`mr-*`
- فونت `Vazirmatn` از `@fontsource/vazirmatn`
- آیکن‌ها Heroicons v2 · نمودار Recharts · تُست `sonner`
### نام‌گذاری و مسیر
- صفحه: `assets/admin/pages/XxxPage.tsx` · کامپوننت PascalCase · هوک `useXxx.ts`
- کامپوننت عمومی → `components/ui/` · کامپوزیت مخصوص فیچر → `components/*.tsx`
- تایپ‌های مشترک → `types/index.ts`
- مسیر در `App.tsx` (React Router v7)، زیر `/admin/*`
---
## سایت عمومی `nobat724_front` — Next.js 15 App Router + MUI v5 + Tailwind
- تم MUI در `mui/index.js` با `direction: rtl` و فونت Vazir — تم جدید نساز
- Tailwind با `darkMode: "class"`؛ صفحات عمومی `data-theme`، پنل `class`
(`next-themes` در `app/Providers.js`)
- **فونت فقط Vazir** — در `app/globals.css` با `@font-face`. فونت دیگر اضافه نکن.
- هر صفحه باید `generateMetadata` صادر کند · همیشه `await params`
- شهر از subdomain: server-side `lib/getStateInfo.js` · client-side `useProvince()`
- فراخوانی API: `services/response.js` → `request.*`؛ برای auth `{ requireAuth: true }`
- داده server-side: `lib/req.js` → `fetchReq(url)`
- slug پزشک/کلینیک = `uuid`
- JSON-LD مستقیم در JSX صفحات doctor/clinic/blog
- تاریخ شمسی با `jalali-moment` / `dayjs`
- کامپوننت‌های موجود `components/appointment/*` را توسعه بده، مسیر موازی نساز
---
## چک‌لیست UI — در `checklist.md` هر تسکی که صفحه یا کامپوننت می‌سازد
هر ردیف باید یکی از ✅ / 🔄 / ⏳ / ⚠️ بگیرد:
```
□ هیچ رنگ/شعاع/سایهٔ hard-code نیست — همه از توکن‌های styles.css
□ دارک‌مود بررسی شد (data-theme="dark") و چیزی نمی‌شکند
□ حالت فشرده بررسی شد (data-density="compact")
□ همهٔ select ها SearchableSelect اند، هیچ <select> بومی نیست
□ زیرصفحه‌ها backTo یا <BackButton /> دارند
□ وضعیت لیست (جستجو/فیلتر/صفحه) در URL است با useUrlState
□ لیست‌ها از DataTable استفاده می‌کنند با skeleton و empty state فارسی
□ هیچ کامپوننت موازیِ چیزی که در components/ui/ هست ساخته نشد
□ همهٔ رشته‌ها فارسی و از i18n
□ تاریخ‌ها شمسی با formatDate · مبالغ با formatRial
□ RTL بررسی شد (ms/me نه ml/mr)
□ موبایل بررسی شد (بدون اسکرول افقی)
□ فرم‌ها با React Hook Form + Zod
□ داده با TanStack Query و استخراج envelope درست
□ خطاها با پیام فارسی از ErrorCodes نمایش داده می‌شوند
```
@@ -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<string,int> $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 && (
<>
<PersianDateInput value={date} onChange={setDate} label="تاریخ" />
<TimeField value={start} onChange={setStart} label="ساعت شروع" />
<TimeField value={end} onChange={setEnd} label="ساعت پایان" />
</>
)}
// حالت سرویسی: انتخاب چند سرویس + picker زمان
{isServiceMode && (
<ServiceSlotPicker
doctorUuid={doctorUuid}
clinicUuid={clinicUuid}
services={services}
selectedUuids={serviceUuids}
onServicesChange={setServiceUuids}
date={date}
onDateChange={setDate}
picked={pickedSlot}
onPick={setPickedSlot}
excludeAppointmentUuid={uuid} // ← prop جدید
management
/>
)}
```
`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` چندانتخابی — نه `<select multiple>`
- تاریخ با `PersianDateInput` موجود
- `ReserveAppointmentsPage` جدول خام دارد (`<td style={td}>`) — در همین تسک به
`DataTable` مهاجرت کند، چون داریم دستش می‌زنیم و توکن‌های inline خلاف قاعده‌اند
- مدت و بافر با فارسی و واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)»
@@ -0,0 +1,118 @@
# چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro)
**وضعیت کلی:** ⏳ شروع نشده
**آخرین بازبینی:**
قواعد: [_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)
---
## ۰. خط سرخ — منطق اسلاتی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `SlotModeFrozenTest` + سه fixture ساخته شد **پیش از** هر تغییر کد | ⏳ | ترتیب مهم است |
| ۰.۲ | fixture ها با تاریخ ثابت‌اند، نه `time()` | ⏳ | |
| ۰.۳ | کامنت «read-only، هیچ تسکی به‌روزش نمی‌کند» بالای هر سه fixture | ⏳ | |
| ۰.۴ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | ⏳ | فقط پارامتر اختیاری `excludeAppointmentId` روی `getServiceStartTimes` |
| ۰.۵ | `GET /appointment-slots` بیت‌به‌بیت دست‌نخورده | ⏳ | |
| ۰.۶ | `GET /month-availability/{doctorUuid}` دست‌نخورده | ⏳ | |
| ۰.۷ | `active_slot_key` و `refreshActiveSlotKey()` دست‌نخورده | ⏳ | تنها استثنا: صدا زدنش در `setIsReserve` |
| ۰.۸ | `isSlotTaken` امضا و معنا دست‌نخورده | ⏳ | |
| ۰.۹ | `--group=slot-mode-frozen` در پایان سبز | ⏳ | |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `ServiceBookingCalculator` ساخته شد | ⏳ | |
| ۱.۲ | `serviceSlots()` موجود از آن استفاده می‌کند، **خروجی‌اش عوض نشده** | ⏳ | |
| ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ⏳ | |
| ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | ⏳ | |
| ۱.۵ | `PATCH /appointment/{uuid}` توسعه یافت — منطق جدید داخل `isServiceMode()` | ⏳ | |
| ۱.۶ | `PATCH` مقدار `service_item_uuids[]` می‌پذیرد | ⏳ | |
| ۱.۷ | `ReserveConversionService` + `POST /appointment/{uuid}/convert-reserve` | ⏳ | |
| ۱.۸ | نوبت رزرو در حالت سرویسی سرویس‌ها را ذخیره می‌کند | ⏳ | |
| ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | ⏳ | همان الگوی `isSlotTaken` |
| ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ⏳ | |
| ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ⏳ | چهار مصرف‌کننده رویش خوانده‌اند |
| ۱.۱۲ | `setIsReserve()` صدا زدن `refreshActiveSlotKey()` | ⏳ | |
| ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ⏳ | |
| ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ⏳ | |
| ۱.۱۵ | `allowInactive` فقط برای سرویس‌های موجود نوبت، نه uuid های تازه | ⏳ | |
| ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ⏳ | |
## ۲. دیتابیس و مهاجرت
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | `service_total_minutes` و `service_buffer_minutes` (تهی‌پذیر) | ⏳ | |
| ۲.۲ | هیچ ستون موجودی حذف/تغییر نوع/تغییر معنا نداد | ⏳ | |
| ۲.۳ | دو کد خطای جدید در `ErrorCodes.php` با پیام فارسی | ⏳ | شمارهٔ واقعی از خود فایل |
| ۲.۴ | `app:appointment:backfill-service-duration` — dry-run پیش‌فرض، idempotent | ⏳ | |
| ۲.۵ | backfill مقدار را از خود نوبت می‌گیرد، نه بازمحاسبه از سرویس‌ها | ⏳ | |
| ۲.۶ | migration اجرا شد و `TenantSchemaCoverageTest` سبز | ⏳ | |
## ۳. UI — پنل ادمین
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `AppointmentEditPage`: حالت سرویسی `ServiceSlotPicker` نشان می‌دهد | ⏳ | |
| ۳.۲ | `AppointmentEditPage`: حالت اسلاتی **دقیقاً** رفتار امروز | ⏳ | سه فیلد ساعت |
| ۳.۳ | ورودی دستی ساعت در حالت سرویسی **پنهان**، نه disabled | ⏳ | |
| ۳.۴ | `ServiceSlotPicker` موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد | ⏳ | فقط prop `excludeAppointmentUuid` |
| ۳.۵ | `ReserveAppointmentsPage`: سرویس‌ها + دکمهٔ تبدیل | ⏳ | |
| ۳.۶ | `ReserveAppointmentsPage` از جدول خام به `DataTable` مهاجرت کرد | ⏳ | `<td style={td}>` حذف شد |
| ۳.۷ | مدت و بافر فارسی با واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)» | ⏳ | |
| ۳.۸ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن‌های `styles.css` | ⏳ | |
| ۳.۹ | دارک‌مود (`data-theme="dark"`) بررسی شد | ⏳ | |
| ۳.۱۰ | حالت فشرده (`data-density="compact"`) بررسی شد | ⏳ | |
| ۳.۱۱ | انتخاب چند سرویس با `SearchableSelect`؛ هیچ `<select>` بومی | ⏳ | |
| ۳.۱۲ | `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 .` | ⏳ | |
| ۶.۱۲ | موارد به‌تعویق‌افتاده با دلیل و تسک مقصد ثبت شدند | ⏳ | |
@@ -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` باید بدون تغییر سبز بماند.
@@ -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`
صفحه امروز جدول خام با `<td style={td}>` دارد. چون در این تسک دستش می‌زنیم، همان‌جا به
`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` که ماتریس کامل را ثبت کند —
تسک ۰۶ حالت سومی به همین ماتریس اضافه می‌کند.
@@ -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) کامل‌شده
@@ -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
// وضعیت فعلی
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب سرویس</h2>
className={active ? "border-[#5559CE] bg-[#5559CE]/5"
: "border-gray-200 bg-white hover:border-[#5559CE]"}
```
```jsx
// هدف — همان ساختار DOM، رنگ از تم
<h2 className="text-base font-bold text-foreground mb-4">۱. انتخاب سرویس</h2>
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
<span>مدت تقریبی: {minutes} دقیقه</span> // پیش از انتخاب روز
<span>مدت نوبت: {minutes} دقیقه</span> // پس از انتخاب روز، از همان پاسخ
```
`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 && (
<span className="…">{turn.service_items.map((s) => s.name).join("، ")}</span>
)}
{turn.service_total_minutes && (
<span className="…">{turn.service_total_minutes} دقیقه</span>
)}
```
شرط `&&` اجباری است: نوبت اسلاتی این دو را ندارد و کارتش باید **دقیقاً** مثل امروز
بماند. نوبت سرویسیِ قدیمی هم ممکن است `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` سالم بماند
@@ -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` |
@@ -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 تسک ۰۶ است.
@@ -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
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب سرویس</h2>
<p className="text-[14px] text-[#7A7A7A]"></p>
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) کامل‌شده