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
@@ -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,
};
}),
)
}
>