# انتقال برنامه کاری، اصلاح فرم تنظیمات نوبت، فیلدهای عددی لاتین، رفع باگ هزینه ویزیت در مودال، و لاگ/تایم‌لاین لغو نوبت

## پروژه

`clinicpro` (backend Symfony 7.4 + پنل ادمین React 19 داخل Webpack Encore). تک-ریپو، cross-repo نیست.

> تست: پنل ادمین همیشه با `09390039833 / 09390039833`. اجرا داخل ddev (`ddev exec ...`, `https://clinic-pro.ddev.site`).

## زمینه

فیچر «الزامی کردن هزینه ویزیت» قبلاً پیاده شده (پرامپت `require-visit-price-setting.md`): فلگ روی `EntityInsurancePricing.require_visit_price` ذخیره می‌شود و کنترلر ایجاد نوبت با `VisitPriceRequirementResolver` آن را چک می‌کند. اما چند مشکل UX/باگ باقی مانده و همچنین دو تغییر ساختاری (انتقال برنامه کاری و لاگ لغو) لازم است. این پرامپت ۵ تسک مستقل ولی هم‌حوزه را پوشش می‌دهد؛ **هر تسک را جدا پیاده‌سازی، تست و کامیت کن**.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه `/admin/appointment-settings` (۵۰ خط) — مقصد برنامه کاری، محل دکمه ذخیره |
| `assets/admin/pages/DoctorProfilePage.tsx` | wrapper پروفایل → `<DoctorDetailPage isOwnProfile />` |
| `assets/admin/pages/DoctorDetailPage.tsx` | تعریف `ScheduleSection` (L2058-2089)، رندر آن L2899، `WeeklyScheduleTab` (export L1252) |
| `assets/admin/components/FreeVisitPrice.tsx` | کارت قیمت ویزیت + toggle الزامی + دکمه ذخیره (L66-68) |
| `assets/admin/pages/AppointmentsPage.tsx` | صفحه `/admin/appointments` + `NewAppointmentModal` (L102، رندر L627) — **باگ هزینه ویزیت اینجاست** |
| `assets/admin/pages/AppointmentCreatePage.tsx` | صفحه کامل ثبت نوبت (`/admin/appointments/new`) — مرجع درست هزینه ویزیت (L109-151, 477-488) |
| `assets/admin/components/NewAppointmentDrawer.tsx` | drawer «افزودن نوبت» (L126-140) — همان باگ هزینه ویزیت |
| `assets/admin/components/ui/Input.tsx` | input پایه design-system (`cp-input`) — نقطه تمرکز فیلد عددی سراسری |
| `assets/admin/components/ui/DigitInput.tsx` / `PriceInput.tsx` / `MobileInput.tsx` | فیلدهای عددی موجود (همه `inputMode="numeric"` + `dir="ltr"`) |
| `assets/admin/lib/utils.ts` | `toEnglishDigits` (L106-111)، `sanitizeMobileInput` (L114-116)، `tomanToRial`/`rialToToman` (L5-6) |
| `src/Appointment/Controller/AppointmentController.php` | `updateStatus` (L634-669) و `update` (L677-776) — نقطه لغو نوبت |
| `src/Appointment/Entity/Appointment.php` | ثابت‌های وضعیت (L28-29)، جدول transition (L37-41)، `transitionTo()` (L299-314) |
| `src/Shared/Logging/DbLogger.php` / `AppLog.php` | زیرساخت لاگ موجود (فقط WARNING به بالا persist) |
| `docs/api/appointment.md`, `docs/api/insurance.md` | به‌روزرسانی مستندات (Standing Rule) |

---

## تسک ۱ — انتقال کامل «برنامه کاری» از پروفایل به تنظیمات نوبت‌دهی

### وضعیت فعلی

- `AppointmentSettingsPage.tsx:45` همین الان فقط زیرتب `WeeklyScheduleTab` را دارد (بدون تب‌های «تاریخ‌های خاص» و «تعطیلات»):
```tsx
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
```
- `DoctorDetailPage.tsx:2899` کل `ScheduleSection` (سه‌تب: `weekly` / `overrides` / `holidays` + هدر «برنامه کاری») را رندر می‌کند و این برای **هم پروفایل و هم نمای ادمینِ جزئیات پزشک** اجرا می‌شود:
```tsx
{uuid && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
```

