Wizard: - failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing - material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows Accept flow: - quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: []) Dates/time: - JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis. Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
158 lines
5.3 KiB
TypeScript
158 lines
5.3 KiB
TypeScript
// ============================================================
|
||
// ابزار تقویم شمسی — پورت jalali.js به TypeScript
|
||
// تبدیلها با الگوریتم استاندارد Kazimierz M. Borkowski
|
||
// ============================================================
|
||
|
||
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
|
||
|
||
export function div(a: number, b: number): number {
|
||
return Math.floor(a / b);
|
||
}
|
||
|
||
export function jalaliToGregorian(jy: number, jm: number, jd: number): [number, number, number] {
|
||
jy = +jy;
|
||
jm = +jm;
|
||
jd = +jd;
|
||
let gy = jy <= 979 ? 621 : 1600;
|
||
jy -= jy <= 979 ? 0 : 979;
|
||
let days =
|
||
365 * jy +
|
||
div(jy, 33) * 8 +
|
||
div((jy % 33) + 3, 4) +
|
||
78 +
|
||
jd +
|
||
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
|
||
gy += 400 * div(days, 146097);
|
||
days %= 146097;
|
||
if (days > 36524) {
|
||
gy += 100 * div(--days, 36524);
|
||
if (days === 36524) days = 0;
|
||
days++;
|
||
}
|
||
gy += 4 * div(days, 1461);
|
||
days %= 1461;
|
||
if (days > 365) {
|
||
gy += div(days - 1, 365);
|
||
days = (days - 1) % 365;
|
||
}
|
||
let gd = days + 1;
|
||
const sal_a = [0, 31, (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||
let gm = 0;
|
||
for (; gm < 13 && gd > sal_a[gm]; gm++) gd -= sal_a[gm];
|
||
return [gy, gm, gd];
|
||
}
|
||
|
||
export function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] {
|
||
gy = +gy;
|
||
gm = +gm;
|
||
gd = +gd;
|
||
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||
let jy = gy <= 1600 ? 0 : 979;
|
||
gy -= gy <= 1600 ? 621 : 1600;
|
||
let days =
|
||
365 * gy +
|
||
div(gy + 3, 4) -
|
||
div(gy + 99, 100) +
|
||
div(gy + 399, 400) -
|
||
80 +
|
||
gd +
|
||
g_d_m[gm - 1];
|
||
jy += 33 * div(days, 12053);
|
||
days %= 12053;
|
||
jy += 4 * div(days, 1461);
|
||
days %= 1461;
|
||
if (days > 365) {
|
||
jy += div(days - 1, 365);
|
||
days = (days - 1) % 365;
|
||
}
|
||
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
|
||
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
|
||
return [jy, jm, jd];
|
||
}
|
||
|
||
export function isLeapJalali(jy: number): boolean {
|
||
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178];
|
||
const jp = breaks[0];
|
||
const jm = breaks[1];
|
||
let jump = 0;
|
||
let jp2 = jp;
|
||
for (const jl of breaks) {
|
||
jump = jl - jp2;
|
||
if (jy <= jl) break;
|
||
jp2 = jl;
|
||
}
|
||
let n = jy - jm;
|
||
if (n < jump) n = n + jump + 1;
|
||
return (n % 33) % 4 === 1;
|
||
}
|
||
|
||
// ---------- formatting (Persian digits) ----------
|
||
export function toFa(s: string | number): string {
|
||
return String(s).replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]);
|
||
}
|
||
|
||
// ماههای شمسی (نسخهی درستشده — «مهر» بهجای «آذر» تکراری)
|
||
export const MONTHS = [
|
||
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
|
||
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
|
||
];
|
||
|
||
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||
|
||
export function toAsciiDigits(s: string | number | null | undefined): string {
|
||
if (s == null) return "";
|
||
return String(s)
|
||
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||
}
|
||
|
||
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
||
export function isoToJalaliStr(iso: string | null | undefined): string {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`;
|
||
}
|
||
|
||
// format with month name: "۱۸ مرداد ۱۴۰۴"
|
||
export function isoToJalaliLong(iso: string | null | undefined): string {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||
}
|
||
|
||
// parse a jalali "yyyy/m/d" or "yyyy/m/d h:m" string to a JS Date (gregorian) ISO string
|
||
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
||
if (!str) return null;
|
||
const s = toAsciiDigits(str).trim();
|
||
const m = s.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||
if (!m) {
|
||
const nums = s
|
||
.split(/[/\-.]/)
|
||
.map((x) => parseInt(x, 10));
|
||
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
||
const [jy, jm, jd] = nums;
|
||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||
const dt = new Date(gy, gm - 1, gd);
|
||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||
}
|
||
const jy = parseInt(m[1], 10);
|
||
const jm = parseInt(m[2], 10);
|
||
const jd = parseInt(m[3], 10);
|
||
const h = m[4] != null ? parseInt(m[4], 10) : 0;
|
||
const min = m[5] != null ? parseInt(m[5], 10) : 0;
|
||
const sec = m[6] != null ? parseInt(m[6], 10) : 0;
|
||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||
const dt = new Date(gy, gm - 1, gd, h, min, sec);
|
||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||
}
|
||
|
||
export function daysInMonth(jy: number, jm: number): number {
|
||
if (jm <= 6) return 31;
|
||
if (jm <= 11) return 30;
|
||
return isLeapJalali(jy) ? 30 : 29;
|
||
}
|