Files
clinicpro/.claude/prompt/service-management-refinements.md
hamed 42d9ad26c5 Add tests and implementation for ServiceDetailPage and PriceInput components
- Implement PriceInput component tests to validate Persian and Arabic numeral handling, input formatting, and controlled behavior.
- Create ServiceDetailPage component with detailed service information, including pricing, insurance coverage, and editing capabilities.
- Add API tests for service item detail retrieval and coverage synchronization with insurance contracts.
- Ensure proper error handling and user feedback for service item retrieval and coverage management.
2026-07-18 12:10:49 +03:30

19 KiB
Raw Permalink Blame History

اصلاحات بخش مدیریت سرویس‌ها (بیمه، ورودی‌های عددی، صفحه جزئیات)

پروژه

clinicpro — پنل ادمین React (assets/admin/) + یک اندپوینت جدید در backend (src/ClinicService/). cross-repo نیست؛ سایت عمومی این بخش را مصرف نمی‌کند.

زمینه

بخش «مدیریت سرویس‌ها» (/admin/clinic-services) الان یک صفحه واحد است: نمای بخش‌ها → نمای کارت‌های سرویس، و همهٔ عملیات (ویرایش، تعرفهٔ سالانه، پوشش بیمه) در مودال باز می‌شود. سه اشکال دارد:

  1. دو جای تنظیم بیمه: در مودال ویرایش سرویس یک سوییچ «این خدمت شامل بیمه می‌شود» + «قیمت تقریبی با بیمه» وجود دارد، در حالی که تنظیمات واقعی بیمه (درصد پوشش، فرانشیز، سقف، به تفکیک هر بیمه‌گر) در ServiceInsuranceModal است. کاربر دو منبع حقیقت می‌بیند.
  2. NaN با کیبورد فارسی: فیلد «درصد پوشش» در ServiceInsuranceModal مقدار خام را Number() می‌کند؛ رقم فارسی → NaN.
  3. صفحهٔ جزئیات ندارد: کلیک روی سرویس هیچ کاری نمی‌کند؛ همه‌چیز در مودال پراکنده است.

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

فایل نقش
assets/admin/pages/ClinicServicesPage.tsx صفحهٔ اصلی (۶۳۴ خط): نمای بخش‌ها + کارت سرویس‌ها + مودال ایجاد/ویرایش سرویس
assets/admin/components/ServiceInsuranceModal.tsx مودال پوشش بیمه به تفکیک قرارداد بیمه‌گر (منشأ باگ NaN، خط ۱۳۴)
assets/admin/components/ServiceTariffModal.tsx مودال تعرفه‌های سالانه
assets/admin/components/ui/PriceInput.tsx ورودی مبلغ (تبدیل رقم را درست انجام می‌دهد ولی رفتار ویرایش ناقص است)
assets/admin/lib/forms.ts numericField() / latinDigitsField() — wrapper صحیح برای RHF
assets/admin/lib/utils.ts toEnglishDigits, digitsOnly, rialToToman, tomanToRial, formatRial
assets/admin/components/ui/DigitInput.tsx ورودی فقط-رقم برای state معمولی (غیر RHF)
assets/admin/App.tsx جدول route ها (خط ۲۴۸: clinic-services)
src/ClinicService/Controller/ClinicServiceController.php اندپوینت‌های سرویس/بخش/تعرفه
src/ClinicService/Entity/ServiceItem.php Entity + toArray() (خط ~۱۵۰)
docs/api/clinicservice.md (یا معادلش) مستندات API که باید در همین session به‌روز شود

وضعیت فعلی

۱) سوییچ بیمه در مودال سرویس — ClinicServicesPage.tsx:547-591

{/* بیمه */}
<div style={{ border: '1px solid var(--border)', ... }}>
  <label ...>
    <ShieldCheckIcon ... />
    <div>
      <div>این خدمت شامل بیمه می‌شود</div>
      <div>نشانه‌ی سریع برای فهرست سرویس‌ها</div>
    </div>
    <span className="switch">
      <input type="checkbox" checked={itemForm.watch('insurance_covered') ?? false} ... />
    </span>
  </label>
  {itemForm.watch('insurance_covered') && (
    <PriceInput value={itemForm.watch('insurance_price_rials') ?? 0} ... />   // قیمت تقریبی با بیمه
  )}
