import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { track } from "../../lib/analytics"; import { 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, 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 { 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"; const TOTAL_STEPS = 5; // ویزارد ۵ مرحله‌ای افزودن پیشنهاد جدید export function WizardPage() { useStoreData(); const navigate = useNavigate(); const [params] = useSearchParams(); const quotes = useQuotes(); const suppliers = useSuppliers(); const materials = useMaterials(); const projects = useProjects(); 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(() => [ { tempId: "it0", cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null }, ]); const [cards, setCards] = useState(() => [ { key: "ts0", milestones: [{ when_iso: null, amount: null }] }, ]); const [coverage, setCoverage] = useState>({}); const [assignment, setAssignment] = useState>({}); const [quoteDate, setQuoteDate] = useState(() => { return new Date().toISOString(); }); const [expiryDate, setExpiryDate] = useState(null); const [notes, setNotes] = useState(""); const [proforma, setProforma] = useState(null); const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]); const [confirmOpen, setConfirmOpen] = useState(false); const [loadedDraftId, setLoadedDraftId] = useState(null); const draftFetchedRef = useRef(false); const lastRemoteUpdatedAtRef = useRef(null); const isSelfUpdatingRef = useRef(false); const isCommittedOrCancelledRef = useRef(false); const initialLoadedRef = useRef(false); // ---------- تابع اعمال داده‌های پیش‌نویس به فیلدهای ویزارد ---------- const applyDraftPayloadToForm = useCallback( (p: Record) => { 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 = {}; 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 = {}; 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=) ---------- useEffect(() => { const t = params.get("topic"); if (!t) return; const topic = topicByName(t, topics); if (!topic) return; 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); } } }, [params, suppliers, topics]); // ---------- بارگذاری اولیه پیش‌نویس ربات بله (?draft=&draft_id=) ---------- useEffect(() => { const draftId = params.get("draft") || params.get("draft_id"); if (!draftId || draftFetchedRef.current) return; draftFetchedRef.current = true; void (async () => { try { const draft = await api.drafts.get(draftId); if (!draft || !draft.payload) return; setLoadedDraftId(draft.id); lastRemoteUpdatedAtRef.current = draft.updated_at || null; applyDraftPayloadToForm(draft.payload); setHighestVisited(5); initialLoadedRef.current = true; toast.success("پیش‌نویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", { duration: 6000, }); } catch (err) { toast.error(`خطا در فراخوانی پیش‌نویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`); } })(); }, [params, applyDraftPayloadToForm]); // ---------- پولینگ دوره‌ای برای دریافت تغییرات یا ثبت از سمت ربات بله ---------- useEffect(() => { const draftId = params.get("draft") || params.get("draft_id"); if (!draftId) return; const interval = setInterval(async () => { if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return; try { const draft = await api.drafts.get(draftId); if (!draft) return; if (draft.status === "committed") { isCommittedOrCancelledRef.current = true; toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 }); navigate("/"); return; } if (draft.status === "cancelled") { isCommittedOrCancelledRef.current = true; 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); toast.info("🔄 اصلاحات جدید از ربات بله دریافت و در ویزارد اعمال شد.", { duration: 4000 }); } } catch { /* خطای موقت شبکه در پولینگ نادیده گرفته می‌شود */ } }, 2000); return () => clearInterval(interval); }, [params, navigate, applyDraftPayloadToForm]); // ---------- همگام‌سازی خودکار تغییرات ویزارد به پیش‌نویس ربات (Debounced Auto-Save) ---------- useEffect(() => { const draftId = params.get("draft") || params.get("draft_id"); if (!draftId || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return; const timer = setTimeout(async () => { isSelfUpdatingRef.current = true; try { const payloadToSave: Record = { 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, }; 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]); // ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- const completedSteps = useMemo(() => { const s = new Set(); 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(() => { const names = new Set(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]); 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(); items.forEach((it) => (assignment[it.tempId] || []).forEach((p) => projSet.add(p))); const coveredTempIds = new Set(); 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(); 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 draftId = params.get("draft") || params.get("draft_id"); 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 }); navigate("/"); } catch (e) { const target = resolveErrorTarget(e); sectionError(target, "خطا در ثبت پیشنهاد", e); } }; const handleCancel = async () => { const draftId = params.get("draft") || params.get("draft_id"); if (draftId) { isCommittedOrCancelledRef.current = true; try { await api.drafts.cancel(draftId); } catch (err) { console.warn("Could not cancel draft:", err); } } 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(); }; return (
{loadedDraftId && (
ربات بله اطلاعات پیش‌نویس استعلام با هوش مصنوعی بارگذاری شد. لطفاً اقلام و شرایط را بازبینی فرمایید.
#{loadedDraftId.slice(0, 8)}
)} {/* نوار مراحل استپر */} {/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */} {currentStep === 1 && (
{ 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} />
)} {currentStep === 2 && (
)} {currentStep === 3 && (
setCoverage((c) => ({ ...c, [key]: ids }))} />
)} {currentStep === 4 && (
{ void addProject.mutateAsync({ id: nextId("p", projects), name }); }} />
)} {currentStep === 5 && (
)} {/* پاورقی کنترل مراحل */}
{currentStep > 1 && ( )}
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */}
{currentStep < TOTAL_STEPS && ( )} {currentStep === TOTAL_STEPS && ( )}
{/* دیالوگ فیلدهای خالی */} فیلدهای خالی
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که می‌خواهید ادامه دهید؟
{emptyFields.map((f) => ( ))}
); }