diff --git a/docker-compose.yml b/docker-compose.yml index b2187e0..92006db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} ports: - - "5433:5432" + - "5455:5432" networks: - default diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c1b872..8fa9445 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; + 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 ; + } return <>{children}; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e5f6ca..d17fe3f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1020,6 +1020,41 @@ export const api = { }, }, + drafts: { + get: async ( + id: string, + ): Promise<{ + id: string; + chat_id: number; + kind: string; + status: string; + payload: Record; + 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, + ): Promise => { + await patchData(`/drafts/${id}`, { payload }); + }, + commit: async ( + id: string, + quoteId?: string, + ): Promise => { + await postData(`/drafts/${id}/commit`, { quote_id: quoteId }); + }, + cancel: async ( + id: string, + ): Promise => { + await postData(`/drafts/${id}/cancel`, {}); + }, + }, + suppliers: { list: async (): Promise => { const raw = await getData(`/suppliers?page_size=${PAGE_SIZE}`); diff --git a/frontend/src/pages/Auth/LoginPage.tsx b/frontend/src/pages/Auth/LoginPage.tsx index 9a78d68..68e4ec6 100644 --- a/frontend/src/pages/Auth/LoginPage.tsx +++ b/frontend/src/pages/Auth/LoginPage.tsx @@ -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("/"); }; diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx index 46903bc..31f5ceb 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -1,6 +1,6 @@ -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 { useNavigate, useSearchParams } from "react-router-dom"; import { ChevronRight, ChevronLeft, Check } from "lucide-react"; import { toast } from "sonner"; import type { LineItem, Quote } from "../../lib/types"; @@ -82,6 +82,120 @@ export function WizardPage() { const [proforma, setProforma] = useState(null); const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]); const [confirmOpen, setConfirmOpen] = useState(false); + const [loadedDraftId, setLoadedDraftId] = useState(null); + const draftFetchedRef = useRef(false); + const lastRemoteUpdatedAtRef = useRef(null); + const isSelfUpdatingRef = useRef(false); + const isCommittedOrCancelledRef = useRef(false); + const initialLoadedRef = useRef(false); + + // ---------- تابع اعمال داده‌های پیش‌نویس به فیلدهای ویزارد ---------- + const applyDraftPayloadToForm = useCallback( + (p: Record) => { + if (!p) return; + + // ۱. تأمین‌کننده و وضعیت رسمی + if (p.supplier_name !== undefined) { + setSupplierName(p.supplier_name || ""); + setSupplierInvalid(false); + } + if (p.is_official !== undefined) { + setIsOfficial(Boolean(p.is_official)); + } + + // استنتاج تاپیک: ابتدا از اطلاعات تأمین‌کننده، سپس متریال، سپس اولین تاپیک موجود + let topicToSet = ""; + if (p.supplier_name) { + const sup = suppliers.find((s) => normalize(s.name) === normalize(p.supplier_name)); + if (sup?.topic) topicToSet = sup.topic; + } + if (!topicToSet && Array.isArray(p.items) && p.items.length > 0) { + for (const it of p.items) { + if (it.material_name) { + const mat = materials.find((m) => normalize(m.name) === normalize(it.material_name)); + if (mat?.topic) { + topicToSet = mat.topic; + break; + } + } + } + } + if (!topicToSet && topics.length > 0) { + topicToSet = topics[0].name; + } + if (topicToSet) { + setSelectedTopic(topicToSet); + } + + // ۲. اقلام (Step 2) + let newItems: ItemDraft[] = []; + if (Array.isArray(p.items) && p.items.length > 0) { + newItems = p.items.map((it: any, idx: number) => { + const qty = it.quantity != null ? Number(it.quantity) : null; + const price = it.unit_price != null ? Number(it.unit_price) : null; + return { + tempId: "it" + idx, + cat: it.material_name || "", + brand: it.brand_name || "", + specs: Array.isArray(it.specs) ? it.specs : [], + qty, + unit: it.unit_name || "", + price, + total: qty != null && price != null ? qty * price : null, + }; + }); + setItems(newItems); + } + + // ۳. شرایط پرداخت / termsheet (Step 3) + if (Array.isArray(p.termsheets) && p.termsheets.length > 0) { + const newCards: TsCardDraft[] = p.termsheets.map((ts: any, idx: number) => ({ + key: "ts" + idx, + milestones: + Array.isArray(ts.milestones) && ts.milestones.length > 0 + ? ts.milestones.map((m: any) => ({ + when_iso: m.due_date || m.when_iso || null, + amount: m.amount != null ? Number(m.amount) : null, + })) + : [{ when_iso: null, amount: null }], + })); + setCards(newCards); + + const targetIds = (newItems.length > 0 ? newItems : items).map((i) => i.tempId); + const newCov: Record = {}; + newCards.forEach((c) => { + newCov[c.key] = targetIds; + }); + setCoverage(newCov); + } + + // ۴. پروژه (Step 4) + const projNames: string[] = []; + if (Array.isArray(p.project_names)) { + projNames.push(...p.project_names.filter(Boolean)); + } + if (p.project_name && !projNames.includes(p.project_name)) { + projNames.push(p.project_name); + } + if (projNames.length > 0) { + const newAssignment: Record = {}; + const targetItems = newItems.length > 0 ? newItems : items; + targetItems.forEach((it) => { + newAssignment[it.tempId] = projNames; + }); + setAssignment(newAssignment); + } + + // ۵. یادداشت و تاریخ انقضا (Step 5) + if (p.notes !== undefined) { + setNotes(p.notes || ""); + } + if (p.expiry_date !== undefined) { + setExpiryDate(p.expiry_date || null); + } + }, + [suppliers, materials, topics, items], + ); // ---------- prefill از لینک‌ها (?topic=&supplier=) ---------- useEffect(() => { @@ -100,6 +214,122 @@ export function WizardPage() { } }, [params, suppliers, topics]); + // ---------- بارگذاری اولیه پیش‌نویس ربات بله (?draft=&draft_id=) ---------- + useEffect(() => { + const draftId = params.get("draft") || params.get("draft_id"); + if (!draftId || draftFetchedRef.current) return; + + draftFetchedRef.current = true; + void (async () => { + try { + const draft = await api.drafts.get(draftId); + if (!draft || !draft.payload) return; + setLoadedDraftId(draft.id); + lastRemoteUpdatedAtRef.current = draft.updated_at || null; + + applyDraftPayloadToForm(draft.payload); + setHighestVisited(5); + initialLoadedRef.current = true; + + toast.success("پیش‌نویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", { + duration: 6000, + }); + } catch (err) { + toast.error(`خطا در فراخوانی پیش‌نویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`); + } + })(); + }, [params, applyDraftPayloadToForm]); + + // ---------- پولینگ دوره‌ای برای دریافت تغییرات یا ثبت از سمت ربات بله ---------- + useEffect(() => { + const draftId = params.get("draft") || params.get("draft_id"); + if (!draftId) return; + + const interval = setInterval(async () => { + if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return; + try { + const draft = await api.drafts.get(draftId); + if (!draft) return; + + if (draft.status === "committed") { + isCommittedOrCancelledRef.current = true; + toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 }); + navigate("/"); + return; + } + if (draft.status === "cancelled") { + isCommittedOrCancelledRef.current = true; + toast.error("این پیش‌نویس از طریق ربات بله لغو شد.", { duration: 6000 }); + navigate("/"); + return; + } + + if ( + draft.updated_at && + lastRemoteUpdatedAtRef.current && + draft.updated_at > lastRemoteUpdatedAtRef.current && + !isSelfUpdatingRef.current + ) { + lastRemoteUpdatedAtRef.current = draft.updated_at; + applyDraftPayloadToForm(draft.payload); + toast.info("🔄 اصلاحات جدید از ربات بله دریافت و در ویزارد اعمال شد.", { duration: 4000 }); + } + } catch { + /* خطای موقت شبکه در پولینگ نادیده گرفته می‌شود */ + } + }, 2000); + + return () => clearInterval(interval); + }, [params, navigate, applyDraftPayloadToForm]); + + // ---------- همگام‌سازی خودکار تغییرات ویزارد به پیش‌نویس ربات (Debounced Auto-Save) ---------- + useEffect(() => { + const draftId = params.get("draft") || params.get("draft_id"); + if (!draftId || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return; + + const timer = setTimeout(async () => { + isSelfUpdatingRef.current = true; + try { + const payloadToSave: Record = { + supplier_name: supplierName.trim() || null, + is_official: isOfficial, + items: items.map((it) => ({ + material_name: it.cat.trim() || null, + brand_name: it.brand.trim() || null, + specs: it.specs, + quantity: it.qty, + unit_name: it.unit.trim() || null, + unit_price: it.price, + })), + termsheets: cards.map((c, i) => ({ + label: "گزینه " + faNum(i + 1), + milestones: (c.milestones || []).map((m) => ({ + due_date: m.when_iso, + amount: m.amount, + })), + })), + project_names: [...new Set(Object.values(assignment).flat())], + notes: notes.trim() || null, + expiry_date: expiryDate || null, + }; + + await api.drafts.update(draftId, payloadToSave); + const updated = await api.drafts.get(draftId); + if (updated?.updated_at) { + lastRemoteUpdatedAtRef.current = updated.updated_at; + } + } catch (err) { + console.warn("Auto-save to draft failed:", err); + } finally { + setTimeout(() => { + isSelfUpdatingRef.current = false; + }, 600); + } + }, 800); + + return () => clearTimeout(timer); + }, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params]); + // ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- const completedSteps = useMemo(() => { const s = new Set(); @@ -393,6 +623,15 @@ export function WizardPage() { 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); @@ -402,7 +641,7 @@ export function WizardPage() { }); } } - toast.success("پیشنهاد ذخیره شد."); + toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید."); track("quote_submitted", { quote_id: created.id, items: lineItems.length }); navigate("/"); } catch (e) { @@ -411,6 +650,19 @@ export function WizardPage() { } }; + 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 }); @@ -434,6 +686,17 @@ export function WizardPage() { backLabel="بازگشت به دفتر" /> + {loadedDraftId && ( +
+
+ + ربات بله + + اطلاعات پیش‌نویس استعلام با هوش مصنوعی بارگذاری شد. لطفاً اقلام و شرایط را بازبینی فرمایید. +
+ #{loadedDraftId.slice(0, 8)} +
+ )} {currentStep === 1 && ( @@ -524,12 +787,13 @@ export function WizardPage() { {/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */}
- void handleCancel()} + className="inline-flex items-center justify-center px-4 py-2 rounded-xl 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" > انصراف - + {/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */} {currentStep < TOTAL_STEPS && (