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