### هدف

برنامه کاری فقط از صفحه تنظیمات نوبت‌دهی مدیریت شود؛ از پروفایل کاملاً حذف شود.

### وظایف

1. در `AppointmentSettingsPage.tsx`، به‌جای `WeeklyScheduleTab` تنها، از **`ScheduleSection` کامل** استفاده کن (تا هر سه تب weekly/overrides/holidays در تنظیمات نوبت‌دهی باشد). `ScheduleSection` را از `DoctorDetailPage` export/import کن (اگر export نیست، `export function ScheduleSection` کن) و با همان props فعلی (`doctorUuid={uuid}`) بده. `addresses` را دیگر لازم نیست جدا بدهی چون `ScheduleSection` خودش `available-locations` را fetch می‌کند (L2061-2067).
2. رندر `ScheduleSection` در پروفایل حذف شود. چون خط L2899 هم پروفایل و هم نمای ادمین را سرو می‌کند، آن را **مشروط** کن که فقط وقتی پروفایلِ خودِ کاربر **نیست** رندر شود — تا نمای ادمینِ جزئیات پزشک دست‌نخورده بماند:
```tsx
{uuid && !isOwnProfile && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
```
(نام دقیق prop تشخیص پروفایل را از خود کامپوننت بردار — `isOwnProfile` که `DoctorProfilePage` پاس می‌دهد.)

### نکات

- بعد از انتقال، مطمئن شو دکمه‌های ذخیره داخل تب‌ها (`ذخیره برنامه هفتگی` L1608-1612 و مشابه در overrides/holidays) درست کار می‌کنند — آن‌ها API خودشان را دارند و مستقل از دکمه ذخیره تسک ۲ هستند.
- نمای ادمینِ «جزئیات پزشک» (وقتی ادمین پزشک دیگری را می‌بیند) باید همچنان برنامه کاری را نشان دهد؛ فقط پروفایلِ شخصی نباید.

---

## تسک ۲ — اصلاح چیدمان دکمه ذخیره در صفحه تنظیمات نوبت‌دهی

### وضعیت فعلی

در `AppointmentSettingsPage.tsx` ترتیب فعلی: عنوان → `<FreeVisitPrice/>` (شامل toggle «الزامی کردن هزینه ویزیت» و دکمه ذخیره خودش L66-68) → `ScheduleSection`. دکمه ذخیرهٔ کارت قیمت ویزیت داخل خود کارت است ولی از نظر بصری بعد از toggle در جای مناسبی قرار نمی‌گیرد.

### هدف (بهترین UX انتخاب و پیاده‌سازی شود)

راهکار توصیه‌شده: **دکمه ذخیرهٔ کارت `FreeVisitPrice` بلافاصله زیر فیلد/toggle «الزامی کردن هزینه ویزیت» و در انتهای همان کارت قرار گیرد** (نه شناور بالا). چون منطقاً دکمه ذخیره باید آخرین المان فرمِ آن کارت باشد.

### وظایف

1. در `FreeVisitPrice.tsx` ترتیب داخل کارت را طوری کن که: فیلد قیمت ویزیت آزاد → toggle «الزامی کردن هزینه ویزیت» (L77-93) → **سپس** دکمه ذخیره (L66-68) در انتهای کارت، تراز راست (`marginInlineStart: 'auto'`) با فاصله مناسب از toggle.
2. اگر دکمه ذخیره فعلاً بالای toggle رندر می‌شود، آن را به انتهای JSX کارت منتقل کن.

### نکات

- منطق `save`/state دست‌نخورده بماند؛ فقط ترتیب رندر و استایل جای دکمه.
- الگوی دکمه: `className="btn primary sm"`.

---

## تسک ۳ — اجبار ورودی لاتین در همه فیلدهای عددی سراسری

### وضعیت فعلی

