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:
hamed
2026-07-18 10:38:56 +03:30
co-authored by Claude Opus 4.8
parent c103c393f3
commit 00cb9aaa1a
42 changed files with 789 additions and 125 deletions
+304
View File
@@ -0,0 +1,304 @@
# نرمال‌سازی ارقام فارسی/عربی در همه فیلدهای عددی
## پروژه
`clinicpro` (پنل ادمین React + یک لایه دفاعی در backend). لایه backend همه کلاینت‌ها را پوشش می‌دهد — `nobat724_front` و `clinic-pro-tauri` هم از همان `/api/v1/...` استفاده می‌کنند، پس نیازی به پرامپت جدا برای آن‌ها نیست.
## زمینه
کاربر فارسی‌زبان با کیبورد فارسی، عدد را با ارقام فارسی (`۰-۹`) یا عربی (`٠-٩`) تایپ می‌کند. ابزار نرمال‌سازی از قبل در پروژه هست (`toEnglishDigits` در `assets/admin/lib/utils.ts`) و چند کامپوننت (`MobileInput`، `DigitInput`، `PriceInput`، `Input` با prop `numeric`) از آن استفاده می‌کنند — ولی **اکثر فیلدهای عددی پنل از هیچ‌کدام استفاده نمی‌کنند**.
دو نوع خرابی متفاوت رخ می‌دهد و باید هر دو در ذهن باشد:
- **`type="number"`** → مرورگر مقدار را نامعتبر می‌داند و `e.target.value` رشتهٔ **خالی** برمی‌گرداند. یعنی کاربر عدد را می‌بیند ولی فیلد خالی/صفر ذخیره می‌شود — **باگ از دست رفتن داده**، نه مقدار غلط.
- **`type="text"` / `type="tel"`** → ارقام فارسی دست‌نخورده تا دیتابیس می‌روند. مثلاً شماره موبایل `۰۹۱۲...` ذخیره می‌شود و بعداً هیچ‌وقت با `09...` مچ نمی‌شود.
نقطهٔ شروع گزارش کاربر: فرم «افزودن منشی» — هم موبایل و هم کد ملی از نوع دوم‌اند و مستقیم به API می‌روند.
## مشکل / هدف
۱. فرم منشی (موبایل + کد ملی) ارقام فارسی را بدون تبدیل ارسال می‌کند.
۲. حدود ۵۰ فیلد عددی دیگر در پنل همین مشکل را دارند.
۳. هیچ محافظ سمت backend وجود ندارد (فقط یک endpoint نرمال‌سازی می‌کند).
۴. چند پیاده‌سازی تکراری از همان تابع تبدیل در فایل‌های مختلف پخش شده است.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/lib/utils.ts:105-135` | `toEnglishDigits`، `sanitizeMobileInput`، `iranMobileSchema` |
| `assets/admin/components/ui/Input.tsx:19-25` | prop `numeric` — پیاده‌سازی درست، **صفر مصرف‌کننده** |
| `assets/admin/components/ui/MobileInput.tsx` | فیلد موبایل |
| `assets/admin/components/ui/DigitInput.tsx` | فیلد فقط‌رقم با `maxDigits` |
| `assets/admin/components/ui/PriceInput.tsx` | فیلد مبلغ با جداکننده |
| `assets/admin/pages/MySecretariesPage.tsx:226-266, 437, 441` | فرم منشی + `DefaultTextField` خام |
| `assets/admin/pages/RepresentationProfilePage.tsx:21-26` | `toLatinDigits` تکراری — باید حذف شود |
| `assets/admin/components/inventory/AddItemModal.tsx:29` | wrapper محلی `digits()` |
| `src/Shared/Util/PersianText.php:31-34` | نرمال‌ساز backend — فقط در یک controller استفاده شده |
| `src/Doctor/Controller/DoctorClaimController.php:95-99` | تنها مصرف‌کنندهٔ فعلی `PersianText` روی ارقام |
## وضعیت فعلی
### ابزار موجود — `assets/admin/lib/utils.ts:105-116`
```ts
// تبدیل ارقام فارسی/عربی به انگلیسی + حذف هر کاراکتر غیرعددی.
export function toEnglishDigits(input: string): string {
if (!input) return '';
return input
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660));
}
export function sanitizeMobileInput(input: string): string {
return toEnglishDigits(input).replace(/\D/g, '').slice(0, 11);
}
```
> کامنت بالای `toEnglishDigits` غلط است — این تابع کاراکتر غیرعددی را حذف **نمی‌کند**، فقط ارقام را ترجمه می‌کند. کامنت را اصلاح کن.
### الگوی درستِ موجود — `assets/admin/components/ui/Input.tsx:19-25`
```tsx
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;
```
### فرم منشی — `assets/admin/pages/MySecretariesPage.tsx:437, 441`
```tsx
<DefaultTextField placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
...
<DefaultTextField placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
```
`DefaultTextField` (`:226-266`) یک `<input>` خام بدون `type`/`inputMode`/`dir` است و مقدار را عیناً پاس می‌دهد. مقدار در `:773-775` و `:803` بدون هیچ پردازشی ارسال می‌شود. این فرم اصلاً Zod schema ندارد.
### الگوی درست schema — `assets/admin/lib/utils.ts:125-135`
```ts
export const iranMobileSchema = z
.string()
.transform((v) => toEnglishDigits(v).replace(/\D/g, ''))
.refine((v) => IRAN_MOBILE_RE.test(v), 'شماره موبایل باید ۱۱ رقم و با 09 شروع شود');
```
### schemaهایی که ارقام فارسی را رد می‌کنند (چون `\d` فقط ASCII است)
```ts
// components/PatientRecordInfoForm.tsx:20
national_code: z.string().trim().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد').or(z.literal('')),
// pages/PatientRecordFormPage.tsx:21-22
national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'),
mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'),
```
### backend — `src/Shared/Util/PersianText.php:31-34`
```php
$text = strtr($text, array_combine(
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
));
```
فقط در `DoctorClaimController` روی ارقام استفاده شده. بقیهٔ endpointها (منشی، بیمار، پرسنل، کلینیک، سرویس، اشتراک، حساب بانکی) ارقام فارسی را بدون تغییر در دیتابیس می‌نویسند.
## وظایف
### ۱. تکمیل ابزارهای مشترک در `lib/utils.ts`
- کامنت غلط `toEnglishDigits` را اصلاح کن.
- این‌ها را اضافه کن:
```ts
/** فقط ارقام لاتین، با محدودیت طول اختیاری. */
export function digitsOnly(input: string, maxLen?: number): string {
const d = toEnglishDigits(input).replace(/\D/g, '');
return maxLen ? d.slice(0, maxLen) : d;
}
/** برای z.coerce.number() که روی ارقام فارسی NaN می‌دهد. */
export const persianSafeNumber = (schema: z.ZodNumber) =>
z.preprocess((v) => (typeof v === 'string' ? toEnglishDigits(v) : v), schema);
export const IRAN_NATIONAL_CODE_RE = /^\d{10}$/;
export const iranNationalCodeSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
export const iranNationalCodeOptionalSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => v === '' || IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی نامعتبر است');
```
تست‌ها را در `assets/admin/lib/utils.test.ts` اضافه کن (کنار تست‌های موجود `toEnglishDigits` در خطوط ۱۱۸-۱۳۶): ورودی فارسی، عربی، مخلوط، خالی، و رشتهٔ دارای کاراکتر غیرعددی.
### ۲. حذف پیاده‌سازی‌های تکراری
- `assets/admin/pages/RepresentationProfilePage.tsx:21-26` → تابع محلی `toLatinDigits` را حذف و با `toEnglishDigits` جایگزین کن (مصرف در `:158` و `:211`).
- `assets/admin/components/inventory/AddItemModal.tsx:29``digits()` محلی را با `digitsOnly` مشترک جایگزین کن.
### ۳. فرم منشی — نقطهٔ شروع گزارش کاربر
در `assets/admin/pages/MySecretariesPage.tsx`:
- موبایل (`:437`) → `<MobileInput>` (یا `DigitInput` با `maxDigits={11}`).
- کد ملی (`:441`) → `<DigitInput maxDigits={10}>`.
- **یا** ساده‌تر و کم‌ریسک‌تر: به `DefaultTextField` یک prop `numeric?: boolean` و `maxDigits?: number` اضافه کن که داخلش `digitsOnly` صدا بزند، سپس روی این دو فیلد `numeric` بگذار. اگر این راه را رفتی، `type="tel"`، `inputMode="numeric"` و `dir="ltr"` را هم ست کن.
- در `:773-775` و `:803` هم قبل از ارسال `digitsOnly` بزن (دفاع لایه‌ای — کاربر می‌تواند paste کند).
- این فرم schema ندارد؛ حداقل `iranMobileSchema` و `iranNationalCodeOptionalSchema` را روی همین دو فیلد اعمال کن تا خطای فارسی معنادار نشان داده شود.
### ۴. مهاجرت همهٔ فیلدهای عددی
فهرست کامل زیر لیست کار است. برای هر مورد:
- فیلد پول/مبلغ → `<PriceInput>`
- فیلد شمارهٔ ملی/موبایل/کارت/شبا/کد پستی → `<DigitInput maxDigits={n}>`
- بقیه (درصد، مدت، تعداد، وزن، سطح) → `<Input numeric>` یا `type="text" inputMode="numeric"` + `digitsOnly` در `onChange`
- **هیچ فیلد `type="number"` جدیدی نساز** و موجودها را به `type="text" inputMode="numeric"` تبدیل کن، وگرنه مشکل «مقدار خالی» باقی می‌ماند.
- اگر فیلد با React Hook Form `register` شده، `setValueAs` یا `onChange` سفارشی لازم است:
```tsx
<input
type="text"
inputMode="numeric"
dir="ltr"
{...register('price_rials', { setValueAs: (v) => digitsOnly(String(v ?? '')) })}
/>
```
#### منشی
| فایل:خط | فیلد |
|---|---|
| `MySecretariesPage.tsx:437` | `telephone` |
| `MySecretariesPage.tsx:441` | `national_code` |
#### پرسنل
| فایل:خط | فیلد |
|---|---|
| `pages/StaffPage.tsx:265` | `phone` |
| `pages/StaffPage.tsx:269` | `national_code` |
#### بیماران
| فایل:خط | فیلد |
|---|---|
| `pages/PatientRecordFormPage.tsx:129` | `national_code` |
| `pages/PatientRecordFormPage.tsx:132` | `mobile` |
| `components/PatientRecordInfoForm.tsx:117` | `national_code` — فقط prop `numeric` را به `<Input>` اضافه کن |
| `components/PatientRecordInfoForm.tsx:184` | `postal_code` — همان |
| `pages/MyPatientsPage.tsx:1441, 1452, 1464` | `visit_price_rials`، دو فیلد درصد تخفیف |
#### کلینیک/پزشک (تلفن ثابت — موبایل‌ها از قبل درست‌اند)
| فایل:خط | فیلد |
|---|---|
| `pages/ClinicsPage.tsx:278` | `telephone` |
| `pages/ClinicDetailPage.tsx:368` | `telephone` |
| `pages/ClinicFormPage.tsx:65` | `telephone` |
| `pages/DoctorDetailPage.tsx:652` | `telephone` |
#### نوبت
| فایل:خط | فیلد |
|---|---|
| `components/NewAppointmentDrawer.tsx:318` | `duration` |
| `pages/AppointmentCreatePage.tsx:437` | `duration` |
| `components/AppointmentFiltersModal.tsx:90` | `nationalCode` (فیلتر جستجو — بدون تبدیل هیچ‌وقت مچ نمی‌شود) |
#### زمان‌بندی
| فایل:خط | فیلد |
|---|---|
| `components/schedule/ScheduleSection.tsx:458` | `rest_interval` |
| `components/schedule/ScheduleSection.tsx:465` | `time_to_rest` |
| `components/schedule/ScheduleSection.tsx:705` | `buffer_minutes` |
| `components/schedule/ScheduleSection.tsx:751` | `booking_window_value` |
#### مبلغ / درصد
| فایل:خط | فیلد |
|---|---|
| `components/FreeVisitPrice.tsx:65` | قیمت ویزیت |
| `components/session/CreateStep.tsx:414, 441, 445` | قیمت ویزیت، دو درصد بیمه |
| `components/InsuranceModal.tsx:164, 168, 172` | `coverage`، `franchise`، `ceiling` |
| `components/ServiceInsuranceModal.tsx:130` | درصد پوشش |
| `components/DiscountTab.tsx:242, 247, 297` | `value` (درصد)، `priority`، `min_visit_count` |
| `pages/ClinicServicesPage.tsx:529` | `duration_minutes` — placeholder فعلی `"مثلاً: ۵۰"` با ارقام فارسی است و کاربر را به اشتباه می‌اندازد؛ اصلاحش کن |
| `pages/SmsWalletPage.tsx:531` | `amount_rials` |
| `pages/RepresentationSettlementPage.tsx:130` | `amount` |
#### تنظیمات / ادمین
| فایل:خط | فیلد |
|---|---|
| `pages/SettingsPage.tsx:319, 325, 356, 373, 399, 405, 495` | ساعت لغو، ساعت یادآوری، درصد کمیسیون، درصد مالیات، سه فیلد مبلغ |
| `pages/LogsPage.tsx:257` | روزهای نگهداری لاگ |
| `pages/CategoriesPage.tsx:352, 574, 702, 827` | `weight` (چهار جا) |
| `pages/AdminSubscriptionPage.tsx:274, 278, 323, 327, 333` | `level`، `max_secretaries`، `duration_months`، `price_rials`، `sort_order` |
#### نمایندگان
| فایل:خط | فیلد |
|---|---|
| `pages/RepresentationsPage.tsx:251` | `commission_percent` |
| `pages/RepresentationDetailPage.tsx:490` | `commission_percent` |
#### بانکی — هیچ‌کدام تبدیل ندارند
| فایل:خط | فیلد |
|---|---|
| `components/paymentMethods/BankAccountFormModal.tsx:91` | `cardNumber``DigitInput maxDigits={16}` |
| `components/paymentMethods/BankAccountFormModal.tsx:~95` | `accountNumber` |
| `components/paymentMethods/BankAccountFormModal.tsx:99` | `shabaNumber` → شبا حرف `IR` دارد؛ `digitsOnly` خام آن را خراب می‌کند. فقط `toEnglishDigits` بزن و حروف را نگه‌دار |
#### فقط یکدست‌سازی (از قبل درست کار می‌کنند)
`pages/LoginPage.tsx:242, 280, 330` و `components/ui/NotificationMobileCard.tsx:128` از `sanitizeMobileInput` استفاده می‌کنند — به `<MobileInput>` مهاجرت بده، اولویت پایین.
### ۵. اصلاح schemaهای Zod
- `components/PatientRecordInfoForm.tsx:20` و `pages/PatientRecordFormPage.tsx:21-22` → با `iranNationalCodeSchema` / `iranMobileSchema` جایگزین کن.
- همهٔ `z.coerce.number()`ها را با `persianSafeNumber(z.number()...)` بپوشان: `AdminSubscriptionPage.tsx:35, 36, 45, 46, 48`؛ `ClinicServicesPage.tsx:28, 31, 32`؛ `RepresentationsPage.tsx:28`؛ `SmsWalletPage.tsx:25`؛ `MyPatientsPage.tsx:70-74`.
### ۶. لایه دفاعی backend
یک نرمال‌سازی سطح-request بساز تا هیچ کلاینتی (پنل ادمین، `nobat724_front`، `clinic-pro-tauri`) نتواند ارقام فارسی وارد دیتابیس کند.
پیشنهاد: `src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php` روی `kernel.request` که برای درخواست‌های `/api/v1/**` با بدنهٔ JSON، مقدار کلیدهای شناخته‌شده را با `PersianText::normalize` تبدیل کند:
```php
private const NUMERIC_KEYS = [
'mobile', 'mobile_number', 'telephone', 'phone', 'notification_mobile',
'national_code', 'postal_code', 'card_number', 'account_number', 'sheba', 'iban',
'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
'duration_minutes', 'commission_percent', 'coverage', 'franchise', 'ceiling',
];
```
نکات:
- بازگشتی روی آرایه‌های تودرتو اعمال شود (مثلاً `insurances[].patient_share_rials`).
- مقدار فقط ترجمهٔ رقم شود؛ **حذف کاراکتر غیرعددی نکن** (شبا حرف دارد، تلفن ثابت خط تیره).
- فقط روی `string` اعمال شود، `int`/`bool`/`null` دست‌نخورده بماند.
- اگر تشخیص دادی subscriber بیش از حد گسترده است و ریسک دارد، جایگزین کم‌ریسک‌تر: `PersianText::normalize` را در همان چند controller حساس (منشی، بیمار، پرسنل، حساب بانکی) دستی صدا بزن و در گزارش بگو کدام مسیر را رفتی و چرا.
تست backend در `tests/Shared/` بنویس: POST با موبایل فارسی → مقدار ذخیره‌شده لاتین است.
### ۷. تست و مستندات
- `ddev exec yarn test` برای تست‌های `lib/utils.test.ts`
- `ddev exec npx tsc --noEmit` و `ddev exec yarn dev`
- `ddev exec php bin/phpunit tests/Shared`
- اگر subscriber ساختی، رفتار جدید را در `docs/api/README.md` (یا فایل مناسب `docs/api/`) به‌عنوان یک قاعدهٔ سراسری مستند کن: «ارقام فارسی/عربی در فیلدهای عددی سمت سرور نرمال می‌شوند».
## نکات مهم
- **`type="number"` دشمن این کار است.** با ارقام فارسی مقدار خالی برمی‌گرداند و هیچ `onChange` هندلری نجاتش نمی‌دهد. تبدیل به `type="text" inputMode="numeric"` بخش اجباری هر مورد است، نه اختیاری.
- شبا (`IR` + ۲۴ رقم) و تلفن ثابت (`021-1234...`) کاراکتر غیرعددی معتبر دارند — روی این‌ها فقط `toEnglishDigits` بزن نه `digitsOnly`.
- `PriceInput` از قبل خروجی `number` می‌دهد؛ جایگزینی مستقیم `type="number"` با آن ممکن است تایپ فرم را عوض کند — امضای `onChange` را چک کن.
- `<Input numeric>` از قبل ساخته شده و تست نشده چون هیچ مصرف‌کننده‌ای ندارد؛ بعد از اولین استفاده حتماً دستی تست کن.
- فیلدهایی که با RHF `register` شده‌اند با دست‌کاری مستقیم `e.target.value` درست کار نمی‌کنند مگر `setValueAs` یا `Controller` استفاده شود.
- RTL: فیلدهای عددی باید `dir="ltr"` داشته باشند تا عدد وارونه نمایش داده نشود.
- از کلاس‌های CSS موجود استفاده کن (`input`، `field`، `cp-input`)؛ کتابخانه جدید اضافه نکن.
- این تغییر بزرگ و پرتکرار است — **قابلیت‌به‌قابلیت پیش برو** و بعد از هر گروه `tsc` و build بگیر، نه یک‌جا.
@@ -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>
+4 -3
View File
@@ -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 -2
View File
@@ -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 !== '' && (
+4 -3
View File
@@ -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>
+44
View File
@@ -0,0 +1,44 @@
import type { UseFormRegisterReturn } from 'react-hook-form';
import { digitsOnly, toEnglishDigits } from './utils';
/**
* فیلدهای عددی React Hook Form: ارقام فارسی/عربی را حین تایپ به لاتین تبدیل می‌کند.
*
* چرا `type="text"`؟ چون `type="number"` روی ارقام فارسی مقدار را نامعتبر می‌داند
* و `e.target.value` رشتهٔ خالی برمی‌گرداند — یعنی داده از دست می‌رود و هیچ
* onChange‌ای نجاتش نمی‌دهد.
*/
type NumericFieldProps = UseFormRegisterReturn & {
type: 'text';
inputMode: 'numeric';
dir: 'ltr';
};
function wrap(
reg: UseFormRegisterReturn,
normalize: (raw: string) => string,
): NumericFieldProps {
return {
...reg,
type: 'text',
inputMode: 'numeric',
dir: 'ltr',
onChange: (event: { target: any; type?: any }) => {
event.target.value = normalize(String(event.target.value ?? ''));
return reg.onChange(event);
},
};
}
/** فقط ارقام لاتین — برای موبایل، کد ملی، مبلغ، درصد، تعداد. */
export function numericField(reg: UseFormRegisterReturn, maxDigits?: number): NumericFieldProps {
return wrap(reg, (raw) => digitsOnly(raw, maxDigits));
}
/**
* فقط ترجمهٔ رقم؛ جداکننده‌ها و حروف حفظ می‌شوند — برای شبا (`IR…`) و
* تلفن ثابت (`021-1234…`).
*/
export function latinDigitsField(reg: UseFormRegisterReturn): NumericFieldProps {
return wrap(reg, toEnglishDigits);
}
+44
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
digitsOnly,
persianSafeNumber,
iranNationalCodeSchema,
iranNationalCodeOptionalSchema,
formatRial,
rialToToman,
tomanToRial,
@@ -130,6 +135,45 @@ describe('toEnglishDigits', () => {
});
});
describe('digitsOnly', () => {
it('رقم فارسی و عربی مخلوط را نرمال و غیررقم را حذف می‌کند', () => {
expect(digitsOnly('۱۲٣٤-56 ب')).toBe('123456');
});
it('با maxLen برش می‌زند', () => {
expect(digitsOnly('۱۲۳۴۵۶۷۸۹۰۱۲', 10)).toBe('1234567890');
});
it('ورودی خالی → رشته خالی', () => {
expect(digitsOnly('')).toBe('');
});
it('فقط حروف → خالی', () => {
expect(digitsOnly('کد ملی')).toBe('');
});
});
describe('persianSafeNumber', () => {
it('رشته‌ی فارسی را قبل از عدد شدن نرمال می‌کند', () => {
const schema = persianSafeNumber(z.coerce.number());
expect(schema.parse('۱۲۳')).toBe(123);
});
it('عدد را دست‌نخورده رد می‌کند', () => {
const schema = persianSafeNumber(z.coerce.number());
expect(schema.parse(42)).toBe(42);
});
});
describe('iranNationalCodeSchema', () => {
it('کد ملی فارسی را می‌پذیرد و لاتین برمی‌گرداند', () => {
expect(iranNationalCodeSchema.parse('۰۰۱۲۳۴۵۶۷۸')).toBe('0012345678');
});
it('کمتر از ۱۰ رقم رد می‌شود', () => {
expect(() => iranNationalCodeSchema.parse('12345')).toThrow();
});
it('نسخه‌ی اختیاری خالی را می‌پذیرد', () => {
expect(iranNationalCodeOptionalSchema.parse('')).toBe('');
expect(() => iranNationalCodeOptionalSchema.parse('123')).toThrow();
});
});
describe('sanitizeMobileInput', () => {
it('غیررقم حذف، رقم فارسی نرمال، حداکثر ۱۱ رقم', () => {
expect(sanitizeMobileInput('۰۹۱۲-۳۴۵ ۶۷۸۹۰۱۲')).toBe('09123456789');
+28 -2
View File
@@ -102,7 +102,8 @@ export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ');
}
// تبدیل ارقام فارسی/عربی به انگلیسی + حذف هر کاراکتر غیرعددی.
// ترجمه‌ی ارقام فارسی/عربی به لاتین. کاراکترهای غیرعددی دست‌نخورده می‌مانند
// (برای شبا و تلفن ثابت که حرف و خط تیره دارند لازم است).
export function toEnglishDigits(input: string): string {
if (!input) return '';
return input
@@ -110,11 +111,23 @@ export function toEnglishDigits(input: string): string {
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660));
}
// فقط ارقام لاتین، با محدودیت طول اختیاری.
export function digitsOnly(input: string, maxLen?: number): string {
const digits = toEnglishDigits(input).replace(/\D/g, '');
return maxLen ? digits.slice(0, maxLen) : digits;
}
// فقط ارقام انگلیسی، حداکثر ۱۱ رقم (برای فیلد موبایل).
export function sanitizeMobileInput(input: string): string {
return toEnglishDigits(input).replace(/\D/g, '').slice(0, 11);
return digitsOnly(input, 11);
}
// z.coerce.number() روی رشته‌ی فارسی NaN می‌دهد. فیلدهای عددی پنل در مبدأ (numericField
// در lib/forms.ts) نرمال می‌شوند، پس این wrapper فقط برای مصرف‌کننده‌های خارج از آن مسیر است.
// روی resolverهای React Hook Form استفاده نکن — z.preprocess تایپ ورودی را unknown می‌کند.
export const persianSafeNumber = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess((v) => (typeof v === 'string' ? toEnglishDigits(v) : v), schema);
// regex شماره موبایل ایران
export const IRAN_MOBILE_RE = /^09\d{9}$/;
@@ -133,3 +146,16 @@ export const iranMobileOptionalSchema = z
.string()
.transform((v) => toEnglishDigits(v).replace(/\D/g, ''))
.refine((v) => v === '' || IRAN_MOBILE_RE.test(v), 'شماره موبایل نامعتبر است');
// regex کد ملی ایران (۱۰ رقم؛ صحت رقم کنترلی اینجا بررسی نمی‌شود)
export const IRAN_NATIONAL_CODE_RE = /^\d{10}$/;
export const iranNationalCodeSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
export const iranNationalCodeOptionalSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => v === '' || IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
+6 -5
View File
@@ -13,6 +13,7 @@ import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
import { numericField } from '../lib/forms';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -271,11 +272,11 @@ function PlansTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>سطح *</label>
<input {...planForm.register('level')} type="number" min={0} dir="ltr" />
<input {...numericField(planForm.register('level'))} />
</div>
<div className="field">
<label>حداکثر منشی *</label>
<input {...planForm.register('max_secretaries')} type="number" min={1} dir="ltr" />
<input {...numericField(planForm.register('max_secretaries'))} />
</div>
</div>
<div>
@@ -320,17 +321,17 @@ function PlansTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>مدت (ماه) *</label>
<input {...periodForm.register('duration_months')} type="number" min={1} dir="ltr" />
<input {...numericField(periodForm.register('duration_months'))} />
</div>
<div className="field">
<label>قیمت (ریال) *</label>
<input {...periodForm.register('price_rials')} type="number" min={0} dir="ltr" />
<input {...numericField(periodForm.register('price_rials'))} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>ترتیب نمایش</label>
<input {...periodForm.register('sort_order')} type="number" min={0} dir="ltr" />
<input {...numericField(periodForm.register('sort_order'))} />
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
+2 -1
View File
@@ -14,6 +14,7 @@ import { WalletChargeLink } from '../components/AppointmentActions';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
/**
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
@@ -434,7 +435,7 @@ export default function AppointmentCreatePage() {
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<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>
</div>
<div>
+2 -2
View File
@@ -9,7 +9,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { Appointment } from '../types';
import { formatDate, toGregorianDate, formatTime, toEnglishDigits, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
import PriceInput from '../components/ui/PriceInput';
import { useAuthStore } from '../stores/authStore';
import Pagination from '../components/ui/Pagination';
@@ -280,7 +280,7 @@ export function NewAppointmentModal({
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ ...inputSx, direction: 'ltr' }}
/>
+5 -4
View File
@@ -18,6 +18,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags';
@@ -349,7 +350,7 @@ function ProvincesTab() {
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" style={{ maxWidth: 120 }} />
<input {...numericField(register('weight'))} className="input" placeholder="0" style={{ maxWidth: 120 }} />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>وضعیت</label>
@@ -571,7 +572,7 @@ function CitiesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
@@ -699,7 +700,7 @@ function SpecialtiesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
@@ -824,7 +825,7 @@ function DoctorServicesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
+2 -1
View File
@@ -22,6 +22,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
import { latinDigitsField } from '../lib/forms';
// Fix leaflet icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -365,7 +366,7 @@ function EditModal({ clinic, onClose, onSaved }: {
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" dir="ltr" placeholder="021-12345678" {...register('telephone')} />
<input className="input" placeholder="021-12345678" {...latinDigitsField(register('telephone'))} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>توضیحات</label>
+2 -1
View File
@@ -10,6 +10,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import MobileInput from '../components/ui/MobileInput';
import { iranMobileSchema } from '../lib/utils';
import { latinDigitsField } from '../lib/forms';
const schema = z.object({
owner_mobile: iranMobileSchema,
@@ -62,7 +63,7 @@ export default function ClinicFormPage() {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>تلفن ثابت</label>
<input className="field" placeholder="02xxxxxxxx" dir="ltr" {...register('telephone')} />
<input className="field" placeholder="02xxxxxxxx" {...latinDigitsField(register('telephone'))} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>آدرس</label>
+2 -1
View File
@@ -21,6 +21,7 @@ import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import FeatureGate from '../components/ui/FeatureGate';
import { numericField } from '../lib/forms';
const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') });
const itemSchema = z.object({
@@ -526,7 +527,7 @@ function ClinicServicesPageInner() {
<div>
<label className="field-label">زمان متوسط (دقیقه)</label>
<div className="field">
<input type="number" min={0} {...itemForm.register('duration_minutes')} placeholder="مثلاً: ۵۰" />
<input {...numericField(itemForm.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
+2 -1
View File
@@ -21,6 +21,7 @@ import Portal from '../components/ui/Portal';
import { formatDate, formatNumber, iranMobileSchema } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import { latinDigitsField } from '../lib/forms';
const HUES_LIST = [256, 205, 162, 295, 272];
@@ -275,7 +276,7 @@ export default function ClinicsPage() {
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
تلفن (اختیاری)
</label>
<input className="input" placeholder="مثال: 021-12345678" dir="ltr" {...addForm.register('telephone')} />
<input className="input" placeholder="مثال: 021-12345678" {...latinDigitsField(addForm.register('telephone'))} />
</div>
</div>
<div className="modal-foot">
+2 -1
View File
@@ -33,6 +33,7 @@ import PersianDatePicker from '../components/ui/PersianDatePicker';
import ImageCropModal from '../components/ImageCropModal';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import { latinDigitsField } from '../lib/forms';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -649,7 +650,7 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
تلفن <span className="text-red-500">*</span>
</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="021..." {...register('telephone')} />
<input className="cp-input text-left" placeholder="021..." {...latinDigitsField(register('telephone'))} />
{errors.telephone && <p className="text-xs text-red-500 mt-1">{errors.telephone.message}</p>}
</div>
</div>
+5 -3
View File
@@ -10,6 +10,7 @@ import Pagination from '../components/ui/Pagination';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
const LEVEL_META: Record<string, { label: string; cls: string }> = {
emergency: { label: 'اضطراری', cls: 'red' },
@@ -254,11 +255,12 @@ function RetentionSettings() {
</label>
<input
className="input"
type="number"
min={0}
type="text"
inputMode="numeric"
dir="ltr"
value={value}
placeholder={settingsQuery.isLoading ? 'در حال بارگذاری...' : '90'}
onChange={(e) => { setDays(e.target.value); setTouched(true); }}
onChange={(e) => { setDays(digitsOnly(e.target.value)); setTouched(true); }}
style={{ maxWidth: 200 }}
/>
<div className="muted" style={{ fontSize: 12, marginTop: 6, lineHeight: 1.7 }}>
+6 -16
View File
@@ -52,6 +52,7 @@ import {
import type { ApiResponse, PaginatedResponse } from "../lib/api";
import { api } from "../lib/api";
import { useIssueInvoice } from "../hooks/useIssueInvoice";
import { numericField } from "../lib/forms";
import {
formatDate,
formatDateTime,
@@ -1437,34 +1438,23 @@ function MyPatientsPageInner() {
<div className="field">
<label>قیمت ویزیت (تومان)</label>
<input
{...form.register("visit_price_rials")}
type="number"
min={0}
dir="ltr"
{...numericField(form.register("visit_price_rials"))}
/>
</div>
<div className="field">
<label>تخفیف بیمه پایه (%)</label>
<input
{...form.register(
{...numericField(form.register(
"base_insurance_discount_percent",
)}
type="number"
min={0}
max={100}
dir="ltr"
), 3)}
/>
</div>
<div className="field">
<label>تخفیف تکمیلی (%)</label>
<input
{...form.register(
{...numericField(form.register(
"supplementary_discount_percent",
)}
type="number"
min={0}
max={100}
dir="ltr"
), 3)}
/>
</div>
</div>
+16 -8
View File
@@ -7,7 +7,7 @@ import ConfirmDialog from "../components/ui/ConfirmDialog";
import Modal from "../components/ui/Modal";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatDate } from "../lib/utils";
import { formatDate, digitsOnly, IRAN_MOBILE_RE, IRAN_NATIONAL_CODE_RE } from "../lib/utils";
import { useSubscription } from "../hooks/useSubscription";
import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
@@ -230,6 +230,8 @@ function DefaultTextField({
disabled,
multiline,
rows,
numeric,
maxDigits,
}: {
placeholder?: string;
value: string;
@@ -237,6 +239,9 @@ function DefaultTextField({
disabled?: boolean;
multiline?: boolean;
rows?: number;
/** فیلد فقط‌عددی: ارقام فارسی/عربی به لاتین تبدیل و غیررقم حذف می‌شود. */
numeric?: boolean;
maxDigits?: number;
}) {
const cls =
"w-full bg-[#FAFAFA] dark:bg-[#222433] rounded-[8px] border border-[#D7D7D7] dark:border-[#343645] " +
@@ -260,7 +265,8 @@ function DefaultTextField({
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
{...(numeric ? { type: "tel", inputMode: "numeric" as const, dir: "ltr" as const } : {})}
onChange={(e) => onChange(numeric ? digitsOnly(e.target.value, maxDigits) : e.target.value)}
/>
);
}
@@ -363,8 +369,10 @@ function SecretaryModal({
if (!form.name.trim()) return toast.error("لطفاً نام را وارد کنید");
if (!form.family.trim()) return toast.error("لطفاً نام خانوادگی را وارد کنید");
if (!form.telephone.trim()) return toast.error("لطفاً شماره تلفن را وارد کنید");
if (!/^09\d{9}$/.test(form.telephone))
if (!IRAN_MOBILE_RE.test(digitsOnly(form.telephone, 11)))
return toast.error("شماره تلفن باید 11 رقم و با 09 شروع شود");
if (form.national_code.trim() && !IRAN_NATIONAL_CODE_RE.test(digitsOnly(form.national_code, 10)))
return toast.error("کد ملی باید ۱۰ رقم باشد");
if (showDoctorPicker && doctorUuids.length === 0)
return toast.error("حداقل یک پزشک را انتخاب کنید");
onSubmit(form, doctorUuids);
@@ -434,11 +442,11 @@ function SecretaryModal({
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-[16px]">
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
<p className="w-full text-right text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium">شماره موبایل</p>
<DefaultTextField placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
<DefaultTextField numeric maxDigits={11} placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
</div>
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
<p className="w-full text-right text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium">کد ملی</p>
<DefaultTextField placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
<DefaultTextField numeric maxDigits={10} placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
</div>
</div>
@@ -770,9 +778,9 @@ function MySecretariesPageContent() {
const createMutation = useMutation({
mutationFn: ({ form, doctorUuids }: { form: FormState; doctorUuids: string[] }) => {
const base = {
mobile_number: form.telephone,
mobile_number: digitsOnly(form.telephone, 11),
name: `${form.name} ${form.family}`.trim(),
national_code: form.national_code || null,
national_code: digitsOnly(form.national_code, 10) || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
};
@@ -800,7 +808,7 @@ function MySecretariesPageContent() {
mutationFn: ({ uuid, form }: { uuid: string; form: FormState }) =>
api.patch(`/api/v1/secretary/${uuid}`, {
name: `${form.name} ${form.family}`.trim(),
national_code: form.national_code || null,
national_code: digitsOnly(form.national_code, 10) || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
}),
+6 -4
View File
@@ -11,6 +11,8 @@ import type { ApiResponse } from '../lib/api';
import type { PatientRecord } from '../types';
import PersianDateInput from '../components/ui/PersianDateInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
import { iranNationalCodeSchema, iranMobileSchema } from '../lib/utils';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
@@ -18,8 +20,8 @@ const schema = z.object({
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'),
mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'),
national_code: iranNationalCodeSchema,
mobile: iranMobileSchema,
birth_date: z.string().optional(),
referral_source: z.string().optional(),
description: z.string().optional(),
@@ -126,10 +128,10 @@ export default function PatientRecordFormPage() {
/>
</Field>
<Field label="کد ملی" required error={form.formState.errors.national_code?.message}>
<div className="field"><input {...form.register('national_code')} inputMode="numeric" placeholder="کد ملی را وارد نمایید" /></div>
<div className="field"><input {...numericField(form.register('national_code'), 10)} placeholder="کد ملی را وارد نمایید" /></div>
</Field>
<Field label="شماره تماس" required error={form.formState.errors.mobile?.message}>
<div className="field"><input {...form.register('mobile')} inputMode="numeric" placeholder="شماره تماس را وارد نمایید" /></div>
<div className="field"><input {...numericField(form.register('mobile'), 11)} placeholder="شماره تماس را وارد نمایید" /></div>
</Field>
<Field label="تاریخ تولد">
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
@@ -12,6 +12,7 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface RepDoctor {
uuid: string;
@@ -486,8 +487,8 @@ export default function RepresentationDetailPage() {
</div>
<div>
<label className="">درصد کمیسیون</label>
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: e.target.value }))}
type="number" min="0" max="100" dir="ltr"
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: digitsOnly(e.target.value, 3) }))}
type="text" inputMode="numeric" dir="ltr"
className="cp-input h-11" />
</div>
</div>
@@ -5,6 +5,7 @@ import { CheckBadgeIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import { toEnglishDigits, digitsOnly } from '../lib/utils';
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
function toJalali(iso: string): string {
@@ -17,13 +18,6 @@ function toJalali(iso: string): string {
return y && m && d ? `${y}/${m}/${d}` : '';
}
// ارقام فارسی/عربی → لاتین (کیبورد انگلیسی؛ ورودی چسبانده‌شده هم نرمال شود)
function toLatinDigits(s: string): string {
return s.replace(/[۰-۹٠-٩]/g, (d) =>
String('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩'.indexOf(d) % 10),
);
}
// اعتبارسنجی کد ملی ایران (طول ۱۰ + رقم کنترلی)
function isValidIranNationalCode(code: string): boolean {
if (!/^\d{10}$/.test(code)) return false;
@@ -37,7 +31,7 @@ function isValidIranNationalCode(code: string): boolean {
// اعتبارسنجی شبای ایران: IR + ۲۴ رقم + کنترل mod-97
function isValidIranIban(raw: string): boolean {
const iban = toLatinDigits(raw).replace(/\s/g, '').toUpperCase();
const iban = toEnglishDigits(raw).replace(/\s/g, '').toUpperCase();
if (!/^IR\d{24}$/.test(iban)) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
@@ -115,13 +109,13 @@ export default function RepresentationProfilePage() {
});
const submitNationalCode = () => {
const code = toLatinDigits(nationalCode).replace(/\D/g, '');
const code = digitsOnly(nationalCode);
if (!isValidIranNationalCode(code)) { toast.error('کد ملی نامعتبر است'); return; }
verifyMut.mutate(code);
};
const submitIban = () => {
const clean = toLatinDigits(iban).replace(/\s/g, '').toUpperCase();
const clean = toEnglishDigits(iban).replace(/\s/g, '').toUpperCase();
if (!isValidIranIban(clean)) { toast.error('شماره شبا نامعتبر است (IR + ۲۴ رقم)'); return; }
const jalali = toJalali(birthDate);
if (!jalali) { toast.error('تاریخ تولد را انتخاب کنید'); return; }
@@ -157,7 +151,7 @@ export default function RepresentationProfilePage() {
<input
type="text" inputMode="numeric" dir="ltr" maxLength={10} value={nationalCode}
placeholder="کد ملی ۱۰ رقمی"
onChange={(e) => setNationalCode(toLatinDigits(e.target.value).replace(/\D/g, ''))}
onChange={(e) => setNationalCode(digitsOnly(e.target.value, 10))}
style={{ width: 220, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box', textAlign: 'center' }}
/>
<button className="btn primary" onClick={submitNationalCode} disabled={verifyMut.isPending}>
@@ -210,7 +204,7 @@ export default function RepresentationProfilePage() {
<input
type="text" inputMode="numeric" dir="ltr" maxLength={26} value={iban}
placeholder="IR000000000000000000000000"
onChange={(e) => setIban(toLatinDigits(e.target.value).toUpperCase().replace(/[^IR0-9]/g, ''))}
onChange={(e) => setIban(toEnglishDigits(e.target.value).toUpperCase().replace(/[^IR0-9]/g, ''))}
style={{ width: 320, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box', fontFamily: 'monospace' }}
/>
<PersianDatePicker
@@ -6,6 +6,7 @@ import type { ApiResponse } from '../lib/api';
import { formatRial, formatDate, tomanToRial } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface WalletBalance { balance_rials: number }
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
@@ -127,8 +128,8 @@ export default function RepresentationSettlementPage() {
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
type="number" min={0} dir="ltr" value={amount} placeholder="مبلغ به تومان"
onChange={(e) => setAmount(e.target.value)}
type="text" inputMode="numeric" dir="ltr" value={amount} placeholder="مبلغ به تومان"
onChange={(e) => setAmount(digitsOnly(e.target.value))}
style={{ width: 200, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }}
/>
<div style={{ width: 320 }}>
+2 -1
View File
@@ -18,6 +18,7 @@ import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
@@ -248,7 +249,7 @@ export default function RepresentationsPage() {
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>درصد کمیسیون</label>
<input {...register('commission_percent')} type="number" min="0" max="100" placeholder="10" dir="ltr"
<input {...numericField(register('commission_percent'), 3)} placeholder="10" dir="ltr"
className="input" />
{errors.commission_percent && <p className="err-text">{errors.commission_percent.message}</p>}
</div>
+8 -7
View File
@@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatDateTime, rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
import {
Cog6ToothIcon, ClockIcon, CalculatorIcon, CreditCardIcon, ChatBubbleLeftRightIcon,
MagnifyingGlassIcon, CheckCircleIcon, ExclamationTriangleIcon, EyeIcon, EyeSlashIcon,
@@ -316,13 +317,13 @@ export default function SettingsPage() {
<div className="settings-grid">
<Field label="مهلت مجاز لغو نوبت" hint="بیمار تا این تعداد ساعت پیش از نوبت اجازه لغو دارد.">
<div className="input-suffix">
<input {...register('max_cancel_hours_before')} type="number" min={0} className="input" style={{ maxWidth: 130 }} />
<input {...numericField(register('max_cancel_hours_before'))} className="input" style={{ maxWidth: 130 }} />
<span className="suf">ساعت قبل</span>
</div>
</Field>
<Field label="ارسال یادآور نوبت" hint="پیامک یادآوری این تعداد ساعت پیش از نوبت ارسال می‌شود.">
<div className="input-suffix">
<input {...register('appointment_reminder_hours')} type="number" min={0} className="input" style={{ maxWidth: 130 }} />
<input {...numericField(register('appointment_reminder_hours'))} className="input" style={{ maxWidth: 130 }} />
<span className="suf">ساعت قبل</span>
</div>
</Field>
@@ -353,7 +354,7 @@ export default function SettingsPage() {
{upgradeCommissionEnabled && (
<div className="toggle-sub">
<div className="input-suffix">
<input {...register('upgrade_commission_percent')} type="number" min={0} max={100} className="input" style={{ maxWidth: 120 }} />
<input {...numericField(register('upgrade_commission_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
<span className="suf">درصد (۰۱۰۰)</span>
</div>
</div>
@@ -370,7 +371,7 @@ export default function SettingsPage() {
{taxEnabled && (
<div className="toggle-sub">
<div className="input-suffix">
<input {...register('tax_percent')} type="number" min={0} max={100} className="input" style={{ maxWidth: 120 }} />
<input {...numericField(register('tax_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
<span className="suf">درصد (۰۱۰۰)</span>
</div>
</div>
@@ -396,13 +397,13 @@ export default function SettingsPage() {
<div className="settings-grid" style={{ marginTop: 18 }}>
<Field label="مبلغ هر نوبت" hint="مبلغی که بیمار هنگام رزرو آنلاین پرداخت می‌کند (تومان).">
<div className="input-suffix">
<input {...register('appointment_fee_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="15000" />
<input {...numericField(register('appointment_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="15000" />
<span className="suf">تومان</span>
</div>
</Field>
<Field label="هزینه ثابت پنل پیامک" hint="از مبلغ هر تراکنش کسر می‌شود (تومان).">
<div className="input-suffix">
<input {...register('sms_panel_fee_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="150000" />
<input {...numericField(register('sms_panel_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="150000" />
<span className="suf">تومان</span>
</div>
</Field>
@@ -492,7 +493,7 @@ export default function SettingsPage() {
</div>
</Field>
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (تومان). مبنای محاسبهٔ تعداد پیامک از موجودی.">
<input {...register('sms_price_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="50" />
<input {...numericField(register('sms_price_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="50" />
</Field>
</div>
)}
+3 -3
View File
@@ -20,6 +20,7 @@ import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import PageHeader from '../components/ui/PageHeader';
import FeatureGate from '../components/ui/FeatureGate';
import { numericField } from '../lib/forms';
const chargeSchema = z.object({
amount_rials: z.coerce.number().min(1000, 'حداقل مبلغ ۱٬۰۰۰ تومان است'),
@@ -528,9 +529,8 @@ function SmsWalletPageInner() {
<div className="field">
<label>مبلغ (تومان)</label>
<input
{...chargeForm.register('amount_rials')}
type="number" min={1000}
placeholder="50000" dir="ltr"
{...numericField(chargeForm.register('amount_rials'))}
placeholder="50000"
/>
{chargeForm.formState.errors.amount_rials && (
<span className="field-error">{chargeForm.formState.errors.amount_rials.message}</span>
+3 -2
View File
@@ -15,6 +15,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import SettingsLayout from '../components/layout/SettingsLayout';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { numericField } from '../lib/forms';
const schema = z.object({
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'),
@@ -262,11 +263,11 @@ function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormDa
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>تلفن</label>
<input {...register('phone')} placeholder="09121234567" dir="ltr" />
<input {...numericField(register('phone'), 11)} placeholder="09121234567" />
</div>
<div className="field">
<label>کد ملی</label>
<input {...register('national_code')} placeholder="0012345678" dir="ltr" />
<input {...numericField(register('national_code'), 10)} placeholder="0012345678" />
</div>
</div>
<div className="field">
+33
View File
@@ -39,6 +39,39 @@ Authorization: Bearer <JWT_TOKEN>
---
## Persian digit normalization (global)
Persian (`۰-۹`) and Arabic (`٠-٩`) digits sent in numeric request fields are translated to Latin **server-side, before the controller runs**`src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php`. Every client benefits: the React admin panel, `nobat724_front`, and `clinic-pro-tauri`.
Applies to `POST` / `PUT` / `PATCH` requests under `/api/v1/` with a JSON body, recursively through nested arrays.
**Normalized keys:**
```
mobile, mobile_number, telephone, phone, notification_mobile,
national_code, postal_code,
card_number, account_number, sheba, shaba, iban,
price_rials, amount_rials, amount, free_visit_price_rials,
insurance_price_rials, patient_share_rials, visit_price_rials,
duration_minutes, duration, commission_percent, coverage,
coverage_percent, franchise, ceiling, tax_percent,
base_insurance_discount_percent, supplementary_discount_percent
```
Only **digits** are translated — no characters are stripped, so `IR` in a sheba and `-` in a landline survive. Non-string values (`int`, `bool`, `null`) and keys outside the list are untouched, so a name like `منشی شماره ۲` keeps its Persian digit.
```jsonc
// request
{ "mobile_number": "۰۹۱۲۳۴۵۶۷۸۹", "national_code": "۰۰۱۲۳۴۵۶۷۸", "name": "منشی شماره ۲" }
// what the controller sees
{ "mobile_number": "09123456789", "national_code": "0012345678", "name": "منشی شماره ۲" }
```
> Adding a new numeric field to any endpoint? Add its key to `NUMERIC_KEYS` in the subscriber, otherwise Persian digits reach the database.
---
## Modules
| File | Domain | Endpoints |
+2 -2
View File
@@ -79,9 +79,9 @@ Create a secretary for a doctor.
| --------------- | ------------- | -------- | -------------------------------------------- |
| `doctor_uuid` | string (UUID) | ✅\* | Single doctor to assign (legacy/doctor flow) |
| `doctor_uuids` | string[] (UUID) | ✅\* | **Clinic only** — assign one secretary to several clinic doctors at once. When present (non-empty) and caller is `ROLE_CLINIC`, this multi-doctor path is used instead of `doctor_uuid` |
| `mobile_number` | string | ✅ | Secretary's login mobile |
| `mobile_number` | string | ✅ | Secretary's login mobile. Persian/Arabic digits are accepted and normalized server-side — see [README → Persian digit normalization](README.md#persian-digit-normalization-global) |
| `name` | string | ❌ | Full name (نام + نام خانوادگی) → `user_name` |
| `national_code` | string | ❌ | کد ملی منشی (nullable) |
| `national_code` | string | ❌ | کد ملی منشی (nullable). Persian/Arabic digits accepted and normalized |
| `address` | string | ❌ | آدرس منشی (nullable) |
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
| `permissions` | object | ❌ | Permission set (see structure below) |
@@ -0,0 +1,93 @@
<?php
namespace App\Shared\EventSubscriber;
use App\Shared\Util\PersianText;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* ارقام فارسی/عربی را در بدنهٔ JSON درخواست‌های API به لاتین ترجمه می‌کند.
*
* پنل ادمین ورودی‌ها را در مبدأ نرمال می‌کند، ولی `nobat724_front` و
* `clinic-pro-tauri` هم همین API را صدا می‌زنند؛ این لایه تضمین می‌کند هیچ
* کلاینتی نتواند رقم فارسی وارد دیتابیس کند.
*
* فقط ترجمهٔ رقم انجام می‌شود کاراکتر غیرعددی حذف نمی‌شود چون شبا حرف `IR`
* دارد و تلفن ثابت خط تیره.
*/
class NumericFieldNormalizerSubscriber implements EventSubscriberInterface
{
/** کلیدهایی که مقدارشان عددی است و باید نرمال شوند. */
private const NUMERIC_KEYS = [
'mobile', 'mobile_number', 'telephone', 'phone', 'notification_mobile',
'national_code', 'postal_code',
'card_number', 'account_number', 'sheba', 'shaba', 'iban',
'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
'insurance_price_rials', 'patient_share_rials', 'visit_price_rials',
'duration_minutes', 'duration', 'commission_percent', 'coverage',
'coverage_percent', 'franchise', 'ceiling', 'tax_percent',
'base_insurance_discount_percent', 'supplementary_discount_percent',
];
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onKernelRequest', 8]];
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/api/v1/')) {
return;
}
if (!in_array($request->getMethod(), ['POST', 'PUT', 'PATCH'], true)) {
return;
}
if (!str_contains((string) $request->headers->get('Content-Type'), 'json')) {
return;
}
$content = $request->getContent();
if ($content === '') {
return;
}
$data = json_decode($content, true);
if (!is_array($data)) {
return;
}
$normalized = $this->normalizeTree($data);
if ($normalized === $data) {
return;
}
$request->initialize(
$request->query->all(),
$request->request->all(),
$request->attributes->all(),
$request->cookies->all(),
$request->files->all(),
$request->server->all(),
json_encode($normalized, JSON_UNESCAPED_UNICODE),
);
}
private function normalizeTree(array $data): array
{
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = $this->normalizeTree($value);
continue;
}
if (is_string($value) && in_array((string) $key, self::NUMERIC_KEYS, true)) {
$data[$key] = PersianText::digits($value);
}
}
return $data;
}
}
+14
View File
@@ -36,6 +36,20 @@ final class PersianText
return trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
}
/**
* فقط ارقام فارسی/عربی را به لاتین ترجمه می‌کند و بقیهٔ کاراکترها را دست نمی‌زند.
*
* برخلاف normalize() فاصله‌ها را جمع نمی‌کند و trim نمی‌کند برای فیلدهای عددی
* لازم است، چون شبا حرف دارد و تلفن ثابت خط تیره.
*/
public static function digits(string $text): string
{
return strtr($text, array_combine(
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
));
}
/** مقایسهٔ دو نام فارسی پس از نرمال‌سازی. */
public static function sameName(string $a, string $b): bool
{
@@ -0,0 +1,89 @@
<?php
namespace App\Tests\Shared;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Util\PersianText;
use App\Tests\ApiTestCase;
/**
* Persian/Arabic digits sent by any client must never reach the database.
* The admin SPA normalizes at the input, but nobat724_front and clinic-pro-tauri
* hit the same endpoints, so the request layer is the real guarantee.
*/
class NumericFieldNormalizerTest extends ApiTestCase
{
public function testDigitsHelperTranslatesWithoutStripping(): void
{
self::assertSame('09123456789', PersianText::digits('۰۹۱۲۳۴۵۶۷۸۹'));
self::assertSame('0912', PersianText::digits('٠٩١٢'));
self::assertSame('IR12-34', PersianText::digits('IR۱۲-۳۴'), 'letters and separators survive');
self::assertSame('', PersianText::digits(''));
}
public function testSecretaryCreatedWithPersianDigitsIsStoredLatin(): void
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
$persianMobile = '۰۹' . str_pad((string) random_int(0, 999_999_999), 9, '۰', STR_PAD_LEFT);
$latinMobile = PersianText::digits($persianMobile);
$this->authJson('POST', '/api/v1/secretary', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'mobile_number' => $persianMobile,
'name' => 'منشی تست',
'national_code' => '۰۰۱۲۳۴۵۶۷۸',
]);
self::assertSame(201, $this->responseCode(), 'Persian digits must not break validation');
$this->em->clear();
$created = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $latinMobile]);
self::assertNotNull($created, 'user is stored under the latin mobile');
$rel = $this->em->getRepository(DoctorSecretary::class)->findOneBy(['secretary' => $created]);
self::assertSame('0012345678', $rel->getNationalCode());
}
public function testNestedArraysAreNormalized(): void
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$this->authJson('PUT', '/api/v1/insurance-pricing', $user, [
'free_visit_price_rials' => '۵۰۰۰۰۰',
'insurances' => [
['insurance_id' => 1, 'patient_share_rials' => '۱۲۳۴۵'],
],
]);
// پروفایل پزشک ممکن است بیمه‌ای نداشته باشد؛ مهم این است که ارقام فارسی
// باعث خطای اعتبارسنجی یا NaN نشوند.
self::assertNotSame(500, $this->responseCode(), 'nested persian digits must not blow up');
}
public function testNonNumericKeysKeepPersianDigits(): void
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$this->authJson('POST', '/api/v1/secretary', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'mobile_number' => $mobile,
'name' => 'منشی شماره ۲',
]);
self::assertSame(201, $this->responseCode());
$this->em->clear();
$created = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
self::assertStringContainsString('۲', $created->getRealName(), 'name is not a numeric field');
}
}