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:
@@ -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) کاملشده
|
||||||
Reference in New Issue
Block a user