قیمت پایه (تومان) *
@@ -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}
+ {/* تصمیمِ «آنلاین رزرو بشود یا نه» یک تنظیم است، نه یک فیلد فرم؛ پیش از این
+ در نیمهٔ خالیِ ردیفِ «زمان متوسط» با پدینگ دستی همتراز شده بود. */}
+
+ form.setValue('bookable', v, { shouldDirty: true })}
+ label="نمایش در نوبتدهی آنلاین"
+ hint="با فعال بودن این گزینه، بیمار میتواند این سرویس را بهصورت آنلاین رزرو کند."
/>
-
- درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
-
@@ -330,7 +337,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
{editing && onManageInsurance && (
{ const it = editing; onClose(); onManageInsurance(it); }}
>
diff --git a/assets/admin/components/appointments/ServiceSlotPicker.tsx b/assets/admin/components/appointments/ServiceSlotPicker.tsx
index b4115ed1..c16cbcde 100644
--- a/assets/admin/components/appointments/ServiceSlotPicker.tsx
+++ b/assets/admin/components/appointments/ServiceSlotPicker.tsx
@@ -111,7 +111,7 @@ export default function ServiceSlotPicker({
{resourceUuid
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویسها»ی همین منبع اضافه کنید.'
- : 'سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.'}
+ : 'سرویسی با «نمایش در نوبتدهی آنلاین» برای این پزشک تعریف نشده است.'}
);
}
diff --git a/assets/admin/components/resources/ResourceServicesPanel.test.tsx b/assets/admin/components/resources/ResourceServicesPanel.test.tsx
index ebef33d5..707190df 100644
--- a/assets/admin/components/resources/ResourceServicesPanel.test.tsx
+++ b/assets/admin/components/resources/ResourceServicesPanel.test.tsx
@@ -9,6 +9,9 @@ import ResourceServicesPanel from './ResourceServicesPanel';
const resource = { uuid: 'r-1', name: 'دستگاه شمارهٔ ۲' } as never;
+/** PriceInput مبلغ را با جداکنندهٔ fa-IR نشان میدهد، نه رقمِ خام. */
+const fa = (n: number) => new Intl.NumberFormat('fa-IR').format(n);
+
const services = [
{ uuid: 's-1', name: 'لیزر پا', duration_minutes: 30, price_rials: 8_000_000 },
{ uuid: 's-2', name: 'لیزر دست', duration_minutes: 20, price_rials: 5_000_000 },
@@ -57,12 +60,17 @@ describe('ResourceServicesPanel', () => {
expect(price).toHaveValue('');
});
- it('override را ذخیره میکند', async () => {
+ it('قیمت به تومان گرفته میشود و به ریال ذخیره میشود', async () => {
const onSave = vi.fn();
renderWithProviders( );
+ // برچسب و placeholder هر دو توماناند؛ پیش از این برچسب «ریال» بود و ورودی
+ // کاربر ده برابر کمتر از چیزی میشد که فکر میکرد وارد کرده.
+ expect(screen.getByText('قیمت (تومان)')).toBeInTheDocument();
+ expect(screen.queryByText('قیمت (ریال)')).not.toBeInTheDocument();
+
const price = screen.getByPlaceholderText(/پیشفرض سرویس/);
- await userEvent.type(price, '9500000');
+ await userEvent.type(price, '950000');
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
expect(onSave).toHaveBeenCalledWith([
@@ -70,6 +78,33 @@ describe('ResourceServicesPanel', () => {
]);
});
+ it('قیمتِ override شده به تومان در فیلد مینشیند', () => {
+ renderWithProviders( );
+
+ expect(screen.getByDisplayValue(fa(950_000))).toBeInTheDocument();
+ });
+
+ it('سرویس تازه با مدت و قیمت پیشفرض خودش پر میشود', async () => {
+ const onSave = vi.fn();
+ renderWithProviders( );
+
+ await userEvent.click(screen.getByText('یک سرویس انتخاب کنید'));
+ await userEvent.click(await screen.findByText('لیزر دست'));
+
+ // ۵٬۰۰۰٬۰۰۰ ریال = ۵۰۰٬۰۰۰ تومان
+ expect(screen.getByDisplayValue(fa(500_000))).toBeInTheDocument();
+ expect(screen.getByDisplayValue('20')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
+
+ expect(onSave).toHaveBeenCalledWith([
+ { service_uuid: 's-1', duration_minutes: '15', price_rials: '', active: true },
+ { service_uuid: 's-2', duration_minutes: '20', price_rials: '5000000', active: true },
+ ]);
+ });
+
it('پاککردن مقدار یعنی بازگشت به ارث، نه صفر', async () => {
const onSave = vi.fn();
renderWithProviders( );
diff --git a/assets/admin/components/resources/ResourceServicesPanel.tsx b/assets/admin/components/resources/ResourceServicesPanel.tsx
index ebbc0f0b..0f2f2234 100644
--- a/assets/admin/components/resources/ResourceServicesPanel.tsx
+++ b/assets/admin/components/resources/ResourceServicesPanel.tsx
@@ -1,14 +1,16 @@
import React, { useEffect, useState } from 'react';
+import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect';
import Switch from '../ui/Switch';
-import { formatRial } from '../../lib/utils';
+import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
type Line = {
service_uuid: string;
service_name: string;
duration_minutes: string;
- price_rials: string;
+ /** مقدارِ داخل فیلد به **تومان** است؛ تبدیل به ریال فقط موقع ذخیره انجام میشود. */
+ price_tomans: string;
active: boolean;
effective_duration_minutes: number | null;
effective_price_rials: number;
@@ -63,7 +65,9 @@ export default function ResourceServicesPanel({
service_uuid: o.service_uuid,
service_name: o.service_name,
duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes),
- price_rials: o.price_rials === null ? '' : String(o.price_rials),
+ // ورودی به تومان است تا با placeholder و بقیهٔ پنل یکی باشد؛ پیش از این
+ // برچسب «ریال» بود و placeholder تومان — یعنی ده برابر اختلاف در یک فیلد.
+ price_tomans: o.price_rials === null ? '' : String(rialToToman(o.price_rials)),
active: o.active,
effective_duration_minutes: o.effective_duration_minutes,
effective_price_rials: o.effective_price_rials,
@@ -142,12 +146,12 @@ export default function ResourceServicesPanel({
-
قیمت (ریال)
-
قیمت (تومان)
+
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('');
});
+
+ /**
+ * برچسبِ کنارِ بعضی فیلدهای مبلغ `` واقعی نیست (span/div)، پس نامِ
+ * دسترسیپذیر باید صریح داده شود؛ پیش از این آن فیلدها input خام بودند و
+ * `aria-label` خودشان را داشتند.
+ */
+ it('ariaLabel نام دسترسیپذیر میسازد', () => {
+ render( );
+
+ expect(screen.getByRole('textbox', { name: 'قیمت ویزیت' })).toBeTruthy();
+ });
+
+ it('ariaInvalid فقط در حالت خطا روی فیلد مینشیند', () => {
+ const { rerender } = render( );
+ expect(input().getAttribute('aria-invalid')).toBe('true');
+
+ rerender( );
+ expect(input().getAttribute('aria-invalid')).toBeNull();
+ });
});
diff --git a/assets/admin/components/ui/PriceInput.tsx b/assets/admin/components/ui/PriceInput.tsx
index ff21fb96..a87f8c5e 100644
--- a/assets/admin/components/ui/PriceInput.tsx
+++ b/assets/admin/components/ui/PriceInput.tsx
@@ -14,6 +14,11 @@ interface PriceInputProps {
latin?: boolean;
/** واحد نمایشی داخل فیلد، مثلاً «تومان». */
suffix?: string;
+ /** وقتی برچسبِ کنار فیلد `` واقعی نیست (span/div)، نامِ دسترسیپذیر. */
+ ariaLabel?: string;
+ /** حالت خطا برای صفحهخوان — معادل `aria-invalid` روی input خام. */
+ ariaInvalid?: boolean;
+ id?: string;
}
function formatDisplay(num: number, latin: boolean): string {
@@ -44,6 +49,9 @@ export default function PriceInput({
max,
latin = false,
suffix,
+ ariaLabel,
+ ariaInvalid,
+ id,
}: PriceInputProps) {
const [text, setText] = useState(() => textFromValue(value, latin));
const textRef = useRef(text);
@@ -77,6 +85,9 @@ export default function PriceInput({
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() {
@@ -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;