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:
@@ -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`.
|
||||
- در گزارش پایانی صریح بنویس چه چیزی ساخته نشد و چرا (کالاهای مرتبط، لاگ تغییرات).
|
||||
Reference in New Issue
Block a user