diff --git a/assets/admin/components/FreeVisitPrice.test.tsx b/assets/admin/components/FreeVisitPrice.test.tsx index 3ccbfb2b..e345e85f 100644 --- a/assets/admin/components/FreeVisitPrice.test.tsx +++ b/assets/admin/components/FreeVisitPrice.test.tsx @@ -28,7 +28,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => { get.mockResolvedValue(pricing(0, false)); renderWithProviders(); - await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0')); + // PriceInput صفر را خالی نشان می‌دهد، پس خالی‌بودن فیلد نشانهٔ «داده رسید» نیست؛ + // انتظار روی خودِ فراخوانی بسته می‌شود تا کلیک قبل از بارگذاری نیفتد. + await waitFor(() => expect(get).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('')); fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })); fireEvent.click(screen.getByText('ذخیره')); @@ -40,7 +43,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => { get.mockResolvedValue(pricing(0, false)); renderWithProviders(); - await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0')); + // PriceInput صفر را خالی نشان می‌دهد، پس خالی‌بودن فیلد نشانهٔ «داده رسید» نیست؛ + // انتظار روی خودِ فراخوانی بسته می‌شود تا کلیک قبل از بارگذاری نیفتد. + await waitFor(() => expect(get).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('')); fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })); fireEvent.change(screen.getByRole('textbox'), { target: { value: '50000' } }); @@ -55,7 +61,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ می‌شود (ارسال مجاز)', async () => { get.mockResolvedValue(pricing(0, false)); renderWithProviders(); - await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0')); + // PriceInput صفر را خالی نشان می‌دهد، پس خالی‌بودن فیلد نشانهٔ «داده رسید» نیست؛ + // انتظار روی خودِ فراخوانی بسته می‌شود تا کلیک قبل از بارگذاری نیفتد. + await waitFor(() => expect(get).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('')); fireEvent.click(screen.getByText('ذخیره')); @@ -71,6 +80,8 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked()); expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*'); - expect(screen.getByRole('textbox')).toHaveValue('50000'); + await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue( + new Intl.NumberFormat('fa-IR').format(50_000), + )); }); }); diff --git a/assets/admin/components/FreeVisitPrice.tsx b/assets/admin/components/FreeVisitPrice.tsx index e22df190..74287b9c 100644 --- a/assets/admin/components/FreeVisitPrice.tsx +++ b/assets/admin/components/FreeVisitPrice.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api } from '../lib/api'; import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; -import { digitsOnly } from '../lib/utils'; +import PriceInput from './ui/PriceInput'; import Switch from './ui/Switch'; interface Pricing { free_visit_price_rials: number; require_visit_price: boolean } @@ -63,10 +63,11 @@ export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { docto - { setValue(digitsOnly(e.target.value)); setError(''); }} + { setValue(String(v)); setError(''); }} /> {value !== '' && ( diff --git a/assets/admin/components/ServiceItemFormModal.test.tsx b/assets/admin/components/ServiceItemFormModal.test.tsx new file mode 100644 index 00000000..b244abea --- /dev/null +++ b/assets/admin/components/ServiceItemFormModal.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; +import ServiceItemFormModal from './ServiceItemFormModal'; +import { api } from '../lib/api'; + +const BOOKABLE_LABEL = 'نمایش در نوبت‌دهی آنلاین'; + +beforeEach(() => { + vi.spyOn(api, 'get').mockImplementation(async (path: string) => { + if (path.startsWith('/api/v1/staff')) return { data: [] } as never; + if (path.startsWith('/api/v1/inventory-packages')) return { data: [] } as never; + if (path.startsWith('/api/v1/inventory-items')) return { data: { items: [] } } as never; + return { data: [] } as never; + }); +}); + +const renderModal = () => + renderWithProviders( + {}} />, + ); + +describe('ServiceItemFormModal', () => { + it('سوییچ رزرو آنلاین برچسب صریح دارد', () => { + renderModal(); + + expect(screen.getByRole('switch', { name: BOOKABLE_LABEL })).toBeInTheDocument(); + // برچسب قدیمی مبهم بود: معلوم نبود «نوبت‌دهی» یعنی تقویم داخلی یا رزرو بیمار. + expect(screen.queryByRole('switch', { name: 'نمایش در نوبت‌دهی' })).not.toBeInTheDocument(); + }); + + it('متن راهنما می‌گوید فعال بودنش یعنی چه', () => { + renderModal(); + + expect( + screen.getByText(/بیمار می‌تواند این سرویس را به‌صورت آنلاین رزرو کند/), + ).toBeInTheDocument(); + }); + + it('راهنما نامِ دسترسی‌پذیرِ سوییچ را آلوده نمی‌کند', () => { + renderModal(); + + // نام باید همان برچسب کوتاه بماند، نه برچسب + یک جملهٔ کامل. + expect(screen.getByRole('switch').getAttribute('aria-label')).toBe(BOOKABLE_LABEL); + }); + + it('قیمت و زمان متوسط یک ردیف‌اند و پرسنل تمام‌عرض', () => { + renderModal(); + + // مودال در portal رندر می‌شود، پس جستجو از document است نه container. + const grids = Array.from(document.querySelectorAll('div')) + .filter((el) => (el as HTMLElement).style.gridTemplateColumns === '1fr 1fr'); + + // تنها ردیفِ دوستونی همان جفتِ عددی است؛ پرسنل با چیپ‌هایش ردیف را می‌شکست. + expect(grids).toHaveLength(1); + expect(within(grids[0] as HTMLElement).getByText('قیمت پایه (تومان) *')).toBeInTheDocument(); + expect(within(grids[0] as HTMLElement).getByText('زمان متوسط (دقیقه)')).toBeInTheDocument(); + }); + + it('دکمهٔ انصراف واریانت دارد و نامرئی نیست', () => { + renderModal(); + + expect(screen.getByRole('button', { name: 'انصراف' })).toHaveClass('secondary'); + }); +}); diff --git a/assets/admin/components/ServiceItemFormModal.tsx b/assets/admin/components/ServiceItemFormModal.tsx index 1cb18bf5..ca258150 100644 --- a/assets/admin/components/ServiceItemFormModal.tsx +++ b/assets/admin/components/ServiceItemFormModal.tsx @@ -160,7 +160,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan size="md" footer={ <> - + @@ -183,6 +183,8 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan )} + {/* دو عددِ کوتاه کنار هم؛ پیش از این «قیمت» با «پرسنل مسئول» هم‌ردیف بود و + با افزودن هر پرسنل، چیپ‌ها ستون را بلند می‌کردند و ردیف به‌هم می‌ریخت. */}
@@ -200,16 +202,40 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan )}
- - { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }} - placeholder="افزودن پرسنل (اختیاری)" - noOptionsMessage="پرسنلی باقی نمانده" - height={42} - /> - {selectedStaffUuids.length > 0 && ( + +
+ +
+
+
+ +
+ + ({ value: c.key, label: c.label }))} + value={form.watch('service_category') || null} + onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }} + placeholder="انتخاب نوع خدمت" + noOptionsMessage="نوعی تعریف نشده است" + height={42} + /> + + درصد پوشش بیمه بر اساس همین نوع محاسبه می‌شود. + +
+ + {/* تمام‌عرض: چیپ‌های پرسنل چند سطر می‌شوند و در نیمْ‌ستون می‌شکستند. */} +
+ + { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }} + placeholder="افزودن پرسنل (اختیاری)" + noOptionsMessage="پرسنلی باقی نمانده" + height={42} + /> + {selectedStaffUuids.length > 0 && (
{selectedStaffUuids.map((uuid) => ( )} -
-
-
- -
- -
-
-
- form.setValue('bookable', v, { shouldDirty: true })} - label="نمایش در نوبت‌دهی" - /> -
-
- -
- - ({ value: c.key, label: c.label }))} - value={form.watch('service_category') || null} - onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }} - placeholder="انتخاب نوع خدمت" - noOptionsMessage="نوعی تعریف نشده است" - height={42} + {/* تصمیمِ «آنلاین رزرو بشود یا نه» یک تنظیم است، نه یک فیلد فرم؛ پیش از این + در نیمهٔ خالیِ ردیفِ «زمان متوسط» با پدینگ دستی هم‌تراز شده بود. */} +
+ form.setValue('bookable', v, { shouldDirty: true })} + label="نمایش در نوبت‌دهی آنلاین" + hint="با فعال بودن این گزینه، بیمار می‌تواند این سرویس را به‌صورت آنلاین رزرو کند." /> - - درصد پوشش بیمه بر اساس همین نوع محاسبه می‌شود. -
@@ -330,7 +337,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan {editing && onManageInsurance && (
- - قیمت (تومان) + patch(index, { price_rials: e.target.value })} + ariaLabel={`قیمت ${line.service_name} (تومان)`} + value={line.price_tomans === '' ? '' : Number(line.price_tomans)} + onChange={(v) => patch(index, { price_tomans: v === 0 ? '' : String(v) })} placeholder={`${formatRial(line.effective_price_rials)} — ${SOURCE_LABELS[line.price_source] ?? '—'}`} />
@@ -172,8 +176,11 @@ export default function ResourceServicesPanel({ { service_uuid: picked.uuid, service_name: picked.name, - duration_minutes: '', - price_rials: '', + // سرویسِ تازه با پیش‌فرض خودش پر می‌شود تا کاربر عددی جلوی چشمش + // باشد و لازم نباشد از placeholder رونویسی کند. پاک‌کردنش همان + // «ارث از سرویس» را برمی‌گرداند؛ ردیف‌های موجود دست نمی‌خورند. + duration_minutes: picked.duration_minutes == null ? '' : String(picked.duration_minutes), + price_tomans: picked.price_rials == null ? '' : String(rialToToman(picked.price_rials)), active: true, effective_duration_minutes: picked.duration_minutes ?? null, effective_price_rials: picked.price_rials ?? 0, @@ -189,8 +196,9 @@ export default function ResourceServicesPanel({ )}

- خالی گذاشتن مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را - جایگزین می‌کند؛ سرویسی که اینجا نباشد از این منبع برداشته می‌شود. + سرویس تازه با مدت و قیمت پیش‌فرض خودش پر می‌شود؛ می‌توانید عوضش کنید. خالی گذاشتن + مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را جایگزین + می‌کند؛ سرویسی که اینجا نباشد از این منبع برداشته می‌شود.

@@ -203,12 +211,16 @@ export default function ResourceServicesPanel({ disabled={saving} onClick={() => onSave( - lines.map((l) => ({ - service_uuid: l.service_uuid, - duration_minutes: l.duration_minutes.trim(), - price_rials: l.price_rials.trim(), - active: l.active, - })), + lines.map((l) => { + const toman = l.price_tomans.trim(); + return { + service_uuid: l.service_uuid, + duration_minutes: l.duration_minutes.trim(), + // قرارداد سرور ریال است؛ رشتهٔ خالی یعنی «ارث» و نباید صفر شود. + price_rials: toman === '' ? '' : String(tomanToRial(Number(toman))), + active: l.active, + }; + }), ) } > diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index efb9ee0c..63a69568 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -776,7 +776,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly دقیقه

- مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود. لازم است حداقل یک سرویس با «نمایش در نوبت‌دهی» در بخش {clinicUuid ? 'سرویس‌های کلینیک' : 'سرویس‌ها'} تعریف کنید، وگرنه ذخیره نمی‌شود. + مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود. لازم است حداقل یک سرویس با «نمایش در نوبت‌دهی آنلاین» در بخش {clinicUuid ? 'سرویس‌های کلینیک' : 'سرویس‌ها'} تعریف کنید، وگرنه ذخیره نمی‌شود.

) : ( diff --git a/assets/admin/components/session/CreateStep.test.tsx b/assets/admin/components/session/CreateStep.test.tsx index 1e39b800..c17b7cb3 100644 --- a/assets/admin/components/session/CreateStep.test.tsx +++ b/assets/admin/components/session/CreateStep.test.tsx @@ -35,6 +35,9 @@ const renderStep = (onCreated = vi.fn()) => { return onCreated; }; +/** PriceInput مبلغ را با جداکنندهٔ fa-IR نشان می‌دهد، نه رقمِ خام. */ +const fa = (n: number) => new Intl.NumberFormat('fa-IR').format(n); + describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ require_visit_price', () => { it('فلگ فعال + قیمت صفر → خطای inline، ستاره روی label و عدم ارسال', async () => { mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true }); @@ -78,7 +81,7 @@ describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ r mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false }); renderStep(); - await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000')); + await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(fa(30_000))); }); }); @@ -171,7 +174,7 @@ describe('CreateStep — نوع خدمت بیمه', () => { ); // ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان (ورودی قیمت ویزیت رقم خام است) - await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('595200')); + await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(fa(595_200))); expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument(); }); @@ -215,7 +218,7 @@ describe('CreateStep — نمایش و پیش‌انتخاب بیمه', () => { {}} />, ); - await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000')); + await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(fa(30_000))); expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument(); }); diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx index 1664ef9e..f2190713 100644 --- a/assets/admin/components/session/CreateStep.tsx +++ b/assets/admin/components/session/CreateStep.tsx @@ -7,6 +7,7 @@ import type { ApiResponse } from '../../lib/api'; import type { PatientProfile, ServiceSection, ServiceItem } from '../../types'; import { formatRial, rialToToman, tomanToRial } from '../../lib/utils'; import SearchableSelect from '../ui/SearchableSelect'; +import PriceInput from '../ui/PriceInput'; import PersianDateInput from '../ui/PersianDateInput'; import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons'; import { useAuthStore } from '../../stores/authStore'; @@ -479,11 +480,12 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e قیمت ویزیت (تومان){requireVisit && *} - { setVisitPrice(digitsOnly(e.target.value)); setVisitPriceError(''); }} + { setVisitPrice(String(v)); setVisitPriceError(''); }} /> {visitPriceError && ( {visitPriceError} diff --git a/assets/admin/components/ui/PriceInput.test.tsx b/assets/admin/components/ui/PriceInput.test.tsx index f57f3679..1a995ded 100644 --- a/assets/admin/components/ui/PriceInput.test.tsx +++ b/assets/admin/components/ui/PriceInput.test.tsx @@ -115,4 +115,23 @@ describe('PriceInput', () => { rerender(); expect(input().value).toBe(''); }); + + /** + * برچسبِ کنارِ بعضی فیلدهای مبلغ `
diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index 4e546e51..dcce8dce 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -40,6 +40,7 @@ import Modal from "../components/ui/Modal"; import PageHeader from "../components/ui/PageHeader"; import Pagination from "../components/ui/Pagination"; import SearchableSelect from "../components/ui/SearchableSelect"; +import PriceInput from "../components/ui/PriceInput"; import PatientRecordInfoForm from "../components/PatientRecordInfoForm"; import { usePermissions } from "../hooks/usePermissions"; import { @@ -1470,8 +1471,10 @@ function MyPatientsPageInner() { >
- form.setValue("visit_price_rials", v)} + ariaLabel="قیمت ویزیت (تومان)" />
diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index 0381f4a6..2e367814 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -62,7 +62,7 @@ const FIELD_LABELS: Record = { price_rials: 'قیمت پایه', active: 'وضعیت', duration_minutes: 'زمان متوسط', - bookable: 'نمایش در نوبت‌دهی', + bookable: 'نمایش در نوبت‌دهی آنلاین', insurance_covered: 'پوشش بیمه', inventory_package: 'پکیج کالا', }; @@ -112,7 +112,7 @@ function InfoTab({ item }: { item: ServiceItem }) { ? {formatNumber(Number(item.duration_minutes))} دقیقه : '—'} - } label="نمایش در نوبت‌دهی"> + } label="نمایش در نوبت‌دهی آنلاین"> {item.bookable ? فعال : غیرفعال} diff --git a/assets/admin/pages/SettingsPage.tsx b/assets/admin/pages/SettingsPage.tsx index ea77e780..ce698ff8 100644 --- a/assets/admin/pages/SettingsPage.tsx +++ b/assets/admin/pages/SettingsPage.tsx @@ -14,6 +14,7 @@ import { } from '@heroicons/react/24/outline'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import Switch from '../components/ui/Switch'; +import PriceInput from '../components/ui/PriceInput'; interface TaxHistoryRow { tax_percent: number; @@ -436,13 +437,27 @@ export default function SettingsPage() {
- + setValue('appointment_fee_rials', String(v), { shouldDirty: true })} + /> تومان
- + setValue('sms_panel_fee_rials', String(v), { shouldDirty: true })} + /> تومان
@@ -532,7 +547,14 @@ export default function SettingsPage() {
- + setValue('sms_price_rials', String(v), { shouldDirty: true })} + />
)} diff --git a/assets/admin/pages/SmsWalletPage.tsx b/assets/admin/pages/SmsWalletPage.tsx index 88923a40..d63f5fc5 100644 --- a/assets/admin/pages/SmsWalletPage.tsx +++ b/assets/admin/pages/SmsWalletPage.tsx @@ -17,6 +17,7 @@ import { usePaymentConfig } from '../hooks/usePaymentConfig'; import { formatRial, formatNumber, formatDateTime, tomanToRial } from '../lib/utils'; import Modal from '../components/ui/Modal'; import Pagination from '../components/ui/Pagination'; +import PriceInput from '../components/ui/PriceInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import PageHeader from '../components/ui/PageHeader'; import FeatureGate from '../components/ui/FeatureGate'; @@ -536,9 +537,11 @@ function SmsWalletPageInner() {
- chargeForm.setValue('amount_rials', v, { shouldValidate: true })} + placeholder="۵۰,۰۰۰" + ariaLabel="مبلغ شارژ (تومان)" /> {chargeForm.formState.errors.amount_rials && ( {chargeForm.formState.errors.amount_rials.message} diff --git a/assets/admin/styles.css b/assets/admin/styles.css index 573d919b..cb54c075 100644 --- a/assets/admin/styles.css +++ b/assets/admin/styles.css @@ -710,19 +710,23 @@ table.t tbody tr:last-child td { border-bottom: none; } background: rgba(8,13,22,.5); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fade-in .2s var(--ease); } +/* هدر و فوتر ثابت‌اند و فقط بدنه اسکرول می‌شود: پیش از این کلِ کارت اسکرول + می‌شد و در فرم‌های بلند (ثبت نوبت) دکمهٔ اصلی از دید خارج می‌ماند. */ .modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); box-shadow: var(--shadow-lg); width: 100%; max-width: 540px; max-height: 90vh; - overflow-y: auto; overflow-x: hidden; animation: pop .26s var(--ease); + display: flex; flex-direction: column; min-height: 0; + overflow: hidden; animation: pop .26s var(--ease); } .modal-head { display: flex; align-items: center; justify-content: space-between; - padding: 22px 24px; border-bottom: 1px solid var(--border); + padding: 22px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0; } .modal-head h2 { font-size: 17px; font-weight: 700; color: var(--text); margin: 0; } -.modal-body { padding: 24px; } +.modal-body { padding: 24px; overflow-y: auto; overflow-x: hidden; min-height: 0; flex: 1; } .modal-foot { - display: flex; gap: 10px; padding: 18px 24px; border-top: 1px solid var(--border); justify-content: flex-end; + display: flex; gap: 10px; padding: 18px 24px; border-top: 1px solid var(--border); + justify-content: flex-end; flex-shrink: 0; } .settings-panel { width: 360px; height: 100%; overflow-y: auto;