Files
ERP/frontend/src/pages/Wizard/WizardPage.tsx
T
MMD 64bd292335 merge: pm (unified access matrix, limited procurement flow, platform rebrand)
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/pages/Auth/LoginPage.tsx
#	frontend/src/pages/Wizard/WizardPage.tsx
2026-10-06 15:45:23 +03:30

1130 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}