Files
ERP/frontend/src/pages/Wizard/WizardPage.tsx
T

857 lines
32 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 } 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>
);
}