refactor: update pricing input handling to use PriceInput component

- Replaced raw input fields for pricing with PriceInput component across various forms and modals to ensure consistent formatting and accessibility.
- Updated tests to reflect changes in pricing input handling, ensuring values are displayed in toman with proper formatting.
- Enhanced accessibility by adding aria-labels and aria-invalid attributes to PriceInput components.
- Adjusted UI elements to improve layout and user experience, particularly in forms related to service items and scheduling.
- Changed labels from "نمایش در نوبت‌دهی" to "نمایش در نوبت‌دهی آنلاین" for clarity.
This commit is contained in:
hamed
2026-08-09 08:38:23 +03:30
parent e48ab9a974
commit b49abee52f
18 changed files with 299 additions and 96 deletions
@@ -28,7 +28,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
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(<FreeVisitPrice />);
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(<FreeVisitPrice />);
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),
));
});
});
+6 -5
View File
@@ -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
<label style={{ fontSize: 11.5, fontWeight: 600 }}>
قیمت (تومان){required && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<input
type="text" inputMode="numeric" dir="ltr" className="input" style={{ width: 200 }}
aria-invalid={!!error}
value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }}
<PriceInput
className="input"
style={{ width: 200 }}
value={value === '' ? '' : Number(value)}
onChange={(v) => { setValue(String(v)); setError(''); }}
/>
</div>
{value !== '' && (
@@ -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(
<ServiceItemFormModal item="create" sectionUuid="section-1" onClose={() => {}} />,
);
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');
});
});
@@ -160,7 +160,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
size="md"
footer={
<>
<button type="button" className="btn" onClick={onClose}>انصراف</button>
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button type="submit" form="service-item-form" className="btn primary" disabled={saving}>
{saving ? 'در حال ذخیره...' : 'ذخیره سرویس'}
</button>
@@ -183,6 +183,8 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
)}
</div>
{/* دو عددِ کوتاه کنار هم؛ پیش از این «قیمت» با «پرسنل مسئول» هم‌ردیف بود و
با افزودن هر پرسنل، چیپ‌ها ستون را بلند می‌کردند و ردیف به‌هم می‌ریخت. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="field-label">قیمت پایه (تومان) *</label>
@@ -200,16 +202,40 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
)}
</div>
<div>
<label className="field-label">پرسنل مسئول</label>
<SearchableSelect
options={staffOptions}
value={''}
onChange={(v) => { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }}
placeholder="افزودن پرسنل (اختیاری)"
noOptionsMessage="پرسنلی باقی نمانده"
height={42}
/>
{selectedStaffUuids.length > 0 && (
<label className="field-label">زمان متوسط (دقیقه)</label>
<div className="field">
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
</div>
<div>
<label className="field-label">نوع خدمت *</label>
<SearchableSelect
options={categories.map((c) => ({ 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}
/>
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
</span>
</div>
{/* تمام‌عرض: چیپ‌های پرسنل چند سطر می‌شوند و در نیمْ‌ستون می‌شکستند. */}
<div>
<label className="field-label">پرسنل مسئول</label>
<SearchableSelect
options={staffOptions}
value={''}
onChange={(v) => { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }}
placeholder="افزودن پرسنل (اختیاری)"
noOptionsMessage="پرسنلی باقی نمانده"
height={42}
/>
{selectedStaffUuids.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{selectedStaffUuids.map((uuid) => (
<span key={uuid} style={{
@@ -229,39 +255,20 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
))}
</div>
)}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, alignItems: 'end' }}>
<div>
<label className="field-label">زمان متوسط (دقیقه)</label>
<div className="field">
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
<div style={{ padding: '9px 0' }}>
<Switch
inline
checked={form.watch('bookable') ?? false}
onChange={(v) => form.setValue('bookable', v, { shouldDirty: true })}
label="نمایش در نوبت‌دهی"
/>
</div>
</div>
<div>
<label className="field-label">نوع خدمت *</label>
<SearchableSelect
options={categories.map((c) => ({ 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}
{/* تصمیمِ «آنلاین رزرو بشود یا نه» یک تنظیم است، نه یک فیلد فرم؛ پیش از این
در نیمهٔ خالیِ ردیفِ «زمان متوسط» با پدینگ دستی هم‌تراز شده بود. */}
<div style={{
padding: '12px 13px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)',
}}>
<Switch
checked={form.watch('bookable') ?? false}
onChange={(v) => form.setValue('bookable', v, { shouldDirty: true })}
label="نمایش در نوبت‌دهی آنلاین"
hint="با فعال بودن این گزینه، بیمار می‌تواند این سرویس را به‌صورت آنلاین رزرو کند."
/>
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
</span>
</div>
<div>
@@ -330,7 +337,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
{editing && onManageInsurance && (
<button
type="button"
className="btn sm"
className="btn secondary sm"
style={{ flexShrink: 0 }}
onClick={() => { const it = editing; onClose(); onManageInsurance(it); }}
>
@@ -111,7 +111,7 @@ export default function ServiceSlotPicker({
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
{resourceUuid
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویس‌ها»ی همین منبع اضافه کنید.'
: 'سرویسی با «نمایش در نوبت‌دهی» برای این پزشک تعریف نشده است.'}
: 'سرویسی با «نمایش در نوبت‌دهی آنلاین» برای این پزشک تعریف نشده است.'}
</div>
);
}
@@ -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(<ResourceServicesPanel {...props({ onSave })} />);
// برچسب و 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(<ResourceServicesPanel {...props({
offerings: [{ ...offerings[0], price_rials: 9_500_000, price_source: 'resource_option' }] as never,
})} />);
expect(screen.getByDisplayValue(fa(950_000))).toBeInTheDocument();
});
it('سرویس تازه با مدت و قیمت پیش‌فرض خودش پر می‌شود', async () => {
const onSave = vi.fn();
renderWithProviders(<ResourceServicesPanel {...props({ onSave })} />);
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(<ResourceServicesPanel {...props({ onSave })} />);
@@ -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({
</div>
<div className="field-block" style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>قیمت (ریال)</label>
<input
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>قیمت (تومان)</label>
<PriceInput
className="field"
inputMode="numeric"
value={line.price_rials}
onChange={(e) => 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] ?? '—'}`}
/>
</div>
@@ -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({
)}
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
خالی گذاشتن مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را
جایگزین میکند؛ سرویسی که اینجا نباشد از این منبع برداشته میشود.
سرویس تازه با مدت و قیمت پیشفرض خودش پر میشود؛ میتوانید عوضش کنید. خالی گذاشتن
مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را جایگزین
میکند؛ سرویسی که اینجا نباشد از این منبع برداشته میشود.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
@@ -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,
};
}),
)
}
>
@@ -776,7 +776,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<span className="text-sm text-[var(--text-2)]">دقیقه</span>
</div>
<p className="text-xs text-[var(--text-3)] leading-relaxed">
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی» در بخش <span className="font-medium">{clinicUuid ? 'سرویس‌های کلینیک' : 'سرویس‌ها'}</span> تعریف کنید، وگرنه ذخیره نمیشود.
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی آنلاین» در بخش <span className="font-medium">{clinicUuid ? 'سرویس‌های کلینیک' : 'سرویس‌ها'}</span> تعریف کنید، وگرنه ذخیره نمیشود.
</p>
</>
) : (
@@ -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 — نمایش و پیش‌انتخاب بیمه', () => {
<CreateStep recordUuid="r-1" profile={null} onCreated={vi.fn()} onCancel={() => {}} />,
);
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(fa(30_000)));
expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument();
});
@@ -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
<span style={fieldLabel}>
قیمت ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
</span>
<input
className="input" type="text" inputMode="numeric" dir="ltr" aria-label="قیمت ویزیت"
aria-invalid={!!visitPriceError}
value={visitPrice}
onChange={(e) => { setVisitPrice(digitsOnly(e.target.value)); setVisitPriceError(''); }}
<PriceInput
className="input"
ariaLabel="قیمت ویزیت"
ariaInvalid={!!visitPriceError}
value={visitPrice === '' ? '' : Number(visitPrice)}
onChange={(v) => { setVisitPrice(String(v)); setVisitPriceError(''); }}
/>
{visitPriceError && (
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>{visitPriceError}</span>
@@ -115,4 +115,23 @@ describe('PriceInput', () => {
rerender(<PriceInput value={0} onChange={vi.fn()} />);
expect(input().value).toBe('');
});
/**
* برچسبِ کنارِ بعضی فیلدهای مبلغ `<label>` واقعی نیست (span/div)، پس نامِ
* دسترسی‌پذیر باید صریح داده شود؛ پیش از این آن فیلدها input خام بودند و
* `aria-label` خودشان را داشتند.
*/
it('ariaLabel نام دسترسی‌پذیر می‌سازد', () => {
render(<PriceInput value={0} onChange={vi.fn()} ariaLabel="قیمت ویزیت" />);
expect(screen.getByRole('textbox', { name: 'قیمت ویزیت' })).toBeTruthy();
});
it('ariaInvalid فقط در حالت خطا روی فیلد می‌نشیند', () => {
const { rerender } = render(<PriceInput value={0} onChange={vi.fn()} ariaInvalid />);
expect(input().getAttribute('aria-invalid')).toBe('true');
rerender(<PriceInput value={0} onChange={vi.fn()} />);
expect(input().getAttribute('aria-invalid')).toBeNull();
});
});
+11
View File
@@ -14,6 +14,11 @@ interface PriceInputProps {
latin?: boolean;
/** واحد نمایشی داخل فیلد، مثلاً «تومان». */
suffix?: string;
/** وقتی برچسبِ کنار فیلد `<label>` واقعی نیست (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({
<input
type="text"
inputMode="numeric"
id={id}
aria-label={ariaLabel}
aria-invalid={ariaInvalid || undefined}
value={text}
onChange={handleChange}
onBlur={handleBlur}
+6 -1
View File
@@ -12,6 +12,7 @@ import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../li
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import PriceInput from '../components/ui/PriceInput';
import Pagination from '../components/ui/Pagination';
import { numericField } from '../lib/forms';
@@ -369,7 +370,11 @@ function PlansTab() {
</div>
<div className="field">
<label>قیمت (ریال) *</label>
<input {...numericField(periodForm.register('price_rials'))} />
<PriceInput
value={periodForm.watch('price_rials') ?? ''}
onChange={(v) => periodForm.setValue('price_rials', v)}
ariaLabel="قیمت دوره (ریال)"
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
+5 -2
View File
@@ -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() {
>
<div className="field">
<label>قیمت ویزیت (تومان)</label>
<input
{...numericField(form.register("visit_price_rials"))}
<PriceInput
value={watchVisit || ''}
onChange={(v) => form.setValue("visit_price_rials", v)}
ariaLabel="قیمت ویزیت (تومان)"
/>
</div>
<div className="field">
+2 -2
View File
@@ -62,7 +62,7 @@ const FIELD_LABELS: Record<string, string> = {
price_rials: 'قیمت پایه',
active: 'وضعیت',
duration_minutes: 'زمان متوسط',
bookable: 'نمایش در نوبت‌دهی',
bookable: 'نمایش در نوبت‌دهی آنلاین',
insurance_covered: 'پوشش بیمه',
inventory_package: 'پکیج کالا',
};
@@ -112,7 +112,7 @@ function InfoTab({ item }: { item: ServiceItem }) {
? <span className="badge blue" style={{ fontSize: 11 }}>{formatNumber(Number(item.duration_minutes))} دقیقه</span>
: '—'}
</Row>
<Row icon={<CalendarDaysIcon style={{ width: 15 }} />} label="نمایش در نوبت‌دهی">
<Row icon={<CalendarDaysIcon style={{ width: 15 }} />} label="نمایش در نوبت‌دهی آنلاین">
{item.bookable
? <span className="badge green" style={{ fontSize: 11 }}>فعال</span>
: <span className="badge gray" style={{ fontSize: 11 }}>غیرفعال</span>}
+25 -3
View File
@@ -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() {
<div className="settings-grid" style={{ marginTop: 18 }}>
<Field label="مبلغ هر نوبت" hint="مبلغی که بیمار هنگام رزرو آنلاین پرداخت می‌کند (تومان).">
<div className="input-suffix">
<input {...numericField(register('appointment_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="15000" />
<PriceInput
className="input"
style={{ maxWidth: 200 }}
ariaLabel="مبلغ هر نوبت (تومان)"
placeholder="۱۵,۰۰۰"
value={watch('appointment_fee_rials') ? Number(watch('appointment_fee_rials')) : ''}
onChange={(v) => setValue('appointment_fee_rials', String(v), { shouldDirty: true })}
/>
<span className="suf">تومان</span>
</div>
</Field>
<Field label="هزینه ثابت پنل پیامک" hint="از مبلغ هر تراکنش کسر می‌شود (تومان).">
<div className="input-suffix">
<input {...numericField(register('sms_panel_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="150000" />
<PriceInput
className="input"
style={{ maxWidth: 200 }}
ariaLabel="هزینه ثابت پنل پیامک (تومان)"
placeholder="۱۵۰,۰۰۰"
value={watch('sms_panel_fee_rials') ? Number(watch('sms_panel_fee_rials')) : ''}
onChange={(v) => setValue('sms_panel_fee_rials', String(v), { shouldDirty: true })}
/>
<span className="suf">تومان</span>
</div>
</Field>
@@ -532,7 +547,14 @@ export default function SettingsPage() {
</div>
</Field>
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (تومان). مبنای محاسبهٔ تعداد پیامک از موجودی.">
<input {...numericField(register('sms_price_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="50" />
<PriceInput
className="input"
style={{ maxWidth: 200 }}
ariaLabel="هزینه هر پیامک (تومان)"
placeholder="۵۰"
value={watch('sms_price_rials') ? Number(watch('sms_price_rials')) : ''}
onChange={(v) => setValue('sms_price_rials', String(v), { shouldDirty: true })}
/>
</Field>
</div>
)}
+6 -3
View File
@@ -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() {
<div className="field">
<label>مبلغ (تومان)</label>
<input
{...numericField(chargeForm.register('amount_rials'))}
placeholder="50000"
<PriceInput
value={watchAmount ?? ''}
onChange={(v) => chargeForm.setValue('amount_rials', v, { shouldValidate: true })}
placeholder="۵۰,۰۰۰"
ariaLabel="مبلغ شارژ (تومان)"
/>
{chargeForm.formState.errors.amount_rials && (
<span className="field-error">{chargeForm.formState.errors.amount_rials.message}</span>
+8 -4
View File
@@ -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;