- ابزار موجود: `toEnglishDigits` در `utils.ts:106-111` (فارسی/عربی → لاتین)، و `DigitInput`/`PriceInput`/`MobileInput` که همگی `inputMode="numeric"` + `dir="ltr"` دارند.
- **مشکل**: خیلی از inputها المان خام `<input>` هستند و از این کامپوننت‌ها استفاده نمی‌کنند (مثلاً `NewAppointmentModal` L215-273، `NewAppointmentDrawer` L165-199/303-313). `Input.tsx` پایه design-system است ولی **هیچ** `inputMode`/`lang`/تبدیل رقم ندارد و adoption ناقص است. تبدیل رقم در سه جای تکراری است (`toEnglishDigits`، `PriceInput.toLatinDigits`، regex inline در AppointmentsPage L176-180).

### هدف

هر فیلدی که فقط عدد می‌گیرد، هنگام تایپ رقم لاتین وارد شود (نه فارسی)، بدون شکستن فیلدهای غیرعددی.

### وظایف

1. **`Input.tsx` را ارتقا بده** تا یک prop اختیاری `numeric?: boolean` بگیرد. وقتی `numeric` است:
   - `inputMode="numeric"`, `dir="ltr"`, `lang="en"` روی input ست شود.
   - در `onChange`، مقدار با `toEnglishDigits` نرمال شود قبل از فراخوانی `onChange` والد (رقم فارسی/عربی تایپ‌شده بلافاصله به لاتین تبدیل شود). از همان `toEnglishDigits` مشترک `utils.ts` استفاده کن — تبدیل‌های تکراری (`PriceInput.toLatinDigits`، regex inline) را با import از `utils.ts` یکدست کن.
2. **حذف تکرار**: `PriceInput.tsx` و `onMobileChange` در `AppointmentsPage.tsx` (L176-180) به‌جای map/regex محلی از `toEnglishDigits` مشترک استفاده کنند.
3. **پوشش inputهای خام عددی**: فیلدهای عددیِ خام موجود در مودال/drawer نوبت و سایر فرم‌ها (کدملی، موبایل، مبالغ، تعداد) که از `Input`/`DigitInput`/`MobileInput`/`PriceInput` استفاده نمی‌کنند را یا به این کامپوننت‌ها مهاجرت بده یا حداقل `inputMode="numeric"` + `dir="ltr"` + نرمال‌سازی `toEnglishDigits` در onChange اضافه کن. حداقل این نقاط: `NewAppointmentModal` (کدملی/موبایل)، `NewAppointmentDrawer`.

### نکات

- فیلدهای متنی (نام، آدرس، توضیحات) نباید عددی شوند — فقط فیلدهایی که «فقط عدد» می‌گیرند.
- `inputMode="numeric"` صفحه‌کلید موبایل را عددی می‌کند؛ `dir="ltr"` + نرمال‌سازی `toEnglishDigits` تضمین می‌کند رقم فارسی paste/تایپ‌شده هم لاتین ذخیره شود. هر دو لازم است.
- تبدیل باید در **onChange** انجام شود نه فقط onBlur، تا کاربر بلافاصله رقم لاتین ببیند.

---

## تسک ۴ — رفع باگ: ثبت نوبت هنگام الزامی بودن هزینه ویزیت (۴۲۲)

### وضعیت فعلی

- backend درست است: `MyAppointmentsController::createAppointment` (L132-135) وقتی `isRequiredForDoctor` و `visit_price_rials <= 0` → `422 "هزینه ویزیت الزامی است"`.
- **باگ در frontend**: `NewAppointmentModal` (`AppointmentsPage.tsx:102`) — payload آن (L143-152) **اصلاً `visit_price_rials` ندارد**، هیچ فیلد قیمت ویزیت رندر نمی‌کند و تنظیم `insurance-pricing`/`require_visit_price` را نمی‌خواند:
```tsx
mutationFn: () => api.post(createEndpoint, {
  doctor_uuid: slot.doctor_uuid,
  slot_start: serviceMode ? pick.slot!.start : slot.start,
  slot_end:   serviceMode ? pick.slot!.end   : slot.end,
  patient_mobile: mobile,
  patient_name: effectiveName,
  patient_national_code: effectiveNationalCode,
  ...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
}),
```
- مرجع درست: `AppointmentCreatePage.tsx` که همین را دارد — خواندن تنظیم (L109-114)، state + prefill از `freeVisit` (L116-120)، گیت اعتبارسنجی (L129)، فیلد ورودی (L477-488)، و ارسال شرطی (L149):
```tsx
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
```
- `NewAppointmentDrawer.tsx` (L126-140) هم همین باگ را دارد.