</div>

۲) باگ NaN — ServiceInsuranceModal.tsx:129-135

<input
  type="text" inputMode="numeric" dir="ltr" className="input"
  value={draft.coverage_percent ?? ''}
  placeholder="ارث"
  onChange={(e) => setDraft((d) => ({
    ...d,
    coverage_percent: e.target.value === '' ? null : Number(e.target.value),   // ← «۲۰» ⇒ NaN
  }))}
/>

placeholder="ارث" هم غلط تایپی است (باید «ارث از قرارداد» باشد).

۳) PriceInputui/PriceInput.tsx:31-42

const [display, setDisplay] = useState(...);
useEffect(() => { setDisplay(value !== '' && Number(value) > 0 ? formatDisplay(Number(value), latin) : ''); }, [value, latin]);

const handleChange = (e) => {
  const raw = toEnglishDigits(e.target.value).replace(/[^0-9]/g, '');
  const num = raw === '' ? 0 : Math.max(min, parseInt(raw, 10));
  onChange(num);
  setDisplay(num > 0 ? formatDisplay(num, latin) : '');
};

تبدیل رقم درست است، ولی: مقدار 0 همیشه به رشتهٔ خالی تبدیل می‌شود (کاربر نمی‌تواند صفر را ببیند/بنویسد)، Math.max(min, …) هنگام تایپ رقمِ اول مقدار را به min می‌پراند، و واحد (تومان) در خود فیلد دیده نمی‌شود در حالی که label می‌گوید «قیمت پایه (تومان)» ولی مقدار ذخیره‌شده ریال است (tomanToRial در mutation).

۴) نبود صفحهٔ جزئیات

App.tsx:248 فقط یک route دارد:

<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClinicServicesPage /></RoleRoute>} />

backend هم اندپوینت «یک سرویس با uuid» ندارد؛ فقط GET /api/v1/service-items (همه) و GET /api/v1/service-items/{sectionUuid} (به‌تفکیک بخش).

وظایف

ترتیب اجرا مهم است: ۲ → ۳ → ۱ → ۴ → ۵. اول ابزار عددی درست شود، بعد UI روی آن بنا شود.

۱. حذف تنظیمات بیمه از مودال ایجاد/ویرایش سرویس

  • بلاک «بیمه» (ClinicServicesPage.tsx:547-591) کامل حذف شود؛ insurance_covered و insurance_price_rials از itemSchema، از openEditItem/openCreateItem و از payload های createItem/editItem حذف شوند.
  • backend را تغییر نده: ستون‌های insurance_covered / insurance_price_rials روی ServiceItem باقی می‌مانند (دادهٔ قدیمی + استفاده در جای دیگر). فقط دیگر از این فرم ارسال نمی‌شوند. اندپوینت‌ها isset()-based هستند (ClinicServiceController.php:183,227) پس نبودِ فیلد در body مشکلی ایجاد نمی‌کند.
  • به‌جای آن، در همان محلِ حذف‌شده یک اشارهٔ کوتاه بگذار که کاربر را به مدیریت بیمه هدایت کند — یک باکس اطلاع با همان استایل باکس راهنمای موجود در ServiceInsuranceModal.tsx:196-202 (background: var(--primary-soft)) و یک دکمهٔ btn sm که همان setInsuranceItem(item) را باز می‌کند. در حالت «سرویس جدید» (هنوز uuid ندارد) فقط متن راهنما نمایش داده شود، بدون دکمه.
  • کارت سرویس (ClinicServicesPage.tsx:390-392) که item.insurance_covered را نشان می‌دهد باید به‌جای فیلد حذف‌شده، وضعیت واقعی بیمه را از پوشش‌های ثبت‌شده نشان دهد یا اگر داده در دسترس نیست، آن ردیف حذف شود. ساده‌ترین راه سازگار: ردیف «سهم بیمار (بیمه)» از کارت حذف شود و اطلاعات بیمه فقط در صفحهٔ جزئیات (وظیفهٔ ۴) بیاید.

