Files
clinicpro/assets/admin/lib/utils.ts
T
hamedandClaude Opus 5 f144401dd3 feat(holidays): one official calendar, inherited everywhere
The holiday model was already right — national holidays global, a per-tenant
override in both directions, per-doctor and per-resource exceptions — but
nothing could create a national holiday. The only writer was an import
command, so the calendar the whole product inherits from had no owner.

Three admin-only routes give it one. POST upserts, because `date` is unique
and re-sending a day should rename it rather than surface a raw database
error; PATCH takes only the title, because moving a date means a different
holiday. The system admin has no work environment, so the list endpoint now
returns the calendar with an empty `overrides` for that role instead of the
403 `pair()` would raise — the person who maintains the calendar has to be
able to read it.

Both holiday tabs — the doctor's and the resource's — now open with the
official calendar above their own exceptions, from one shared card rather
than two copies that would drift. Each row can be opted out of with a single
click, which is the existing holiday-override endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:39:48 +03:30

223 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), 'کد ملی باید ۱۰ رقم باشد');
/**
* سالِ شمسی جاری از فرمت‌کنندهٔ شمسیِ مرورگر، نه با محاسبهٔ دستی.
*
* دو مصرف‌کننده دارد (صفحهٔ تعطیلات و کارت تعطیلات رسمی)؛ دو کپی یعنی دو رفتار در
* مرز سال.
*/
export function currentJalaliYear(): number {
const formatted = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).format(new Date());
return Number(formatted.replace(/\D/g, '')) || 1405;
}