feat(admin): normalize Persian/Arabic digits in every numeric field

Users typing on a Persian keyboard produced two distinct failures. Fields with
type="number" silently returned an empty string — the browser rejects Persian
digits, so the value was lost and saved as empty or zero. Text fields passed the
Persian characters straight through to the database, where a mobile stored as
۰۹۱۲… never matches 09… again. The secretary form hit the second case with no
validation at all.

Frontend:
- Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms
  with numericField()/latinDigitsField() wrappers for React Hook Form fields.
- Converts every type="number" input to type="text" inputMode="numeric" with
  digit normalization; none remain. Fields that legitimately carry non-digits
  (sheba, landline) only get the digits translated, keeping IR and separators.
- Points the patient national-code and mobile schemas at the shared normalizing
  schemas, which accept Persian input instead of rejecting it.
- Drops two duplicate local digit converters in favour of the shared helper.

Backend:
- Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted
  numeric keys of JSON request bodies under /api/v1/ before controllers run, so
  nobat724_front and clinic-pro-tauri are covered too. Translation only — no
  characters are stripped, non-string values and other keys are untouched.

Three component tests asserted on role="spinbutton" and numeric input values;
both are properties of type="number", so they were updated to match the new
text inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 10:38:56 +03:30
co-authored by Claude Opus 4.8
parent c103c393f3
commit 00cb9aaa1a
42 changed files with 789 additions and 125 deletions
+6 -5
View File
@@ -13,6 +13,7 @@ import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
import { numericField } from '../lib/forms';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -271,11 +272,11 @@ function PlansTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>سطح *</label>
<input {...planForm.register('level')} type="number" min={0} dir="ltr" />
<input {...numericField(planForm.register('level'))} />
</div>
<div className="field">
<label>حداکثر منشی *</label>
<input {...planForm.register('max_secretaries')} type="number" min={1} dir="ltr" />
<input {...numericField(planForm.register('max_secretaries'))} />
</div>
</div>
<div>
@@ -320,17 +321,17 @@ function PlansTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>مدت (ماه) *</label>
<input {...periodForm.register('duration_months')} type="number" min={1} dir="ltr" />
<input {...numericField(periodForm.register('duration_months'))} />
</div>
<div className="field">
<label>قیمت (ریال) *</label>
<input {...periodForm.register('price_rials')} type="number" min={0} dir="ltr" />
<input {...numericField(periodForm.register('price_rials'))} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>ترتیب نمایش</label>
<input {...periodForm.register('sort_order')} type="number" min={0} dir="ltr" />
<input {...numericField(periodForm.register('sort_order'))} />
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
+2 -1
View File
@@ -14,6 +14,7 @@ import { WalletChargeLink } from '../components/AppointmentActions';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
/**
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
@@ -434,7 +435,7 @@ export default function AppointmentCreatePage() {
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
</div>
</div>
<div>
+2 -2
View File
@@ -9,7 +9,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { Appointment } from '../types';
import { formatDate, toGregorianDate, formatTime, toEnglishDigits, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
import PriceInput from '../components/ui/PriceInput';
import { useAuthStore } from '../stores/authStore';
import Pagination from '../components/ui/Pagination';
@@ -280,7 +280,7 @@ export function NewAppointmentModal({
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ ...inputSx, direction: 'ltr' }}
/>
+5 -4
View File
@@ -18,6 +18,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags';
@@ -349,7 +350,7 @@ function ProvincesTab() {
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" style={{ maxWidth: 120 }} />
<input {...numericField(register('weight'))} className="input" placeholder="0" style={{ maxWidth: 120 }} />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>وضعیت</label>
@@ -571,7 +572,7 @@ function CitiesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
@@ -699,7 +700,7 @@ function SpecialtiesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
@@ -824,7 +825,7 @@ function DoctorServicesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
+2 -1
View File
@@ -22,6 +22,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
import { latinDigitsField } from '../lib/forms';
// Fix leaflet icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -365,7 +366,7 @@ function EditModal({ clinic, onClose, onSaved }: {
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" dir="ltr" placeholder="021-12345678" {...register('telephone')} />
<input className="input" placeholder="021-12345678" {...latinDigitsField(register('telephone'))} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>توضیحات</label>
+2 -1
View File
@@ -10,6 +10,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import MobileInput from '../components/ui/MobileInput';
import { iranMobileSchema } from '../lib/utils';
import { latinDigitsField } from '../lib/forms';
const schema = z.object({
owner_mobile: iranMobileSchema,
@@ -62,7 +63,7 @@ export default function ClinicFormPage() {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>تلفن ثابت</label>
<input className="field" placeholder="02xxxxxxxx" dir="ltr" {...register('telephone')} />
<input className="field" placeholder="02xxxxxxxx" {...latinDigitsField(register('telephone'))} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>آدرس</label>
+2 -1
View File
@@ -21,6 +21,7 @@ import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import FeatureGate from '../components/ui/FeatureGate';
import { numericField } from '../lib/forms';
const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') });
const itemSchema = z.object({
@@ -526,7 +527,7 @@ function ClinicServicesPageInner() {
<div>
<label className="field-label">زمان متوسط (دقیقه)</label>
<div className="field">
<input type="number" min={0} {...itemForm.register('duration_minutes')} placeholder="مثلاً: ۵۰" />
<input {...numericField(itemForm.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
+2 -1
View File
@@ -21,6 +21,7 @@ import Portal from '../components/ui/Portal';
import { formatDate, formatNumber, iranMobileSchema } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import { latinDigitsField } from '../lib/forms';
const HUES_LIST = [256, 205, 162, 295, 272];
@@ -275,7 +276,7 @@ export default function ClinicsPage() {
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
تلفن (اختیاری)
</label>
<input className="input" placeholder="مثال: 021-12345678" dir="ltr" {...addForm.register('telephone')} />
<input className="input" placeholder="مثال: 021-12345678" {...latinDigitsField(addForm.register('telephone'))} />
</div>
</div>
<div className="modal-foot">
+2 -1
View File
@@ -33,6 +33,7 @@ import PersianDatePicker from '../components/ui/PersianDatePicker';
import ImageCropModal from '../components/ImageCropModal';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import { latinDigitsField } from '../lib/forms';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -649,7 +650,7 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
تلفن <span className="text-red-500">*</span>
</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="021..." {...register('telephone')} />
<input className="cp-input text-left" placeholder="021..." {...latinDigitsField(register('telephone'))} />
{errors.telephone && <p className="text-xs text-red-500 mt-1">{errors.telephone.message}</p>}
</div>
</div>
+5 -3
View File
@@ -10,6 +10,7 @@ import Pagination from '../components/ui/Pagination';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
const LEVEL_META: Record<string, { label: string; cls: string }> = {
emergency: { label: 'اضطراری', cls: 'red' },
@@ -254,11 +255,12 @@ function RetentionSettings() {
</label>
<input
className="input"
type="number"
min={0}
type="text"
inputMode="numeric"
dir="ltr"
value={value}
placeholder={settingsQuery.isLoading ? 'در حال بارگذاری...' : '90'}
onChange={(e) => { setDays(e.target.value); setTouched(true); }}
onChange={(e) => { setDays(digitsOnly(e.target.value)); setTouched(true); }}
style={{ maxWidth: 200 }}
/>
<div className="muted" style={{ fontSize: 12, marginTop: 6, lineHeight: 1.7 }}>
+6 -16
View File
@@ -52,6 +52,7 @@ import {
import type { ApiResponse, PaginatedResponse } from "../lib/api";
import { api } from "../lib/api";
import { useIssueInvoice } from "../hooks/useIssueInvoice";
import { numericField } from "../lib/forms";
import {
formatDate,
formatDateTime,
@@ -1437,34 +1438,23 @@ function MyPatientsPageInner() {
<div className="field">
<label>قیمت ویزیت (تومان)</label>
<input
{...form.register("visit_price_rials")}
type="number"
min={0}
dir="ltr"
{...numericField(form.register("visit_price_rials"))}
/>
</div>
<div className="field">
<label>تخفیف بیمه پایه (%)</label>
<input
{...form.register(
{...numericField(form.register(
"base_insurance_discount_percent",
)}
type="number"
min={0}
max={100}
dir="ltr"
), 3)}
/>
</div>
<div className="field">
<label>تخفیف تکمیلی (%)</label>
<input
{...form.register(
{...numericField(form.register(
"supplementary_discount_percent",
)}
type="number"
min={0}
max={100}
dir="ltr"
), 3)}
/>
</div>
</div>
+16 -8
View File
@@ -7,7 +7,7 @@ import ConfirmDialog from "../components/ui/ConfirmDialog";
import Modal from "../components/ui/Modal";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatDate } from "../lib/utils";
import { formatDate, digitsOnly, IRAN_MOBILE_RE, IRAN_NATIONAL_CODE_RE } from "../lib/utils";
import { useSubscription } from "../hooks/useSubscription";
import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
@@ -230,6 +230,8 @@ function DefaultTextField({
disabled,
multiline,
rows,
numeric,
maxDigits,
}: {
placeholder?: string;
value: string;
@@ -237,6 +239,9 @@ function DefaultTextField({
disabled?: boolean;
multiline?: boolean;
rows?: number;
/** فیلد فقط‌عددی: ارقام فارسی/عربی به لاتین تبدیل و غیررقم حذف می‌شود. */
numeric?: boolean;
maxDigits?: number;
}) {
const cls =
"w-full bg-[#FAFAFA] dark:bg-[#222433] rounded-[8px] border border-[#D7D7D7] dark:border-[#343645] " +
@@ -260,7 +265,8 @@ function DefaultTextField({
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
{...(numeric ? { type: "tel", inputMode: "numeric" as const, dir: "ltr" as const } : {})}
onChange={(e) => onChange(numeric ? digitsOnly(e.target.value, maxDigits) : e.target.value)}
/>
);
}
@@ -363,8 +369,10 @@ function SecretaryModal({
if (!form.name.trim()) return toast.error("لطفاً نام را وارد کنید");
if (!form.family.trim()) return toast.error("لطفاً نام خانوادگی را وارد کنید");
if (!form.telephone.trim()) return toast.error("لطفاً شماره تلفن را وارد کنید");
if (!/^09\d{9}$/.test(form.telephone))
if (!IRAN_MOBILE_RE.test(digitsOnly(form.telephone, 11)))
return toast.error("شماره تلفن باید 11 رقم و با 09 شروع شود");
if (form.national_code.trim() && !IRAN_NATIONAL_CODE_RE.test(digitsOnly(form.national_code, 10)))
return toast.error("کد ملی باید ۱۰ رقم باشد");
if (showDoctorPicker && doctorUuids.length === 0)
return toast.error("حداقل یک پزشک را انتخاب کنید");
onSubmit(form, doctorUuids);
@@ -434,11 +442,11 @@ function SecretaryModal({
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-[16px]">
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
<p className="w-full text-right text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium">شماره موبایل</p>
<DefaultTextField placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
<DefaultTextField numeric maxDigits={11} placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
</div>
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
<p className="w-full text-right text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium">کد ملی</p>
<DefaultTextField placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
<DefaultTextField numeric maxDigits={10} placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
</div>
</div>
@@ -770,9 +778,9 @@ function MySecretariesPageContent() {
const createMutation = useMutation({
mutationFn: ({ form, doctorUuids }: { form: FormState; doctorUuids: string[] }) => {
const base = {
mobile_number: form.telephone,
mobile_number: digitsOnly(form.telephone, 11),
name: `${form.name} ${form.family}`.trim(),
national_code: form.national_code || null,
national_code: digitsOnly(form.national_code, 10) || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
};
@@ -800,7 +808,7 @@ function MySecretariesPageContent() {
mutationFn: ({ uuid, form }: { uuid: string; form: FormState }) =>
api.patch(`/api/v1/secretary/${uuid}`, {
name: `${form.name} ${form.family}`.trim(),
national_code: form.national_code || null,
national_code: digitsOnly(form.national_code, 10) || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
}),
+6 -4
View File
@@ -11,6 +11,8 @@ import type { ApiResponse } from '../lib/api';
import type { PatientRecord } from '../types';
import PersianDateInput from '../components/ui/PersianDateInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
import { iranNationalCodeSchema, iranMobileSchema } from '../lib/utils';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
@@ -18,8 +20,8 @@ const schema = z.object({
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'),
mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'),
national_code: iranNationalCodeSchema,
mobile: iranMobileSchema,
birth_date: z.string().optional(),
referral_source: z.string().optional(),
description: z.string().optional(),
@@ -126,10 +128,10 @@ export default function PatientRecordFormPage() {
/>
</Field>
<Field label="کد ملی" required error={form.formState.errors.national_code?.message}>
<div className="field"><input {...form.register('national_code')} inputMode="numeric" placeholder="کد ملی را وارد نمایید" /></div>
<div className="field"><input {...numericField(form.register('national_code'), 10)} placeholder="کد ملی را وارد نمایید" /></div>
</Field>
<Field label="شماره تماس" required error={form.formState.errors.mobile?.message}>
<div className="field"><input {...form.register('mobile')} inputMode="numeric" placeholder="شماره تماس را وارد نمایید" /></div>
<div className="field"><input {...numericField(form.register('mobile'), 11)} placeholder="شماره تماس را وارد نمایید" /></div>
</Field>
<Field label="تاریخ تولد">
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
@@ -12,6 +12,7 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface RepDoctor {
uuid: string;
@@ -486,8 +487,8 @@ export default function RepresentationDetailPage() {
</div>
<div>
<label className="">درصد کمیسیون</label>
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: e.target.value }))}
type="number" min="0" max="100" dir="ltr"
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: digitsOnly(e.target.value, 3) }))}
type="text" inputMode="numeric" dir="ltr"
className="cp-input h-11" />
</div>
</div>
@@ -5,6 +5,7 @@ import { CheckBadgeIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import { toEnglishDigits, digitsOnly } from '../lib/utils';
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
function toJalali(iso: string): string {
@@ -17,13 +18,6 @@ function toJalali(iso: string): string {
return y && m && d ? `${y}/${m}/${d}` : '';
}
// ارقام فارسی/عربی → لاتین (کیبورد انگلیسی؛ ورودی چسبانده‌شده هم نرمال شود)
function toLatinDigits(s: string): string {
return s.replace(/[۰-۹٠-٩]/g, (d) =>
String('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩'.indexOf(d) % 10),
);
}
// اعتبارسنجی کد ملی ایران (طول ۱۰ + رقم کنترلی)
function isValidIranNationalCode(code: string): boolean {
if (!/^\d{10}$/.test(code)) return false;
@@ -37,7 +31,7 @@ function isValidIranNationalCode(code: string): boolean {
// اعتبارسنجی شبای ایران: IR + ۲۴ رقم + کنترل mod-97
function isValidIranIban(raw: string): boolean {
const iban = toLatinDigits(raw).replace(/\s/g, '').toUpperCase();
const iban = toEnglishDigits(raw).replace(/\s/g, '').toUpperCase();
if (!/^IR\d{24}$/.test(iban)) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
@@ -115,13 +109,13 @@ export default function RepresentationProfilePage() {
});
const submitNationalCode = () => {
const code = toLatinDigits(nationalCode).replace(/\D/g, '');
const code = digitsOnly(nationalCode);
if (!isValidIranNationalCode(code)) { toast.error('کد ملی نامعتبر است'); return; }
verifyMut.mutate(code);
};
const submitIban = () => {
const clean = toLatinDigits(iban).replace(/\s/g, '').toUpperCase();
const clean = toEnglishDigits(iban).replace(/\s/g, '').toUpperCase();
if (!isValidIranIban(clean)) { toast.error('شماره شبا نامعتبر است (IR + ۲۴ رقم)'); return; }
const jalali = toJalali(birthDate);
if (!jalali) { toast.error('تاریخ تولد را انتخاب کنید'); return; }
@@ -157,7 +151,7 @@ export default function RepresentationProfilePage() {
<input
type="text" inputMode="numeric" dir="ltr" maxLength={10} value={nationalCode}
placeholder="کد ملی ۱۰ رقمی"
onChange={(e) => setNationalCode(toLatinDigits(e.target.value).replace(/\D/g, ''))}
onChange={(e) => setNationalCode(digitsOnly(e.target.value, 10))}
style={{ width: 220, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box', textAlign: 'center' }}
/>
<button className="btn primary" onClick={submitNationalCode} disabled={verifyMut.isPending}>
@@ -210,7 +204,7 @@ export default function RepresentationProfilePage() {
<input
type="text" inputMode="numeric" dir="ltr" maxLength={26} value={iban}
placeholder="IR000000000000000000000000"
onChange={(e) => setIban(toLatinDigits(e.target.value).toUpperCase().replace(/[^IR0-9]/g, ''))}
onChange={(e) => setIban(toEnglishDigits(e.target.value).toUpperCase().replace(/[^IR0-9]/g, ''))}
style={{ width: 320, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box', fontFamily: 'monospace' }}
/>
<PersianDatePicker
@@ -6,6 +6,7 @@ import type { ApiResponse } from '../lib/api';
import { formatRial, formatDate, tomanToRial } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface WalletBalance { balance_rials: number }
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
@@ -127,8 +128,8 @@ export default function RepresentationSettlementPage() {
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
type="number" min={0} dir="ltr" value={amount} placeholder="مبلغ به تومان"
onChange={(e) => setAmount(e.target.value)}
type="text" inputMode="numeric" dir="ltr" value={amount} placeholder="مبلغ به تومان"
onChange={(e) => setAmount(digitsOnly(e.target.value))}
style={{ width: 200, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }}
/>
<div style={{ width: 320 }}>
+2 -1
View File
@@ -18,6 +18,7 @@ import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
@@ -248,7 +249,7 @@ export default function RepresentationsPage() {
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>درصد کمیسیون</label>
<input {...register('commission_percent')} type="number" min="0" max="100" placeholder="10" dir="ltr"
<input {...numericField(register('commission_percent'), 3)} placeholder="10" dir="ltr"
className="input" />
{errors.commission_percent && <p className="err-text">{errors.commission_percent.message}</p>}
</div>
+8 -7
View File
@@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatDateTime, rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
import {
Cog6ToothIcon, ClockIcon, CalculatorIcon, CreditCardIcon, ChatBubbleLeftRightIcon,
MagnifyingGlassIcon, CheckCircleIcon, ExclamationTriangleIcon, EyeIcon, EyeSlashIcon,
@@ -316,13 +317,13 @@ export default function SettingsPage() {
<div className="settings-grid">
<Field label="مهلت مجاز لغو نوبت" hint="بیمار تا این تعداد ساعت پیش از نوبت اجازه لغو دارد.">
<div className="input-suffix">
<input {...register('max_cancel_hours_before')} type="number" min={0} className="input" style={{ maxWidth: 130 }} />
<input {...numericField(register('max_cancel_hours_before'))} className="input" style={{ maxWidth: 130 }} />
<span className="suf">ساعت قبل</span>
</div>
</Field>
<Field label="ارسال یادآور نوبت" hint="پیامک یادآوری این تعداد ساعت پیش از نوبت ارسال می‌شود.">
<div className="input-suffix">
<input {...register('appointment_reminder_hours')} type="number" min={0} className="input" style={{ maxWidth: 130 }} />
<input {...numericField(register('appointment_reminder_hours'))} className="input" style={{ maxWidth: 130 }} />
<span className="suf">ساعت قبل</span>
</div>
</Field>
@@ -353,7 +354,7 @@ export default function SettingsPage() {
{upgradeCommissionEnabled && (
<div className="toggle-sub">
<div className="input-suffix">
<input {...register('upgrade_commission_percent')} type="number" min={0} max={100} className="input" style={{ maxWidth: 120 }} />
<input {...numericField(register('upgrade_commission_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
<span className="suf">درصد (۰۱۰۰)</span>
</div>
</div>
@@ -370,7 +371,7 @@ export default function SettingsPage() {
{taxEnabled && (
<div className="toggle-sub">
<div className="input-suffix">
<input {...register('tax_percent')} type="number" min={0} max={100} className="input" style={{ maxWidth: 120 }} />
<input {...numericField(register('tax_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
<span className="suf">درصد (۰۱۰۰)</span>
</div>
</div>
@@ -396,13 +397,13 @@ export default function SettingsPage() {
<div className="settings-grid" style={{ marginTop: 18 }}>
<Field label="مبلغ هر نوبت" hint="مبلغی که بیمار هنگام رزرو آنلاین پرداخت می‌کند (تومان).">
<div className="input-suffix">
<input {...register('appointment_fee_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="15000" />
<input {...numericField(register('appointment_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="15000" />
<span className="suf">تومان</span>
</div>
</Field>
<Field label="هزینه ثابت پنل پیامک" hint="از مبلغ هر تراکنش کسر می‌شود (تومان).">
<div className="input-suffix">
<input {...register('sms_panel_fee_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="150000" />
<input {...numericField(register('sms_panel_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="150000" />
<span className="suf">تومان</span>
</div>
</Field>
@@ -492,7 +493,7 @@ export default function SettingsPage() {
</div>
</Field>
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (تومان). مبنای محاسبهٔ تعداد پیامک از موجودی.">
<input {...register('sms_price_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="50" />
<input {...numericField(register('sms_price_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="50" />
</Field>
</div>
)}
+3 -3
View File
@@ -20,6 +20,7 @@ import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import PageHeader from '../components/ui/PageHeader';
import FeatureGate from '../components/ui/FeatureGate';
import { numericField } from '../lib/forms';
const chargeSchema = z.object({
amount_rials: z.coerce.number().min(1000, 'حداقل مبلغ ۱٬۰۰۰ تومان است'),
@@ -528,9 +529,8 @@ function SmsWalletPageInner() {
<div className="field">
<label>مبلغ (تومان)</label>
<input
{...chargeForm.register('amount_rials')}
type="number" min={1000}
placeholder="50000" dir="ltr"
{...numericField(chargeForm.register('amount_rials'))}
placeholder="50000"
/>
{chargeForm.formState.errors.amount_rials && (
<span className="field-error">{chargeForm.formState.errors.amount_rials.message}</span>
+3 -2
View File
@@ -15,6 +15,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import SettingsLayout from '../components/layout/SettingsLayout';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { numericField } from '../lib/forms';
const schema = z.object({
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'),
@@ -262,11 +263,11 @@ function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormDa
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>تلفن</label>
<input {...register('phone')} placeholder="09121234567" dir="ltr" />
<input {...numericField(register('phone'), 11)} placeholder="09121234567" />
</div>
<div className="field">
<label>کد ملی</label>
<input {...register('national_code')} placeholder="0012345678" dir="ltr" />
<input {...numericField(register('national_code'), 10)} placeholder="0012345678" />
</div>
</div>
<div className="field">