- 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.
19 KiB
اصلاحات بخش مدیریت سرویسها (بیمه، ورودیهای عددی، صفحه جزئیات)
پروژه
clinicpro — پنل ادمین React (assets/admin/) + یک اندپوینت جدید در backend (src/ClinicService/).
cross-repo نیست؛ سایت عمومی این بخش را مصرف نمیکند.
زمینه
بخش «مدیریت سرویسها» (/admin/clinic-services) الان یک صفحه واحد است: نمای بخشها → نمای کارتهای سرویس، و
همهٔ عملیات (ویرایش، تعرفهٔ سالانه، پوشش بیمه) در مودال باز میشود. سه اشکال دارد:
- دو جای تنظیم بیمه: در مودال ویرایش سرویس یک سوییچ «این خدمت شامل بیمه میشود» + «قیمت تقریبی با بیمه» وجود دارد،
در حالی که تنظیمات واقعی بیمه (درصد پوشش، فرانشیز، سقف، به تفکیک هر بیمهگر) در
ServiceInsuranceModalاست. کاربر دو منبع حقیقت میبیند. - NaN با کیبورد فارسی: فیلد «درصد پوشش» در
ServiceInsuranceModalمقدار خام راNumber()میکند؛ رقم فارسی →NaN. - صفحهٔ جزئیات ندارد: کلیک روی سرویس هیچ کاری نمیکند؛ همهچیز در مودال پراکنده است.
فایلهای مرتبط
| فایل | نقش |
|---|---|
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="ارث" هم غلط تایپی است (باید «ارث از قرارداد» باشد).
۳) PriceInput — ui/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:530—duration_minutesازnumericField()استفاده میکند (درست است)؛ اما چونz.coerce.number()روی رشتهٔ خالی0میدهد، schema بهz.coerce.number().min(0).optional().or(z.literal(''))یا یکpreprocessتبدیل شود تا «خالی» بهundefinedنگاشت شود، نه صفر.- سراسر پنل — این موارد بررسی و اصلاح شوند (نتیجهٔ grep روی
assets/admin):components/ImageCropModal.tsx:59—Number(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/وجود ندارد (InventoryPackagepolymorphic است با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. - در گزارش پایانی صریح بنویس چه چیزی ساخته نشد و چرا (کالاهای مرتبط، لاگ تغییرات).