۲. رفع ریشه‌ای NaN در ورودی‌های عددی

هیچ فیلد عددی نباید مستقیم Number(e.target.value) بزند. یک ابزار مشترک در lib/utils.ts اضافه کن:

/**
 * رشتهٔ ورودی کاربر (با ارقام فارسی/عربی، کاما، فاصله) را به عدد امن تبدیل می‌کند.
 * هرگز NaN برنمی‌گرداند؛ ورودی نامعتبر ⇒ null.
 */
export function parseUserNumber(raw: string | number | null | undefined): number | null {
  if (raw == null || raw === '') return null;
  const s = toEnglishDigits(String(raw)).replace(/[,\s٫٬]/g, '');
  if (!/^-?\d*\.?\d+$/.test(s)) return null;
  const n = Number(s);
  return Number.isFinite(n) ? n : null;
}

/** همان، با clamp اختیاری — برای درصد (۰..۱۰۰) و مقادیر غیرمنفی. */
export function parseUserNumberClamped(raw: string | number | null | undefined, min: number, max: number): number | null {
  const n = parseUserNumber(raw);
  return n == null ? null : Math.min(max, Math.max(min, n));
}

سپس:

  • ServiceInsuranceModal.tsx:129-135 — فیلد «درصد پوشش» بازنویسی شود: مقدار نمایشی را در یک state رشته‌ای نگه دار (تا کاربر بتواند فیلد را خالی کند یا در حال تایپ باشد)، و مقدارِ ذخیره‌شونده را با parseUserNumberClamped(v, 0, 100) بساز. placeholder="ارث"placeholder="ارث از قرارداد". فرانشیز و سقف قبلاً PriceInput هستند و بعد از وظیفهٔ ۳ خودبه‌خود درست می‌شوند.
  • ClinicServicesPage.tsx:530duration_minutes از numericField() استفاده می‌کند (درست است)؛ اما چون z.coerce.number() روی رشتهٔ خالی 0 می‌دهد، schema به z.coerce.number().min(0).optional().or(z.literal('')) یا یک preprocess تبدیل شود تا «خالی» به undefined نگاشت شود، نه صفر.
  • سراسر پنل — این موارد بررسی و اصلاح شوند (نتیجهٔ grep روی assets/admin):
    • components/ImageCropModal.tsx:59Number(e.target.value) روی <input type="range">؛ چون range همیشه مقدار لاتین می‌دهد بی‌خطر است؛ فقط تأیید کن و دست نزن.
    • components/paymentMethods/PosFormModal.tsx:88,92 — «شماره ترمینال» و «شماره حساب» ورودی آزادند و رقم فارسی را همان‌طور ذخیره می‌کنند؛ باید به DigitInput تبدیل شوند.
    • فایل‌های دارای z.coerce.number(): ClinicServicesPage.tsx, SmsWalletPage.tsx, AdminSubscriptionPage.tsx, RepresentationsPage.tsx, MyPatientsPage.tsx — در هرکدام مطمئن شو input متناظر با numericField(register(...)) یا PriceInput رندر می‌شود، نه register(...) خام. هرجا خام بود اصلاح کن.
  • تست: برای parseUserNumber تست واحد بنویس (lib/utils.test.ts یا فایل جدید) با موارد: '۲۵' → 25، '٢٥' → 25، '1,200' → 1200، '' → null، 'abc' → null، '۱۲.۵' → 12.5، '-۳' → -3. و یک تست کامپوننتی برای فیلد درصد پوشش که با تایپ '۲۵' مقدار 25 می‌دهد و هرگز NaN نمایش نمی‌دهد.

۳. اصلاح PriceInput (نمایش و ورود مبلغ)

