# انتقال برنامه کاری، اصلاح فرم تنظیمات نوبت، فیلدهای عددی لاتین، رفع باگ هزینه ویزیت در مودال، و لاگ/تایم‌لاین لغو نوبت ## پروژه `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 پروفایل → `` | | `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 ``` - `DoctorDetailPage.tsx:2899` کل `ScheduleSection` (سه‌تب: `weekly` / `overrides` / `holidays` + هدر «برنامه کاری») را رندر می‌کند و این برای **هم پروفایل و هم نمای ادمینِ جزئیات پزشک** اجرا می‌شود: ```tsx {uuid && } ``` ### هدف برنامه کاری فقط از صفحه تنظیمات نوبت‌دهی مدیریت شود؛ از پروفایل کاملاً حذف شود. ### وظایف 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 && } ``` (نام دقیق prop تشخیص پروفایل را از خود کامپوننت بردار — `isOwnProfile` که `DoctorProfilePage` پاس می‌دهد.) ### نکات - بعد از انتقال، مطمئن شو دکمه‌های ذخیره داخل تب‌ها (`ذخیره برنامه هفتگی` L1608-1612 و مشابه در overrides/holidays) درست کار می‌کنند — آن‌ها API خودشان را دارند و مستقل از دکمه ذخیره تسک ۲ هستند. - نمای ادمینِ «جزئیات پزشک» (وقتی ادمین پزشک دیگری را می‌بیند) باید همچنان برنامه کاری را نشان دهد؛ فقط پروفایلِ شخصی نباید. --- ## تسک ۲ — اصلاح چیدمان دکمه ذخیره در صفحه تنظیمات نوبت‌دهی ### وضعیت فعلی در `AppointmentSettingsPage.tsx` ترتیب فعلی: عنوان → `` (شامل 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ها المان خام `` هستند و از این کامپوننت‌ها استفاده نمی‌کنند (مثلاً `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). - یک فیلد ورودی «هزینه ویزیت (تومان)» با `` اضافه کن؛ اگر `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`، نه `