### هدف

مودال (و drawer) ثبت نوبت مثل `AppointmentCreatePage` هزینه ویزیت را بگیرد و ارسال کند تا ۴۲۲ رخ ندهد.

### وظایف

1. در `NewAppointmentModal`:
   - تنظیم را بخوان: `useQuery(['insurance-pricing'])` → `requireVisit` و `freeVisit` (دقیقاً مثل `AppointmentCreatePage.tsx:109-114`). `doctor_uuid` مودال از `slot.doctor_uuid`.
   - state `visitPriceToman` با prefill از `freeVisit` (مثل L116-120).
   - یک فیلد ورودی «هزینه ویزیت (تومان)» با `<PriceInput>` اضافه کن؛ اگر `requireVisit` است ستاره `*` روی label و پیام خطای «هزینه ویزیت الزامی است» زیر فیلد وقتی `visitPriceToman <= 0`.
   - گیت submit: دکمه «ثبت نوبت» (L285) وقتی `requireVisit && visitPriceToman <= 0` غیرفعال شود.
   - در payload (L143-152) خط شرطی اضافه کن: `...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {})`.
2. همین اصلاح را در `NewAppointmentDrawer.tsx` (L126-140) اعمال کن.

### نکات

- `visit_price_rials` بر حسب **ریال** ارسال می‌شود؛ ورودی UI تومان است → `tomanToRial()` از `utils.ts`.
- وقتی `requireVisit` غیرفعال است رفتار فعلی حفظ شود (فیلد اختیاری، بدون مقدار → فیلد در payload نیاید).
- فیلد قیمت باید عددی/لاتین باشد (با تسک ۳ سازگار — `PriceInput` این را دارد).

---

## تسک ۵ — ثبت لاگ و رویداد Timeline هنگام لغو نوبت

### وضعیت فعلی (مهم — سیستم Timeline وجود ندارد)

- لغو نوبت از طریق `AppointmentController::updateStatus` (L634-669) با گذار وضعیت به `cancelled_by_doctor` / `cancelled_by_user` انجام می‌شود (و نیز `update` L677-776, transition L752-764). `transitionTo()` (Entity L299-314) فقط status/updatedAt را ست می‌کند، **هیچ لاگ یا reason ندارد**.
- **هیچ فیلد `cancel_reason`** در Entity یا بدنه request وجود ندارد (grep صفر).
- **هیچ سیستم Timeline/ActivityLog/رویدادِ per-appointment** در backend یا پنل ادمین clinicpro وجود ندارد. `TurnsTimeline.tsx` صرفاً نمای روزانهٔ نوبت‌هاست، نه تاریخچهٔ رویدادهای یک نوبت. پس این تسک **اولین** سیستم رویداد نوبت را می‌سازد.
- زیرساخت لاگ موجود: `DbLogger` → جدول `app_log`، اما فقط سطح WARNING به بالا persist می‌شود.

### هدف

هر بار یک نوبت لغو می‌شود: (الف) یک Log ثبت شود، (ب) یک رویداد جدید با عنوان «نوبت لغو شد» شامل زمان لغو، کاربرِ لغوکننده و دلیل لغو (در صورت وجود) در Timeline نوبت نمایش داده شود.

### وظایف

1. **Entity رویداد نوبت (جدید)** — `src/Appointment/Entity/AppointmentEvent.php`:
   - ستون‌ها: `id`, `uuid`, `appointment_id` (FK/int به نوبت), `type` string (مثل `cancelled`), `title` string («نوبت لغو شد»), `actor_user_id` (nullable int — کاربر لغوکننده), `actor_name` string (nullable — کش نام برای نمایش), `reason` text nullable, `created_at` int (Unix timestamp صحیح — نه DateTime).
   - migration لازم است: `ddev exec php bin/console make:migration` سپس `ddev exec php bin/console doctrine:migrations:migrate -n`.