ui/PriceInput.tsx بازنویسی شود با این رفتار:

  • ارقام فارسی/عربی و کاما و فاصله در ورودی پذیرفته و نرمال شوند (از parseUserNumber استفاده کن).
  • پیست (paste) با متن مثل «۸۵,۰۰۰ تومان» باید به 85000 تبدیل شود، نه خطا.
  • مقدار 0 نباید به رشتهٔ خالی تبدیل شود مگر کاربر خودش پاک کرده باشد؛ تفکیک «خالی» از «صفر» لازم است (state داخلی رشته‌ای + onChange(number)).
  • Math.max(min, …) نباید حین تایپ اعمال شود (clamp فقط onBlur).
  • نمایش با جداکنندهٔ هزارگان fa-IR (رفتار فعلی) حفظ شود؛ direction: ltr و text-align: left بماند.
  • یک suffix اختیاری اضافه شود (suffix="تومان") تا واحد داخل فیلد دیده شود؛ در ClinicServicesPage.tsx:480 و همهٔ کاربردهای مبلغ استفاده شود.
  • مقدار ذخیره‌شده همیشه عدد معتبر باشد (هرگز NaN/undefined).
  • تست موجود اگر هست به‌روز شود؛ اگر نیست تست واحد بنویس (تایپ فارسی، پیست با واحد، صفر، خالی، clamp روی blur).

مراقب باش: label «تومان» است ولی مقدار API ریال است (tomanToRial در createItem/editItem و rialToToman در openEditItem). این نگاشت را تغییر نده.

۴. صفحهٔ اختصاصی جزئیات سرویس

Backend — یک اندپوینت جدید (تنها موردی که واقعاً لازم است):

اندپوینت «یک سرویس با uuid» وجود ندارد؛ گرفتن کل لیست و فیلتر سمت کلاینت با refresh مستقیم روی صفحهٔ جزئیات شکننده است. اضافه کن:

#[Route('/api/v1/service-item/{uuid}', methods: ['GET'])]
public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse
{
    // همان الگوی مالکیت/tenant که در updateItem (خط ۲۰۵) استفاده شده
    // خروجی: $this->success($item->toArray())  ← بدون nest اضافه
}
  • ServiceItem::toArray() باید section (uuid + name)، created_at، updated_at، staff_members، bookable، duration_minutes را داشته باشد؛ اگر ندارد اضافه کن.
  • docs/api/ مربوطه در همین session به‌روز شود (قانون استاندارد پروژه).
  • migration لازم نیست (تغییر schema نداریم).

Frontend:

  • فایل جدید assets/admin/pages/ServiceDetailPage.tsx.
  • route جدید در App.tsx کنار route فعلی، با همان RoleRoute roles={['doctor', 'clinic']} blockClinicScope: <Route path="clinic-services/:uuid" element={...} />.
  • در ClinicServicesPage.tsx کلیک روی بدنهٔ کارت سرویس → navigate('/admin/clinic-services/' + item.uuid). منوی ⋮ و سوییچ‌ها باید e.stopPropagation() داشته باشند تا ناوبری اتفاق نیفتد (الگوی موجود در کارت بخش‌ها، خط ۲۶۰).
  • محتوای صفحه:
    بخش منبع داده
    اطلاعات پایه (نام، بخش، وضعیت فعال، نمایش در نوبت‌دهی، زمان متوسط، پرسنل) GET /api/v1/service-item/{uuid}
    قیمت پایه همان (نمایش با formatRial)
    تعرفه‌های سالانه GET /api/v1/service-items/{uuid}/tariffs (موجود)
    بیمه‌های مرتبط و پوشش GET /api/v1/billing/tenant-insurances + .../{uuid}/service-coverage — همان کوئری‌های ServiceInsuranceModal
    تاریخ ایجاد / آخرین ویرایش created_at / updated_at با formatDateTime (شمسی)
  • کالاهای مرتبط: الان هیچ رابطه‌ای بین ServiceItem و src/Inventory/ وجود ندارد (InventoryPackage polymorphic است با entity_type/entity_id ولی هیچ‌جا با service_item پر نمی‌شود). بنابراین در این پرامپت این سکشن ساخته نشود. اگر لازم شد، به‌عنوان کار جدا مطرح کن و در گزارش پایانی بنویس چه چیزی لازم است (رابطهٔ جدید + endpoint + UI).
  • لاگ تغییرات: هیچ زیرساخت audit-log برای ServiceItem وجود ندارد. این سکشن هم ساخته نشود؛ به‌جایش فقط «تاریخ ایجاد» و «آخرین ویرایش» نمایش داده شود. در گزارش پایانی ذکر کن.

