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.
This commit is contained in:
hamed
2026-07-18 12:10:49 +03:30
parent f1d58e1604
commit 42d9ad26c5
27 changed files with 1758 additions and 284 deletions
@@ -0,0 +1,259 @@
# اصلاحات بخش مدیریت سرویس‌ها (بیمه، ورودی‌های عددی، صفحه جزئیات)
## پروژه
`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
{/* بیمه */}
<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`
```tsx
<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`
```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
<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` اضافه کن:
```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 مستقیم روی صفحهٔ جزئیات
شکننده است. اضافه کن:
```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`:
`<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`.
- در گزارش پایانی صریح بنویس چه چیزی ساخته نشد و چرا (کالاهای مرتبط، لاگ تغییرات).