# اصلاحات بخش مدیریت سرویس‌ها (بیمه، ورودی‌های عددی، صفحه جزئیات) ## پروژه `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` ```tsx {/* بیمه */}
{itemForm.watch('insurance_covered') && ( // قیمت تقریبی با بیمه )}
``` ### ۲) باگ NaN — `ServiceInsuranceModal.tsx:129-135` ```tsx setDraft((d) => ({ ...d, coverage_percent: e.target.value === '' ? null : Number(e.target.value), // ← «۲۰» ⇒ NaN }))} /> ``` `placeholder="ارث"` هم غلط تایپی است (باید «ارث از قرارداد» باشد). ### ۳) `PriceInput` — `ui/PriceInput.tsx:31-42` ```tsx 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 دارد: ```tsx } /> ``` 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` اضافه کن: ```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)` روی ``؛ چون 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 مستقیم روی صفحهٔ جزئیات شکننده است. اضافه کن: ```php #[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`: ``. - در `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` (نه `