From 42d9ad26c536f31e0484eb2c59ddeb607d690c4a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 12:10:49 +0330 Subject: [PATCH] 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. --- .../prompt/service-management-refinements.md | 259 ++++++++++++ assets/admin/App.tsx | 2 + .../components/ServiceInsuranceModal.test.tsx | 84 ++++ .../components/ServiceInsuranceModal.tsx | 15 +- .../admin/components/ServiceItemFormModal.tsx | 232 +++++++++++ .../admin/components/ServiceTariffModal.tsx | 8 +- .../components/WalletTransactionModal.tsx | 4 +- .../components/inventory/AddPackageModal.tsx | 4 +- .../paymentMethods/PosFormModal.tsx | 5 +- .../admin/components/ui/PriceInput.test.tsx | 118 ++++++ assets/admin/components/ui/PriceInput.tsx | 64 ++- assets/admin/lib/utils.test.ts | 61 +++ assets/admin/lib/utils.ts | 27 ++ .../pages/ClinicAppointmentSettingsPage.tsx | 13 +- .../admin/pages/ClinicServicesPage.test.tsx | 50 +++ assets/admin/pages/ClinicServicesPage.tsx | 285 ++----------- assets/admin/pages/ServiceDetailPage.test.tsx | 135 +++++++ assets/admin/pages/ServiceDetailPage.tsx | 375 ++++++++++++++++++ assets/admin/types/index.ts | 4 + docs/api/clinic-services.md | 44 +- docs/api/insurance.md | 6 + .../Controller/ClinicServiceController.php | 13 + src/ClinicService/Entity/ServiceItem.php | 1 + .../TenantServiceCoverageRepository.php | 11 + .../Service/TenantInsuranceService.php | 18 + .../ServiceItemDetailApiTest.php | 87 ++++ .../ServiceCoverageSyncsItemFlagTest.php | 117 ++++++ 27 files changed, 1758 insertions(+), 284 deletions(-) create mode 100644 .claude/prompt/service-management-refinements.md create mode 100644 assets/admin/components/ServiceInsuranceModal.test.tsx create mode 100644 assets/admin/components/ServiceItemFormModal.tsx create mode 100644 assets/admin/components/ui/PriceInput.test.tsx create mode 100644 assets/admin/pages/ServiceDetailPage.test.tsx create mode 100644 assets/admin/pages/ServiceDetailPage.tsx create mode 100644 tests/ClinicService/ServiceItemDetailApiTest.php create mode 100644 tests/Insurance/ServiceCoverageSyncsItemFlagTest.php diff --git a/.claude/prompt/service-management-refinements.md b/.claude/prompt/service-management-refinements.md new file mode 100644 index 00000000..7acecec1 --- /dev/null +++ b/.claude/prompt/service-management-refinements.md @@ -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 +{/* بیمه */} +
+ + {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` (نه ` setDraft((d) => ({ ...d, coverage_percent: e.target.value === '' ? null : Number(e.target.value) }))} + value={percentText} + placeholder="ارث از قرارداد" + onChange={(e) => { + const digits = digitsOnly(e.target.value, 3); + setPercentText(digits); + setDraft((d) => ({ ...d, coverage_percent: parseUserNumberClamped(digits, 0, 100) })); + }} + onBlur={() => setPercentText(draft.coverage_percent == null ? '' : String(draft.coverage_percent))} />
diff --git a/assets/admin/components/ServiceItemFormModal.tsx b/assets/admin/components/ServiceItemFormModal.tsx new file mode 100644 index 00000000..964e21f0 --- /dev/null +++ b/assets/admin/components/ServiceItemFormModal.tsx @@ -0,0 +1,232 @@ +import { useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ShieldCheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { ServiceItem, ClinicStaff } from '../types'; +import { rialToToman, tomanToRial } from '../lib/utils'; +import { numericField } from '../lib/forms'; +import Modal from './ui/Modal'; +import PriceInput from './ui/PriceInput'; +import SearchableSelect from './ui/SearchableSelect'; + +const itemSchema = z.object({ + name: z.string().min(1, 'نام سرویس الزامی است'), + price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'), + staff_uuids: z.array(z.string()).optional(), + duration_minutes: z.coerce.number().min(0).optional(), + bookable: z.boolean().optional(), +}); +type ItemForm = z.infer; + +const EMPTY_FORM: ItemForm = { name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined, bookable: false }; + +interface Props { + /** `'create'` برای سرویس جدید، شیء سرویس برای ویرایش، `null` یعنی بسته. */ + item: 'create' | ServiceItem | null; + /** بخش مقصد؛ برای حالت ایجاد الزامی است. */ + sectionUuid: string | null; + onClose: () => void; + /** برای باز کردن مودال پوشش بیمه از داخل فرم (فقط در حالت ویرایش). */ + onManageInsurance?: (item: ServiceItem) => void; +} + +/** + * فرم ایجاد/ویرایش سرویس — مشترک بین فهرست سرویس‌ها و صفحه‌ی جزئیات سرویس. + * + * تنظیمات بیمه اینجا نیست: پوشش هر بیمه‌گر تنها در «پوشش بیمه» مدیریت می‌شود و + * پرچم `insurance_covered` سمت سرور از همان‌جا همگام می‌شود. + */ +export default function ServiceItemFormModal({ item, sectionUuid, onClose, onManageInsurance }: Props) { + const qc = useQueryClient(); + const editing = item !== null && typeof item === 'object' ? item : null; + + const { data: staffData } = useQuery>({ + queryKey: ['staff'], + queryFn: () => api.get('/api/v1/staff'), + enabled: item !== null, + }); + const allStaff = staffData?.data ?? []; + + const form = useForm({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM }); + + useEffect(() => { + if (item === null) return; + form.reset(editing + ? { + name: editing.name, + price_rials: rialToToman(editing.price_rials), + staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid), + duration_minutes: editing.duration_minutes ?? undefined, + bookable: editing.bookable ?? false, + } + : EMPTY_FORM); + }, [item]); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ['service-items'] }); + qc.invalidateQueries({ queryKey: ['service-sections'] }); + if (editing) qc.invalidateQueries({ queryKey: ['service-item', editing.uuid] }); + }; + + const createItem = useMutation({ + mutationFn: (body: ItemForm) => api.post('/api/v1/service-item', { + ...body, + section_uuid: sectionUuid, + price_rials: tomanToRial(body.price_rials), + }), + onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ایجاد شد'); }, + onError: (e: Error) => toast.error(e.message), + }); + + const editItem = useMutation({ + mutationFn: (body: ItemForm) => api.patch(`/api/v1/service-item/${editing!.uuid}`, { + ...body, + price_rials: tomanToRial(body.price_rials), + }), + onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ویرایش شد'); }, + onError: (e: Error) => toast.error(e.message), + }); + + const saving = createItem.isPending || editItem.isPending; + + const selectedStaffUuids = form.watch('staff_uuids') ?? []; + const editingMembers = editing ? (editing.staff_members ?? (editing.staff ? [editing.staff] : [])) : []; + const staffOptions = allStaff + .filter((s) => s.active || editingMembers.some((m) => m.uuid === s.uuid)) + .filter((s) => !selectedStaffUuids.includes(s.uuid)) + .map((s) => ({ value: s.uuid, label: s.active ? s.full_name : `${s.full_name} (غیرفعال)` })); + const staffNameOf = (uuid: string) => + allStaff.find((s) => s.uuid === uuid)?.full_name + ?? editingMembers.find((m) => m.uuid === uuid)?.full_name + ?? uuid; + + return ( + + + + + } + > +
(editing ? editItem : createItem).mutate(d))} + style={{ display: 'flex', flexDirection: 'column', gap: 20 }} + > +
+
+ +
+ +
+ {form.formState.errors.name && ( + {form.formState.errors.name.message} + )} +
+ +
+
+ +
+ form.setValue('price_rials', v)} + placeholder="۸۵,۰۰۰" + min={0} + suffix="تومان" + /> +
+ {form.formState.errors.price_rials && ( + {form.formState.errors.price_rials.message} + )} +
+
+ + { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }} + placeholder="افزودن پرسنل (اختیاری)" + noOptionsMessage="پرسنلی باقی نمانده" + height={42} + /> + {selectedStaffUuids.length > 0 && ( +
+ {selectedStaffUuids.map((uuid) => ( + + {staffNameOf(uuid)} + + + ))} +
+ )} +
+
+ +
+
+ +
+ +
+
+ +
+
+ + {/* بیمه — تنظیمات فقط در «پوشش بیمه» مدیریت می‌شود تا داده‌ی تکراری ساخته نشود. */} +
+ + + پوشش بیمه‌ی این خدمت — درصد، فرانشیز و سقف هر بیمه‌گر — در بخش «پوشش بیمه» تنظیم می‌شود. + + {editing && onManageInsurance && ( + + )} +
+
+
+ ); +} diff --git a/assets/admin/components/ServiceTariffModal.tsx b/assets/admin/components/ServiceTariffModal.tsx index e7b75c23..74176733 100644 --- a/assets/admin/components/ServiceTariffModal.tsx +++ b/assets/admin/components/ServiceTariffModal.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; -import { formatRial, formatNumber, rialToToman, tomanToRial } from '../lib/utils'; +import { formatRial, formatYear, rialToToman, tomanToRial } from '../lib/utils'; import Modal from './ui/Modal'; import PriceInput from './ui/PriceInput'; import SearchableSelect from './ui/SearchableSelect'; @@ -46,7 +46,7 @@ export default function ServiceTariffModal({ item, onClose }: { item: ServiceIte const opts: { value: number; label: string }[] = []; for (let y = currentYear + 2; y >= currentYear - 5; y--) { if (used.has(y)) continue; - opts.push({ value: y, label: y === currentYear ? `${formatNumber(y)} (سال جاری)` : formatNumber(y) }); + opts.push({ value: y, label: y === currentYear ? `${formatYear(y)} (سال جاری)` : formatYear(y) }); } return opts; }, [currentYear, tariffs]); @@ -85,7 +85,7 @@ export default function ServiceTariffModal({ item, onClose }: { item: ServiceIte display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)', background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7, }}> - قیمت پایه‌ی سرویس همان تعرفه‌ی سال جاری ({currentYear ? formatNumber(currentYear) : '—'}) است و همه‌جا از همین استفاده می‌شود. تعرفه‌ی سال‌های دیگر فقط برای صورتحساب همان سال به‌کار می‌رود. + قیمت پایه‌ی سرویس همان تعرفه‌ی سال جاری ({currentYear ? formatYear(currentYear) : '—'}) است و همه‌جا از همین استفاده می‌شود. تعرفه‌ی سال‌های دیگر فقط برای صورتحساب همان سال به‌کار می‌رود.
)} @@ -131,7 +131,7 @@ export default function ServiceTariffModal({ item, onClose }: { item: ServiceIte background: isCurrent ? 'var(--primary-subtle)' : 'var(--bg)', }}> - سال {formatNumber(t.year)} + سال {formatYear(t.year)} {isCurrent && جاری} diff --git a/assets/admin/components/WalletTransactionModal.tsx b/assets/admin/components/WalletTransactionModal.tsx index 72c31005..1730d0f0 100644 --- a/assets/admin/components/WalletTransactionModal.tsx +++ b/assets/admin/components/WalletTransactionModal.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; import { XMarkIcon } from '@heroicons/react/24/outline'; import SearchableSelect from './ui/SearchableSelect'; import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods'; -import { formatRial, formatNumber, tomanToRial } from '../lib/utils'; +import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils'; export type WalletMode = 'charge' | 'withdraw'; @@ -154,7 +154,7 @@ export default function WalletTransactionModal({ open, balanceRials, submitting, inputMode="numeric" value={amountToman > 0 ? formatNumber(amountToman) : ''} onChange={(e) => { - const raw = e.target.value.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)).replace(/[^\d]/g, ''); + const raw = digitsOnly(e.target.value); setAmountToman(raw ? parseInt(raw, 10) : 0); }} placeholder="مبلغ دلخواه (تومان)" diff --git a/assets/admin/components/inventory/AddPackageModal.tsx b/assets/admin/components/inventory/AddPackageModal.tsx index 4c723f75..ca30a437 100644 --- a/assets/admin/components/inventory/AddPackageModal.tsx +++ b/assets/admin/components/inventory/AddPackageModal.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { TrashIcon } from '@heroicons/react/24/outline'; import Modal from '../ui/Modal'; import SearchableSelect from '../ui/SearchableSelect'; -import { formatRial } from '../../lib/utils'; +import { formatRial, digitsOnly } from '../../lib/utils'; import type { InventoryItem, InventoryPackage, PackagePayload } from '../../hooks/useInventory'; interface Props { @@ -89,7 +89,7 @@ export default function AddPackageModal({ open, editing, items, saving, onClose,
- setAmount(e.target.value.replace(/[^0-9]/g, ''))} placeholder="1" /> + setAmount(digitsOnly(e.target.value))} placeholder="1" />
@@ -358,9 +274,9 @@ function ClinicServicesPageInner() {
{menuOpen === item.uuid && ( <> -
setMenuOpen(null)} /> -
- +
{ e.stopPropagation(); setMenuOpen(null); }} /> +
e.stopPropagation()} style={{ position: 'absolute', top: 40, insetInlineStart: 8, zIndex: 41, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, minWidth: 176, display: 'flex', flexDirection: 'column', gap: 2 }}> + @@ -387,9 +303,14 @@ function ClinicServicesPageInner() {
- {item.insurance_covered && item.insurance_price_rials != null && ( - - )} +
+ + بیمه: + + {item.insurance_covered + ? تحت پوشش + : } +
@@ -436,162 +357,12 @@ function ClinicServicesPageInner() { {/* ───────── Modal سرویس ───────── */} - setItemModal(null)} - title={itemModal === 'create' ? 'سرویس جدید' : 'ویرایش سرویس'} - size="md" - footer={ - <> - - - - } - > -
{ - if (itemModal === 'create' && selectedSection) { - createItem.mutate({ ...d, section_uuid: selectedSection.uuid }); - } else if (itemModal !== null && typeof itemModal === 'object') { - editItem.mutate({ uuid: itemModal.uuid, body: d }); - } - })} - style={{ display: 'flex', flexDirection: 'column', gap: 20 }} - > - {/* اطلاعات پایه */} -
-
- -
- -
- {itemForm.formState.errors.name && ( - {itemForm.formState.errors.name.message} - )} -
- -
-
- -
- itemForm.setValue('price_rials', v)} - placeholder="۸۵,۰۰۰" - min={0} - /> -
- {itemForm.formState.errors.price_rials && ( - {itemForm.formState.errors.price_rials.message} - )} -
-
- - { - if (v != null) itemForm.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); - }} - placeholder="افزودن پرسنل (اختیاری)" - noOptionsMessage="پرسنلی باقی نمانده" - height={42} - /> - {selectedStaffUuids.length > 0 && ( -
- {selectedStaffUuids.map((uuid) => ( - - {staffNameOf(uuid)} - - - ))} -
- )} -
-
- -
-
- -
- -
-
- -
-
- - {/* بیمه */} -
- - - {itemForm.watch('insurance_covered') && ( -
- -
- itemForm.setValue('insurance_price_rials', v)} - placeholder="سهم تقریبی بیمار" - min={0} - /> -
-
- - برای محاسبه‌ی دقیق سهم بیمار و ساخت مطالبات، پوشش هر بیمه‌گر را از دکمه‌ی «پوشش بیمه» در فهرست سرویس‌ها تنظیم کنید. -
-
- )} -
-
-
- + onManageInsurance={setInsuranceItem} + /> setTariffItem(null)} /> setInsuranceItem(null)} /> diff --git a/assets/admin/pages/ServiceDetailPage.test.tsx b/assets/admin/pages/ServiceDetailPage.test.tsx new file mode 100644 index 00000000..e29b872c --- /dev/null +++ b/assets/admin/pages/ServiceDetailPage.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import ServiceDetailPage from './ServiceDetailPage'; + +const get = api.get as ReturnType; + +const ITEM = { + uuid: 'it1', + name: 'سرم ۵۰۰cc', + section_uuid: 'sec1', + section_name: 'تزریقات', + price_rials: 8_500_000, + active: true, + bookable: true, + duration_minutes: 30, + insurance_covered: true, + staff: { uuid: 'st1', full_name: 'مریم امینی' }, + staff_members: [{ uuid: 'st1', full_name: 'مریم امینی' }], + created_at: 1_700_000_000, + updated_at: 1_800_000_000, +}; + +const mockApi = (item: unknown = ITEM, notFound = false) => { + get.mockImplementation((url: string) => { + if (url.includes('/subscription/my')) return Promise.resolve({ success: true, data: { + subscription: null, used_trial: false, + effective_plan: { name: 'professional', level: 2, max_secretaries: 10, features: { services: true } }, + } }); + if (url.includes('/payment/config')) return Promise.resolve({ success: true, data: { test_mode: true, gateways: [] } }); + if (url.includes('/service-item/')) { + return notFound ? Promise.reject(new Error('یافت نشد')) : Promise.resolve({ success: true, data: item }); + } + if (url.includes('/tariffs')) return Promise.resolve({ success: true, data: { + current_year: 1404, default_price_rials: 8_500_000, + data: [ + { uuid: 't1', year: 1404, price_rials: 8_500_000, is_active: true }, + { uuid: 't2', year: 1403, price_rials: 7_000_000, is_active: false }, + ], + } }); + if (url.includes('/tenant-insurances')) return Promise.resolve({ data: { data: [ + { uuid: 'ins1', insurance_name: 'بیمه ایران', insurance_kind: 'basic', coverage_percent: 70 }, + ] } }); + return Promise.resolve({ success: true, data: [] }); + }); +}; + +beforeEach(() => { + useAuthStore.setState({ primaryRole: 'doctor' }); + get.mockReset(); + mockApi(); +}); + +// صفحه uuid را از useParams می‌گیرد، پس به یک Route واقعی نیاز دارد. +const render = () => + renderWithProviders( + + } /> + , + { route: '/admin/clinic-services/it1' }, + ); + +describe('ServiceDetailPage (جزئیات سرویس)', () => { + it('اطلاعات پایه سرویس را با breadcrumb نمایش می‌دهد', async () => { + render(); + + expect(await screen.findByRole('heading', { name: 'سرم ۵۰۰cc' })).toBeInTheDocument(); + expect(screen.getByText('سرویس‌ها')).toBeInTheDocument(); + expect(screen.getAllByText('تزریقات').length).toBeGreaterThan(0); + expect(screen.getByText('مریم امینی')).toBeInTheDocument(); + expect(screen.getByText('۳۰ دقیقه')).toBeInTheDocument(); + expect(screen.getByText('تحت پوشش')).toBeInTheDocument(); + }); + + it('تاریخ ایجاد و آخرین ویرایش را شمسی نشان می‌دهد', async () => { + render(); + + expect(await screen.findByText('تاریخ ایجاد')).toBeInTheDocument(); + expect(screen.getByText('آخرین ویرایش')).toBeInTheDocument(); + // سال شمسی معادل ۱۷۰۰۰۰۰۰۰۰ ⇒ ۱۴۰۲ + expect(screen.getByText(/۱۴۰۲/)).toBeInTheDocument(); + }); + + it('تب تعرفه‌ها فهرست سال‌ها را با نشان «سال جاری» می‌آورد', async () => { + render(); + fireEvent.click(await screen.findByText('تعرفه‌ها')); + + expect(await screen.findByText('سال جاری')).toBeInTheDocument(); + expect(screen.getByText('۱۴۰۴')).toBeInTheDocument(); + expect(screen.getByText('۱۴۰۳')).toBeInTheDocument(); + }); + + it('تب بیمه‌ها قراردادها و درصد پوشش را می‌آورد', async () => { + render(); + fireEvent.click(await screen.findByText('بیمه‌ها')); + + expect(await screen.findByText('بیمه ایران')).toBeInTheDocument(); + expect(screen.getByText(/پوشش ۷۰٪/)).toBeInTheDocument(); + }); + + it('سرویس ناموجود → پیام خطا و دکمه بازگشت', async () => { + mockApi(null, true); + render(); + + expect(await screen.findByText('سرویس یافت نشد')).toBeInTheDocument(); + expect(screen.getByText('بازگشت به سرویس‌ها')).toBeInTheDocument(); + }); + + it('دکمه ویرایش، فرم مشترک سرویس را باز می‌کند', async () => { + render(); + fireEvent.click(await screen.findByText('ویرایش')); + + expect(await screen.findByText('ویرایش سرویس')).toBeInTheDocument(); + // تنظیمات بیمه نباید در فرم باشد + expect(screen.queryByText('این خدمت شامل بیمه می‌شود')).not.toBeInTheDocument(); + }); + + it('سرویس غیرفعال نشان «غیرفعال» و دکمه فعال‌کردن دارد', async () => { + mockApi({ ...ITEM, active: false }); + render(); + + expect(await screen.findByText('غیرفعال')).toBeInTheDocument(); + expect(screen.getByText('فعال‌کردن')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx new file mode 100644 index 00000000..f6c0aab0 --- /dev/null +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -0,0 +1,375 @@ +import { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + BanknotesIcon, ShieldCheckIcon, ClockIcon, UsersIcon, PencilIcon, + CalendarDaysIcon, WrenchScrewdriverIcon, CubeIcon, +} from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { ServiceItem } from '../types'; +import { formatRial, formatNumber, formatYear, formatDateTime } from '../lib/utils'; +import PageHeader from '../components/ui/PageHeader'; +import FeatureGate from '../components/ui/FeatureGate'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import ServiceTariffModal from '../components/ServiceTariffModal'; +import ServiceInsuranceModal from '../components/ServiceInsuranceModal'; +import ServiceItemFormModal from '../components/ServiceItemFormModal'; + +interface Tariff { + uuid: string; + year: number; + price_rials: number; + is_active: boolean; +} + +interface TariffList { + current_year: number; + default_price_rials: number; + data: Tariff[]; +} + +interface TenantInsurance { + uuid: string; + insurance_name: string | null; + insurance_kind: 'basic' | 'supplementary' | null; + coverage_percent: number; +} + +interface CoverageRow { + service_item_uuid: string | null; + covered: boolean; + coverage_percent: number | null; + franchise_rials: number | null; + ceiling_rials: number | null; +} + +const TABS = [ + { id: 'info', label: 'اطلاعات سرویس' }, + { id: 'tariffs', label: 'تعرفه‌ها' }, + { id: 'insurance', label: 'بیمه‌ها' }, +] as const; +type TabId = typeof TABS[number]['id']; + +const KIND = { + basic: { label: 'پایه', cls: 'blue' }, + supplementary: { label: 'تکمیلی', cls: 'violet' }, +} as const; + +function Row({ icon, label, children }: { icon: React.ReactNode; label: string; children: React.ReactNode }) { + return ( +
+ + {icon} {label} + + {children} +
+ ); +} + +function InfoTab({ item }: { item: ServiceItem }) { + const members = item.staff_members ?? (item.staff ? [item.staff] : []); + + return ( +
+ } label="قیمت پایه"> + {formatRial(item.price_rials)} + + } label="بخش"> + {item.section_name ?? '—'} + + } label="زمان متوسط"> + {item.duration_minutes + ? {formatNumber(Number(item.duration_minutes))} دقیقه + : '—'} + + } label="نمایش در نوبت‌دهی"> + {item.bookable + ? فعال + : غیرفعال} + + } label="پوشش بیمه"> + {item.insurance_covered + ? تحت پوشش + : '—'} + + } label="پرسنل مسئول"> + {members.length > 0 ? ( + + {members.map((m) => ( + {m.full_name} + ))} + + ) : '—'} + + } label="تاریخ ایجاد"> + {item.created_at ? formatDateTime(item.created_at) : '—'} + +
+ + آخرین ویرایش + + + {item.updated_at ? formatDateTime(item.updated_at) : '—'} + +
+
+ ); +} + +function TariffsTab({ item, onManage }: { item: ServiceItem; onManage: () => void }) { + const { data, isLoading } = useQuery>({ + queryKey: ['service-tariffs', item.uuid], + queryFn: () => api.get(`/api/v1/service-items/${item.uuid}/tariffs`), + }); + + const list = data?.data; + const tariffs = list?.data ?? []; + + return ( +
+
+
+ تعرفه‌های سالانه +
+ قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است. +
+
+ +
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : tariffs.length === 0 ? ( +
+ +

تعرفه‌ای ثبت نشده است

+
+ ) : ( +
+ {tariffs.map((t) => ( +
+ + {formatYear(t.year)} + {t.year === list?.current_year && سال جاری} + {!t.is_active && غیرفعال} + + {formatRial(t.price_rials)} +
+ ))} +
+ )} +
+ ); +} + +function InsuranceTab({ item, onManage }: { item: ServiceItem; onManage: () => void }) { + const { data: contractsData, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({ + queryKey: ['tenant-insurances'], + queryFn: () => api.get('/api/v1/billing/tenant-insurances'), + }); + + const contracts = (contractsData as any)?.data?.data as TenantInsurance[] | undefined ?? []; + + return ( +
+
+
+ بیمه‌های مرتبط +
+ درصد پوشش، فرانشیز و سقف هر بیمه‌گر برای این خدمت. +
+
+ +
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : contracts.length === 0 ? ( +
+ +

قرارداد بیمه‌ی فعالی ندارید

+
+ ) : ( +
+ {contracts.map((c) => ( + + ))} +
+ )} +
+ ); +} + +function ContractCoverageRow({ contract, itemUuid }: { contract: TenantInsurance; itemUuid: string }) { + const { data } = useQuery<{ data: { data: CoverageRow[] } }>({ + queryKey: ['service-coverage', contract.uuid], + queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${contract.uuid}/service-coverage`), + }); + + const row = ((data as any)?.data?.data as CoverageRow[] | undefined) + ?.find((r) => r.service_item_uuid === itemUuid); + const kind = contract.insurance_kind ? KIND[contract.insurance_kind] : null; + const percent = row?.coverage_percent ?? contract.coverage_percent; + + return ( +
+ + + + {contract.insurance_name ?? 'بیمه'} + + {kind && {kind.label}} + + {row && !row.covered ? ( + بدون پوشش + ) : ( + + پوشش {formatNumber(percent)}٪ + {row?.franchise_rials ? فرانشیز {formatRial(row.franchise_rials)} : null} + {row?.ceiling_rials ? سقف {formatRial(row.ceiling_rials)} : null} + {!row && (ارث از قرارداد)} + + )} +
+ ); +} + +function ServiceDetailPageInner() { + const { uuid } = useParams<{ uuid: string }>(); + const navigate = useNavigate(); + const qc = useQueryClient(); + + const [tab, setTab] = useState('info'); + const [editOpen, setEditOpen] = useState(false); + const [tariffOpen, setTariffOpen] = useState(false); + const [insuranceOpen, setInsuranceOpen] = useState(false); + const [toggleOpen, setToggleOpen] = useState(false); + + const { data, isLoading, isError } = useQuery>({ + queryKey: ['service-item', uuid], + queryFn: () => api.get(`/api/v1/service-item/${uuid}`), + enabled: !!uuid, + }); + + const item = data?.data; + + const toggleActive = useMutation({ + mutationFn: () => api.patch(`/api/v1/service-item/${uuid}`, { active: !item!.active }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['service-item', uuid] }); + qc.invalidateQueries({ queryKey: ['service-items'] }); + setToggleOpen(false); + toast.success(item!.active ? 'سرویس غیرفعال شد' : 'سرویس فعال شد'); + }, + onError: (e: Error) => { toast.error(e.message); setToggleOpen(false); }, + }); + + if (isLoading) { + return
در حال بارگذاری...
; + } + + if (isError || !item) { + return ( +
+ +
سرویس یافت نشد
+ +
+ ); + } + + return ( +
+ + + +
+ } + /> + +
+ + {item.active ? 'فعال' : 'غیرفعال'} + +
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'info' && } + {tab === 'tariffs' && setTariffOpen(true)} />} + {tab === 'insurance' && setInsuranceOpen(true)} />} + + setEditOpen(false)} + onManageInsurance={() => setInsuranceOpen(true)} + /> + setTariffOpen(false)} /> + setInsuranceOpen(false)} /> + + toggleActive.mutate()} + onCancel={() => setToggleOpen(false)} + loading={toggleActive.isPending} + /> +
+ ); +} + +export default function ServiceDetailPage() { + return ( + + + + ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 9cdaf708..5cd0de7f 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -514,6 +514,10 @@ export interface ServiceItem { uuid: string; name: string; price_rials: number; + section_uuid?: string; + section_name?: string; + created_at?: number; + updated_at?: number; /** primary staff (first member) — kept for backward compatibility */ staff: { uuid: string; full_name: string } | null; /** all personnel assigned to this service */ diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index bff99f14..215029e4 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -115,6 +115,46 @@ --- +## GET /api/v1/service-item/{uuid} + +یک سرویس مشخص — پشتیبان صفحه‌ی اختصاصی «جزئیات سرویس» (`/admin/clinic-services/{uuid}`) که باید با +refresh مستقیم هم کار کند، بنابراین فیلترکردن سمت کلاینت از فهرست کامل کافی نبود. + +**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+ · فقط سرویس‌های همان مطب/کلینیک +(`ServiceItem→section→entity_type/entity_id`). + +**Response 200:** یک ServiceItem object (ساختار یکسان با آیتم‌های فهرست، شامل `section_uuid`، +`section_name`، `staff_members`، `created_at` و `updated_at`): + +```json +{ + "success": true, + "data": { + "uuid": "...", + "section_uuid": "...", + "section_name": "تزریقات", + "name": "سرم ۵۰۰cc", + "price_rials": 850000, + "active": true, + "insurance_covered": true, + "insurance_price_rials": null, + "duration_minutes": 30, + "bookable": true, + "staff": { "uuid": "...", "full_name": "مریم امینی" }, + "staff_members": [{ "uuid": "...", "full_name": "مریم امینی" }], + "created_at": 1718000000, + "updated_at": 1718000000 + } +} +``` + +**خطاها:** `404 ERR_SERVICE_NOT_FOUND` — هم برای uuid ناموجود و هم برای سرویس متعلق به tenant دیگر +(وجود سرویس نباید لو برود) · `401` بدون احراز هویت. + +> `section_name` تازه به `ServiceItem::toArray()` اضافه شده و در **همه‌ی** پاسخ‌های این فایل هست، نه فقط این endpoint. + +--- + ## POST /api/v1/service-item ایجاد سرویس جدید. @@ -142,8 +182,8 @@ | price_rials | integer | ❌ (پیش‌فرض 0) — «قیمت پایه» | | staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده می‌شود | | staff_uuid | UUID | ❌ — legacy تک‌پرسنل (اگر `staff_uuids` نباشد استفاده می‌شود) | -| insurance_covered | boolean | ❌ (پیش‌فرض false) — آیا خدمت شامل بیمه می‌شود | -| insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه | +| insurance_covered | boolean | ❌ (پیش‌فرض false) — **deprecated برای نوشتن.** پنل ادمین دیگر این فیلد را نمی‌فرستد؛ مقدارش به‌صورت خودکار از ردیف‌های پوشش بیمه همگام می‌شود (به [insurance.md](insurance.md#put-apiv1billingtenant-insurancesuuidservice-coverage) نگاه کن). endpoint هنوز آن را می‌پذیرد تا کلاینت‌های قدیمی نشکنند، ولی ذخیره‌ی پوشش بعداً آن را بازنویسی می‌کند | +| insurance_price_rials | integer\|null | ❌ — **deprecated.** سهم تقریبی بیمار؛ از فرم سرویس حذف شد. محاسبه‌ی دقیق سهم بیمار از `TenantServiceCoverage` انجام می‌شود | | duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) | | bookable | boolean | ❌ (پیش‌فرض false) — «نمایش در نوبت‌دهی». فقط سرویس‌های `bookable=true` در حالت نوبت‌دهی سرویسی قابل‌انتخاب‌اند | diff --git a/docs/api/insurance.md b/docs/api/insurance.md index f1a971df..44872c58 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -477,6 +477,12 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی سرویس باید متعلق به همان مطب/کلینیکِ قرارداد باشد (`ServiceItem→section→entity_type/entity_id`). +**اثر جانبی — همگام‌سازی `ServiceItem.insurance_covered`:** پس از ذخیره‌ی ردیف پوشش، پرچم +`insurance_covered` همان خدمت بازمحاسبه می‌شود: اگر زیر **هر** قرارداد بیمه‌ای دست‌کم یک ردیف با +`covered=true` بماند ⇒ `true`، وگرنه `false`. پنل ادمین دیگر این پرچم را دستی نمی‌فرستد (سوییچ «این خدمت +شامل بیمه می‌شود» از فرم سرویس حذف شد)، پس این endpoint تنها منبع حقیقت آن است. پیاده‌سازی: +`TenantInsuranceService::syncServiceItemInsuranceFlag()` + `TenantServiceCoverageRepository::hasActiveCoverage()`. + پاسخ `200`: `{ success, data: { message } }`. خطاها: `404 ERR_NOT_FOUND_001` قرارداد یافت نشد · `422 ERR_VALIDATION_001` سرویس یافت نشد · `403 ERR_FORBIDDEN_001` سرویس متعلق به شما نیست. diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index a7d865be..0a30dea6 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -154,6 +154,19 @@ class ClinicServiceController extends BaseController return $this->success($items); } + #[Route('/api/v1/service-item/{uuid}', methods: ['GET'])] + public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + + $item = $this->itemRepo->findByUuid($uuid); + if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); + } + + return $this->success($item->toArray()); + } + #[Route('/api/v1/service-item', methods: ['POST'])] public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse { diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index f17ebefe..f0fff694 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -142,6 +142,7 @@ class ServiceItem return [ 'uuid' => $this->uuid, 'section_uuid' => $this->section->getUuid(), + 'section_name' => $this->section->getName(), 'staff_uuid' => $primary?->getUuid(), 'staff_name' => $primary?->getFullName(), 'staff' => $primary !== null diff --git a/src/Insurance/Repository/TenantServiceCoverageRepository.php b/src/Insurance/Repository/TenantServiceCoverageRepository.php index e9bb4106..c65af030 100644 --- a/src/Insurance/Repository/TenantServiceCoverageRepository.php +++ b/src/Insurance/Repository/TenantServiceCoverageRepository.php @@ -27,6 +27,17 @@ class TenantServiceCoverageRepository extends ServiceEntityRepository return $this->findBy(['tenantInsuranceId' => $tenantInsuranceId]); } + /** آیا این خدمت زیر هر قرارداد بیمه‌ای پوشش فعال دارد؟ */ + public function hasActiveCoverage(int $serviceItemId): bool + { + return (int) $this->createQueryBuilder('c') + ->select('COUNT(c.id)') + ->where('c.serviceItemId = :item AND c.covered = true') + ->setParameter('item', $serviceItemId) + ->getQuery() + ->getSingleScalarResult() > 0; + } + public function save(TenantServiceCoverage $entity, bool $flush = true): void { $this->getEntityManager()->persist($entity); diff --git a/src/Insurance/Service/TenantInsuranceService.php b/src/Insurance/Service/TenantInsuranceService.php index e2152701..c465c10a 100644 --- a/src/Insurance/Service/TenantInsuranceService.php +++ b/src/Insurance/Service/TenantInsuranceService.php @@ -162,5 +162,23 @@ class TenantInsuranceService ->setCeilingRials($ceilingRials); $this->coverageRepo->save($override); + $this->syncServiceItemInsuranceFlag($serviceItemId); + } + + /** + * پرچم insurance_covered خدمت را با پوشش‌های واقعی هم‌تراز می‌کند. پنل دیگر این + * پرچم را دستی نمی‌گیرد؛ تنها منبع حقیقت، ردیف‌های پوشش قراردادهای بیمه است. + */ + private function syncServiceItemInsuranceFlag(int $serviceItemId): void + { + $serviceItem = $this->serviceItemRepo->find($serviceItemId); + if ($serviceItem === null) { + return; + } + + $covered = $this->coverageRepo->hasActiveCoverage($serviceItemId); + if ($serviceItem->isInsuranceCovered() !== $covered) { + $this->serviceItemRepo->save($serviceItem->setInsuranceCovered($covered)); + } } } diff --git a/tests/ClinicService/ServiceItemDetailApiTest.php b/tests/ClinicService/ServiceItemDetailApiTest.php new file mode 100644 index 00000000..d5e25a74 --- /dev/null +++ b/tests/ClinicService/ServiceItemDetailApiTest.php @@ -0,0 +1,87 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست جزئیات'); + $this->em->persist($doctor); + $this->em->flush(); + + $section = new ServiceSection('doctor', $doctor->getId(), $sectionName); + $item = new ServiceItem($section, 'سرم ۵۰۰cc'); + $item->setPriceRials(850_000)->setDurationMinutes(30)->setBookable(true); + $this->em->persist($section); + $this->em->persist($item); + $this->em->flush(); + + return [$owner, $item]; + } + + public function testReturnsTheItemWithEverythingTheDetailPageNeeds(): void + { + [$owner, $item] = $this->makeDoctorWithItem(); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner); + + $this->assertSame(200, $this->responseCode()); + $row = $body['data']; + $this->assertSame($item->getUuid(), $row['uuid']); + $this->assertSame('سرم ۵۰۰cc', $row['name']); + $this->assertSame('تزریقات', $row['section_name'], 'breadcrumb به نام بخش نیاز دارد'); + $this->assertSame($item->getSection()->getUuid(), $row['section_uuid']); + $this->assertSame(850_000, $row['price_rials']); + $this->assertSame(30, $row['duration_minutes']); + $this->assertTrue($row['bookable']); + $this->assertIsInt($row['created_at']); + $this->assertIsInt($row['updated_at']); + $this->assertArrayHasKey('staff_members', $row); + $this->assertArrayHasKey('insurance_covered', $row); + } + + public function testUnknownUuidReturns404(): void + { + [$owner] = $this->makeDoctorWithItem(); + + $this->authJson('GET', '/api/v1/service-item/00000000-0000-4000-8000-000000000000', $owner); + + $this->assertSame(404, $this->responseCode()); + } + + public function testAnotherTenantCannotReadTheItem(): void + { + [, $item] = $this->makeDoctorWithItem(); + + $stranger = $this->createUser(['ROLE_DOCTOR']); + $this->em->persist(new Doctor($stranger, 'دکتر غریبه')); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $stranger); + + $this->assertSame(404, $this->responseCode(), 'وجود سرویس نباید به tenant دیگر لو برود'); + } + + public function testRequiresAuthentication(): void + { + [, $item] = $this->makeDoctorWithItem(); + + $this->client->request('GET', '/api/v1/service-item/' . $item->getUuid()); + + $this->assertSame(401, $this->responseCode()); + } +} diff --git a/tests/Insurance/ServiceCoverageSyncsItemFlagTest.php b/tests/Insurance/ServiceCoverageSyncsItemFlagTest.php new file mode 100644 index 00000000..e34ed871 --- /dev/null +++ b/tests/Insurance/ServiceCoverageSyncsItemFlagTest.php @@ -0,0 +1,117 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست همگام‌سازی'); + $this->em->persist($doctor); + $this->em->flush(); + + $contract = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), 1); + $section = new ServiceSection(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), 'بخش'); + $this->em->persist($contract); + $this->em->persist($section); + $this->em->flush(); + + $item = new ServiceItem($section, 'سرم ۵۰۰cc'); + $this->em->persist($item); + $this->em->flush(); + + return [$owner, $contract, $item]; + } + + /** EntityManager بین requestها clear می‌شود، پس پرچم باید دوباره از DB خوانده شود. */ + private function isInsured(string $uuid): bool + { + return $this->em->getRepository(ServiceItem::class) + ->findOneBy(['uuid' => $uuid]) + ->isInsuranceCovered(); + } + + private function putCoverage( + \App\Auth\Entity\User $owner, + TenantInsurance $contract, + ServiceItem $item, + bool $covered, + ): void { + $this->authJson( + 'PUT', + '/api/v1/billing/tenant-insurances/' . $contract->getUuid() . '/service-coverage', + $owner, + ['service_item_uuid' => $item->getUuid(), 'covered' => $covered, 'coverage_percent' => 70], + ); + } + + public function testSavingCoveredCoverageMarksItemAsInsured(): void + { + [$owner, $contract, $item] = $this->makeDoctorContractAndItem(); + $this->assertFalse($item->isInsuranceCovered(), 'یک خدمت تازه نباید تحت پوشش باشد'); + + $this->putCoverage($owner, $contract, $item, true); + + $this->assertSame(200, $this->responseCode()); + $this->assertTrue($this->isInsured($item->getUuid())); + } + + public function testRemovingTheOnlyCoverageClearsTheFlag(): void + { + [$owner, $contract, $item] = $this->makeDoctorContractAndItem(); + $uuid = $item->getUuid(); + + $this->putCoverage($owner, $contract, $item, true); + $this->assertTrue($this->isInsured($uuid)); + + $this->putCoverage($owner, $contract, $item, false); + + $this->assertSame(200, $this->responseCode()); + $this->assertFalse($this->isInsured($uuid)); + } + + public function testFlagStaysSetWhileAnotherContractStillCoversTheItem(): void + { + [$owner, $first, $item] = $this->makeDoctorContractAndItem(); + $uuid = $item->getUuid(); + + $second = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $first->getEntityId(), 2); + $this->em->persist($second); + $this->em->flush(); + + $this->putCoverage($owner, $first, $item, true); + $this->putCoverage($owner, $second, $item, true); + + $this->putCoverage($owner, $first, $item, false); + + $this->assertTrue($this->isInsured($uuid), 'قرارداد دوم هنوز پوشش دارد'); + } + + public function testUnknownServiceItemIsRejected(): void + { + [$owner, $contract] = $this->makeDoctorContractAndItem(); + + $this->authJson( + 'PUT', + '/api/v1/billing/tenant-insurances/' . $contract->getUuid() . '/service-coverage', + $owner, + ['service_item_uuid' => 'no-such-uuid', 'covered' => true], + ); + + $this->assertSame(422, $this->responseCode()); + } +}