feat(admin): normalize Persian/Arabic digits in every numeric field
Users typing on a Persian keyboard produced two distinct failures. Fields with type="number" silently returned an empty string — the browser rejects Persian digits, so the value was lost and saved as empty or zero. Text fields passed the Persian characters straight through to the database, where a mobile stored as ۰۹۱۲… never matches 09… again. The secretary form hit the second case with no validation at all. Frontend: - Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms with numericField()/latinDigitsField() wrappers for React Hook Form fields. - Converts every type="number" input to type="text" inputMode="numeric" with digit normalization; none remain. Fields that legitimately carry non-digits (sheba, landline) only get the digits translated, keeping IR and separators. - Points the patient national-code and mobile schemas at the shared normalizing schemas, which accept Persian input instead of rejecting it. - Drops two duplicate local digit converters in favour of the shared helper. Backend: - Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted numeric keys of JSON request bodies under /api/v1/ before controllers run, so nobat724_front and clinic-pro-tauri are covered too. Translation only — no characters are stripped, non-string values and other keys are untouched. Three component tests asserted on role="spinbutton" and numeric input values; both are properties of type="number", so they were updated to match the new text inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import type { ApiResponse } from '../lib/api';
|
||||
import type { Appointment } from '../types';
|
||||
import Modal from './ui/Modal';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
|
||||
interface Option { uuid: string; name?: string }
|
||||
|
||||
@@ -87,7 +88,7 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
|
||||
</div>
|
||||
<label style={label}>جستجو براساس کد ملی</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||
<input value={f.nationalCode} onChange={e => setF(v => ({ ...v, nationalCode: e.target.value }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." dir="ltr" />
|
||||
<input value={f.nationalCode} onChange={e => setF(v => ({ ...v, nationalCode: digitsOnly(e.target.value, 10) }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." inputMode="numeric" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<label style={label}>بخش</label>
|
||||
|
||||
@@ -11,6 +11,7 @@ import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
|
||||
const TYPE_LABELS: Record<DiscountRuleType, string> = {
|
||||
patient_tag: 'تگ بیمار',
|
||||
@@ -239,12 +240,12 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
|
||||
<div>
|
||||
<label style={labelStyle()}>{f.discount_type === 'percent' ? 'درصد تخفیف (۰ تا ۱۰۰)' : 'مبلغ تخفیف (تومان)'}</label>
|
||||
{f.discount_type === 'percent'
|
||||
? <input className="cp-input" style={{ width: '100%' }} type="number" inputMode="numeric" min={0} max={100} value={f.value} onChange={(e) => set('value', Number(e.target.value) || 0)} />
|
||||
? <input className="cp-input" style={{ width: '100%' }} type="text" inputMode="numeric" dir="ltr" value={f.value} onChange={(e) => set('value', Number(digitsOnly(e.target.value, 3)) || 0)} />
|
||||
: <PriceInput className="cp-input" style={{ width: '100%' }} value={f.value} onChange={(v) => set('value', v)} />}
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle()}>اولویت (بزرگتر = مهمتر)</label>
|
||||
<input className="cp-input" type="number" inputMode="numeric" min={0} value={f.priority} onChange={(e) => set('priority', Number(e.target.value) || 0)} />
|
||||
<input className="cp-input" type="text" inputMode="numeric" dir="ltr" value={f.priority} onChange={(e) => set('priority', Number(digitsOnly(e.target.value)) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,7 +295,7 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
|
||||
{f.type === 'visit_count' && (
|
||||
<div>
|
||||
<label style={labelStyle()}>حداقل تعداد مراجعه</label>
|
||||
<input className="cp-input" type="number" inputMode="numeric" min={1} value={f.min_visit_count} onChange={(e) => set('min_visit_count', Number(e.target.value) || 0)} />
|
||||
<input className="cp-input" type="text" inputMode="numeric" dir="ltr" value={f.min_visit_count} onChange={(e) => set('min_visit_count', Number(digitsOnly(e.target.value)) || 0)} />
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'occasion' && (
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
@@ -40,10 +40,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '50000' } });
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '50000' } });
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
|
||||
@@ -55,7 +55,7 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ میشود (ارسال مجاز)', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
@@ -71,6 +71,6 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
|
||||
expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(50_000);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('50000');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +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';
|
||||
|
||||
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
|
||||
|
||||
@@ -62,9 +63,9 @@ export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string })
|
||||
قیمت (تومان){required && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number" min={0} dir="ltr" className="input" style={{ width: 200 }}
|
||||
type="text" inputMode="numeric" dir="ltr" className="input" style={{ width: 200 }}
|
||||
aria-invalid={!!error}
|
||||
value={value} onChange={(e) => { setValue(e.target.value); setError(''); }}
|
||||
value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }}
|
||||
/>
|
||||
</div>
|
||||
{value !== '' && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import Modal from './ui/Modal';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
import { isoToUnix, rialToToman, tomanToRial, unixToIso } from '../lib/utils';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
|
||||
export interface InsuranceOption {
|
||||
insurance_id: number;
|
||||
@@ -161,15 +162,15 @@ export default function InsuranceModal({ open, editContract, options, kind, onCl
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
<div style={field}>
|
||||
<label style={label}>درصد پوشش</label>
|
||||
<input type="number" min={0} max={100} dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: e.target.value })} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: digitsOnly(e.target.value, 3) })} />
|
||||
</div>
|
||||
<div style={field}>
|
||||
<label style={label}>فرانشیز (تومان)</label>
|
||||
<input type="number" min={0} dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: e.target.value })} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
|
||||
</div>
|
||||
<div style={field}>
|
||||
<label style={label}>سقف تعهد (تومان)</label>
|
||||
<input type="number" min={0} dir="ltr" className="input" placeholder="بینهایت" value={form.ceiling} onChange={(e) => set({ ceiling: e.target.value })} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" className="input" placeholder="بینهایت" value={form.ceiling} onChange={(e) => set({ ceiling: digitsOnly(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import PersianDateInput from './ui/PersianDateInput';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { WalletChargeLink } from './AppointmentActions';
|
||||
import { tehranWallClockToUnix, tomanToRial, rialToToman, toEnglishDigits, sanitizeMobileInput } from '../lib/utils';
|
||||
import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils';
|
||||
|
||||
interface Option { uuid: string; name?: string; full_name?: string }
|
||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||||
@@ -210,7 +210,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
</div>
|
||||
<label style={label}>کد ملی</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||
<input value={nationalCode} onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
|
||||
<input value={nationalCode} onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
||||
placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
|
||||
</div>
|
||||
</>
|
||||
@@ -315,7 +315,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
<>
|
||||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||||
<div className="field" style={{ margin: '6px 0 10px' }}>
|
||||
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
|
||||
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
|
||||
</div>
|
||||
{!isReserve && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import MobileInput from './ui/MobileInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import type { SelectOption } from './ui/SearchableSelect';
|
||||
import PersianDatePicker from './ui/PersianDatePicker';
|
||||
import { iranNationalCodeOptionalSchema, iranMobileOptionalSchema } from '../lib/utils';
|
||||
|
||||
/**
|
||||
* اسکیمای فرم «اطلاعات پرونده». مطابق قواعد بکاند:
|
||||
@@ -16,8 +17,8 @@ import PersianDatePicker from './ui/PersianDatePicker';
|
||||
*/
|
||||
export const patientFormSchema = z.object({
|
||||
name: z.string().trim().min(1, 'نام و نام خانوادگی الزامی است'),
|
||||
mobile: z.string().trim().regex(/^09\d{9}$/, 'شماره موبایل نامعتبر است').or(z.literal('')),
|
||||
national_code: z.string().trim().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد').or(z.literal('')),
|
||||
mobile: iranMobileOptionalSchema,
|
||||
national_code: iranNationalCodeOptionalSchema,
|
||||
gender: z.string().nullable(),
|
||||
birth_date: z.string(),
|
||||
fathers_name: z.string(),
|
||||
@@ -114,7 +115,7 @@ export default function PatientRecordInfoForm({
|
||||
{sel('gender', 'جنسیت', options.gender)}
|
||||
|
||||
<Field label="کدملی" error={errors.national_code?.message}>
|
||||
<Input {...register('national_code')} hasError={!!errors.national_code} dir="ltr" maxLength={10} placeholder="کد ملی" />
|
||||
<Input {...register('national_code')} numeric hasError={!!errors.national_code} maxLength={10} placeholder="کد ملی" />
|
||||
</Field>
|
||||
|
||||
<Field label="شماره تماس" error={errors.mobile?.message}>
|
||||
@@ -181,7 +182,7 @@ export default function PatientRecordInfoForm({
|
||||
</Field>
|
||||
|
||||
<Field label="کد پستی">
|
||||
<Input {...register('postal_code')} dir="ltr" placeholder="کدپستی محل سکونت را وارد نمایید..." />
|
||||
<Input {...register('postal_code')} numeric maxLength={10} placeholder="کدپستی محل سکونت را وارد نمایید..." />
|
||||
</Field>
|
||||
|
||||
{sel('referral_source', 'نحوه آشنایی', options.referral)}
|
||||
|
||||
@@ -127,7 +127,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className="field-label">درصد پوشش</label>
|
||||
<input
|
||||
type="number" min={0} max={100} dir="ltr" className="input"
|
||||
type="text" inputMode="numeric" dir="ltr" className="input"
|
||||
style={{ height: 40, textAlign: 'left' }}
|
||||
value={draft.coverage_percent ?? ''}
|
||||
placeholder="ارث"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import { rialToToman, tomanToRial, toEnglishDigits } from '../../lib/utils';
|
||||
import { rialToToman, tomanToRial, digitsOnly } from '../../lib/utils';
|
||||
import type { InventoryItem, InventoryMeta, ItemPayload } from '../../hooks/useInventory';
|
||||
|
||||
interface Props {
|
||||
@@ -26,7 +26,6 @@ interface FormState {
|
||||
const DEFAULT_UNIT = 'عدد';
|
||||
const BLANK: FormState = { name: '', consumable: '', category: '', unit: DEFAULT_UNIT, price: '', stock: '', alertThreshold: '' };
|
||||
|
||||
const digits = (v: string) => toEnglishDigits(v).replace(/\D/g, '');
|
||||
// group thousands: "1200000" → "1,200,000"
|
||||
const group = (v: string) => v.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
@@ -54,7 +53,7 @@ export default function AddItemModal({ open, editing, meta, saving, onClose, onS
|
||||
const set = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
const setNum = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [k]: digits(e.target.value) }));
|
||||
setForm((f) => ({ ...f, [k]: digitsOnly(e.target.value) }));
|
||||
const setSelect = (k: keyof FormState) => (v: string | number | null) =>
|
||||
setForm((f) => ({ ...f, [k]: v == null ? '' : String(v) }));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useUpdateBankAccount,
|
||||
type BankAccount,
|
||||
} from '../../hooks/usePaymentMethods';
|
||||
import { digitsOnly, toEnglishDigits } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* فرم افزودن/ویرایش حساب بانکی — پورت مبدأ ModalAddBankAccount.jsx.
|
||||
@@ -88,15 +89,15 @@ export default function BankAccountFormModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شماره کارت</label>
|
||||
<input className="input" value={cardNumber} onChange={(e) => setCardNumber(e.target.value)} placeholder="شماره کارت" />
|
||||
<input className="input" type="tel" inputMode="numeric" dir="ltr" value={cardNumber} onChange={(e) => setCardNumber(digitsOnly(e.target.value, 16))} placeholder="شماره کارت" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شماره حساب</label>
|
||||
<input className="input" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="شماره حساب" />
|
||||
<input className="input" type="tel" inputMode="numeric" dir="ltr" value={accountNumber} onChange={(e) => setAccountNumber(digitsOnly(e.target.value))} placeholder="شماره حساب" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شبا</label>
|
||||
<input className="input" value={shabaNumber} onChange={(e) => setShabaNumber(e.target.value)} placeholder="شبا" />
|
||||
<input className="input" inputMode="numeric" dir="ltr" value={shabaNumber} onChange={(e) => setShabaNumber(toEnglishDigits(e.target.value).toUpperCase())} placeholder="شبا" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import { formatNumber } from '../../lib/utils';
|
||||
import { formatNumber, digitsOnly } from '../../lib/utils';
|
||||
import Modal from '../ui/Modal';
|
||||
import ConfirmDialog from '../ui/ConfirmDialog';
|
||||
import GlobalSearchableSelect from '../ui/SearchableSelect';
|
||||
@@ -455,15 +455,15 @@ function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = f
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>هر (دقیقه کار)</div>
|
||||
<div className="field">
|
||||
<input type="number" min={1} value={session.rest_interval}
|
||||
onChange={e => upd('rest_interval', Number(e.target.value))} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" value={session.rest_interval}
|
||||
onChange={e => upd('rest_interval', Number(digitsOnly(e.target.value)) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>استراحت (دقیقه)</div>
|
||||
<div className="field">
|
||||
<input type="number" min={1} value={session.time_to_rest}
|
||||
onChange={e => upd('time_to_rest', Number(e.target.value))} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" value={session.time_to_rest}
|
||||
onChange={e => upd('time_to_rest', Number(digitsOnly(e.target.value)) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -702,10 +702,11 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">فاصله بین نوبتها</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
value={meta.buffer_minutes}
|
||||
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(e.target.value) || 0) }))}
|
||||
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(digitsOnly(e.target.value)) || 0) }))}
|
||||
className="w-16 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">دقیقه</span>
|
||||
@@ -748,11 +749,12 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">رزرو آنلاین تا</span>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
value={meta.booking_window_value}
|
||||
disabled={!meta.online_booking_enabled}
|
||||
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(e.target.value) || 1) }))}
|
||||
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(digitsOnly(e.target.value)) || 1) }))}
|
||||
className="w-14 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 focus:outline-none focus:ring-0 px-2 py-1.5"
|
||||
/>
|
||||
<div style={{ width: 110 }}>
|
||||
|
||||
@@ -78,6 +78,6 @@ describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ r
|
||||
mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false });
|
||||
renderStep();
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(30_000));
|
||||
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import SearchableSelect from '../ui/SearchableSelect';
|
||||
import PersianDateInput from '../ui/PersianDateInput';
|
||||
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { digitsOnly } from '../../lib/utils';
|
||||
|
||||
interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null }
|
||||
interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
|
||||
@@ -411,10 +412,10 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
قیمت ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||||
</span>
|
||||
<input
|
||||
className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت"
|
||||
className="input" type="text" inputMode="numeric" dir="ltr" aria-label="قیمت ویزیت"
|
||||
aria-invalid={!!visitPriceError}
|
||||
value={visitPrice}
|
||||
onChange={(e) => { setVisitPrice(e.target.value); setVisitPriceError(''); }}
|
||||
onChange={(e) => { setVisitPrice(digitsOnly(e.target.value)); setVisitPriceError(''); }}
|
||||
/>
|
||||
{visitPriceError && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>{visitPriceError}</span>
|
||||
@@ -438,11 +439,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<span style={fieldLabel}>تخفیف بیمه پایه (%)</span>
|
||||
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(e.target.value)} />
|
||||
<input className="input" type="text" inputMode="numeric" dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(digitsOnly(e.target.value, 3))} />
|
||||
</div>
|
||||
<div>
|
||||
<span style={fieldLabel}>تخفیف تکمیلی (%)</span>
|
||||
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(e.target.value)} />
|
||||
<input className="input" type="text" inputMode="numeric" dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(digitsOnly(e.target.value, 3))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user