2. **repository جدید** `AppointmentEventRepository` با متد لیستِ رویدادهای یک نوبت به‌صورت **DQL array hydration** (`getArrayResult()`)، مرتب بر `created_at`.
3. **ثبت رویداد در نقطه لغو** — در `AppointmentController::updateStatus` (بعد از `transitionTo`, حدود L656) و نیز مسیر `update` (L760): اگر `$newStatus` یکی از `STATUS_CANCELLED_BY_DOCTOR` / `STATUS_CANCELLED_BY_USER` بود:
   - `reason` را از بدنه request بخوان: `$data['cancel_reason'] ?? null` (اختیاری).
   - یک `AppointmentEvent` با `type='cancelled'`, `title='نوبت لغو شد'`, `actor_user_id`/`actor_name` از `$user`, `reason`, `created_at=time()` بساز و persist کن.
   - همزمان `LoggerInterface` را با فرمت غنی پروژه (الگوی `project-logging`) صدا بزن، سطح `warning` تا در `app_log` هم persist شود:
     ```php
     $this->logger->warning(sprintf(
         'Appointment cancelled: uuid=%s status=%s by user=%d(%s) reason=%s',
         $appointment->getUuid(), $newStatus, $user->getId(), $user->getName() ?? '-', $reason ?? '-'
     ));
     ```
   - سرویس لاگ/EntityManager را در constructor کنترلر inject کن (الان هیچ‌کدام inject نشده — L29-39).
4. **خروجی رویدادها در API**: یک endpoint `GET /api/v1/appointment/{uuid}/events` (یا رویدادها را داخل پاسخ جزئیات نوبت `toArray()` اضافه کن) که آرایه رویدادها را برمی‌گرداند: `{ type, title, actor_name, reason, created_at }`. envelope با `$this->success()`.
5. **نمایش Timeline در پنل ادمین**: در نمای جزئیات نوبت (مودال/بخش جزئیات که از `AppointmentsPage`/`TurnsTable` باز می‌شود) یک بخش «تاریخچه/Timeline» اضافه کن که رویدادها را از endpoint بالا می‌خواند و هر رویداد را نشان می‌دهد: عنوان («نوبت لغو شد»)، نامِ لغوکننده، زمان لغو (شمسی با `formatDate`)، و دلیل در صورت وجود. اگر نمای جزئیات نوبت مستقل وجود ندارد، یک بخش timeline ساده در همان مودال/سطر گسترش‌یافته اضافه کن.

### نکات

- تاریخ‌ها Unix timestamp صحیح ذخیره شوند؛ نمایش با `formatDate()` شمسی در فرانت.
- لیست‌های admin طبق قانون پروژه با DQL array hydration.
- `cancel_reason` فیلد اختیاری است — اگر فرانت دلیل نفرستد، رویداد بدون reason ثبت شود ولی همچنان «نوبت لغو شد» ثبت گردد.
- (اختیاری، بهبود) در UIِ لغو نوبت یک ورودی «دلیل لغو» اضافه کن تا `cancel_reason` پر شود؛ اگر خارج از scope است، backend همچنان باید null-safe باشد.
- این ساختار قابل‌گسترش است: در آینده رویدادهای دیگر (ایجاد/تأیید/تغییر) هم می‌توانند از همین `AppointmentEvent` استفاده کنند — ولی در این تسک فقط لغو کافی است.

---

## قوانین عمومی پروژه (برای همه تسک‌ها)

- کنترلرها از `BaseController` ارث می‌برند؛ پاسخ‌ها با `$this->success()` / `$this->error()` / `$this->paginated()`.
- تغییر Entity → migration لازم.
- بعد از تغییر API، فایل مربوط در `docs/api/` همان session به‌روز شود (`docs/api/appointment.md`, `docs/api/insurance.md`).
- Admin frontend: JWT در `localStorage['clinicpro-auth']`؛ paginated → items از `data?.data`, total از `data?.meta?.totalRecords`؛ single → `data?.data`.
- select‌ها: همیشه `SearchableSelect`، نه `<select>` خام.
- رشته‌ها فارسی، تاریخ‌ها شمسی، RTL.
- هر تسک جدا تست و کامیت شود. بعد از تغییر کد، `graphify update .` اجرا شود (بعد از کامیت).
