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>
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} />}
هدف
برنامه کاری فقط از صفحه تنظیمات نوبتدهی مدیریت شود؛ از پروفایل کاملاً حذف شود.
وظایف
- در
AppointmentSettingsPage.tsx، بهجایWeeklyScheduleTabتنها، ازScheduleSectionکامل استفاده کن (تا هر سه تب weekly/overrides/holidays در تنظیمات نوبتدهی باشد).ScheduleSectionرا ازDoctorDetailPageexport/import کن (اگر export نیست،export function ScheduleSectionکن) و با همان props فعلی (doctorUuid={uuid}) بده.addressesرا دیگر لازم نیست جدا بدهی چونScheduleSectionخودشavailable-locationsرا fetch میکند (L2061-2067). - رندر
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 «الزامی کردن هزینه ویزیت» و در انتهای همان کارت قرار گیرد (نه شناور بالا). چون منطقاً دکمه ذخیره باید آخرین المان فرمِ آن کارت باشد.
وظایف
- در
FreeVisitPrice.tsxترتیب داخل کارت را طوری کن که: فیلد قیمت ویزیت آزاد → toggle «الزامی کردن هزینه ویزیت» (L77-93) → سپس دکمه ذخیره (L66-68) در انتهای کارت، تراز راست (marginInlineStart: 'auto') با فاصله مناسب از toggle. - اگر دکمه ذخیره فعلاً بالای toggle رندر میشود، آن را به انتهای JSX کارت منتقل کن.
نکات
- منطق
save/state دستنخورده بماند؛ فقط ترتیب رندر و استایل جای دکمه. - الگوی دکمه:
className="btn primary sm".
تسک ۳ — اجبار ورودی لاتین در همه فیلدهای عددی سراسری
وضعیت فعلی
- ابزار موجود:
toEnglishDigitsدرutils.ts:106-111(فارسی/عربی → لاتین)، وDigitInput/PriceInput/MobileInputکه همگیinputMode="numeric"+dir="ltr"دارند. - مشکل: خیلی از inputها المان خام
<input>هستند و از این کامپوننتها استفاده نمیکنند (مثلاًNewAppointmentModalL215-273،NewAppointmentDrawerL165-199/303-313).Input.tsxپایه design-system است ولی هیچinputMode/lang/تبدیل رقم ندارد و adoption ناقص است. تبدیل رقم در سه جای تکراری است (toEnglishDigits،PriceInput.toLatinDigits، regex inline در AppointmentsPage L176-180).
هدف
هر فیلدی که فقط عدد میگیرد، هنگام تایپ رقم لاتین وارد شود (نه فارسی)، بدون شکستن فیلدهای غیرعددی.
وظایف
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یکدست کن.
- حذف تکرار:
PriceInput.tsxوonMobileChangeدرAppointmentsPage.tsx(L176-180) بهجای map/regex محلی ازtoEnglishDigitsمشترک استفاده کنند. - پوشش 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را نمیخواند:
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 هزینه ویزیت را بگیرد و ارسال کند تا ۴۲۲ رخ ندهد.
وظایف
- در
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) } : {}).
- تنظیم را بخوان:
- همین اصلاح را در
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انجام میشود (و نیزupdateL677-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 نوبت نمایش داده شود.
وظایف
- Entity رویداد نوبت (جدید) —
src/Appointment/Entity/AppointmentEvent.php:- ستونها:
id,uuid,appointment_id(FK/int به نوبت),typestring (مثلcancelled),titlestring («نوبت لغو شد»),actor_user_id(nullable int — کاربر لغوکننده),actor_namestring (nullable — کش نام برای نمایش),reasontext nullable,created_atint (Unix timestamp صحیح — نه DateTime). - migration لازم است:
ddev exec php bin/console make:migrationسپسddev exec php bin/console doctrine:migrations:migrate -n.
- ستونها:
- repository جدید
AppointmentEventRepositoryبا متد لیستِ رویدادهای یک نوبت بهصورت DQL array hydration (getArrayResult())، مرتب برcreated_at. - ثبت رویداد در نقطه لغو — در
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).
- خروجی رویدادها در API: یک endpoint
GET /api/v1/appointment/{uuid}/events(یا رویدادها را داخل پاسخ جزئیات نوبتtoArray()اضافه کن) که آرایه رویدادها را برمیگرداند:{ type, title, actor_name, reason, created_at }. envelope با$this->success(). - نمایش 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 .اجرا شود (بعد از کامیت).