۵. UI/UX صفحهٔ جزئیات — بدون طراحی جدید

اجباری: هیچ تم/طرح/کامپوننت جدیدی ساخته نشود. صفحه دقیقاً با Layout و Design System فعلی پنل پیاده شود:

  • از components/ui/PageHeader برای عنوان + breadcrumb («سرویس‌ها ‹ {نام بخش} ‹ {نام سرویس}») + دکمهٔ اقدام.
  • از card / card-pad / card-title-row / section-title / muted / badge / field / field-label و توکن‌های styles.css (--surface, --border, --primary, --r, --gap) استفاده شود. هیچ hex هاردکد.
  • تب‌ها با همان الگوی className="seg" که در pages/ClinicAppointmentSettingsPage.tsx:74-85 استفاده شده.
  • انتخاب‌ها با SearchableSelect (نه <select> بومی)، تأییدها با ConfirmDialog، مبالغ با PriceInput، تاریخ‌ها با formatDate/formatDateTime شمسی، اعداد با formatNumber.
  • ویرایش سرویس، تعرفه و پوشش بیمه از همین صفحه در دسترس باشند با همان مودال‌های موجود (ServiceTariffModal، ServiceInsuranceModal) — مودال جدید ساخته نشود.
  • حالت‌های loading / empty / error با همان الگوی موجود در ClinicServicesPage.tsx (متن در حال بارگذاری...، کارت خالی با آیکون Heroicon).
  • RTL و رشته‌های فارسی؛ آیکون‌ها فقط Heroicons v2 outline.

نکات مهم

  • ترتیب: ابزار عددی (وظیفهٔ ۲ و ۳) اول؛ بعد UI. اگر اول UI بسازی، باگ NaN را در صفحهٔ جدید تکرار می‌کنی.
  • قانون API: اندپوینت جدید فقط GET /api/v1/service-item/{uuid} است و دلیلش بالا نوشته شده. هیچ اندپوینت دیگری ساخته نشود — تعرفه و پوشش بیمه اندپوینت آماده دارند.
  • پاسخ‌ها: $this->success($item->toArray()) — بدون ['data' => ...] که باعث double-nest می‌شود. توجه: اندپوینت‌های billing (tenant-insurances, service-coverage) double-nested هستند و در کد فعلی با (data as any)?.data?.data خوانده می‌شوند؛ همان الگو را در صفحهٔ جدید تکرار کن.
  • مالکیت/tenant: الگوی چک مالکیت را از updateItem (ClinicServiceController.php:205) کپی کن؛ کاربر نباید بتواند سرویس tenant دیگر را ببیند. یک تست خطا برای این حالت لازم است (۴۰۳/۴۰۴).
  • تست (قانون پروژه، بدون استثنا): هر وظیفه تست موفق + خطا + مرزی داشته باشد و تست‌ها اجرا و سبز شوند:
    • PHPUnit برای اندپوینت جدید: سرویس موجود، uuid ناموجود، سرویس متعلق به tenant دیگر.
    • Vitest برای parseUserNumber, PriceInput, فیلد درصد پوشش، و رندر ServiceDetailPage (mock شدهٔ کوئری‌ها).
    • تست موجود pages/ClinicServicesPage.test.tsx بعد از حذف بلاک بیمه احتمالاً می‌شکند — به‌روز شود.
  • بررسی نهایی: ddev exec php bin/phpunit، yarn test، npx tsc --noEmit --project tsconfig.json.
  • در گزارش پایانی صریح بنویس چه چیزی ساخته نشد و چرا (کالاهای مرتبط، لاگ تغییرات).