Files
clinicpro/assets/admin/lib/utils.ts
T
hamed b423a0ae4d refactor: normalize date handling to Tehran timezone
- Updated date handling in BlogSeoFields and ScheduleSection to use Tehran timezone utilities for consistency.
- Introduced `toTehranClockTime`, `tehranWallClockToUnix`, and `todayIso` functions for accurate date representation.
- Modified various components to utilize these new utilities, ensuring that date strings are correctly formatted and timestamps are accurately converted.
- Enhanced API documentation to clarify the handling of date fields, emphasizing the importance of server-local midnight.
- Added tests to verify that date overrides and holidays maintain the correct day without shifting due to timezone discrepancies.
2026-07-27 19:37:44 +03:30

212 lines
10 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);
}
// سال شمسی: رقم فارسی بدون جداکننده‌ی هزارگان («۱۴۰۴» نه «۱٬۴۰۴»).
export function formatYear(year: number): string {
return new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(year);
}
// تایم‌زون رسمی سراسری برنامه = ایران. همهٔ نمایش/تبدیل تاریخ باید با این 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);
}
// ساعت دیواریِ ایران به صورت «HH:MM» با رقم لاتین — برای مقداردهی <input type="time">
// (بر خلاف formatTime که رقم فارسی می‌دهد و در input معتبر نیست).
export function toTehranClockTime(d: Date): string {
return new Intl.DateTimeFormat('en-GB', {
timeZone: APP_TZ, hour12: false, hour: '2-digit', minute: '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));
}
// «امروز» به وقت ایران. هرگز از `new Date().toISOString().slice(0,10)` استفاده نکن؛
// آن تاریخِ UTC است و بعد از ۲۰:۳۰ به‌وقت تهران یک روز جلو می‌افتد.
export function todayIso(): string {
return toGregorianDate(new Date());
}
// عنوان «دکتر» فقط در نمایش افزوده می‌شود و هرگز در فیلد name دیتابیس ذخیره نمی‌شود
// (backend با stripDoctorTitle حذف می‌کند). این helper تنها نقطهٔ افزودن عنوان است تا
// در کل پنل یکسان باشد و از «دکتر دکتر …» یا نمایش بدون عنوان جلوگیری شود.
export function displayDoctorName(name?: string | null): string {
const n = (name ?? '').trim();
if (!n) return '';
return n.startsWith('دکتر') ? n : `دکتر ${n}`;
}
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);
}
/**
* رشته‌ی ورودی کاربر (ارقام فارسی/عربی، جداکننده‌ی هزارگان، فاصله) را به عدد امن تبدیل می‌کند.
* هرگز NaN برنمی‌گرداند؛ ورودی نامعتبر یا خالی ⇒ null.
*/
export function parseUserNumber(raw: string | number | null | undefined): number | null {
if (raw == null || raw === '') return null;
const s = toEnglishDigits(String(raw)).replace(/[,\s٫٬]/g, '');
if (!/^-?(\d+\.?\d*|\.\d+)$/.test(s)) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
/** همان parseUserNumber با محدودکردن بازه — برای درصد (۰..۱۰۰) و مقادیر غیرمنفی. */
export function parseUserNumberClamped(
raw: string | number | null | undefined,
min: number,
max: number,
): number | null {
const n = parseUserNumber(raw);
return n == null ? null : Math.min(max, Math.max(min, n));
}
// 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), 'کد ملی باید ۱۰ رقم باشد');