import { useEffect, useMemo, useState } from "react"; import { track } from "../../lib/analytics"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { ChevronRight, ChevronLeft, Check } from "lucide-react"; import { toast } from "sonner"; import type { LineItem, Quote } from "../../lib/types"; import { api, 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"; import { useAuthStore } from "../../store/auth"; 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(); // سطح limited تدارکات: استعلام فقط برای پروژه‌های عضویت — بدون افزودن پروژه جدید const user = useAuthStore((s) => s.user); const isLimitedProcurement = user?.panels?.procurement === "limited" && user?.role !== "admin"; const memberProjectNames = useMemo( () => projects .filter((p) => (user?.member_project_ids ?? []).includes(p.id)) .map((p) => p.name), [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(() => [ { 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); // ---------- 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]); // ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- 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( isLimitedProcurement ? memberProjectNames : 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]); 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); 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 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 (
{/* نوار مراحل استپر */} {/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */} {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) => ( ))}
); }