feat(admin): force Latin digits in numeric fields globally
- Add a numeric prop to the base Input component that sets inputMode, dir=ltr, lang=en and normalizes Persian/Arabic digits to Latin via the shared toEnglishDigits on every change. - Dedupe digit conversion: PriceInput now uses toEnglishDigits instead of its local map; AppointmentsPage mobile handler uses sanitizeMobileInput. - Fix raw national-code / mobile inputs in NewAppointmentModal and NewAppointmentDrawer that stripped Persian digits without converting. - Cover the numeric Input behaviour with unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import PersianDateInput from './ui/PersianDateInput';
|
|||||||
import PriceInput from './ui/PriceInput';
|
import PriceInput from './ui/PriceInput';
|
||||||
import SearchableSelect from './ui/SearchableSelect';
|
import SearchableSelect from './ui/SearchableSelect';
|
||||||
import { WalletChargeLink } from './AppointmentActions';
|
import { WalletChargeLink } from './AppointmentActions';
|
||||||
import { tehranWallClockToUnix, tomanToRial } from '../lib/utils';
|
import { tehranWallClockToUnix, tomanToRial, toEnglishDigits, sanitizeMobileInput } from '../lib/utils';
|
||||||
|
|
||||||
interface Option { uuid: string; name?: string; full_name?: string }
|
interface Option { uuid: string; name?: string; full_name?: string }
|
||||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||||||
@@ -191,12 +191,12 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
|||||||
</div>
|
</div>
|
||||||
<label style={label}>شماره تماس</label>
|
<label style={label}>شماره تماس</label>
|
||||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||||
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" />
|
<input value={mobile} onChange={e => setMobile(sanitizeMobileInput(e.target.value))} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={11} />
|
||||||
</div>
|
</div>
|
||||||
<label style={label}>کد ملی</label>
|
<label style={label}>کد ملی</label>
|
||||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||||
<input value={nationalCode} onChange={e => setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
<input value={nationalCode} onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
|
||||||
placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" maxLength={10} />
|
placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { toEnglishDigits } from '../../lib/utils';
|
||||||
|
|
||||||
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
|
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||||
hasError?: boolean;
|
hasError?: boolean;
|
||||||
|
/** فیلد فقطعددی: کیبورد عددی، جهت LTR، و تبدیل زندهٔ ارقام فارسی/عربی به لاتین. */
|
||||||
|
numeric?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input متنی پایهٔ design system: کلاس `cp-input`، حالت خطا (قاب قرمز)،
|
* Input متنی پایهٔ design system: کلاس `cp-input`، حالت خطا (قاب قرمز)،
|
||||||
* و forwardRef برای سازگاری با react-hook-form `register`.
|
* و forwardRef برای سازگاری با react-hook-form `register`.
|
||||||
|
* با `numeric`، ورودی به لاتین نرمال میشود تا از ثبت ارقام فارسی جلوگیری شود.
|
||||||
*/
|
*/
|
||||||
export default React.forwardRef<HTMLInputElement, Props>(function Input(
|
export default React.forwardRef<HTMLInputElement, Props>(function Input(
|
||||||
{ hasError, className, style, ...rest },
|
{ hasError, numeric, className, style, onChange, inputMode, dir, lang, ...rest },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
const handleChange = numeric
|
||||||
|
? (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const latin = toEnglishDigits(e.target.value);
|
||||||
|
if (latin !== e.target.value) e.target.value = latin;
|
||||||
|
onChange?.(e);
|
||||||
|
}
|
||||||
|
: onChange;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
{...rest}
|
{...rest}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
onChange={handleChange}
|
||||||
|
inputMode={numeric ? 'numeric' : inputMode}
|
||||||
|
dir={numeric ? 'ltr' : dir}
|
||||||
|
lang={numeric ? 'en' : lang}
|
||||||
className={className ?? 'cp-input'}
|
className={className ?? 'cp-input'}
|
||||||
style={hasError ? { borderColor: 'var(--danger)', ...(style || {}) } : style}
|
style={hasError ? { borderColor: 'var(--danger)', ...(style || {}) } : style}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Field from '@/components/ui/Field';
|
import Field from '@/components/ui/Field';
|
||||||
|
|
||||||
@@ -16,6 +16,31 @@ describe('Input', () => {
|
|||||||
const el = screen.getByPlaceholderText('کدملی') as HTMLInputElement;
|
const el = screen.getByPlaceholderText('کدملی') as HTMLInputElement;
|
||||||
expect(el.style.borderColor).toBe('var(--danger)');
|
expect(el.style.borderColor).toBe('var(--danger)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('با numeric کیبورد عددی و جهت LTR میگیرد', () => {
|
||||||
|
render(<Input placeholder="مبلغ" numeric />);
|
||||||
|
const el = screen.getByPlaceholderText('مبلغ') as HTMLInputElement;
|
||||||
|
expect(el.inputMode).toBe('numeric');
|
||||||
|
expect(el.getAttribute('dir')).toBe('ltr');
|
||||||
|
expect(el.getAttribute('lang')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('با numeric ارقام فارسی را قبل از onChange به لاتین تبدیل میکند', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Input placeholder="مبلغ" numeric onChange={onChange} />);
|
||||||
|
const el = screen.getByPlaceholderText('مبلغ') as HTMLInputElement;
|
||||||
|
fireEvent.change(el, { target: { value: '۱۲۳۴' } });
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange.mock.calls[0][0].target.value).toBe('1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بدون numeric ورودی را دستنخورده میگذارد', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Input placeholder="نام" onChange={onChange} />);
|
||||||
|
const el = screen.getByPlaceholderText('نام') as HTMLInputElement;
|
||||||
|
fireEvent.change(el, { target: { value: 'علی۱۲' } });
|
||||||
|
expect(onChange.mock.calls[0][0].target.value).toBe('علی۱۲');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Field', () => {
|
describe('Field', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { toEnglishDigits } from '../../lib/utils';
|
||||||
|
|
||||||
interface PriceInputProps {
|
interface PriceInputProps {
|
||||||
value: number | '';
|
value: number | '';
|
||||||
@@ -10,17 +11,6 @@ interface PriceInputProps {
|
|||||||
min?: number;
|
min?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERSIAN_TO_LATIN: Record<string, string> = {
|
|
||||||
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
|
|
||||||
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
|
|
||||||
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
|
|
||||||
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
|
|
||||||
};
|
|
||||||
|
|
||||||
function toLatinDigits(str: string): string {
|
|
||||||
return str.replace(/[۰-۹٠-٩]/g, (ch) => PERSIAN_TO_LATIN[ch] ?? ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDisplay(num: number): string {
|
function formatDisplay(num: number): string {
|
||||||
if (num === 0) return '';
|
if (num === 0) return '';
|
||||||
return new Intl.NumberFormat('fa-IR').format(num);
|
return new Intl.NumberFormat('fa-IR').format(num);
|
||||||
@@ -42,7 +32,7 @@ export default function PriceInput({
|
|||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const raw = toLatinDigits(e.target.value).replace(/[^0-9]/g, '');
|
const raw = toEnglishDigits(e.target.value).replace(/[^0-9]/g, '');
|
||||||
const num = raw === '' ? 0 : Math.max(min, parseInt(raw, 10));
|
const num = raw === '' ? 0 : Math.max(min, parseInt(raw, 10));
|
||||||
onChange(num);
|
onChange(num);
|
||||||
setDisplay(num > 0 ? formatDisplay(num) : '');
|
setDisplay(num > 0 ? formatDisplay(num) : '');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { toast } from 'sonner';
|
|||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
||||||
import type { Appointment } from '../types';
|
import type { Appointment } from '../types';
|
||||||
import { formatDate, toGregorianDate, formatTime } from '../lib/utils';
|
import { formatDate, toGregorianDate, formatTime, toEnglishDigits, sanitizeMobileInput } from '../lib/utils';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
||||||
@@ -173,12 +173,7 @@ export function NewAppointmentModal({
|
|||||||
// تغییر موبایل نتیجهی جستجوی قبلی را باطل میکند تا کاربر دوباره جستجو کند.
|
// تغییر موبایل نتیجهی جستجوی قبلی را باطل میکند تا کاربر دوباره جستجو کند.
|
||||||
function onMobileChange(v: string) {
|
function onMobileChange(v: string) {
|
||||||
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته میشود).
|
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته میشود).
|
||||||
const normalized = v
|
setMobile(sanitizeMobileInput(v));
|
||||||
.replace(/[۰-۹]/g, d => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
|
|
||||||
.replace(/[٠-٩]/g, d => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))
|
|
||||||
.replace(/\D/g, '')
|
|
||||||
.slice(0, 11);
|
|
||||||
setMobile(normalized);
|
|
||||||
if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); }
|
if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,9 +260,10 @@ export function NewAppointmentModal({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
lang="en"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
value={nationalCode}
|
value={nationalCode}
|
||||||
onChange={e => setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
|
||||||
placeholder="کد ملی ۱۰ رقمی"
|
placeholder="کد ملی ۱۰ رقمی"
|
||||||
style={{ ...inputSx, direction: 'ltr' }}
|
style={{ ...inputSx, direction: 'ltr' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user