857 lines
32 KiB
TypeScript
857 lines
32 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 } 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<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=) ----------
|
||
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<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,
|
||
};
|
||
|
||
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<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(() => {
|
||
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]);
|
||
|
||
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 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 (
|
||
<div className="min-h-screen pb-10">
|
||
<Header
|
||
title="افزودن پیشنهاد جدید"
|
||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||
backTo="/"
|
||
backLabel="بازگشت به دفتر"
|
||
/>
|
||
<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}
|
||
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>
|
||
);
|
||
} |