Merge remote-tracking branch 'origin/prod-v7' into integration-all

This commit is contained in:
MMD
2026-10-01 14:43:02 +03:30
61 changed files with 6420 additions and 26 deletions
+16 -3
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuthStore } from "./store/auth";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects";
@@ -52,10 +52,22 @@ function Loading() {
);
}
// همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم.
// همه‌ی صفحات پشت ورود هستند — به جز پیش‌نویس‌های ویزارد از ربات یا بدون نشست که به صفحه‌ی ورود می‌روند.
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (!user) return <Navigate to="/login" replace />;
const location = useLocation();
// در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
const searchParams = new URLSearchParams(location.search);
const hasDraft = searchParams.has("draft") || searchParams.has("draft_id");
if (location.pathname === "/wizard" && hasDraft) {
return <>{children}</>;
}
if (!user) {
const returnUrl = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
}
return <>{children}</>;
}
@@ -71,6 +83,7 @@ export default function App() {
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/review" element={<WizardPage isStandalone />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
@@ -89,7 +89,7 @@ export function MobileTabBar() {
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
return null;
}
if (pathname === "/wizard") return null;
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
const onOfficePage =
pathname === "/" ||
pathname === "/projects" ||
+67
View File
@@ -1020,6 +1020,73 @@ export const api = {
},
},
drafts: {
get: async (
id: string,
): Promise<{
id: string;
chat_id: number;
kind: string;
status: string;
payload: Record<string, any>;
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
source?: string;
created_at?: string;
updated_at?: string;
}> => {
return getData(`/drafts/${id}`);
},
update: async (
id: string,
payload: Record<string, any>,
): Promise<void> => {
await patchData(`/drafts/${id}`, { payload });
},
commit: async (
id: string,
quoteId?: string,
): Promise<void> => {
await postData(`/drafts/${id}/commit`, { quote_id: quoteId });
},
cancel: async (
id: string,
): Promise<void> => {
await postData(`/drafts/${id}/cancel`, {});
},
verifyToken: async (
token: string,
): Promise<{
id: string;
chat_id: number;
kind: string;
status: string;
payload: Record<string, any>;
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
source?: string;
created_at?: string;
updated_at?: string;
}> => {
return getData(`/drafts/verify?token=${encodeURIComponent(token)}`);
},
updateByToken: async (
token: string,
payload: Record<string, any>,
): Promise<void> => {
await patchData(`/drafts/by-token?token=${encodeURIComponent(token)}`, { payload });
},
commitByToken: async (
token: string,
quoteId?: string,
): Promise<void> => {
await postData(`/drafts/by-token/commit?token=${encodeURIComponent(token)}`, { quote_id: quoteId });
},
cancelByToken: async (
token: string,
): Promise<void> => {
await postData(`/drafts/by-token/cancel?token=${encodeURIComponent(token)}`, {});
},
},
suppliers: {
list: async (): Promise<Supplier[]> => {
const raw = await getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`);
+12 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
import { api, apiV26 } from "../../lib/api";
@@ -33,6 +33,8 @@ export function LoginPage() {
const setUser = useAuthStore((s) => s.setUser);
const setDevUser = useAuthStore((s) => s.setDevUser);
const navigate = useNavigate();
const [params] = useSearchParams();
const returnUrl = params.get("returnUrl");
// ---- فرم اصلی (Casdoor) ----
const [username, setUsername] = useState("");
@@ -83,8 +85,12 @@ export function LoginPage() {
identifyUser({ id: user.id, name: user.display_name, role: user.role });
track("login_success", { role: user.role });
toast.success(`خوش آمدید، ${user.display_name}`);
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
navigate(user.role === "pm" ? "/inventory" : "/");
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} else {
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
navigate(user.role === "pm" ? "/inventory" : "/");
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally {
@@ -125,8 +131,9 @@ export function LoginPage() {
}
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`);
// ادمین → پنل مدیریت ادمین؛ مدیر تدارکات → دفتر؛ مدیر پروژه → پنل انبار
if (devRole === "admin") navigate("/admin");
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} else if (devRole === "admin") navigate("/admin");
else if (devRole === "pm") navigate("/inventory");
else navigate("/");
};
+497 -16
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { track } from "../../lib/analytics";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
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";
@@ -39,11 +39,111 @@ import {
const TOTAL_STEPS = 5;
// ویزارد ۵ مرحله‌ای افزودن پیشنهاد جدید
export function WizardPage() {
// ---------- صفحه خروج امن (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();
@@ -82,6 +182,120 @@ export function WizardPage() {
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(() => {
@@ -100,6 +314,165 @@ export function WizardPage() {
}
}, [params, suppliers, topics]);
// ---------- بارگذاری اولیه پیش‌نویس ربات بله (?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>();
@@ -378,6 +751,23 @@ export function WizardPage() {
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);
@@ -387,8 +777,17 @@ export function WizardPage() {
});
}
}
toast.success("پیشنهاد ذخیره شد.");
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);
@@ -396,6 +795,34 @@ export function WizardPage() {
}
};
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 });
@@ -410,15 +837,67 @@ export function WizardPage() {
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">
<Header
title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
backTo="/"
backLabel="بازگشت به دفتر"
/>
{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="/"
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} />
@@ -508,13 +987,15 @@ export function WizardPage() {
)}
</div>
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */}
<div className="flex items-center gap-2 sm:gap-3">
<Link
to="/"
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 no-underline"
<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"
>
انصراف
</Link>
</button>
{currentStep < TOTAL_STEPS && (
<button