Rebuild the /admin/insurance-pricing contracts UI to the Figma "مدیریت بیمه" design and inject the coverage/franchise/ceiling fields the design omitted. Backend: - Add contract-level `kind` column to TenantInsurance (basic|supplementary), defaulting to the catalog type; migration Version20260715093358. - POST/PATCH /billing/tenant-insurances now accept effective_from, effective_to, kind; PATCH also toggles is_active without clobbering the user-set effective_to (unlike DELETE/deactivate). - List returns the latest version of every insurance (active + inactive) via TenantInsuranceRepository::findLatestByTenant, for the فعال/غیرفعال toggle. Frontend: - New InsuranceModal (ui/Modal + SearchableSelect + PersianDateInput) with the seven fields; submit "ثبت بیمه". - TenantInsuranceContracts rebuilt: header + search box, desktop table (ردیف/نام/کد/نوع/وضعیت/عملیات) and mobile cards, status toggle -> PATCH. - utils: isoToUnix/unixToIso helpers for contract dates. Tests: TenantInsuranceContractApiTest (create/edit/toggle/list, 5 cases), InsuranceModal + TenantInsuranceContracts vitest suites, docs/api updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
4.0 KiB
TypeScript
100 lines
4.0 KiB
TypeScript
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 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', {
|
||
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', {
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit',
|
||
}).format(d);
|
||
}
|
||
|
||
export function toGregorianDate(d: Date): string {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
// 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 sanitizeMobileInput(input: string): string {
|
||
return toEnglishDigits(input).replace(/\D/g, '').slice(0, 11);
|
||
}
|
||
|
||
// 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), 'شماره موبایل نامعتبر است');
|