From 00cb9aaa1a65ae0a8f3c42d195b62c79aef2edea Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 10:38:56 +0330 Subject: [PATCH] feat(admin): normalize Persian/Arabic digits in every numeric field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/prompt/normalize-persian-digits.md | 304 ++++++++++++++++++ .../components/AppointmentFiltersModal.tsx | 3 +- assets/admin/components/DiscountTab.tsx | 7 +- .../admin/components/FreeVisitPrice.test.tsx | 10 +- assets/admin/components/FreeVisitPrice.tsx | 5 +- assets/admin/components/InsuranceModal.tsx | 7 +- .../admin/components/NewAppointmentDrawer.tsx | 6 +- .../components/PatientRecordInfoForm.tsx | 9 +- .../components/ServiceInsuranceModal.tsx | 2 +- .../components/inventory/AddItemModal.tsx | 5 +- .../paymentMethods/BankAccountFormModal.tsx | 7 +- .../components/schedule/ScheduleSection.tsx | 24 +- .../components/session/CreateStep.test.tsx | 2 +- .../admin/components/session/CreateStep.tsx | 9 +- assets/admin/lib/forms.ts | 44 +++ assets/admin/lib/utils.test.ts | 44 +++ assets/admin/lib/utils.ts | 30 +- assets/admin/pages/AdminSubscriptionPage.tsx | 11 +- assets/admin/pages/AppointmentCreatePage.tsx | 3 +- assets/admin/pages/AppointmentsPage.tsx | 4 +- assets/admin/pages/CategoriesPage.tsx | 9 +- assets/admin/pages/ClinicDetailPage.tsx | 3 +- assets/admin/pages/ClinicFormPage.tsx | 3 +- assets/admin/pages/ClinicServicesPage.tsx | 3 +- assets/admin/pages/ClinicsPage.tsx | 3 +- assets/admin/pages/DoctorDetailPage.tsx | 3 +- assets/admin/pages/LogsPage.tsx | 8 +- assets/admin/pages/MyPatientsPage.tsx | 22 +- assets/admin/pages/MySecretariesPage.tsx | 24 +- assets/admin/pages/PatientRecordFormPage.tsx | 10 +- .../admin/pages/RepresentationDetailPage.tsx | 5 +- .../admin/pages/RepresentationProfilePage.tsx | 18 +- .../pages/RepresentationSettlementPage.tsx | 5 +- assets/admin/pages/RepresentationsPage.tsx | 3 +- assets/admin/pages/SettingsPage.tsx | 15 +- assets/admin/pages/SmsWalletPage.tsx | 6 +- assets/admin/pages/StaffPage.tsx | 5 +- docs/api/README.md | 33 ++ docs/api/secretary.md | 4 +- .../NumericFieldNormalizerSubscriber.php | 93 ++++++ src/Shared/Util/PersianText.php | 14 + tests/Shared/NumericFieldNormalizerTest.php | 89 +++++ 42 files changed, 789 insertions(+), 125 deletions(-) create mode 100644 .claude/prompt/normalize-persian-digits.md create mode 100644 assets/admin/lib/forms.ts create mode 100644 src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php create mode 100644 tests/Shared/NumericFieldNormalizerTest.php diff --git a/.claude/prompt/normalize-persian-digits.md b/.claude/prompt/normalize-persian-digits.md new file mode 100644 index 00000000..ba763601 --- /dev/null +++ b/.claude/prompt/normalize-persian-digits.md @@ -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) => { + 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 + setField("telephone", v)} disabled={disabled || mode === "edit"} /> +... + setField("national_code", v)} disabled={disabled} /> +``` + +`DefaultTextField` (`:226-266`) یک `` خام بدون `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`) → `` (یا `DigitInput` با `maxDigits={11}`). +- کد ملی (`:441`) → ``. +- **یا** ساده‌تر و کم‌ریسک‌تر: به `DefaultTextField` یک prop `numeric?: boolean` و `maxDigits?: number` اضافه کن که داخلش `digitsOnly` صدا بزند، سپس روی این دو فیلد `numeric` بگذار. اگر این راه را رفتی، `type="tel"`، `inputMode="numeric"` و `dir="ltr"` را هم ست کن. +- در `:773-775` و `:803` هم قبل از ارسال `digitsOnly` بزن (دفاع لایه‌ای — کاربر می‌تواند paste کند). +- این فرم schema ندارد؛ حداقل `iranMobileSchema` و `iranNationalCodeOptionalSchema` را روی همین دو فیلد اعمال کن تا خطای فارسی معنادار نشان داده شود. + +### ۴. مهاجرت همهٔ فیلدهای عددی + +فهرست کامل زیر لیست کار است. برای هر مورد: + +- فیلد پول/مبلغ → `` +- فیلد شمارهٔ ملی/موبایل/کارت/شبا/کد پستی → `` +- بقیه (درصد، مدت، تعداد، وزن، سطح) → `` یا `type="text" inputMode="numeric"` + `digitsOnly` در `onChange` +- **هیچ فیلد `type="number"` جدیدی نساز** و موجودها را به `type="text" inputMode="numeric"` تبدیل کن، وگرنه مشکل «مقدار خالی» باقی می‌ماند. +- اگر فیلد با React Hook Form `register` شده، `setValueAs` یا `onChange` سفارشی لازم است: + +```tsx + 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` را به `` اضافه کن | +| `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` استفاده می‌کنند — به `` مهاجرت بده، اولویت پایین. + +### ۵. اصلاح 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` را چک کن. +- `` از قبل ساخته شده و تست نشده چون هیچ مصرف‌کننده‌ای ندارد؛ بعد از اولین استفاده حتماً دستی تست کن. +- فیلدهایی که با RHF `register` شده‌اند با دست‌کاری مستقیم `e.target.value` درست کار نمی‌کنند مگر `setValueAs` یا `Controller` استفاده شود. +- RTL: فیلدهای عددی باید `dir="ltr"` داشته باشند تا عدد وارونه نمایش داده نشود. +- از کلاس‌های CSS موجود استفاده کن (`input`، `field`، `cp-input`)؛ کتابخانه جدید اضافه نکن. +- این تغییر بزرگ و پرتکرار است — **قابلیت‌به‌قابلیت پیش برو** و بعد از هر گروه `tsc` و build بگیر، نه یک‌جا. diff --git a/assets/admin/components/AppointmentFiltersModal.tsx b/assets/admin/components/AppointmentFiltersModal.tsx index d0053694..5354484f 100644 --- a/assets/admin/components/AppointmentFiltersModal.tsx +++ b/assets/admin/components/AppointmentFiltersModal.tsx @@ -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 }: {
- setF(v => ({ ...v, nationalCode: e.target.value }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." dir="ltr" /> + setF(v => ({ ...v, nationalCode: digitsOnly(e.target.value, 10) }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." inputMode="numeric" dir="ltr" />
diff --git a/assets/admin/components/DiscountTab.tsx b/assets/admin/components/DiscountTab.tsx index 08c48833..797edfa4 100644 --- a/assets/admin/components/DiscountTab.tsx +++ b/assets/admin/components/DiscountTab.tsx @@ -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 = { patient_tag: 'تگ بیمار', @@ -239,12 +240,12 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
{f.discount_type === 'percent' - ? set('value', Number(e.target.value) || 0)} /> + ? set('value', Number(digitsOnly(e.target.value, 3)) || 0)} /> : set('value', v)} />}
- set('priority', Number(e.target.value) || 0)} /> + set('priority', Number(digitsOnly(e.target.value)) || 0)} />
@@ -294,7 +295,7 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null {f.type === 'visit_count' && (
- set('min_visit_count', Number(e.target.value) || 0)} /> + set('min_visit_count', Number(digitsOnly(e.target.value)) || 0)} />
)} {f.type === 'occasion' && ( diff --git a/assets/admin/components/FreeVisitPrice.test.tsx b/assets/admin/components/FreeVisitPrice.test.tsx index 5fb10f02..3ccbfb2b 100644 --- a/assets/admin/components/FreeVisitPrice.test.tsx +++ b/assets/admin/components/FreeVisitPrice.test.tsx @@ -28,7 +28,7 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => { get.mockResolvedValue(pricing(0, false)); renderWithProviders(); - 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(); - 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(); - 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'); }); }); diff --git a/assets/admin/components/FreeVisitPrice.tsx b/assets/admin/components/FreeVisitPrice.tsx index e5413005..351c4a07 100644 --- a/assets/admin/components/FreeVisitPrice.tsx +++ b/assets/admin/components/FreeVisitPrice.tsx @@ -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 && *} { setValue(e.target.value); setError(''); }} + value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }} /> {value !== '' && ( diff --git a/assets/admin/components/InsuranceModal.tsx b/assets/admin/components/InsuranceModal.tsx index 692b41a4..a85a3cc8 100644 --- a/assets/admin/components/InsuranceModal.tsx +++ b/assets/admin/components/InsuranceModal.tsx @@ -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
- set({ coverage: e.target.value })} /> + set({ coverage: digitsOnly(e.target.value, 3) })} />
- set({ franchise: e.target.value })} /> + set({ franchise: digitsOnly(e.target.value) })} />
- set({ ceiling: e.target.value })} /> + set({ ceiling: digitsOnly(e.target.value) })} />
diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx index 73551fb7..8d0d8d47 100644 --- a/assets/admin/components/NewAppointmentDrawer.tsx +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -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
- setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))} + setNationalCode(digitsOnly(e.target.value, 10))} placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
@@ -315,7 +315,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey <>
- setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" /> + setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
{!isReserve && (
diff --git a/assets/admin/components/PatientRecordInfoForm.tsx b/assets/admin/components/PatientRecordInfoForm.tsx index b943fde9..a5821011 100644 --- a/assets/admin/components/PatientRecordInfoForm.tsx +++ b/assets/admin/components/PatientRecordInfoForm.tsx @@ -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)} - + @@ -181,7 +182,7 @@ export default function PatientRecordInfoForm({ - + {sel('referral_source', 'نحوه آشنایی', options.referral)} diff --git a/assets/admin/components/ServiceInsuranceModal.tsx b/assets/admin/components/ServiceInsuranceModal.tsx index c2fe241d..86babfbc 100644 --- a/assets/admin/components/ServiceInsuranceModal.tsx +++ b/assets/admin/components/ServiceInsuranceModal.tsx @@ -127,7 +127,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
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) => setForm((f) => ({ ...f, [k]: e.target.value })); const setNum = (k: keyof FormState) => (e: React.ChangeEvent) => - 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) })); diff --git a/assets/admin/components/paymentMethods/BankAccountFormModal.tsx b/assets/admin/components/paymentMethods/BankAccountFormModal.tsx index 13991170..ce6774c7 100644 --- a/assets/admin/components/paymentMethods/BankAccountFormModal.tsx +++ b/assets/admin/components/paymentMethods/BankAccountFormModal.tsx @@ -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({
- setCardNumber(e.target.value)} placeholder="شماره کارت" /> + setCardNumber(digitsOnly(e.target.value, 16))} placeholder="شماره کارت" />
- setAccountNumber(e.target.value)} placeholder="شماره حساب" /> + setAccountNumber(digitsOnly(e.target.value))} placeholder="شماره حساب" />
- setShabaNumber(e.target.value)} placeholder="شبا" /> + setShabaNumber(toEnglishDigits(e.target.value).toUpperCase())} placeholder="شبا" />
diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index a49a4abb..98374277 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -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
هر (دقیقه کار)
- upd('rest_interval', Number(e.target.value))} /> + upd('rest_interval', Number(digitsOnly(e.target.value)) || 0)} />
استراحت (دقیقه)
- upd('time_to_rest', Number(e.target.value))} /> + upd('time_to_rest', Number(digitsOnly(e.target.value)) || 0)} />
@@ -702,10 +702,11 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
فاصله بین نوبت‌ها 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" /> دقیقه @@ -748,11 +749,12 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { رزرو آنلاین تا
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" />
diff --git a/assets/admin/components/session/CreateStep.test.tsx b/assets/admin/components/session/CreateStep.test.tsx index cd1d44ab..014bbbda 100644 --- a/assets/admin/components/session/CreateStep.test.tsx +++ b/assets/admin/components/session/CreateStep.test.tsx @@ -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')); }); }); diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx index 6c8556eb..c7c83988 100644 --- a/assets/admin/components/session/CreateStep.tsx +++ b/assets/admin/components/session/CreateStep.tsx @@ -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 && *} { setVisitPrice(e.target.value); setVisitPriceError(''); }} + onChange={(e) => { setVisitPrice(digitsOnly(e.target.value)); setVisitPriceError(''); }} /> {visitPriceError && ( {visitPriceError} @@ -438,11 +439,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
تخفیف بیمه پایه (%) - setBasePercent(e.target.value)} /> + setBasePercent(digitsOnly(e.target.value, 3))} />
تخفیف تکمیلی (%) - setSuppPercent(e.target.value)} /> + setSuppPercent(digitsOnly(e.target.value, 3))} />
diff --git a/assets/admin/lib/forms.ts b/assets/admin/lib/forms.ts new file mode 100644 index 00000000..b611ef0e --- /dev/null +++ b/assets/admin/lib/forms.ts @@ -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); +} diff --git a/assets/admin/lib/utils.test.ts b/assets/admin/lib/utils.test.ts index e486541a..5082bf66 100644 --- a/assets/admin/lib/utils.test.ts +++ b/assets/admin/lib/utils.test.ts @@ -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'); diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts index 0a318b55..b4ba9ad5 100644 --- a/assets/admin/lib/utils.ts +++ b/assets/admin/lib/utils.ts @@ -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 = (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), 'کد ملی باید ۱۰ رقم باشد'); diff --git a/assets/admin/pages/AdminSubscriptionPage.tsx b/assets/admin/pages/AdminSubscriptionPage.tsx index 6e3a8f37..38a1fc29 100644 --- a/assets/admin/pages/AdminSubscriptionPage.tsx +++ b/assets/admin/pages/AdminSubscriptionPage.tsx @@ -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() {
- +
- +
@@ -320,17 +321,17 @@ function PlansTab() {
- +
- +
- +
diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index e291add2..a60d19bc 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -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() {
- setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" /> + setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 94c12437..72a9addf 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -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' }} /> diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx index 95a82869..52c65c5a 100644 --- a/assets/admin/pages/CategoriesPage.tsx +++ b/assets/admin/pages/CategoriesPage.tsx @@ -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() {
- +
@@ -571,7 +572,7 @@ function CitiesTab() {
- +
@@ -699,7 +700,7 @@ function SpecialtiesTab() {
- +
@@ -824,7 +825,7 @@ function DoctorServicesTab() {
- +
diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 5a724020..12a8afc4 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -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 }: {
- +
diff --git a/assets/admin/pages/ClinicFormPage.tsx b/assets/admin/pages/ClinicFormPage.tsx index baf41f64..5053e369 100644 --- a/assets/admin/pages/ClinicFormPage.tsx +++ b/assets/admin/pages/ClinicFormPage.tsx @@ -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() {
- +
diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index 61b47486..8a443b29 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -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() {
- +
diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index ab64be10..75528d4a 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -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 }: { - + {errors.telephone &&

{errors.telephone.message}

}
diff --git a/assets/admin/pages/LogsPage.tsx b/assets/admin/pages/LogsPage.tsx index 7e938fd1..7da8b12a 100644 --- a/assets/admin/pages/LogsPage.tsx +++ b/assets/admin/pages/LogsPage.tsx @@ -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 = { emergency: { label: 'اضطراری', cls: 'red' }, @@ -254,11 +255,12 @@ function RetentionSettings() { { setDays(e.target.value); setTouched(true); }} + onChange={(e) => { setDays(digitsOnly(e.target.value)); setTouched(true); }} style={{ maxWidth: 200 }} />
diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index 886277dc..30f0195d 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -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() {
diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index adc06fa6..a39aa00d 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -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({

شماره موبایل

- setField("telephone", v)} disabled={disabled || mode === "edit"} /> + setField("telephone", v)} disabled={disabled || mode === "edit"} />

کد ملی

- setField("national_code", v)} disabled={disabled} /> + setField("national_code", v)} disabled={disabled} />
@@ -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 }, }), diff --git a/assets/admin/pages/PatientRecordFormPage.tsx b/assets/admin/pages/PatientRecordFormPage.tsx index ff5a10db..b186bed0 100644 --- a/assets/admin/pages/PatientRecordFormPage.tsx +++ b/assets/admin/pages/PatientRecordFormPage.tsx @@ -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() { /> -
+
-
+
form.setValue('birth_date', v)} enableYearPicker /> diff --git a/assets/admin/pages/RepresentationDetailPage.tsx b/assets/admin/pages/RepresentationDetailPage.tsx index 2942c9d4..ca7db113 100644 --- a/assets/admin/pages/RepresentationDetailPage.tsx +++ b/assets/admin/pages/RepresentationDetailPage.tsx @@ -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() {
- setFormData((p) => ({ ...p, commission_percent: e.target.value }))} - type="number" min="0" max="100" dir="ltr" + setFormData((p) => ({ ...p, commission_percent: digitsOnly(e.target.value, 3) }))} + type="text" inputMode="numeric" dir="ltr" className="cp-input h-11" />
diff --git a/assets/admin/pages/RepresentationProfilePage.tsx b/assets/admin/pages/RepresentationProfilePage.tsx index 837cf2e6..f34dff4b 100644 --- a/assets/admin/pages/RepresentationProfilePage.tsx +++ b/assets/admin/pages/RepresentationProfilePage.tsx @@ -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() { 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' }} />