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:
@@ -103,7 +103,7 @@ export function Step1TopicSupplier({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("field max-w-md", invalid && "invalid")}>
|
||||
<div data-field="supplier_id" className={cn("field max-w-md", invalid && "invalid")}>
|
||||
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
|
||||
@@ -221,7 +221,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
<span>قیمت کل</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div data-field="line_items" className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||
<Combobox
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCover
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div data-field="termsheets" className="flex flex-col gap-3">
|
||||
{cards.map((card, idx) => (
|
||||
<TermsheetCard
|
||||
key={card.key}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-field="project">
|
||||
<h3 className="mb-1 text-[15px] font-bold">
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
|
||||
پروژهها
|
||||
|
||||
@@ -57,20 +57,20 @@ export function Step5Review({
|
||||
|
||||
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
||||
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
||||
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full">
|
||||
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full" data-field="quote_date">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||||
<div className="hint">پیشفرض امروز</div>
|
||||
</div>
|
||||
|
||||
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
||||
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full">
|
||||
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full" data-field="expiry_date">
|
||||
<label>تاریخ انقضای پیشنهاد</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
||||
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
||||
</div>
|
||||
|
||||
<div className="field col-span-12">
|
||||
<div className="field col-span-12" data-field="notes">
|
||||
<label>یادداشت (اختیاری)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
@@ -81,7 +81,7 @@ export function Step5Review({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field col-span-12">
|
||||
<div className="field col-span-12" data-field="proforma">
|
||||
<label>پیشفاکتور (اختیاری)</label>
|
||||
{proforma ? (
|
||||
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { LineItem, Quote } from "../../lib/types";
|
||||
import { api, nextId } from "../../lib/api";
|
||||
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
||||
import {
|
||||
useAddMaterial,
|
||||
useAddProject,
|
||||
@@ -125,16 +125,8 @@ export function WizardPage() {
|
||||
goToStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const rememberMaterial = (rec: MaterialRec) => {
|
||||
void addMaterial.mutateAsync({
|
||||
id: nextId("m", materials),
|
||||
name: (rec.category || rec.brand || rec.specs.join(" ") || "").trim() || "کالای جدید",
|
||||
topic: rec.topic,
|
||||
category: rec.category,
|
||||
brand: rec.brand,
|
||||
specs: rec.specs,
|
||||
base_unit: "",
|
||||
});
|
||||
const rememberMaterial = (_rec: MaterialRec) => {
|
||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
||||
};
|
||||
|
||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||
@@ -193,19 +185,72 @@ export function WizardPage() {
|
||||
|
||||
// ---------- ذخیره ----------
|
||||
const doSave = async () => {
|
||||
const sectionError = (step: number, title: string, err: unknown) => {
|
||||
const resolveErrorTarget = (err: unknown): { step: number; field?: string } => {
|
||||
let field = "";
|
||||
let msg = "";
|
||||
if (err instanceof ApiError) {
|
||||
field = err.field || "";
|
||||
msg = err.message || "";
|
||||
} else if (err instanceof Error) {
|
||||
msg = err.message || "";
|
||||
if ("field" in err && typeof (err as any).field === "string") {
|
||||
field = (err as any).field;
|
||||
}
|
||||
}
|
||||
|
||||
const combined = `${field} ${msg}`.toLowerCase();
|
||||
|
||||
// ۱. تأمینکننده
|
||||
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||
|
||||
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
||||
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||
return { step: 2, field: field || "line_items" };
|
||||
}
|
||||
|
||||
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
||||
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||
return { step: 3, field: field || "termsheets" };
|
||||
}
|
||||
|
||||
// ۴. پروژه
|
||||
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||
|
||||
// ۵. تاریخها، یادداشت، پیشفاکتور
|
||||
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||
if (/notes|یادداشت/i.test(combined)) return { step: 5, field: "notes" };
|
||||
|
||||
return { step: 5, field };
|
||||
};
|
||||
|
||||
const sectionError = (stepOrTarget: number | { step: number; field?: string }, title: string, err: unknown) => {
|
||||
const target = typeof stepOrTarget === "number" ? { step: stepOrTarget } : stepOrTarget;
|
||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||
toast.error(`${title}: ${msg}`, {
|
||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: "رفع خطا",
|
||||
onClick: () => {
|
||||
goToStep(target.step);
|
||||
setTimeout(() => {
|
||||
const targetAttr = target.field ? `[data-field="${target.field}"]` : "";
|
||||
const el = (targetAttr ? document.querySelector(targetAttr) : null) ||
|
||||
document.querySelector(`[data-step="${target.step}"]`);
|
||||
if (el instanceof HTMLElement) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const input = el.querySelector("input, textarea, select") as HTMLElement | null;
|
||||
if (input) input.focus();
|
||||
else el.focus?.();
|
||||
el.classList.add("ring-2", "ring-red-500", "rounded-xl");
|
||||
setTimeout(() => el.classList.remove("ring-2", "ring-red-500", "rounded-xl"), 3500);
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
const guessStep = (msg: string): number => {
|
||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
||||
if (/پروژه|project/i.test(msg)) return 4;
|
||||
return 5;
|
||||
};
|
||||
|
||||
const name = supplierName.trim();
|
||||
let supplierId = "";
|
||||
@@ -244,10 +289,11 @@ export function WizardPage() {
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||
if (!material) {
|
||||
material = materials.find(
|
||||
(m) =>
|
||||
looksLikeUuid(m.id) &&
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
@@ -257,11 +303,11 @@ export function WizardPage() {
|
||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
||||
if (!material && cat) {
|
||||
material = materials.find(
|
||||
(m) => normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
||||
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
||||
);
|
||||
}
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
if (material && looksLikeUuid(material.id)) {
|
||||
materialId = material.id;
|
||||
} else {
|
||||
try {
|
||||
@@ -272,7 +318,7 @@ export function WizardPage() {
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
base_unit: unit || "عدد",
|
||||
});
|
||||
materialId = created.id;
|
||||
} catch (err) {
|
||||
@@ -361,8 +407,8 @@ export function WizardPage() {
|
||||
toast.success("پیشنهاد ذخیره شد.");
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
|
||||
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
|
||||
const target = resolveErrorTarget(e);
|
||||
sectionError(target, "خطا در ثبت پیشنهاد", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -392,7 +438,7 @@ export function WizardPage() {
|
||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||
|
||||
{currentStep === 1 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step1TopicSupplier
|
||||
selectedTopic={selectedTopic}
|
||||
onTopicSelect={(t) => {
|
||||
@@ -416,12 +462,12 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step3Termsheets
|
||||
cards={cards}
|
||||
onCardsChange={setCards}
|
||||
@@ -432,7 +478,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step4Projects
|
||||
items={items}
|
||||
projects={projectNames}
|
||||
@@ -446,7 +492,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step5Review
|
||||
quoteDate={quoteDate}
|
||||
onQuoteDate={setQuoteDate}
|
||||
|
||||
Reference in New Issue
Block a user