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:
@@ -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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
|
||||
const TYPE_LABELS: Record<DiscountRuleType, string> = {
|
||||
patient_tag: 'تگ بیمار',
|
||||
@@ -239,12 +240,12 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
|
||||
<div>
|
||||
<label style={labelStyle()}>{f.discount_type === 'percent' ? 'درصد تخفیف (۰ تا ۱۰۰)' : 'مبلغ تخفیف (تومان)'}</label>
|
||||
{f.discount_type === 'percent'
|
||||
? <input className="cp-input" style={{ width: '100%' }} type="number" inputMode="numeric" min={0} max={100} value={f.value} onChange={(e) => set('value', Number(e.target.value) || 0)} />
|
||||
? <input className="cp-input" style={{ width: '100%' }} type="text" inputMode="numeric" dir="ltr" value={f.value} onChange={(e) => set('value', Number(digitsOnly(e.target.value, 3)) || 0)} />
|
||||
: <PriceInput className="cp-input" style={{ width: '100%' }} value={f.value} onChange={(v) => set('value', v)} />}
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle()}>اولویت (بزرگتر = مهمتر)</label>
|
||||
<input className="cp-input" type="number" inputMode="numeric" min={0} value={f.priority} onChange={(e) => set('priority', Number(e.target.value) || 0)} />
|
||||
<input className="cp-input" type="text" inputMode="numeric" dir="ltr" value={f.priority} onChange={(e) => set('priority', Number(digitsOnly(e.target.value)) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,7 +295,7 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
|
||||
{f.type === 'visit_count' && (
|
||||
<div>
|
||||
<label style={labelStyle()}>حداقل تعداد مراجعه</label>
|
||||
<input className="cp-input" type="number" inputMode="numeric" min={1} value={f.min_visit_count} onChange={(e) => set('min_visit_count', Number(e.target.value) || 0)} />
|
||||
<input className="cp-input" type="text" inputMode="numeric" dir="ltr" value={f.min_visit_count} onChange={(e) => set('min_visit_count', Number(digitsOnly(e.target.value)) || 0)} />
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'occasion' && (
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
@@ -40,10 +40,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '50000' } });
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '50000' } });
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
|
||||
@@ -55,7 +55,7 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ میشود (ارسال مجاز)', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
@@ -71,6 +71,6 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
|
||||
expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(50_000);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('50000');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
|
||||
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
|
||||
|
||||
@@ -62,9 +63,9 @@ export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string })
|
||||
قیمت (تومان){required && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number" min={0} dir="ltr" className="input" style={{ width: 200 }}
|
||||
type="text" inputMode="numeric" dir="ltr" className="input" style={{ width: 200 }}
|
||||
aria-invalid={!!error}
|
||||
value={value} onChange={(e) => { setValue(e.target.value); setError(''); }}
|
||||
value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }}
|
||||
/>
|
||||
</div>
|
||||
{value !== '' && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import Modal from './ui/Modal';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
import { isoToUnix, rialToToman, tomanToRial, unixToIso } from '../lib/utils';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
|
||||
export interface InsuranceOption {
|
||||
insurance_id: number;
|
||||
@@ -161,15 +162,15 @@ export default function InsuranceModal({ open, editContract, options, kind, onCl
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
<div style={field}>
|
||||
<label style={label}>درصد پوشش</label>
|
||||
<input type="number" min={0} max={100} dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: e.target.value })} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: digitsOnly(e.target.value, 3) })} />
|
||||
</div>
|
||||
<div style={field}>
|
||||
<label style={label}>فرانشیز (تومان)</label>
|
||||
<input type="number" min={0} dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: e.target.value })} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
|
||||
</div>
|
||||
<div style={field}>
|
||||
<label style={label}>سقف تعهد (تومان)</label>
|
||||
<input type="number" min={0} dir="ltr" className="input" placeholder="بینهایت" value={form.ceiling} onChange={(e) => set({ ceiling: e.target.value })} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" className="input" placeholder="بینهایت" value={form.ceiling} onChange={(e) => set({ ceiling: digitsOnly(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import PersianDateInput from './ui/PersianDateInput';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { WalletChargeLink } from './AppointmentActions';
|
||||
import { tehranWallClockToUnix, tomanToRial, rialToToman, toEnglishDigits, sanitizeMobileInput } from '../lib/utils';
|
||||
import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils';
|
||||
|
||||
interface Option { uuid: string; name?: string; full_name?: string }
|
||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||||
@@ -210,7 +210,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
</div>
|
||||
<label style={label}>کد ملی</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||
<input value={nationalCode} onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
|
||||
<input value={nationalCode} onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
||||
placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
|
||||
</div>
|
||||
</>
|
||||
@@ -315,7 +315,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
<>
|
||||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||||
<div className="field" style={{ margin: '6px 0 10px' }}>
|
||||
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
|
||||
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
|
||||
</div>
|
||||
{!isReserve && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import MobileInput from './ui/MobileInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import type { SelectOption } from './ui/SearchableSelect';
|
||||
import PersianDatePicker from './ui/PersianDatePicker';
|
||||
import { iranNationalCodeOptionalSchema, iranMobileOptionalSchema } from '../lib/utils';
|
||||
|
||||
/**
|
||||
* اسکیمای فرم «اطلاعات پرونده». مطابق قواعد بکاند:
|
||||
@@ -16,8 +17,8 @@ import PersianDatePicker from './ui/PersianDatePicker';
|
||||
*/
|
||||
export const patientFormSchema = z.object({
|
||||
name: z.string().trim().min(1, 'نام و نام خانوادگی الزامی است'),
|
||||
mobile: z.string().trim().regex(/^09\d{9}$/, 'شماره موبایل نامعتبر است').or(z.literal('')),
|
||||
national_code: z.string().trim().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد').or(z.literal('')),
|
||||
mobile: iranMobileOptionalSchema,
|
||||
national_code: iranNationalCodeOptionalSchema,
|
||||
gender: z.string().nullable(),
|
||||
birth_date: z.string(),
|
||||
fathers_name: z.string(),
|
||||
@@ -114,7 +115,7 @@ export default function PatientRecordInfoForm({
|
||||
{sel('gender', 'جنسیت', options.gender)}
|
||||
|
||||
<Field label="کدملی" error={errors.national_code?.message}>
|
||||
<Input {...register('national_code')} hasError={!!errors.national_code} dir="ltr" maxLength={10} placeholder="کد ملی" />
|
||||
<Input {...register('national_code')} numeric hasError={!!errors.national_code} maxLength={10} placeholder="کد ملی" />
|
||||
</Field>
|
||||
|
||||
<Field label="شماره تماس" error={errors.mobile?.message}>
|
||||
@@ -181,7 +182,7 @@ export default function PatientRecordInfoForm({
|
||||
</Field>
|
||||
|
||||
<Field label="کد پستی">
|
||||
<Input {...register('postal_code')} dir="ltr" placeholder="کدپستی محل سکونت را وارد نمایید..." />
|
||||
<Input {...register('postal_code')} numeric maxLength={10} placeholder="کدپستی محل سکونت را وارد نمایید..." />
|
||||
</Field>
|
||||
|
||||
{sel('referral_source', 'نحوه آشنایی', options.referral)}
|
||||
|
||||
@@ -127,7 +127,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className="field-label">درصد پوشش</label>
|
||||
<input
|
||||
type="number" min={0} max={100} dir="ltr" className="input"
|
||||
type="text" inputMode="numeric" dir="ltr" className="input"
|
||||
style={{ height: 40, textAlign: 'left' }}
|
||||
value={draft.coverage_percent ?? ''}
|
||||
placeholder="ارث"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import { rialToToman, tomanToRial, toEnglishDigits } from '../../lib/utils';
|
||||
import { rialToToman, tomanToRial, digitsOnly } from '../../lib/utils';
|
||||
import type { InventoryItem, InventoryMeta, ItemPayload } from '../../hooks/useInventory';
|
||||
|
||||
interface Props {
|
||||
@@ -26,7 +26,6 @@ interface FormState {
|
||||
const DEFAULT_UNIT = 'عدد';
|
||||
const BLANK: FormState = { name: '', consumable: '', category: '', unit: DEFAULT_UNIT, price: '', stock: '', alertThreshold: '' };
|
||||
|
||||
const digits = (v: string) => toEnglishDigits(v).replace(/\D/g, '');
|
||||
// group thousands: "1200000" → "1,200,000"
|
||||
const group = (v: string) => v.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
@@ -54,7 +53,7 @@ export default function AddItemModal({ open, editing, meta, saving, onClose, onS
|
||||
const set = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
const setNum = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [k]: digits(e.target.value) }));
|
||||
setForm((f) => ({ ...f, [k]: digitsOnly(e.target.value) }));
|
||||
const setSelect = (k: keyof FormState) => (v: string | number | null) =>
|
||||
setForm((f) => ({ ...f, [k]: v == null ? '' : String(v) }));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useUpdateBankAccount,
|
||||
type BankAccount,
|
||||
} from '../../hooks/usePaymentMethods';
|
||||
import { digitsOnly, toEnglishDigits } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* فرم افزودن/ویرایش حساب بانکی — پورت مبدأ ModalAddBankAccount.jsx.
|
||||
@@ -88,15 +89,15 @@ export default function BankAccountFormModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شماره کارت</label>
|
||||
<input className="input" value={cardNumber} onChange={(e) => setCardNumber(e.target.value)} placeholder="شماره کارت" />
|
||||
<input className="input" type="tel" inputMode="numeric" dir="ltr" value={cardNumber} onChange={(e) => setCardNumber(digitsOnly(e.target.value, 16))} placeholder="شماره کارت" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شماره حساب</label>
|
||||
<input className="input" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="شماره حساب" />
|
||||
<input className="input" type="tel" inputMode="numeric" dir="ltr" value={accountNumber} onChange={(e) => setAccountNumber(digitsOnly(e.target.value))} placeholder="شماره حساب" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">شبا</label>
|
||||
<input className="input" value={shabaNumber} onChange={(e) => setShabaNumber(e.target.value)} placeholder="شبا" />
|
||||
<input className="input" inputMode="numeric" dir="ltr" value={shabaNumber} onChange={(e) => setShabaNumber(toEnglishDigits(e.target.value).toUpperCase())} placeholder="شبا" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import { formatNumber } from '../../lib/utils';
|
||||
import { formatNumber, digitsOnly } from '../../lib/utils';
|
||||
import Modal from '../ui/Modal';
|
||||
import ConfirmDialog from '../ui/ConfirmDialog';
|
||||
import GlobalSearchableSelect from '../ui/SearchableSelect';
|
||||
@@ -455,15 +455,15 @@ function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = f
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>هر (دقیقه کار)</div>
|
||||
<div className="field">
|
||||
<input type="number" min={1} value={session.rest_interval}
|
||||
onChange={e => upd('rest_interval', Number(e.target.value))} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" value={session.rest_interval}
|
||||
onChange={e => upd('rest_interval', Number(digitsOnly(e.target.value)) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>استراحت (دقیقه)</div>
|
||||
<div className="field">
|
||||
<input type="number" min={1} value={session.time_to_rest}
|
||||
onChange={e => upd('time_to_rest', Number(e.target.value))} />
|
||||
<input type="text" inputMode="numeric" dir="ltr" value={session.time_to_rest}
|
||||
onChange={e => upd('time_to_rest', Number(digitsOnly(e.target.value)) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -702,10 +702,11 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">فاصله بین نوبتها</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
value={meta.buffer_minutes}
|
||||
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(e.target.value) || 0) }))}
|
||||
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(digitsOnly(e.target.value)) || 0) }))}
|
||||
className="w-16 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">دقیقه</span>
|
||||
@@ -748,11 +749,12 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">رزرو آنلاین تا</span>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
value={meta.booking_window_value}
|
||||
disabled={!meta.online_booking_enabled}
|
||||
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(e.target.value) || 1) }))}
|
||||
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(digitsOnly(e.target.value)) || 1) }))}
|
||||
className="w-14 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 focus:outline-none focus:ring-0 px-2 py-1.5"
|
||||
/>
|
||||
<div style={{ width: 110 }}>
|
||||
|
||||
@@ -78,6 +78,6 @@ describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ r
|
||||
mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false });
|
||||
renderStep();
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(30_000));
|
||||
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import SearchableSelect from '../ui/SearchableSelect';
|
||||
import PersianDateInput from '../ui/PersianDateInput';
|
||||
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { digitsOnly } from '../../lib/utils';
|
||||
|
||||
interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null }
|
||||
interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
|
||||
@@ -411,10 +412,10 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
قیمت ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||||
</span>
|
||||
<input
|
||||
className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت"
|
||||
className="input" type="text" inputMode="numeric" dir="ltr" aria-label="قیمت ویزیت"
|
||||
aria-invalid={!!visitPriceError}
|
||||
value={visitPrice}
|
||||
onChange={(e) => { setVisitPrice(e.target.value); setVisitPriceError(''); }}
|
||||
onChange={(e) => { setVisitPrice(digitsOnly(e.target.value)); setVisitPriceError(''); }}
|
||||
/>
|
||||
{visitPriceError && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>{visitPriceError}</span>
|
||||
@@ -438,11 +439,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<span style={fieldLabel}>تخفیف بیمه پایه (%)</span>
|
||||
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(e.target.value)} />
|
||||
<input className="input" type="text" inputMode="numeric" dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(digitsOnly(e.target.value, 3))} />
|
||||
</div>
|
||||
<div>
|
||||
<span style={fieldLabel}>تخفیف تکمیلی (%)</span>
|
||||
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(e.target.value)} />
|
||||
<input className="input" type="text" inputMode="numeric" dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(digitsOnly(e.target.value, 3))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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), 'کد ملی باید ۱۰ رقم باشد');
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user