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,6 +6,7 @@ import type { ApiResponse } from '../lib/api';
import type { Appointment } from '../types';
import Modal from './ui/Modal';
import SearchableSelect from './ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface Option { uuid: string; name?: string }
@@ -87,7 +88,7 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
</div>
<label style={label}>جستجو براساس کد ملی</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={f.nationalCode} onChange={e => setF(v => ({ ...v, nationalCode: e.target.value }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." dir="ltr" />
<input value={f.nationalCode} onChange={e => setF(v => ({ ...v, nationalCode: digitsOnly(e.target.value, 10) }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." inputMode="numeric" dir="ltr" />
</div>
<label style={label}>بخش</label>
+4 -3
View File
@@ -11,6 +11,7 @@ import ConfirmDialog from './ui/ConfirmDialog';
import SearchableSelect from './ui/SearchableSelect';
import PriceInput from './ui/PriceInput';
import PersianDateInput from './ui/PersianDateInput';
import { digitsOnly } from '../lib/utils';
const TYPE_LABELS: Record<DiscountRuleType, string> = {
patient_tag: 'تگ بیمار',
@@ -239,12 +240,12 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
<div>
<label style={labelStyle()}>{f.discount_type === 'percent' ? 'درصد تخفیف (۰ تا ۱۰۰)' : 'مبلغ تخفیف (تومان)'}</label>
{f.discount_type === 'percent'
? <input className="cp-input" style={{ width: '100%' }} type="number" inputMode="numeric" min={0} max={100} value={f.value} onChange={(e) => set('value', Number(e.target.value) || 0)} />
? <input className="cp-input" style={{ width: '100%' }} type="text" inputMode="numeric" dir="ltr" value={f.value} onChange={(e) => set('value', Number(digitsOnly(e.target.value, 3)) || 0)} />
: <PriceInput className="cp-input" style={{ width: '100%' }} value={f.value} onChange={(v) => set('value', v)} />}
</div>
<div>
<label style={labelStyle()}>اولویت (بزرگتر = مهمتر)</label>
<input className="cp-input" type="number" inputMode="numeric" min={0} value={f.priority} onChange={(e) => set('priority', Number(e.target.value) || 0)} />
<input className="cp-input" type="text" inputMode="numeric" dir="ltr" value={f.priority} onChange={(e) => set('priority', Number(digitsOnly(e.target.value)) || 0)} />
</div>
</div>
@@ -294,7 +295,7 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
{f.type === 'visit_count' && (
<div>
<label style={labelStyle()}>حداقل تعداد مراجعه</label>
<input className="cp-input" type="number" inputMode="numeric" min={1} value={f.min_visit_count} onChange={(e) => set('min_visit_count', Number(e.target.value) || 0)} />
<input className="cp-input" type="text" inputMode="numeric" dir="ltr" value={f.min_visit_count} onChange={(e) => set('min_visit_count', Number(digitsOnly(e.target.value)) || 0)} />
</div>
)}
{f.type === 'occasion' && (
@@ -28,7 +28,7 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
fireEvent.click(screen.getByText('ذخیره'));
@@ -40,10 +40,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '50000' } });
fireEvent.change(screen.getByRole('textbox'), { target: { value: '50000' } });
fireEvent.click(screen.getByText('ذخیره'));
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
@@ -55,7 +55,7 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ می‌شود (ارسال مجاز)', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0));
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
fireEvent.click(screen.getByText('ذخیره'));
@@ -71,6 +71,6 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
expect(screen.getByRole('spinbutton')).toHaveValue(50_000);
expect(screen.getByRole('textbox')).toHaveValue('50000');
});
});
+3 -2
View File
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
@@ -62,9 +63,9 @@ export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string })
قیمت (تومان){required && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<input
type="number" min={0} dir="ltr" className="input" style={{ width: 200 }}
type="text" inputMode="numeric" dir="ltr" className="input" style={{ width: 200 }}
aria-invalid={!!error}
value={value} onChange={(e) => { setValue(e.target.value); setError(''); }}
value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }}
/>
</div>
{value !== '' && (
+4 -3
View File
@@ -3,6 +3,7 @@ import Modal from './ui/Modal';
import SearchableSelect from './ui/SearchableSelect';
import PersianDateInput from './ui/PersianDateInput';
import { isoToUnix, rialToToman, tomanToRial, unixToIso } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
export interface InsuranceOption {
insurance_id: number;
@@ -161,15 +162,15 @@ export default function InsuranceModal({ open, editContract, options, kind, onCl
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div style={field}>
<label style={label}>درصد پوشش</label>
<input type="number" min={0} max={100} dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: e.target.value })} />
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: digitsOnly(e.target.value, 3) })} />
</div>
<div style={field}>
<label style={label}>فرانشیز (تومان)</label>
<input type="number" min={0} dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: e.target.value })} />
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
</div>
<div style={field}>
<label style={label}>سقف تعهد (تومان)</label>
<input type="number" min={0} dir="ltr" className="input" placeholder="بی‌نهایت" value={form.ceiling} onChange={(e) => set({ ceiling: e.target.value })} />
<input type="text" inputMode="numeric" dir="ltr" className="input" placeholder="بی‌نهایت" value={form.ceiling} onChange={(e) => set({ ceiling: digitsOnly(e.target.value) })} />
</div>
</div>
</div>
@@ -9,7 +9,7 @@ import PersianDateInput from './ui/PersianDateInput';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import { WalletChargeLink } from './AppointmentActions';
import { tehranWallClockToUnix, tomanToRial, rialToToman, toEnglishDigits, sanitizeMobileInput } from '../lib/utils';
import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils';
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
@@ -210,7 +210,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
</div>
<label style={label}>کد ملی</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={nationalCode} onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
<input value={nationalCode} onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
</div>
</>
@@ -315,7 +315,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
<>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ margin: '6px 0 10px' }}>
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
</div>
{!isReserve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
@@ -8,6 +8,7 @@ import MobileInput from './ui/MobileInput';
import SearchableSelect from './ui/SearchableSelect';
import type { SelectOption } from './ui/SearchableSelect';
import PersianDatePicker from './ui/PersianDatePicker';
import { iranNationalCodeOptionalSchema, iranMobileOptionalSchema } from '../lib/utils';
/**
* اسکیمای فرم «اطلاعات پرونده». مطابق قواعد بک‌اند:
@@ -16,8 +17,8 @@ import PersianDatePicker from './ui/PersianDatePicker';
*/
export const patientFormSchema = z.object({
name: z.string().trim().min(1, 'نام و نام خانوادگی الزامی است'),
mobile: z.string().trim().regex(/^09\d{9}$/, 'شماره موبایل نامعتبر است').or(z.literal('')),
national_code: z.string().trim().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد').or(z.literal('')),
mobile: iranMobileOptionalSchema,
national_code: iranNationalCodeOptionalSchema,
gender: z.string().nullable(),
birth_date: z.string(),
fathers_name: z.string(),
@@ -114,7 +115,7 @@ export default function PatientRecordInfoForm({
{sel('gender', 'جنسیت', options.gender)}
<Field label="کدملی" error={errors.national_code?.message}>
<Input {...register('national_code')} hasError={!!errors.national_code} dir="ltr" maxLength={10} placeholder="کد ملی" />
<Input {...register('national_code')} numeric hasError={!!errors.national_code} maxLength={10} placeholder="کد ملی" />
</Field>
<Field label="شماره تماس" error={errors.mobile?.message}>
@@ -181,7 +182,7 @@ export default function PatientRecordInfoForm({
</Field>
<Field label="کد پستی">
<Input {...register('postal_code')} dir="ltr" placeholder="کدپستی محل سکونت را وارد نمایید..." />
<Input {...register('postal_code')} numeric maxLength={10} placeholder="کدپستی محل سکونت را وارد نمایید..." />
</Field>
{sel('referral_source', 'نحوه آشنایی', options.referral)}
@@ -127,7 +127,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
<div style={{ minWidth: 0 }}>
<label className="field-label">درصد پوشش</label>
<input
type="number" min={0} max={100} dir="ltr" className="input"
type="text" inputMode="numeric" dir="ltr" className="input"
style={{ height: 40, textAlign: 'left' }}
value={draft.coverage_percent ?? ''}
placeholder="ارث"
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import Modal from '../ui/Modal';
import SearchableSelect from '../ui/SearchableSelect';
import { rialToToman, tomanToRial, toEnglishDigits } from '../../lib/utils';
import { rialToToman, tomanToRial, digitsOnly } from '../../lib/utils';
import type { InventoryItem, InventoryMeta, ItemPayload } from '../../hooks/useInventory';
interface Props {
@@ -26,7 +26,6 @@ interface FormState {
const DEFAULT_UNIT = 'عدد';
const BLANK: FormState = { name: '', consumable: '', category: '', unit: DEFAULT_UNIT, price: '', stock: '', alertThreshold: '' };
const digits = (v: string) => toEnglishDigits(v).replace(/\D/g, '');
// group thousands: "1200000" → "1,200,000"
const group = (v: string) => v.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
@@ -54,7 +53,7 @@ export default function AddItemModal({ open, editing, meta, saving, onClose, onS
const set = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }));
const setNum = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [k]: digits(e.target.value) }));
setForm((f) => ({ ...f, [k]: digitsOnly(e.target.value) }));
const setSelect = (k: keyof FormState) => (v: string | number | null) =>
setForm((f) => ({ ...f, [k]: v == null ? '' : String(v) }));
@@ -8,6 +8,7 @@ import {
useUpdateBankAccount,
type BankAccount,
} from '../../hooks/usePaymentMethods';
import { digitsOnly, toEnglishDigits } from '../../lib/utils';
/**
* فرم افزودن/ویرایش حساب بانکی — پورت مبدأ ModalAddBankAccount.jsx.
@@ -88,15 +89,15 @@ export default function BankAccountFormModal({
</div>
<div>
<label className="field-label">شماره کارت</label>
<input className="input" value={cardNumber} onChange={(e) => setCardNumber(e.target.value)} placeholder="شماره کارت" />
<input className="input" type="tel" inputMode="numeric" dir="ltr" value={cardNumber} onChange={(e) => setCardNumber(digitsOnly(e.target.value, 16))} placeholder="شماره کارت" />
</div>
<div>
<label className="field-label">شماره حساب</label>
<input className="input" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="شماره حساب" />
<input className="input" type="tel" inputMode="numeric" dir="ltr" value={accountNumber} onChange={(e) => setAccountNumber(digitsOnly(e.target.value))} placeholder="شماره حساب" />
</div>
<div>
<label className="field-label">شبا</label>
<input className="input" value={shabaNumber} onChange={(e) => setShabaNumber(e.target.value)} placeholder="شبا" />
<input className="input" inputMode="numeric" dir="ltr" value={shabaNumber} onChange={(e) => setShabaNumber(toEnglishDigits(e.target.value).toUpperCase())} placeholder="شبا" />
</div>
</div>
</Modal>
@@ -10,7 +10,7 @@ import {
import { toast } from 'sonner';
import { api, ApiError } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { formatNumber } from '../../lib/utils';
import { formatNumber, digitsOnly } from '../../lib/utils';
import Modal from '../ui/Modal';
import ConfirmDialog from '../ui/ConfirmDialog';
import GlobalSearchableSelect from '../ui/SearchableSelect';
@@ -455,15 +455,15 @@ function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = f
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>هر (دقیقه کار)</div>
<div className="field">
<input type="number" min={1} value={session.rest_interval}
onChange={e => upd('rest_interval', Number(e.target.value))} />
<input type="text" inputMode="numeric" dir="ltr" value={session.rest_interval}
onChange={e => upd('rest_interval', Number(digitsOnly(e.target.value)) || 0)} />
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>استراحت (دقیقه)</div>
<div className="field">
<input type="number" min={1} value={session.time_to_rest}
onChange={e => upd('time_to_rest', Number(e.target.value))} />
<input type="text" inputMode="numeric" dir="ltr" value={session.time_to_rest}
onChange={e => upd('time_to_rest', Number(digitsOnly(e.target.value)) || 0)} />
</div>
</div>
</div>
@@ -702,10 +702,11 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-slate-600 dark:text-slate-400">فاصله بین نوبتها</span>
<input
type="number"
min={0}
type="text"
inputMode="numeric"
dir="ltr"
value={meta.buffer_minutes}
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(e.target.value) || 0) }))}
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(digitsOnly(e.target.value)) || 0) }))}
className="w-16 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 focus:outline-none focus:ring-0"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">دقیقه</span>
@@ -748,11 +749,12 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
<span className="text-sm text-slate-600 dark:text-slate-400">رزرو آنلاین تا</span>
<div className="flex items-stretch gap-2">
<input
type="number"
min={1}
type="text"
inputMode="numeric"
dir="ltr"
value={meta.booking_window_value}
disabled={!meta.online_booking_enabled}
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(e.target.value) || 1) }))}
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(digitsOnly(e.target.value)) || 1) }))}
className="w-14 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 focus:outline-none focus:ring-0 px-2 py-1.5"
/>
<div style={{ width: 110 }}>
@@ -78,6 +78,6 @@ describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ r
mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false });
renderStep();
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(30_000));
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
});
});
@@ -10,6 +10,7 @@ import SearchableSelect from '../ui/SearchableSelect';
import PersianDateInput from '../ui/PersianDateInput';
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
import { useAuthStore } from '../../stores/authStore';
import { digitsOnly } from '../../lib/utils';
interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null }
interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
@@ -411,10 +412,10 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
قیمت ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
</span>
<input
className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت"
className="input" type="text" inputMode="numeric" dir="ltr" aria-label="قیمت ویزیت"
aria-invalid={!!visitPriceError}
value={visitPrice}
onChange={(e) => { setVisitPrice(e.target.value); setVisitPriceError(''); }}
onChange={(e) => { setVisitPrice(digitsOnly(e.target.value)); setVisitPriceError(''); }}
/>
{visitPriceError && (
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>{visitPriceError}</span>
@@ -438,11 +439,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<span style={fieldLabel}>تخفیف بیمه پایه (%)</span>
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(e.target.value)} />
<input className="input" type="text" inputMode="numeric" dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(digitsOnly(e.target.value, 3))} />
</div>
<div>
<span style={fieldLabel}>تخفیف تکمیلی (%)</span>
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(e.target.value)} />
<input className="input" type="text" inputMode="numeric" dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(digitsOnly(e.target.value, 3))} />
</div>
</div>
</div>
+44
View File
@@ -0,0 +1,44 @@
import type { UseFormRegisterReturn } from 'react-hook-form';
import { digitsOnly, toEnglishDigits } from './utils';
/**
* فیلدهای عددی React Hook Form: ارقام فارسی/عربی را حین تایپ به لاتین تبدیل می‌کند.
*
* چرا `type="text"`؟ چون `type="number"` روی ارقام فارسی مقدار را نامعتبر می‌داند
* و `e.target.value` رشتهٔ خالی برمی‌گرداند — یعنی داده از دست می‌رود و هیچ
* onChange‌ای نجاتش نمی‌دهد.
*/
type NumericFieldProps = UseFormRegisterReturn & {
type: 'text';
inputMode: 'numeric';
dir: 'ltr';
};
function wrap(
reg: UseFormRegisterReturn,
normalize: (raw: string) => string,
): NumericFieldProps {
return {
...reg,
type: 'text',
inputMode: 'numeric',
dir: 'ltr',
onChange: (event: { target: any; type?: any }) => {
event.target.value = normalize(String(event.target.value ?? ''));
return reg.onChange(event);
},
};
}
/** فقط ارقام لاتین — برای موبایل، کد ملی، مبلغ، درصد، تعداد. */
export function numericField(reg: UseFormRegisterReturn, maxDigits?: number): NumericFieldProps {
return wrap(reg, (raw) => digitsOnly(raw, maxDigits));
}
/**
* فقط ترجمهٔ رقم؛ جداکننده‌ها و حروف حفظ می‌شوند — برای شبا (`IR…`) و
* تلفن ثابت (`021-1234…`).
*/
export function latinDigitsField(reg: UseFormRegisterReturn): NumericFieldProps {
return wrap(reg, toEnglishDigits);
}
+44
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
digitsOnly,
persianSafeNumber,
iranNationalCodeSchema,
iranNationalCodeOptionalSchema,
formatRial,
rialToToman,
tomanToRial,
@@ -130,6 +135,45 @@ describe('toEnglishDigits', () => {
});
});
describe('digitsOnly', () => {
it('رقم فارسی و عربی مخلوط را نرمال و غیررقم را حذف می‌کند', () => {
expect(digitsOnly('۱۲٣٤-56 ب')).toBe('123456');
});
it('با maxLen برش می‌زند', () => {
expect(digitsOnly('۱۲۳۴۵۶۷۸۹۰۱۲', 10)).toBe('1234567890');
});
it('ورودی خالی → رشته خالی', () => {
expect(digitsOnly('')).toBe('');
});
it('فقط حروف → خالی', () => {
expect(digitsOnly('کد ملی')).toBe('');
});
});
describe('persianSafeNumber', () => {
it('رشته‌ی فارسی را قبل از عدد شدن نرمال می‌کند', () => {
const schema = persianSafeNumber(z.coerce.number());
expect(schema.parse('۱۲۳')).toBe(123);
});
it('عدد را دست‌نخورده رد می‌کند', () => {
const schema = persianSafeNumber(z.coerce.number());
expect(schema.parse(42)).toBe(42);
});
});
describe('iranNationalCodeSchema', () => {
it('کد ملی فارسی را می‌پذیرد و لاتین برمی‌گرداند', () => {
expect(iranNationalCodeSchema.parse('۰۰۱۲۳۴۵۶۷۸')).toBe('0012345678');
});
it('کمتر از ۱۰ رقم رد می‌شود', () => {
expect(() => iranNationalCodeSchema.parse('12345')).toThrow();
});
it('نسخه‌ی اختیاری خالی را می‌پذیرد', () => {
expect(iranNationalCodeOptionalSchema.parse('')).toBe('');
expect(() => iranNationalCodeOptionalSchema.parse('123')).toThrow();
});
});
describe('sanitizeMobileInput', () => {
it('غیررقم حذف، رقم فارسی نرمال، حداکثر ۱۱ رقم', () => {
expect(sanitizeMobileInput('۰۹۱۲-۳۴۵ ۶۷۸۹۰۱۲')).toBe('09123456789');
+28 -2
View File
@@ -102,7 +102,8 @@ export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ');
}
// تبدیل ارقام فارسی/عربی به انگلیسی + حذف هر کاراکتر غیرعددی.
// ترجمه‌ی ارقام فارسی/عربی به لاتین. کاراکترهای غیرعددی دست‌نخورده می‌مانند
// (برای شبا و تلفن ثابت که حرف و خط تیره دارند لازم است).
export function toEnglishDigits(input: string): string {
if (!input) return '';
return input
@@ -110,11 +111,23 @@ export function toEnglishDigits(input: string): string {
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660));
}
// فقط ارقام لاتین، با محدودیت طول اختیاری.
export function digitsOnly(input: string, maxLen?: number): string {
const digits = toEnglishDigits(input).replace(/\D/g, '');
return maxLen ? digits.slice(0, maxLen) : digits;
}
// فقط ارقام انگلیسی، حداکثر ۱۱ رقم (برای فیلد موبایل).
export function sanitizeMobileInput(input: string): string {
return toEnglishDigits(input).replace(/\D/g, '').slice(0, 11);
return digitsOnly(input, 11);
}
// z.coerce.number() روی رشته‌ی فارسی NaN می‌دهد. فیلدهای عددی پنل در مبدأ (numericField
// در lib/forms.ts) نرمال می‌شوند، پس این wrapper فقط برای مصرف‌کننده‌های خارج از آن مسیر است.
// روی resolverهای React Hook Form استفاده نکن — z.preprocess تایپ ورودی را unknown می‌کند.
export const persianSafeNumber = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess((v) => (typeof v === 'string' ? toEnglishDigits(v) : v), schema);
// regex شماره موبایل ایران
export const IRAN_MOBILE_RE = /^09\d{9}$/;
@@ -133,3 +146,16 @@ export const iranMobileOptionalSchema = z
.string()
.transform((v) => toEnglishDigits(v).replace(/\D/g, ''))
.refine((v) => v === '' || IRAN_MOBILE_RE.test(v), 'شماره موبایل نامعتبر است');
// regex کد ملی ایران (۱۰ رقم؛ صحت رقم کنترلی اینجا بررسی نمی‌شود)
export const IRAN_NATIONAL_CODE_RE = /^\d{10}$/;
export const iranNationalCodeSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
export const iranNationalCodeOptionalSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => v === '' || IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
+6 -5
View File
@@ -13,6 +13,7 @@ import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
import { numericField } from '../lib/forms';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -271,11 +272,11 @@ function PlansTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>سطح *</label>
<input {...planForm.register('level')} type="number" min={0} dir="ltr" />
<input {...numericField(planForm.register('level'))} />
</div>
<div className="field">
<label>حداکثر منشی *</label>
<input {...planForm.register('max_secretaries')} type="number" min={1} dir="ltr" />
<input {...numericField(planForm.register('max_secretaries'))} />
</div>
</div>
<div>
@@ -320,17 +321,17 @@ function PlansTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>مدت (ماه) *</label>
<input {...periodForm.register('duration_months')} type="number" min={1} dir="ltr" />
<input {...numericField(periodForm.register('duration_months'))} />
</div>
<div className="field">
<label>قیمت (ریال) *</label>
<input {...periodForm.register('price_rials')} type="number" min={0} dir="ltr" />
<input {...numericField(periodForm.register('price_rials'))} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>ترتیب نمایش</label>
<input {...periodForm.register('sort_order')} type="number" min={0} dir="ltr" />
<input {...numericField(periodForm.register('sort_order'))} />
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
+2 -1
View File
@@ -14,6 +14,7 @@ import { WalletChargeLink } from '../components/AppointmentActions';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
/**
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
@@ -434,7 +435,7 @@ export default function AppointmentCreatePage() {
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
</div>
</div>
<div>
+2 -2
View File
@@ -9,7 +9,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { Appointment } from '../types';
import { formatDate, toGregorianDate, formatTime, toEnglishDigits, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
import PriceInput from '../components/ui/PriceInput';
import { useAuthStore } from '../stores/authStore';
import Pagination from '../components/ui/Pagination';
@@ -280,7 +280,7 @@ export function NewAppointmentModal({
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ ...inputSx, direction: 'ltr' }}
/>
+5 -4
View File
@@ -18,6 +18,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags';
@@ -349,7 +350,7 @@ function ProvincesTab() {
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" style={{ maxWidth: 120 }} />
<input {...numericField(register('weight'))} className="input" placeholder="0" style={{ maxWidth: 120 }} />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>وضعیت</label>
@@ -571,7 +572,7 @@ function CitiesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
@@ -699,7 +700,7 @@ function SpecialtiesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
@@ -824,7 +825,7 @@ function DoctorServicesTab() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...register('weight')} type="number" dir="ltr" className="input" placeholder="0" />
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
+2 -1
View File
@@ -22,6 +22,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
import { latinDigitsField } from '../lib/forms';
// Fix leaflet icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -365,7 +366,7 @@ function EditModal({ clinic, onClose, onSaved }: {
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" dir="ltr" placeholder="021-12345678" {...register('telephone')} />
<input className="input" placeholder="021-12345678" {...latinDigitsField(register('telephone'))} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>توضیحات</label>
+2 -1
View File
@@ -10,6 +10,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import MobileInput from '../components/ui/MobileInput';
import { iranMobileSchema } from '../lib/utils';
import { latinDigitsField } from '../lib/forms';
const schema = z.object({
owner_mobile: iranMobileSchema,
@@ -62,7 +63,7 @@ export default function ClinicFormPage() {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>تلفن ثابت</label>
<input className="field" placeholder="02xxxxxxxx" dir="ltr" {...register('telephone')} />
<input className="field" placeholder="02xxxxxxxx" {...latinDigitsField(register('telephone'))} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>آدرس</label>
+2 -1
View File
@@ -21,6 +21,7 @@ import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import FeatureGate from '../components/ui/FeatureGate';
import { numericField } from '../lib/forms';
const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') });
const itemSchema = z.object({
@@ -526,7 +527,7 @@ function ClinicServicesPageInner() {
<div>
<label className="field-label">زمان متوسط (دقیقه)</label>
<div className="field">
<input type="number" min={0} {...itemForm.register('duration_minutes')} placeholder="مثلاً: ۵۰" />
<input {...numericField(itemForm.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
+2 -1
View File
@@ -21,6 +21,7 @@ import Portal from '../components/ui/Portal';
import { formatDate, formatNumber, iranMobileSchema } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import { latinDigitsField } from '../lib/forms';
const HUES_LIST = [256, 205, 162, 295, 272];
@@ -275,7 +276,7 @@ export default function ClinicsPage() {
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
تلفن (اختیاری)
</label>
<input className="input" placeholder="مثال: 021-12345678" dir="ltr" {...addForm.register('telephone')} />
<input className="input" placeholder="مثال: 021-12345678" {...latinDigitsField(addForm.register('telephone'))} />
</div>
</div>
<div className="modal-foot">
+2 -1
View File
@@ -33,6 +33,7 @@ import PersianDatePicker from '../components/ui/PersianDatePicker';
import ImageCropModal from '../components/ImageCropModal';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import { latinDigitsField } from '../lib/forms';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -649,7 +650,7 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
تلفن <span className="text-red-500">*</span>
</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="021..." {...register('telephone')} />
<input className="cp-input text-left" placeholder="021..." {...latinDigitsField(register('telephone'))} />
{errors.telephone && <p className="text-xs text-red-500 mt-1">{errors.telephone.message}</p>}
</div>
</div>
+5 -3
View File
@@ -10,6 +10,7 @@ import Pagination from '../components/ui/Pagination';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
const LEVEL_META: Record<string, { label: string; cls: string }> = {
emergency: { label: 'اضطراری', cls: 'red' },
@@ -254,11 +255,12 @@ function RetentionSettings() {
</label>
<input
className="input"
type="number"
min={0}
type="text"
inputMode="numeric"
dir="ltr"
value={value}
placeholder={settingsQuery.isLoading ? 'در حال بارگذاری...' : '90'}
onChange={(e) => { setDays(e.target.value); setTouched(true); }}
onChange={(e) => { setDays(digitsOnly(e.target.value)); setTouched(true); }}
style={{ maxWidth: 200 }}
/>
<div className="muted" style={{ fontSize: 12, marginTop: 6, lineHeight: 1.7 }}>
+6 -16
View File
@@ -52,6 +52,7 @@ import {
import type { ApiResponse, PaginatedResponse } from "../lib/api";
import { api } from "../lib/api";
import { useIssueInvoice } from "../hooks/useIssueInvoice";
import { numericField } from "../lib/forms";
import {
formatDate,
formatDateTime,
@@ -1437,34 +1438,23 @@ function MyPatientsPageInner() {
<div className="field">
<label>قیمت ویزیت (تومان)</label>
<input
{...form.register("visit_price_rials")}
type="number"
min={0}
dir="ltr"
{...numericField(form.register("visit_price_rials"))}
/>
</div>
<div className="field">
<label>تخفیف بیمه پایه (%)</label>
<input
{...form.register(
{...numericField(form.register(
"base_insurance_discount_percent",
)}
type="number"
min={0}
max={100}
dir="ltr"
), 3)}
/>
</div>
<div className="field">
<label>تخفیف تکمیلی (%)</label>
<input
{...form.register(
{...numericField(form.register(
"supplementary_discount_percent",
)}
type="number"
min={0}
max={100}
dir="ltr"
), 3)}
/>
</div>
</div>
+16 -8
View File
@@ -7,7 +7,7 @@ import ConfirmDialog from "../components/ui/ConfirmDialog";
import Modal from "../components/ui/Modal";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatDate } from "../lib/utils";
import { formatDate, digitsOnly, IRAN_MOBILE_RE, IRAN_NATIONAL_CODE_RE } from "../lib/utils";
import { useSubscription } from "../hooks/useSubscription";
import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
@@ -230,6 +230,8 @@ function DefaultTextField({
disabled,
multiline,
rows,
numeric,
maxDigits,
}: {
placeholder?: string;
value: string;
@@ -237,6 +239,9 @@ function DefaultTextField({
disabled?: boolean;
multiline?: boolean;
rows?: number;
/** فیلد فقط‌عددی: ارقام فارسی/عربی به لاتین تبدیل و غیررقم حذف می‌شود. */
numeric?: boolean;
maxDigits?: number;
}) {
const cls =
"w-full bg-[#FAFAFA] dark:bg-[#222433] rounded-[8px] border border-[#D7D7D7] dark:border-[#343645] " +
@@ -260,7 +265,8 @@ function DefaultTextField({
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
{...(numeric ? { type: "tel", inputMode: "numeric" as const, dir: "ltr" as const } : {})}
onChange={(e) => onChange(numeric ? digitsOnly(e.target.value, maxDigits) : e.target.value)}
/>
);
}
@@ -363,8 +369,10 @@ function SecretaryModal({
if (!form.name.trim()) return toast.error("لطفاً نام را وارد کنید");
if (!form.family.trim()) return toast.error("لطفاً نام خانوادگی را وارد کنید");
if (!form.telephone.trim()) return toast.error("لطفاً شماره تلفن را وارد کنید");
if (!/^09\d{9}$/.test(form.telephone))
if (!IRAN_MOBILE_RE.test(digitsOnly(form.telephone, 11)))
return toast.error("شماره تلفن باید 11 رقم و با 09 شروع شود");
if (form.national_code.trim() && !IRAN_NATIONAL_CODE_RE.test(digitsOnly(form.national_code, 10)))
return toast.error("کد ملی باید ۱۰ رقم باشد");
if (showDoctorPicker && doctorUuids.length === 0)
return toast.error("حداقل یک پزشک را انتخاب کنید");
onSubmit(form, doctorUuids);
@@ -434,11 +442,11 @@ function SecretaryModal({
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-[16px]">
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
<p className="w-full text-right text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium">شماره موبایل</p>
<DefaultTextField placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
<DefaultTextField numeric maxDigits={11} placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
</div>
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
<p className="w-full text-right text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium">کد ملی</p>
<DefaultTextField placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
<DefaultTextField numeric maxDigits={10} placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
</div>
</div>
@@ -770,9 +778,9 @@ function MySecretariesPageContent() {
const createMutation = useMutation({
mutationFn: ({ form, doctorUuids }: { form: FormState; doctorUuids: string[] }) => {
const base = {
mobile_number: form.telephone,
mobile_number: digitsOnly(form.telephone, 11),
name: `${form.name} ${form.family}`.trim(),
national_code: form.national_code || null,
national_code: digitsOnly(form.national_code, 10) || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
};
@@ -800,7 +808,7 @@ function MySecretariesPageContent() {
mutationFn: ({ uuid, form }: { uuid: string; form: FormState }) =>
api.patch(`/api/v1/secretary/${uuid}`, {
name: `${form.name} ${form.family}`.trim(),
national_code: form.national_code || null,
national_code: digitsOnly(form.national_code, 10) || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
}),
+6 -4
View File
@@ -11,6 +11,8 @@ import type { ApiResponse } from '../lib/api';
import type { PatientRecord } from '../types';
import PersianDateInput from '../components/ui/PersianDateInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
import { iranNationalCodeSchema, iranMobileSchema } from '../lib/utils';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
@@ -18,8 +20,8 @@ const schema = z.object({
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'),
mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'),
national_code: iranNationalCodeSchema,
mobile: iranMobileSchema,
birth_date: z.string().optional(),
referral_source: z.string().optional(),
description: z.string().optional(),
@@ -126,10 +128,10 @@ export default function PatientRecordFormPage() {
/>
</Field>
<Field label="کد ملی" required error={form.formState.errors.national_code?.message}>
<div className="field"><input {...form.register('national_code')} inputMode="numeric" placeholder="کد ملی را وارد نمایید" /></div>
<div className="field"><input {...numericField(form.register('national_code'), 10)} placeholder="کد ملی را وارد نمایید" /></div>
</Field>
<Field label="شماره تماس" required error={form.formState.errors.mobile?.message}>
<div className="field"><input {...form.register('mobile')} inputMode="numeric" placeholder="شماره تماس را وارد نمایید" /></div>
<div className="field"><input {...numericField(form.register('mobile'), 11)} placeholder="شماره تماس را وارد نمایید" /></div>
</Field>
<Field label="تاریخ تولد">
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
@@ -12,6 +12,7 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface RepDoctor {
uuid: string;
@@ -486,8 +487,8 @@ export default function RepresentationDetailPage() {
</div>
<div>
<label className="">درصد کمیسیون</label>
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: e.target.value }))}
type="number" min="0" max="100" dir="ltr"
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: digitsOnly(e.target.value, 3) }))}
type="text" inputMode="numeric" dir="ltr"
className="cp-input h-11" />
</div>
</div>
@@ -5,6 +5,7 @@ import { CheckBadgeIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import { toEnglishDigits, digitsOnly } from '../lib/utils';
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
function toJalali(iso: string): string {
@@ -17,13 +18,6 @@ function toJalali(iso: string): string {
return y && m && d ? `${y}/${m}/${d}` : '';
}
// ارقام فارسی/عربی → لاتین (کیبورد انگلیسی؛ ورودی چسبانده‌شده هم نرمال شود)
function toLatinDigits(s: string): string {
return s.replace(/[۰-۹٠-٩]/g, (d) =>
String('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩'.indexOf(d) % 10),
);
}
// اعتبارسنجی کد ملی ایران (طول ۱۰ + رقم کنترلی)
function isValidIranNationalCode(code: string): boolean {
if (!/^\d{10}$/.test(code)) return false;
@@ -37,7 +31,7 @@ function isValidIranNationalCode(code: string): boolean {
// اعتبارسنجی شبای ایران: IR + ۲۴ رقم + کنترل mod-97
function isValidIranIban(raw: string): boolean {
const iban = toLatinDigits(raw).replace(/\s/g, '').toUpperCase();
const iban = toEnglishDigits(raw).replace(/\s/g, '').toUpperCase();
if (!/^IR\d{24}$/.test(iban)) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
@@ -115,13 +109,13 @@ export default function RepresentationProfilePage() {
});
const submitNationalCode = () => {
const code = toLatinDigits(nationalCode).replace(/\D/g, '');
const code = digitsOnly(nationalCode);
if (!isValidIranNationalCode(code)) { toast.error('کد ملی نامعتبر است'); return; }
verifyMut.mutate(code);
};
const submitIban = () => {
const clean = toLatinDigits(iban).replace(/\s/g, '').toUpperCase();
const clean = toEnglishDigits(iban).replace(/\s/g, '').toUpperCase();
if (!isValidIranIban(clean)) { toast.error('شماره شبا نامعتبر است (IR + ۲۴ رقم)'); return; }
const jalali = toJalali(birthDate);
if (!jalali) { toast.error('تاریخ تولد را انتخاب کنید'); return; }
@@ -157,7 +151,7 @@ export default function RepresentationProfilePage() {
<input
type="text" inputMode="numeric" dir="ltr" maxLength={10} value={nationalCode}
placeholder="کد ملی ۱۰ رقمی"
onChange={(e) => setNationalCode(toLatinDigits(e.target.value).replace(/\D/g, ''))}
onChange={(e) => setNationalCode(digitsOnly(e.target.value, 10))}
style={{ width: 220, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box', textAlign: 'center' }}
/>
<button className="btn primary" onClick={submitNationalCode} disabled={verifyMut.isPending}>
@@ -210,7 +204,7 @@ export default function RepresentationProfilePage() {
<input
type="text" inputMode="numeric" dir="ltr" maxLength={26} value={iban}
placeholder="IR000000000000000000000000"
onChange={(e) => setIban(toLatinDigits(e.target.value).toUpperCase().replace(/[^IR0-9]/g, ''))}
onChange={(e) => setIban(toEnglishDigits(e.target.value).toUpperCase().replace(/[^IR0-9]/g, ''))}
style={{ width: 320, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box', fontFamily: 'monospace' }}
/>
<PersianDatePicker
@@ -6,6 +6,7 @@ import type { ApiResponse } from '../lib/api';
import { formatRial, formatDate, tomanToRial } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface WalletBalance { balance_rials: number }
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
@@ -127,8 +128,8 @@ export default function RepresentationSettlementPage() {
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
type="number" min={0} dir="ltr" value={amount} placeholder="مبلغ به تومان"
onChange={(e) => setAmount(e.target.value)}
type="text" inputMode="numeric" dir="ltr" value={amount} placeholder="مبلغ به تومان"
onChange={(e) => setAmount(digitsOnly(e.target.value))}
style={{ width: 200, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }}
/>
<div style={{ width: 320 }}>
+2 -1
View File
@@ -18,6 +18,7 @@ import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
@@ -248,7 +249,7 @@ export default function RepresentationsPage() {
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>درصد کمیسیون</label>
<input {...register('commission_percent')} type="number" min="0" max="100" placeholder="10" dir="ltr"
<input {...numericField(register('commission_percent'), 3)} placeholder="10" dir="ltr"
className="input" />
{errors.commission_percent && <p className="err-text">{errors.commission_percent.message}</p>}
</div>
+8 -7
View File
@@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatDateTime, rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
import {
Cog6ToothIcon, ClockIcon, CalculatorIcon, CreditCardIcon, ChatBubbleLeftRightIcon,
MagnifyingGlassIcon, CheckCircleIcon, ExclamationTriangleIcon, EyeIcon, EyeSlashIcon,
@@ -316,13 +317,13 @@ export default function SettingsPage() {
<div className="settings-grid">
<Field label="مهلت مجاز لغو نوبت" hint="بیمار تا این تعداد ساعت پیش از نوبت اجازه لغو دارد.">
<div className="input-suffix">
<input {...register('max_cancel_hours_before')} type="number" min={0} className="input" style={{ maxWidth: 130 }} />
<input {...numericField(register('max_cancel_hours_before'))} className="input" style={{ maxWidth: 130 }} />
<span className="suf">ساعت قبل</span>
</div>
</Field>
<Field label="ارسال یادآور نوبت" hint="پیامک یادآوری این تعداد ساعت پیش از نوبت ارسال می‌شود.">
<div className="input-suffix">
<input {...register('appointment_reminder_hours')} type="number" min={0} className="input" style={{ maxWidth: 130 }} />
<input {...numericField(register('appointment_reminder_hours'))} className="input" style={{ maxWidth: 130 }} />
<span className="suf">ساعت قبل</span>
</div>
</Field>
@@ -353,7 +354,7 @@ export default function SettingsPage() {
{upgradeCommissionEnabled && (
<div className="toggle-sub">
<div className="input-suffix">
<input {...register('upgrade_commission_percent')} type="number" min={0} max={100} className="input" style={{ maxWidth: 120 }} />
<input {...numericField(register('upgrade_commission_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
<span className="suf">درصد (۰۱۰۰)</span>
</div>
</div>
@@ -370,7 +371,7 @@ export default function SettingsPage() {
{taxEnabled && (
<div className="toggle-sub">
<div className="input-suffix">
<input {...register('tax_percent')} type="number" min={0} max={100} className="input" style={{ maxWidth: 120 }} />
<input {...numericField(register('tax_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
<span className="suf">درصد (۰۱۰۰)</span>
</div>
</div>
@@ -396,13 +397,13 @@ export default function SettingsPage() {
<div className="settings-grid" style={{ marginTop: 18 }}>
<Field label="مبلغ هر نوبت" hint="مبلغی که بیمار هنگام رزرو آنلاین پرداخت می‌کند (تومان).">
<div className="input-suffix">
<input {...register('appointment_fee_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="15000" />
<input {...numericField(register('appointment_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="15000" />
<span className="suf">تومان</span>
</div>
</Field>
<Field label="هزینه ثابت پنل پیامک" hint="از مبلغ هر تراکنش کسر می‌شود (تومان).">
<div className="input-suffix">
<input {...register('sms_panel_fee_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="150000" />
<input {...numericField(register('sms_panel_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="150000" />
<span className="suf">تومان</span>
</div>
</Field>
@@ -492,7 +493,7 @@ export default function SettingsPage() {
</div>
</Field>
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (تومان). مبنای محاسبهٔ تعداد پیامک از موجودی.">
<input {...register('sms_price_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="50" />
<input {...numericField(register('sms_price_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="50" />
</Field>
</div>
)}
+3 -3
View File
@@ -20,6 +20,7 @@ import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import PageHeader from '../components/ui/PageHeader';
import FeatureGate from '../components/ui/FeatureGate';
import { numericField } from '../lib/forms';
const chargeSchema = z.object({
amount_rials: z.coerce.number().min(1000, 'حداقل مبلغ ۱٬۰۰۰ تومان است'),
@@ -528,9 +529,8 @@ function SmsWalletPageInner() {
<div className="field">
<label>مبلغ (تومان)</label>
<input
{...chargeForm.register('amount_rials')}
type="number" min={1000}
placeholder="50000" dir="ltr"
{...numericField(chargeForm.register('amount_rials'))}
placeholder="50000"
/>
{chargeForm.formState.errors.amount_rials && (
<span className="field-error">{chargeForm.formState.errors.amount_rials.message}</span>
+3 -2
View File
@@ -15,6 +15,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import SettingsLayout from '../components/layout/SettingsLayout';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { numericField } from '../lib/forms';
const schema = z.object({
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'),
@@ -262,11 +263,11 @@ function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormDa
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>تلفن</label>
<input {...register('phone')} placeholder="09121234567" dir="ltr" />
<input {...numericField(register('phone'), 11)} placeholder="09121234567" />
</div>
<div className="field">
<label>کد ملی</label>
<input {...register('national_code')} placeholder="0012345678" dir="ltr" />
<input {...numericField(register('national_code'), 10)} placeholder="0012345678" />
</div>
</div>
<div className="field">