Files
clinicpro/assets/admin/lib/utils.ts
T
hamedandClaude Opus 4.8 00cb9aaa1a 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>
2026-07-18 10:38:56 +03:30

162 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from 'zod';
// واحد ذخیره/API ریال است؛ نمایش تومان (÷۱۰) و ورودی ×۱۰.
export const RIAL_PER_TOMAN = 10;
export const rialToToman = (rial: number): number => Math.round((Number(rial) || 0) / RIAL_PER_TOMAN);
export const tomanToRial = (toman: number): number => Math.round((Number(toman) || 0) * RIAL_PER_TOMAN);
// ورودی مقدار ریال است (سازگاری با همهٔ فراخوانی‌ها) ولی خروجی به تومان نمایش داده می‌شود.
export function formatRial(rial: number): string {
return new Intl.NumberFormat('fa-IR').format(rialToToman(rial)) + ' تومان';
}
export function formatNumber(n: number): string {
return new Intl.NumberFormat('fa-IR').format(n);
}
// تایم‌زون رسمی سراسری برنامه = ایران. همهٔ نمایش/تبدیل تاریخ باید با این tz باشد،
// مستقل از تایم‌زون مرورگرِ کاربر (اجباری).
export const APP_TZ = 'Asia/Tehran';
export function toDate(val: string | number | null | undefined): Date | null {
if (val == null || val === '') return null;
if (typeof val === 'number') return new Date(val * 1000);
// Y-m-d → treat as local noon to avoid UTC-off-by-one
if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return new Date(`${val}T12:00:00`);
return new Date(val);
}
export function formatDate(val: string | number | null | undefined): string {
const d = toDate(val);
if (!d || isNaN(d.getTime())) return '—';
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
timeZone: APP_TZ,
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
export function formatDateTime(val: string | number | null | undefined): string {
const d = toDate(val);
if (!d || isNaN(d.getTime())) return '—';
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
timeZone: APP_TZ,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
}).format(d);
}
// ساعت (HH:MM) یک timestamp ثانیه‌ای به وقت ایران — برای تایم‌لاین/جدول نوبت‌ها.
export function formatTime(tsSeconds: number): string {
return new Intl.DateTimeFormat('fa-IR', {
timeZone: APP_TZ, hour: '2-digit', minute: '2-digit',
}).format(new Date(tsSeconds * 1000));
}
// اختلاف دقیقه‌ایِ یک تایم‌زون با UTC در لحظهٔ مشخص (ایران ثابت +03:30 است ولی این
// روش عمومی و درست است).
function tzOffsetMinutes(date: Date, tz: string): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(date).reduce<Record<string, string>>((a, p) => (a[p.type] = p.value, a), {});
const asUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second);
return Math.round((asUTC - date.getTime()) / 60000);
}
// «ساعت دیواریِ ایران» (تاریخ Y-m-d + HH:MM) → Unix ثانیه. مستقل از tz مرورگر، تا
// ثبت نوبت با ساعت واردشده دقیقاً همان لحظه به وقت ایران باشد.
export function tehranWallClockToUnix(isoDate: string, time: string): number {
const [Y, M, D] = isoDate.split('-').map(Number);
const [h, m] = (time || '00:00').split(':').map(Number);
const asUtc = Date.UTC(Y, (M || 1) - 1, D || 1, h || 0, m || 0, 0);
const offset = tzOffsetMinutes(new Date(asUtc), APP_TZ);
return Math.floor((asUtc - offset * 60000) / 1000);
}
// تاریخ تقویمیِ میلادی (Y-m-d) یک لحظه، به وقت ایران.
export function toGregorianDate(d: Date): string {
// en-CA → قالب YYYY-MM-DD
return new Intl.DateTimeFormat('en-CA', {
timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
// API stores contract dates as Unix seconds; the date input speaks Y-m-d strings.
export function isoToUnix(iso: string): number | null {
const d = toDate(iso);
return d && !isNaN(d.getTime()) ? Math.floor(d.getTime() / 1000) : null;
}
export function unixToIso(ts: number | null | undefined): string {
if (ts == null) return '';
return toGregorianDate(new Date(ts * 1000));
}
export function maskMobile(mobile: string): string {
if (mobile.length < 7) return mobile;
return mobile.slice(0, 4) + '***' + mobile.slice(-3);
}
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
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.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 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}$/;
export function isValidIranMobile(input: string): boolean {
return IRAN_MOBILE_RE.test(toEnglishDigits(input || ''));
}
// schema قابل‌استفاده‌ی مشترک برای شماره موبایل ایران (ارقام فارسی/عربی را هم می‌پذیرد و نرمال می‌کند).
export const iranMobileSchema = z
.string()
.transform((v) => toEnglishDigits(v).replace(/\D/g, ''))
.refine((v) => IRAN_MOBILE_RE.test(v), 'شماره موبایل باید ۱۱ رقم و با 09 شروع شود');
// نسخه‌ی اختیاری (خالی یا معتبر) برای فیلدهای غیرالزامی.
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), 'کد ملی باید ۱۰ رقم باشد');