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:
@@ -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),
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -199,6 +201,30 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
|
||||
<span className="field-error">{form.formState.errors.price_rials.message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<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
|
||||
@@ -230,39 +256,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' }}>
|
||||
{/* تصمیمِ «آنلاین رزرو بشود یا نه» یک تنظیم است، نه یک فیلد فرم؛ پیش از این
|
||||
در نیمهٔ خالیِ ردیفِ «زمان متوسط» با پدینگ دستی همتراز شده بود. */}
|
||||
<div style={{
|
||||
padding: '12px 13px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||||
}}>
|
||||
<Switch
|
||||
inline
|
||||
checked={form.watch('bookable') ?? false}
|
||||
onChange={(v) => form.setValue('bookable', v, { shouldDirty: true })}
|
||||
label="نمایش در نوبتدهی"
|
||||
label="نمایش در نوبتدهی آنلاین"
|
||||
hint="با فعال بودن این گزینه، بیمار میتواند این سرویس را بهصورت آنلاین رزرو کند."
|
||||
/>
|
||||
</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>
|
||||
@@ -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) => ({
|
||||
lines.map((l) => {
|
||||
const toman = l.price_tomans.trim();
|
||||
return {
|
||||
service_uuid: l.service_uuid,
|
||||
duration_minutes: l.duration_minutes.trim(),
|
||||
price_rials: l.price_rials.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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user