Files
clinicpro/.claude/prompt/appointment-settings-move-schedule-and-cancel-log.md
hamedandClaude Fable 5 476e219165 fix(admin): convert toman to rial when recording session payment/discount
PaymentStep sent the toman amount straight through as amount_rials (and the
fixed discount value as discount_value), so a 500,000 toman payment was stored
as 5,000,000... no — as 500,000 rial (10x too small). Apply tomanToRial before
sending the payment amount and the fixed-discount value; percent discount and
rule-based discount are unaffected. Verified stored value is now correct rial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:38:10 +03:30

20 KiB

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

پروژه

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 را دارد (بدون تب‌های «تاریخ‌های خاص» و «تعطیلات»):
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
  • DoctorDetailPage.tsx:2899 کل ScheduleSection (سه‌تب: weekly / overrides / holidays + هدر «برنامه کاری») را رندر می‌کند و این برای هم پروفایل و هم نمای ادمینِ جزئیات پزشک اجرا می‌شود:
{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 هم پروفایل و هم نمای ادمین را سرو می‌کند، آن را مشروط کن که فقط وقتی پروفایلِ خودِ کاربر نیست رندر شود — تا نمای ادمینِ جزئیات پزشک دست‌نخورده بماند:
{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 <= 0422 "هزینه ویزیت الزامی است".
  • باگ در frontend: NewAppointmentModal (AppointmentsPage.tsx:102) — payload آن (L143-152) اصلاً visit_price_rials ندارد، هیچ فیلد قیمت ویزیت رندر نمی‌کند و تنظیم insurance-pricing/require_visit_price را نمی‌خواند:
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):
...(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 شود:
      $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 . اجرا شود (بعد از کامیت).