# Conflicts: # frontend/src/App.tsx # frontend/src/pages/Auth/LoginPage.tsx # frontend/src/pages/Wizard/WizardPage.tsx
1130 lines
44 KiB
TypeScript
1130 lines
44 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { track } from "../../lib/analytics";
|
||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||
import { ChevronRight, ChevronLeft, Check, ShieldCheck, AlertCircle, X } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import type { LineItem, Quote } from "../../lib/types";
|
||
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
||
import {
|
||
useAddMaterial,
|
||
useAddProject,
|
||
useAddSupplier,
|
||
useCreateQuote,
|
||
useMaterials,
|
||
useProjects,
|
||
useQuotes,
|
||
useStoreData,
|
||
useSuppliers,
|
||
} from "../../hooks";
|
||
import { topicByName } from "../../lib/topics";
|
||
import { useAppStore } from "../../store";
|
||
import { normalize } from "../../lib/normalize";
|
||
import { faNum } from "../../lib/formatters";
|
||
import { matchMaterial } from "../../lib/quoteUtils";
|
||
import { Header, PageLayout } from "../../components/layout/Header";
|
||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||
import { Stepper } from "./Stepper";
|
||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
|
||
import { Step3Termsheets, effectiveCovered, type TsCardDraft } from "./Step3Termsheets";
|
||
import { Step4Projects } from "./Step4Projects";
|
||
import { Step5Review } from "./Step5Review";
|
||
import { Button } from "../../components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "../../components/ui/dialog";
|
||
import { useAuthStore } from "../../store/auth";
|
||
|
||
const TOTAL_STEPS = 5;
|
||
|
||
// ---------- صفحه خروج امن (Safe Exit Screen) ----------
|
||
function SafeExitScreen({
|
||
title,
|
||
subtitle,
|
||
}: {
|
||
title?: string;
|
||
subtitle?: string;
|
||
}) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-4">
|
||
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 p-8 text-center shadow-xl">
|
||
<div className="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-5 bg-emerald-100 dark:bg-emerald-950/60 text-emerald-600 dark:text-emerald-400">
|
||
<Check className="w-8 h-8 stroke-[2.5]" />
|
||
</div>
|
||
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-3">
|
||
{title || "پیشنویس استعلام ذخیره گردید"}
|
||
</h2>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
|
||
{subtitle ||
|
||
"اطلاعات با موفقیت ذخیره شد. اکنون میتوانید این برگه را ببندید و برای ادامه کار به ربات بله بازگردید."}
|
||
</p>
|
||
<div className="space-y-3">
|
||
<Button
|
||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 rounded-xl transition shadow-xs text-sm"
|
||
onClick={() => {
|
||
window.close();
|
||
}}
|
||
>
|
||
بستن این برگه
|
||
</Button>
|
||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||
(در صورت مسدود بودن بستن خودکار توسط مرورگر، لطفاً تب مرورگر را به صورت دستی ببندید)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- صفحه خطای توکن نامعتبر ----------
|
||
function TokenInvalidScreen({ message }: { message: string }) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-4">
|
||
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-2xl border border-rose-200 dark:border-rose-900/50 p-8 text-center shadow-xl">
|
||
<div className="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-5 bg-rose-100 dark:bg-rose-950/60 text-rose-600 dark:text-rose-400">
|
||
<AlertCircle className="w-8 h-8 stroke-[2.5]" />
|
||
</div>
|
||
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-3">
|
||
لینک نامعتبر یا منقضی شده
|
||
</h2>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
|
||
{message ||
|
||
"اعتبار این لینک بازبینی به پایان رسیده است. لطفاً از طریق ربات بله، مجدداً دکمه بازبینی را دریافت فرمایید."}
|
||
</p>
|
||
<Button
|
||
variant="line"
|
||
className="w-full rounded-xl py-2 text-sm"
|
||
onClick={() => {
|
||
window.close();
|
||
}}
|
||
>
|
||
بستن برگه
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ویزارد ۵ مرحلهای افزودن پیشنهاد جدید و بازبینی پیشنویس
|
||
export function WizardPage({ isStandalone = false }: { isStandalone?: boolean }) {
|
||
useStoreData();
|
||
const navigate = useNavigate();
|
||
const [params] = useSearchParams();
|
||
const token = params.get("token");
|
||
const isStandaloneMode = isStandalone || Boolean(token);
|
||
|
||
const [exitInfo, setExitInfo] = useState<{
|
||
show: boolean;
|
||
title?: string;
|
||
subtitle?: string;
|
||
}>({ show: false });
|
||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||
|
||
// Setup History Trap when in standalone mode
|
||
useEffect(() => {
|
||
if (!isStandaloneMode) return;
|
||
try {
|
||
window.history.replaceState({ page: "review_root" }, "", window.location.href);
|
||
window.history.pushState({ page: "review_active" }, "", window.location.href);
|
||
} catch {}
|
||
|
||
const onPop = () => {
|
||
setExitInfo({
|
||
show: true,
|
||
title: "بازبینی به پایان رسید",
|
||
subtitle: "پیشنویس ذخیره گردید. اکنون میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||
});
|
||
try {
|
||
window.history.pushState(null, "", window.location.href);
|
||
} catch {}
|
||
};
|
||
|
||
window.addEventListener("popstate", onPop);
|
||
return () => window.removeEventListener("popstate", onPop);
|
||
}, [isStandaloneMode]);
|
||
|
||
const quotes = useQuotes();
|
||
const suppliers = useSuppliers();
|
||
const materials = useMaterials();
|
||
const projects = useProjects();
|
||
// سطح limited تدارکات: استعلام فقط برای پروژههای عضویت — بدون افزودن پروژه جدید
|
||
const user = useAuthStore((s) => s.user);
|
||
const isLimitedProcurement =
|
||
user?.panels?.procurement === "limited" && user?.role !== "admin";
|
||
const memberProjectNames = useMemo(() => {
|
||
const allowedIds = new Set([
|
||
...(user?.member_project_ids ?? []),
|
||
...(user?.projects ?? []).map((p) => p.id),
|
||
...(user?.project_id ? [user.project_id] : []),
|
||
]);
|
||
const matched = projects
|
||
.filter((p) => allowedIds.has(p.id))
|
||
.map((p) => p.name);
|
||
const directNames = (user?.projects ?? []).map((p) => p.name);
|
||
if (user?.project_name) directNames.push(user.project_name);
|
||
return [...new Set([...matched, ...directNames])];
|
||
}, [projects, user]);
|
||
const topics = useAppStore((s) => s.topics);
|
||
const createQuote = useCreateQuote();
|
||
const addSupplier = useAddSupplier();
|
||
const addMaterial = useAddMaterial();
|
||
const addProject = useAddProject();
|
||
|
||
// ---------- state ----------
|
||
const [currentStep, setCurrentStep] = useState(1);
|
||
const [highestVisited, setHighestVisited] = useState(1);
|
||
|
||
const [selectedTopic, setSelectedTopic] = useState("");
|
||
const [supplierName, setSupplierName] = useState("");
|
||
const [newSup, setNewSup] = useState({ location: "", phone: "", contact: "" });
|
||
const [isOfficial, setIsOfficial] = useState(false);
|
||
const [supplierInvalid, setSupplierInvalid] = useState(false);
|
||
|
||
const [items, setItems] = useState<ItemDraft[]>(() => [
|
||
{ tempId: "it0", cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
|
||
]);
|
||
const [cards, setCards] = useState<TsCardDraft[]>(() => [
|
||
{ key: "ts0", milestones: [{ when_iso: null, amount: null }] },
|
||
]);
|
||
const [coverage, setCoverage] = useState<Record<string, string[]>>({});
|
||
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
|
||
|
||
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
|
||
return new Date().toISOString();
|
||
});
|
||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||
const [notes, setNotes] = useState("");
|
||
const [proforma, setProforma] = useState<File | null>(null);
|
||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||
const [loadedDraftId, setLoadedDraftId] = useState<string | null>(null);
|
||
const draftFetchedRef = useRef(false);
|
||
const lastRemoteUpdatedAtRef = useRef<string | null>(null);
|
||
const isSelfUpdatingRef = useRef<boolean>(false);
|
||
const isCommittedOrCancelledRef = useRef<boolean>(false);
|
||
const initialLoadedRef = useRef<boolean>(false);
|
||
|
||
// ---------- تابع اعمال دادههای پیشنویس به فیلدهای ویزارد ----------
|
||
const applyDraftPayloadToForm = useCallback(
|
||
(p: Record<string, any>) => {
|
||
if (!p) return;
|
||
|
||
// ۱. تأمینکننده و وضعیت رسمی
|
||
if (p.supplier_name !== undefined) {
|
||
setSupplierName(p.supplier_name || "");
|
||
setSupplierInvalid(false);
|
||
}
|
||
if (p.is_official !== undefined) {
|
||
setIsOfficial(Boolean(p.is_official));
|
||
}
|
||
|
||
// استنتاج تاپیک: ابتدا از اطلاعات تأمینکننده، سپس متریال، سپس اولین تاپیک موجود
|
||
let topicToSet = "";
|
||
if (p.supplier_name) {
|
||
const sup = suppliers.find((s) => normalize(s.name) === normalize(p.supplier_name));
|
||
if (sup?.topic) topicToSet = sup.topic;
|
||
}
|
||
if (!topicToSet && Array.isArray(p.items) && p.items.length > 0) {
|
||
for (const it of p.items) {
|
||
if (it.material_name) {
|
||
const mat = materials.find((m) => normalize(m.name) === normalize(it.material_name));
|
||
if (mat?.topic) {
|
||
topicToSet = mat.topic;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!topicToSet && topics.length > 0) {
|
||
topicToSet = topics[0].name;
|
||
}
|
||
if (topicToSet) {
|
||
setSelectedTopic(topicToSet);
|
||
}
|
||
|
||
// ۲. اقلام (Step 2)
|
||
let newItems: ItemDraft[] = [];
|
||
if (Array.isArray(p.items) && p.items.length > 0) {
|
||
newItems = p.items.map((it: any, idx: number) => {
|
||
const qty = it.quantity != null ? Number(it.quantity) : null;
|
||
const price = it.unit_price != null ? Number(it.unit_price) : null;
|
||
return {
|
||
tempId: "it" + idx,
|
||
cat: it.material_name || "",
|
||
brand: it.brand_name || "",
|
||
specs: Array.isArray(it.specs) ? it.specs : [],
|
||
qty,
|
||
unit: it.unit_name || "",
|
||
price,
|
||
total: qty != null && price != null ? qty * price : null,
|
||
};
|
||
});
|
||
setItems(newItems);
|
||
}
|
||
|
||
// ۳. شرایط پرداخت / termsheet (Step 3)
|
||
if (Array.isArray(p.termsheets) && p.termsheets.length > 0) {
|
||
const newCards: TsCardDraft[] = p.termsheets.map((ts: any, idx: number) => ({
|
||
key: "ts" + idx,
|
||
milestones:
|
||
Array.isArray(ts.milestones) && ts.milestones.length > 0
|
||
? ts.milestones.map((m: any) => ({
|
||
when_iso: m.due_date || m.when_iso || null,
|
||
amount: m.amount != null ? Number(m.amount) : null,
|
||
}))
|
||
: [{ when_iso: null, amount: null }],
|
||
}));
|
||
setCards(newCards);
|
||
|
||
const targetIds = (newItems.length > 0 ? newItems : items).map((i) => i.tempId);
|
||
const newCov: Record<string, string[]> = {};
|
||
newCards.forEach((c) => {
|
||
newCov[c.key] = targetIds;
|
||
});
|
||
setCoverage(newCov);
|
||
}
|
||
|
||
// ۴. پروژه (Step 4)
|
||
const projNames: string[] = [];
|
||
if (Array.isArray(p.project_names)) {
|
||
projNames.push(...p.project_names.filter(Boolean));
|
||
}
|
||
if (p.project_name && !projNames.includes(p.project_name)) {
|
||
projNames.push(p.project_name);
|
||
}
|
||
if (projNames.length > 0) {
|
||
const newAssignment: Record<string, string[]> = {};
|
||
const targetItems = newItems.length > 0 ? newItems : items;
|
||
targetItems.forEach((it) => {
|
||
newAssignment[it.tempId] = projNames;
|
||
});
|
||
setAssignment(newAssignment);
|
||
}
|
||
|
||
// ۵. یادداشت و تاریخ انقضا (Step 5)
|
||
if (p.notes !== undefined) {
|
||
setNotes(p.notes || "");
|
||
}
|
||
if (p.expiry_date !== undefined) {
|
||
setExpiryDate(p.expiry_date || null);
|
||
}
|
||
},
|
||
[suppliers, materials, topics, items],
|
||
);
|
||
|
||
// ---------- prefill از لینکها (?topic=&supplier=&project=) ----------
|
||
useEffect(() => {
|
||
const t = params.get("topic");
|
||
if (t) {
|
||
const topic = topicByName(t, topics);
|
||
if (topic) {
|
||
setSelectedTopic(topic.name);
|
||
const sid = params.get("supplier");
|
||
if (sid) {
|
||
const sup = suppliers.find((s) => s.id === sid);
|
||
if (sup && normalize(sup.topic) === normalize(topic.name)) {
|
||
setSupplierName(sup.name);
|
||
setSupplierInvalid(false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const proj = params.get("project");
|
||
if (proj) {
|
||
setAssignment((cur) => {
|
||
const next = { ...cur };
|
||
for (const it of items) {
|
||
if (!next[it.tempId]?.length) {
|
||
next[it.tempId] = [proj];
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
}, [params, suppliers, topics, items]);
|
||
|
||
// ---------- بارگذاری اولیه پیشنویس ربات بله (?token= یا ?draft=) ----------
|
||
useEffect(() => {
|
||
const draftId = params.get("draft") || params.get("draft_id");
|
||
if ((!token && !draftId) || draftFetchedRef.current) return;
|
||
|
||
draftFetchedRef.current = true;
|
||
void (async () => {
|
||
try {
|
||
let draft: any = null;
|
||
if (token) {
|
||
const res = await api.drafts.verifyToken(token);
|
||
draft = (res as any)?.data || res;
|
||
} else if (draftId) {
|
||
draft = await api.drafts.get(draftId);
|
||
}
|
||
if (!draft || !draft.payload) {
|
||
if (token) setTokenError("پیشنویس معتبری یافت نشد.");
|
||
return;
|
||
}
|
||
setLoadedDraftId(draft.id);
|
||
lastRemoteUpdatedAtRef.current = draft.updated_at || null;
|
||
|
||
applyDraftPayloadToForm(draft.payload);
|
||
setHighestVisited(5);
|
||
initialLoadedRef.current = true;
|
||
|
||
toast.success("پیشنویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", {
|
||
duration: 6000,
|
||
});
|
||
} catch (err: any) {
|
||
if (token) {
|
||
setTokenError(err?.message || "لینک بازبینی نامعتبر یا منقضی شده است.");
|
||
} else {
|
||
toast.error(`خطا در فراخوانی پیشنویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
||
}
|
||
}
|
||
})();
|
||
}, [params, token, applyDraftPayloadToForm]);
|
||
|
||
// ---------- پولینگ دورهای برای دریافت تغییرات یا ثبت از سمت ربات بله ----------
|
||
useEffect(() => {
|
||
const draftId = params.get("draft") || params.get("draft_id");
|
||
if (!token && !draftId) return;
|
||
|
||
const interval = setInterval(async () => {
|
||
if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||
try {
|
||
let draft: any = null;
|
||
if (token) {
|
||
const res = await api.drafts.verifyToken(token);
|
||
draft = (res as any)?.data || res;
|
||
} else if (draftId) {
|
||
draft = await api.drafts.get(draftId);
|
||
}
|
||
if (!draft) return;
|
||
|
||
if (draft.status === "committed") {
|
||
isCommittedOrCancelledRef.current = true;
|
||
if (isStandaloneMode) {
|
||
setExitInfo({
|
||
show: true,
|
||
title: "این پیشنهاد ثبت نهایی شد ✅",
|
||
subtitle: "استعلام با موفقیت در سیستم ثبت گردید. میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||
});
|
||
return;
|
||
}
|
||
toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 });
|
||
navigate("/");
|
||
return;
|
||
}
|
||
if (draft.status === "cancelled") {
|
||
isCommittedOrCancelledRef.current = true;
|
||
if (isStandaloneMode) {
|
||
setExitInfo({
|
||
show: true,
|
||
title: "پیشنویس لغو شد",
|
||
subtitle: "این پیشنویس از طریق ربات بله لغو گردید. میتوانید این برگه را ببندید.",
|
||
});
|
||
return;
|
||
}
|
||
toast.error("این پیشنویس از طریق ربات بله لغو شد.", { duration: 6000 });
|
||
navigate("/");
|
||
return;
|
||
}
|
||
|
||
if (
|
||
draft.updated_at &&
|
||
lastRemoteUpdatedAtRef.current &&
|
||
draft.updated_at > lastRemoteUpdatedAtRef.current &&
|
||
!isSelfUpdatingRef.current
|
||
) {
|
||
lastRemoteUpdatedAtRef.current = draft.updated_at;
|
||
applyDraftPayloadToForm(draft.payload);
|
||
}
|
||
} catch {
|
||
/* خطای موقت شبکه در پولینگ نادیده گرفته میشود */
|
||
}
|
||
}, 2000);
|
||
|
||
return () => clearInterval(interval);
|
||
}, [params, token, isStandaloneMode, navigate, applyDraftPayloadToForm]);
|
||
|
||
// ---------- همگامسازی خودکار تغییرات ویزارد به پیشنویس ربات (Debounced Auto-Save) ----------
|
||
useEffect(() => {
|
||
const draftId = params.get("draft") || params.get("draft_id");
|
||
if ((!token && !draftId) || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||
|
||
const timer = setTimeout(async () => {
|
||
isSelfUpdatingRef.current = true;
|
||
try {
|
||
const payloadToSave: Record<string, any> = {
|
||
supplier_name: supplierName.trim() || null,
|
||
is_official: isOfficial,
|
||
items: items.map((it) => ({
|
||
material_name: it.cat.trim() || null,
|
||
brand_name: it.brand.trim() || null,
|
||
specs: it.specs,
|
||
quantity: it.qty,
|
||
unit_name: it.unit.trim() || null,
|
||
unit_price: it.price,
|
||
})),
|
||
termsheets: cards.map((c, i) => ({
|
||
label: "گزینه " + faNum(i + 1),
|
||
milestones: (c.milestones || []).map((m) => ({
|
||
due_date: m.when_iso,
|
||
amount: m.amount,
|
||
})),
|
||
})),
|
||
project_names: [...new Set(Object.values(assignment).flat())],
|
||
notes: notes.trim() || null,
|
||
expiry_date: expiryDate || null,
|
||
};
|
||
|
||
if (token) {
|
||
await api.drafts.updateByToken(token, payloadToSave);
|
||
const res = await api.drafts.verifyToken(token);
|
||
const updated = (res as any)?.data || res;
|
||
if (updated?.updated_at) {
|
||
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
||
}
|
||
} else if (draftId) {
|
||
await api.drafts.update(draftId, payloadToSave);
|
||
const updated = await api.drafts.get(draftId);
|
||
if (updated?.updated_at) {
|
||
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn("Auto-save to draft failed:", err);
|
||
} finally {
|
||
setTimeout(() => {
|
||
isSelfUpdatingRef.current = false;
|
||
}, 600);
|
||
}
|
||
}, 800);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params, token]);
|
||
|
||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||
const completedSteps = useMemo(() => {
|
||
const s = new Set<number>();
|
||
if (selectedTopic && supplierName.trim()) s.add(1);
|
||
if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2);
|
||
if (
|
||
cards.length > 0 &&
|
||
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
|
||
) {
|
||
s.add(3);
|
||
}
|
||
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
|
||
if (highestVisited >= 5) s.add(5);
|
||
return s;
|
||
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
|
||
|
||
const goToStep = (n: number) => {
|
||
setCurrentStep(n);
|
||
setHighestVisited((h) => Math.max(h, n));
|
||
track("wizard_step", { step: n, direction: n > currentStep ? "forward" : "back" });
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
};
|
||
|
||
const next = () => {
|
||
goToStep(currentStep + 1);
|
||
};
|
||
|
||
const rememberMaterial = (_rec: MaterialRec) => {};
|
||
|
||
// ---------- پروژهها ----------
|
||
const projectNames = useMemo(() => {
|
||
if (isLimitedProcurement) {
|
||
return memberProjectNames;
|
||
}
|
||
const names = new Set<string>(projects.map((p) => p.name));
|
||
quotes.forEach((q) => {
|
||
if (q.project) names.add(q.project);
|
||
(q.projects || []).forEach((n) => names.add(n));
|
||
});
|
||
return [...names];
|
||
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
|
||
|
||
// سطح محدود: در صورت داشتن یک پروژه، خودکار اقلام به آن پروژه تخصیص مییابند
|
||
useEffect(() => {
|
||
if (isLimitedProcurement && memberProjectNames.length === 1) {
|
||
const sole = memberProjectNames[0];
|
||
setAssignment((cur) => {
|
||
let changed = false;
|
||
const next = { ...cur };
|
||
for (const it of items) {
|
||
if (!next[it.tempId]?.length) {
|
||
next[it.tempId] = [sole];
|
||
changed = true;
|
||
}
|
||
}
|
||
return changed ? next : cur;
|
||
});
|
||
}
|
||
}, [isLimitedProcurement, memberProjectNames, items]);
|
||
|
||
const assign = (tempId: string, proj: string) => {
|
||
setAssignment((a) => {
|
||
const cur = a[tempId] || [];
|
||
if (cur.includes(proj)) return a;
|
||
return { ...a, [tempId]: [...cur, proj] };
|
||
});
|
||
};
|
||
|
||
const unassign = (tempId: string, proj: string) => {
|
||
setAssignment((a) => {
|
||
const cur = (a[tempId] || []).filter((p) => p !== proj);
|
||
const next = { ...a };
|
||
if (cur.length) next[tempId] = cur;
|
||
else delete next[tempId];
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// ---------- خلاصه مرحله ۵ ----------
|
||
const review = useMemo(() => {
|
||
let grandTotal = 0;
|
||
items.forEach((it) => {
|
||
const t = it.total;
|
||
if (t) grandTotal += t;
|
||
});
|
||
const projSet = new Set<string>();
|
||
items.forEach((it) => (assignment[it.tempId] || []).forEach((p) => projSet.add(p)));
|
||
const coveredTempIds = new Set<string>();
|
||
cards.forEach((card) =>
|
||
effectiveCovered(cards, coverage, items, card.key).forEach((tid) => coveredTempIds.add(tid)),
|
||
);
|
||
const orphanCount = items.filter((it) => !coveredTempIds.has(it.tempId)).length;
|
||
return {
|
||
supplierName: supplierName.trim() || "—",
|
||
topicName: selectedTopic || "—",
|
||
projects: [...projSet],
|
||
itemCount: items.length,
|
||
tsCount: cards.length,
|
||
grandTotal,
|
||
isOfficial,
|
||
orphanCount,
|
||
};
|
||
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
|
||
|
||
// ---------- ذخیره ----------
|
||
const doSave = async () => {
|
||
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" };
|
||
}
|
||
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}`, {
|
||
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 name = supplierName.trim();
|
||
let supplierId = "";
|
||
if (name) {
|
||
const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
|
||
if (exist) {
|
||
supplierId = exist.id;
|
||
} else {
|
||
try {
|
||
const created = await addSupplier.mutateAsync({
|
||
id: nextId("s", suppliers),
|
||
topic: selectedTopic,
|
||
categories: [],
|
||
name,
|
||
location: newSup.location.trim(),
|
||
phone: newSup.phone.trim(),
|
||
contact: newSup.contact.trim(),
|
||
});
|
||
supplierId = created.id;
|
||
} catch (err) {
|
||
sectionError(1, "خطا در بخش تأمینکننده", err);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
const collected: { li: LineItem; tempId: string }[] = [];
|
||
for (const it of items) {
|
||
const cat = it.cat.trim();
|
||
const brand = it.brand.trim();
|
||
const specs = it.specs;
|
||
const unit = it.unit.trim();
|
||
const qty = it.qty;
|
||
const price = it.price;
|
||
const total = it.total;
|
||
if (!cat && !brand && qty == null && price == null) continue;
|
||
|
||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||
if (!material) {
|
||
const matched = matchMaterial(
|
||
materials.filter((m) => looksLikeUuid(m.id)),
|
||
cat,
|
||
brand,
|
||
specs,
|
||
);
|
||
if (matched) material = matched;
|
||
}
|
||
let materialId: string;
|
||
if (material && looksLikeUuid(material.id)) {
|
||
materialId = material.id;
|
||
} else {
|
||
try {
|
||
const created = await addMaterial.mutateAsync({
|
||
id: nextId("m", materials),
|
||
name: wantName,
|
||
topic: selectedTopic,
|
||
brand,
|
||
specs,
|
||
base_unit: unit || "عدد",
|
||
});
|
||
materialId = created.id;
|
||
} catch (err) {
|
||
sectionError(2, "خطا در بخش اقلام (متریال)", err);
|
||
return;
|
||
}
|
||
}
|
||
|
||
collected.push({
|
||
li: {
|
||
id: "li-" + (collected.length + 1),
|
||
material_id: materialId,
|
||
quantity: qty,
|
||
unit,
|
||
unit_price: price,
|
||
total_price: total,
|
||
termsheet_ids: [],
|
||
accepted_ts_id: null,
|
||
rejected: false,
|
||
arrived_date: null,
|
||
},
|
||
tempId: it.tempId,
|
||
});
|
||
}
|
||
|
||
const lineItems = collected.map((c) => c.li);
|
||
|
||
const termsheets = cards
|
||
.map((card, i) => ({
|
||
id: "new-ts-" + (i + 1),
|
||
label: "گزینه " + faNum(i + 1),
|
||
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
|
||
status: "offered" as const,
|
||
}))
|
||
.filter((t) => t.milestones.length);
|
||
|
||
collected.forEach(({ li, tempId }) => {
|
||
const ids = cards
|
||
.map((card, i) =>
|
||
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
|
||
)
|
||
.filter(Boolean);
|
||
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
|
||
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
|
||
});
|
||
|
||
const projSet = new Set<string>();
|
||
collected.forEach(({ li, tempId }) => {
|
||
const list = assignment[tempId] || [];
|
||
li.projects = list.map((p) => ({ id: "", name: p }));
|
||
list.forEach((p) => projSet.add(p));
|
||
});
|
||
const projectsList = [...projSet];
|
||
|
||
const quoteIso = quoteDate || new Date().toISOString();
|
||
const quote: Quote = {
|
||
id: nextId("q", quotes),
|
||
supplier_id: supplierId,
|
||
project: projectsList[0] || "",
|
||
projects: projectsList,
|
||
quote_date: quoteIso,
|
||
expiry_date: expiryDate,
|
||
accepted_date: null,
|
||
arrived_date: null,
|
||
status: "offered",
|
||
is_official: isOfficial,
|
||
proformas: [],
|
||
invoices: [],
|
||
events: [{ type: "offered", occurred_at: quoteIso }],
|
||
notes: notes.trim(),
|
||
line_items: lineItems,
|
||
termsheets,
|
||
};
|
||
|
||
try {
|
||
const created = await createQuote.mutateAsync(quote);
|
||
const token = params.get("token");
|
||
const draftId = params.get("draft") || params.get("draft_id");
|
||
if (token) {
|
||
isCommittedOrCancelledRef.current = true;
|
||
try {
|
||
await api.drafts.commitByToken(token, created.id);
|
||
} catch (err) {
|
||
console.warn("Could not commit draft via token:", err);
|
||
}
|
||
} else if (draftId) {
|
||
isCommittedOrCancelledRef.current = true;
|
||
try {
|
||
await api.drafts.commit(draftId, created.id);
|
||
} catch (err) {
|
||
console.warn("Could not commit draft:", err);
|
||
}
|
||
}
|
||
if (proforma) {
|
||
try {
|
||
await api.files.upload(created.id, "proforma", proforma);
|
||
} catch (err) {
|
||
toast.error(`خطا در آپلود پیشفاکتور: ${err instanceof Error ? err.message : "خطای ناشناخته"}`, {
|
||
action: { label: "رفع خطا", onClick: () => goToStep(5) },
|
||
});
|
||
}
|
||
}
|
||
toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید.");
|
||
track("quote_submitted", { quote_id: created.id, items: lineItems.length });
|
||
|
||
if (isStandaloneMode) {
|
||
setExitInfo({
|
||
show: true,
|
||
title: "پیشنهاد با موفقیت در سیستم ثبت گردید ✅",
|
||
subtitle: `شناسه استعلام: #${created.id.slice(0, 8)} — تغییرات شما با موفقیت اعمال شد. اکنون میتوانید این برگه را ببندید و به ربات بله بازگردید.`,
|
||
});
|
||
return;
|
||
}
|
||
navigate("/");
|
||
} catch (e) {
|
||
const target = resolveErrorTarget(e);
|
||
sectionError(target, "خطا در ثبت پیشنهاد", e);
|
||
}
|
||
};
|
||
|
||
const handleCancel = async () => {
|
||
const token = params.get("token");
|
||
const draftId = params.get("draft") || params.get("draft_id");
|
||
isCommittedOrCancelledRef.current = true;
|
||
if (token) {
|
||
try {
|
||
await api.drafts.cancelByToken(token);
|
||
} catch (err) {
|
||
console.warn("Could not cancel draft via token:", err);
|
||
}
|
||
} else if (draftId) {
|
||
try {
|
||
await api.drafts.cancel(draftId);
|
||
} catch (err) {
|
||
console.warn("Could not cancel draft:", err);
|
||
}
|
||
}
|
||
if (isStandaloneMode) {
|
||
setExitInfo({
|
||
show: true,
|
||
title: "پیشنویس لغو شد",
|
||
subtitle: "این پیشنویس استعلام لغو گردید. میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||
});
|
||
return;
|
||
}
|
||
navigate("/");
|
||
};
|
||
|
||
const save = () => {
|
||
const incomplete: { label: string; step: number }[] = [];
|
||
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
||
if (!completedSteps.has(2)) incomplete.push({ label: "اقلام", step: 2 });
|
||
if (!completedSteps.has(3)) incomplete.push({ label: "شرایط پرداخت", step: 3 });
|
||
if (!completedSteps.has(4)) incomplete.push({ label: "پروژه", step: 4 });
|
||
if (incomplete.length > 0) {
|
||
setEmptyFields(incomplete);
|
||
setConfirmOpen(true);
|
||
return;
|
||
}
|
||
void doSave();
|
||
};
|
||
|
||
if (exitInfo.show) {
|
||
return <SafeExitScreen title={exitInfo.title} subtitle={exitInfo.subtitle} />;
|
||
}
|
||
|
||
if (tokenError) {
|
||
return <TokenInvalidScreen message={tokenError} />;
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen pb-10">
|
||
{isStandaloneMode ? (
|
||
<header className="sticky top-0 z-40 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-md border-b border-line px-4 py-3.5 shadow-xs">
|
||
<div className="max-w-5xl mx-auto flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-9 w-9 rounded-xl bg-blue-600 text-white flex items-center justify-center shadow-xs">
|
||
<ShieldCheck className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h1 className="text-base font-bold text-ink">
|
||
بازبینی و تکمیل پیشنویس استعلام
|
||
</h1>
|
||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 dark:bg-blue-950/80 dark:text-blue-300">
|
||
ربات بله
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-muted mt-0.5">
|
||
محیط امن بازبینی — اطلاعات با ربات بله همگامسازی میشود
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted hover:text-rose-600 rounded-xl hover:bg-panel transition-colors"
|
||
onClick={handleCancel}
|
||
>
|
||
<X className="w-4 h-4" />
|
||
<span>انصراف و خروج</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
) : (
|
||
<Header
|
||
title="افزودن پیشنهاد جدید"
|
||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||
backTo={isLimitedProcurement ? "/requester" : "/"}
|
||
backLabel={isLimitedProcurement ? "بازگشت به استعلامها" : "بازگشت به دفتر"}
|
||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||
/>
|
||
)}
|
||
<PageLayout>
|
||
{loadedDraftId && (
|
||
<div className="mb-4 flex items-center justify-between rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/40 dark:text-blue-200 shadow-xs">
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-flex h-6 px-2.5 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||
ربات بله
|
||
</span>
|
||
<span>اطلاعات پیشنویس استعلام با هوش مصنوعی بارگذاری شد. لطفاً اقلام و شرایط را بازبینی فرمایید.</span>
|
||
</div>
|
||
<span className="text-xs text-blue-600 dark:text-blue-400 font-mono font-medium">#{loadedDraftId.slice(0, 8)}</span>
|
||
</div>
|
||
)}
|
||
{/* نوار مراحل استپر */}
|
||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||
|
||
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||
{currentStep === 1 && (
|
||
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||
<Step1TopicSupplier
|
||
selectedTopic={selectedTopic}
|
||
onTopicSelect={(t) => {
|
||
setSelectedTopic(t);
|
||
setSupplierName("");
|
||
setNewSup({ location: "", phone: "", contact: "" });
|
||
setIsOfficial(false);
|
||
setSupplierInvalid(false);
|
||
}}
|
||
supplierName={supplierName}
|
||
onSupplierName={(v) => {
|
||
setSupplierName(v);
|
||
setSupplierInvalid(false);
|
||
}}
|
||
newSup={newSup}
|
||
onNewSup={(p) => setNewSup((s) => ({ ...s, ...p }))}
|
||
isOfficial={isOfficial}
|
||
onOfficial={setIsOfficial}
|
||
invalid={supplierInvalid}
|
||
/>
|
||
</div>
|
||
)}
|
||
{currentStep === 2 && (
|
||
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||
</div>
|
||
)}
|
||
{currentStep === 3 && (
|
||
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||
<Step3Termsheets
|
||
cards={cards}
|
||
onCardsChange={setCards}
|
||
items={items}
|
||
coverage={coverage}
|
||
onCoverageChange={(key, ids) => setCoverage((c) => ({ ...c, [key]: ids }))}
|
||
/>
|
||
</div>
|
||
)}
|
||
{currentStep === 4 && (
|
||
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||
<Step4Projects
|
||
items={items}
|
||
projects={projectNames}
|
||
assignment={assignment}
|
||
onAssign={assign}
|
||
onUnassign={unassign}
|
||
canAddProject={!isLimitedProcurement}
|
||
onAddProject={(name) => {
|
||
void addProject.mutateAsync({ id: nextId("p", projects), name });
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
{currentStep === 5 && (
|
||
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||
<Step5Review
|
||
quoteDate={quoteDate}
|
||
onQuoteDate={setQuoteDate}
|
||
expiryDate={expiryDate}
|
||
onExpiryDate={setExpiryDate}
|
||
notes={notes}
|
||
onNotes={setNotes}
|
||
proforma={proforma}
|
||
onProforma={setProforma}
|
||
summary={review}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* پاورقی کنترل مراحل */}
|
||
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
|
||
<div>
|
||
{currentStep > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => goToStep(currentStep - 1)}
|
||
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
<span>قبلی</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
||
<div className="flex items-center gap-2 sm:gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleCancel()}
|
||
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors cursor-pointer border-none bg-transparent"
|
||
>
|
||
انصراف
|
||
</button>
|
||
|
||
{currentStep < TOTAL_STEPS && (
|
||
<button
|
||
type="button"
|
||
onClick={next}
|
||
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||
>
|
||
<span>بعدی</span>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
|
||
{currentStep === TOTAL_STEPS && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void save()}
|
||
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||
>
|
||
<span>ذخیره پیشنهاد</span>
|
||
<Check className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* دیالوگ فیلدهای خالی */}
|
||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="text-[13.5px] leading-7 text-gray-700">
|
||
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||
</div>
|
||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||
{emptyFields.map((f) => (
|
||
<button
|
||
key={f.step}
|
||
type="button"
|
||
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
|
||
onClick={() => {
|
||
setConfirmOpen(false);
|
||
goToStep(f.step);
|
||
}}
|
||
>
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<DialogFooter className="sm:justify-between">
|
||
<Button
|
||
variant="green"
|
||
onClick={() => {
|
||
setConfirmOpen(false);
|
||
void doSave();
|
||
}}
|
||
>
|
||
ذخیره کردن
|
||
</Button>
|
||
<Button
|
||
className="bg-red text-white hover:brightness-95"
|
||
onClick={() => {
|
||
setConfirmOpen(false);
|
||
goToStep(emptyFields[0]?.step ?? 1);
|
||
}}
|
||
>
|
||
تکمیل فیلدها
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
} |