From 2573f1e583ec8b7e526e3b7e1ea667f6177f860f Mon Sep 17 00:00:00 2001 From: MMD Date: Sun, 13 Sep 2026 18:07:27 +0330 Subject: [PATCH] refactor(ui): modernize proposal wizard, dashboard cards and daily control calendar Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev docker-compose.yml change (b11cb36) and backups churn excluded. --- frontend/src/components/TermsheetCard.tsx | 75 ++-- frontend/src/components/TopicGrid.tsx | 16 +- .../src/pages/Dashboard/DashboardPage.tsx | 63 ++-- frontend/src/pages/Dashboard/QuoteCard.tsx | 90 +++-- .../src/pages/Inventory/DailyControlPage.tsx | 252 ++++++++++---- frontend/src/pages/Wizard/Step2LineItems.tsx | 65 ++-- frontend/src/pages/Wizard/Step5Review.tsx | 47 ++- frontend/src/pages/Wizard/WizardPage.tsx | 324 +++++++++--------- 8 files changed, 549 insertions(+), 383 deletions(-) diff --git a/frontend/src/components/TermsheetCard.tsx b/frontend/src/components/TermsheetCard.tsx index 36079c5..8543cc9 100644 --- a/frontend/src/components/TermsheetCard.tsx +++ b/frontend/src/components/TermsheetCard.tsx @@ -8,19 +8,17 @@ export interface TermsheetItem { } export interface TermsheetCardProps { - index: number; // 1-based → «گزینه N» + index: number; milestones: Milestone[]; onMilestonesChange: (ms: Milestone[]) => void; - // اقلام قابل تیک (مرحله ۲) items?: TermsheetItem[]; - coveredIds?: string[]; // tempIdهایی که تیک دارند + coveredIds?: string[]; onCoverageChange?: (ids: string[]) => void; onRemove?: () => void; - defaultAll?: boolean; // وقتی هیچ تیکی نیست، پیش‌فرض همه تیک بخورند + defaultAll?: boolean; removable?: boolean; } -// کارت ترم‌شیت — هم در ویزارد (مرحله ۳) هم در مودال پذیرش/ویرایش export function TermsheetCard({ index, milestones, @@ -33,7 +31,6 @@ export function TermsheetCard({ removable = true, }: TermsheetCardProps) { const covered = new Set(coveredIds); - // پیش‌فرض: وقتی هیچ قلمی تیک نخورده و defaultAll فعال است → همه تیک const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds; const toggle = (tempId: string, checked: boolean) => { @@ -49,17 +46,25 @@ export function TermsheetCard({ }; return ( -
-
- گزینه {faNum(index)} +
+
+ گزینه {faNum(index)} {removable && onRemove && ( - )}
+
مراحل پرداخت:
-
+
{milestones.map((m, i) => ( ))}
- {items.length > 0 && ( -
-
این termsheet کدام اقلام (مرحله ۲) را پوشش می‌دهد؟
- {items.length === 0 ? ( -
هنوز قلمی در مرحله‌ی ۲ اضافه نشده.
- ) : ( -
- {items.map((it) => ( - - ))} -
- )} +
+
این termsheet کدام اقلام (مرحله ۲) را پوشش می‌دهد؟
+
+ {items.map((it) => ( + + ))} +
)}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/TopicGrid.tsx b/frontend/src/components/TopicGrid.tsx index 53e9532..9575ec8 100644 --- a/frontend/src/components/TopicGrid.tsx +++ b/frontend/src/components/TopicGrid.tsx @@ -26,15 +26,21 @@ export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false, ); })}
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index 0e1dcd7..bfe4808 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -1,7 +1,6 @@ import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { Users, FolderKanban, Ruler, Plus } from "lucide-react"; -import { Button } from "../../components/ui/button"; import { useMaterials, useProjects, @@ -47,7 +46,7 @@ export function DashboardPage() { [quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics], ); - // فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمی‌شوند + // فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) const filtered = useMemo(() => { const list = filterQuotes(store, filters); const createdTs = (q: (typeof store.quotes)[number]) => { @@ -80,35 +79,43 @@ export function DashboardPage() { brand menu={} actions={ - <> - +
+ {/* دکمه تأمین‌کننده‌ها */} + + + تأمین‌کننده‌ها + - + {/* دکمه پروژه‌ها */} + + + پروژه‌ها + - + {/* دکمه ضرایب تبدیل */} + + + ضرایب تبدیل + - - + {/* دکمه اصلی: افزودن پیشنهاد جدید */} + + + افزودن پیشنهاد جدید + +
} /> diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index 88fcf20..39b5ebb 100644 --- a/frontend/src/pages/Dashboard/QuoteCard.tsx +++ b/frontend/src/pages/Dashboard/QuoteCard.tsx @@ -36,7 +36,7 @@ function InfoCell({ label, children }: { label: string; children: React.ReactNod ); } -// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه +// کارت ساده و یکدست یک پیشنهاد در دفتر export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) { const sup = supplierById(store.suppliers, q.supplier_id); const expired = isExpired(q); @@ -46,11 +46,15 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit const unitWarns = unitWarning(store, q); const summary = itemsSummary(store, q); - // ستون پذیرش (با ساعت) + // دکمه یا وضعیت پذیرش let acceptedCell: React.ReactNode; if (q.status === "offered") { acceptedCell = ( - ); @@ -60,8 +64,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit acceptedCell = —; } - // ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام می‌شود؛ - // اینجا فقط وضعیت نمایش داده می‌شود. با رسید جزیی: «رسیده X از Y» + «در راه Z». const activeItems = (q.line_items || []).filter((li) => !li.rejected); const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0); const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0); @@ -71,8 +73,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit const anyReceived = receivedSum > 0; const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived; - // نشان پیشرفت تکمیل در هدر کارت (جایگزین نشان وضعیت در حالت پذیرش/رسید): - // در راه (آبی) → رسید: X از Y قلم (کهربایی) → تکمیل شد (سبز) let fulfillmentBadge: React.ReactNode = null; if (q.status === "accepted" || q.status === "arrived") { if (fullyDelivered || q.status === "arrived") { @@ -97,7 +97,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit } let arrivedCell: React.ReactNode; - // آخرین لحظه‌ی رسید ثبت‌شده بین قلم‌ها (رسیدهای جزیی توسط مدیر پروژه) const lastReceiptAt = activeItems.reduce((acc, li) => { if (!li.arrived_date) return acc; const t = new Date(li.arrived_date).getTime(); @@ -137,37 +136,49 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit return (
- {/* سربرگ کارت */} -
- + {/* هدر یکپارچه و تمیز بدون کادر تیره */} +
+ {sup.name.slice(0, 1)}
{sup.name} {sup.location &&
{sup.location}
}
-
+ + {/* دکمه‌های عملیاتی راست به چپ */} +
{fulfillmentBadge ?? ( {STATUS_LABEL[q.status]} )} - + + {/* دکمه ویرایش مینیمال */} + + {/* فلش آکاردئون */} +
- {/* اطلاعات کلیدی */} -
+ {/* اطلاعات کلیدی کارت */} +
{summary.count > 1 ? (
{fmtNum(summary.count)} قلم
- {store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => { - const m = materialById(store.materials, li.material_id); - return ( - - - {materialLabel(m)} - - {fmtNum(li.quantity)} {li.unit} + {store.quotes + .find((x) => x.id === q.id) + ?.line_items?.filter((li) => !li.rejected) + .map((li) => { + const m = materialById(store.materials, li.material_id); + return ( + + + {materialLabel(m)} + + {fmtNum(li.quantity)} {li.unit} + - - ); - })} + ); + })}
) : ( @@ -251,8 +265,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
- {/* تاریخ‌ها و اقدامات */} -
+ {/* پاورقی تمیز و روشن */} +
پیشنهاد: {fmtDateTime(q.quote_date)} @@ -277,4 +291,4 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit {expanded && }
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Inventory/DailyControlPage.tsx b/frontend/src/pages/Inventory/DailyControlPage.tsx index da57d90..560dad9 100644 --- a/frontend/src/pages/Inventory/DailyControlPage.tsx +++ b/frontend/src/pages/Inventory/DailyControlPage.tsx @@ -1,5 +1,16 @@ import { useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; +import { + ChevronRight, + ChevronLeft, + Calendar as CalendarIcon, + Building2, + Plus, + RotateCcw, + PackageX, + Trash2, + Clock +} from "lucide-react"; import { Header, PageLayout } from "../../components/layout/Header"; import { useAuthStore } from "../../store/auth"; import { inventorySubheader } from "./panelMenu"; @@ -16,9 +27,8 @@ function todayKey(): string { return gregKey(new Date()); } -// کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی export function DailyControlPage() { - const user = useAuthStore((s) => s.user); + const user = useAuthStore((s) => s.user); const data = useInventoryData(); const navigate = useNavigate(); const isPM = user?.role === "pm"; @@ -26,7 +36,6 @@ export function DailyControlPage() { const [anchor, setAnchor] = useState(new Date()); const [sel, setSel] = useState(todayKey()); const [actRec, setActRec] = useState(null); - // فیلتر پروژه — فقط برای مدیر تدارکات/ادمین (مدیر پروژه به پروژه خودش محدود است) const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const [projFilter, setProjFilter] = useState(""); @@ -46,7 +55,6 @@ export function DailyControlPage() { return map; }, [data.transactions, data.warehouses, projFilter]); - // cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی const cells = useMemo(() => { const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate()); let first: Date | null = null; @@ -59,7 +67,7 @@ export function DailyControlPage() { } } if (!first) first = new Date(anchor.getTime() - 33 * 86400000); - const offset = (first.getDay() + 1) % 7; // شنبه = 0 + const offset = (first.getDay() + 1) % 7; const gridStart = new Date(first.getTime() - offset * 86400000); const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = []; for (let c = 0; c < 42; c++) { @@ -79,7 +87,6 @@ export function DailyControlPage() { const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate()); const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`; - // جابه‌جایی بین ماه‌های شمسی (نه ±۳۳ روز) — ماه قبل/بعد با سال‌شماری درست const shiftMonth = (delta: number) => { let nm = tm + delta; let ny = ty; @@ -90,18 +97,12 @@ export function DailyControlPage() { nm = 1; ny += 1; } - // روزِ برگشتی را نگه می‌داریم (ماه شمسی از میانه‌ی ماه میلادی شروع می‌شود: - // مثلاً مهر ۱ = ۲۳ سپتامبر). اگر به‌جای آن روز «۱» استفاده شود، لنگر در - // اولِ ماهِ میلادی می‌نشیند و هنوز در همان ماه شمسی است — دکمه «ماه بعد» - // ظاهراً هیچ کاری نمی‌کند. const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1); setAnchor(new Date(gy, gm - 1, gd)); }; const dayTx = txByDay.get(sel) || []; - // فعال‌سازی ناوبری ماه از چند مسیر (pointerup / click / کیبورد) با حذف - // اجرای تکراری — اگر مرورگر رویداد click را بخورد، pointerup جواب می‌دهد. const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 }); const navFire = (key: string, action: () => void) => { const now = Date.now(); @@ -144,114 +145,219 @@ export function DailyControlPage() { const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate()); return ( -
+
- - {isAdmin && ( -
- - - - {projFilter - ? "نمایش کنترل روزانه فقط برای پروژه انتخابی" - : "نمایش کنترل روزانه همه پروژه‌ها"} + + {/* نوار پروژه سفید و شفاف */} +
+
+ + + پروژه: + + {isAdmin ? ( + + ) : ( + پروژه فعال + )} + + {projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
- )} -
- {/* تقویم */} -
-
-
📅 {monthTitle}
-
- - - + + + + ثبت خروج / تغییر موجودی + +
+ +
+ {/* ستون تقویم — سفید، خوانا و بدون هدر تیره */} +
+
+
+ + {monthTitle} +
+
+ + +
-
+ + {/* روزهای هفته */} +
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => ( -
{d}
+
{d}
))} {cells.map((c, i) => { - // gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانه‌ی تقویم const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate()); const k = gregKey(c.d); + const isSelected = sel === k; + return ( ); })}
- {/* برداشت‌های روز */} -
-
-

🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}

- ▼ برداشت‌ها: {faNum(wGroups.length)} قلم + {/* ستون جزئیات روز — سفید یکدست */} +
+
+
+ +

+ {isToday ? "امروز — " : "کنترل — "} + {toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)} +

+
+ 0 + ? "bg-amber-50 text-amber-700 border border-amber-200" + : "text-slate-400 border border-slate-100" + }`} + > + {faNum(wGroups.length)} برداشت +
-

- مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید. + +

+ اقلام برداشت‌شده از انبار در این تاریخ:

+ {!wGroups.length ? ( -
- {isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همین‌جا نمایش داده می‌شود. +
+ + {isToday ? "امروز هنوز هیچ برداشتی ثبت نشده است." : "در این تاریخ برداشتی ثبت نشده است."} + + + + ثبت برداشت جدید +
) : ( -
+
{wGroups.map((g) => ( -
- + {actRec === g.recId && isPM && ( -
+
)} @@ -264,4 +370,4 @@ export function DailyControlPage() {
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index c5b6c05..95b3761 100644 --- a/frontend/src/pages/Wizard/Step2LineItems.tsx +++ b/frontend/src/pages/Wizard/Step2LineItems.tsx @@ -41,9 +41,7 @@ export function itemRowLabel(it: ItemDraft): string { return qtyPart ? `${name} -- ${qtyPart}` : name; } -// نام پایه‌ی متریال از نامِ ذخیره‌شده: پیشوند برند و پسوند مشخصه حذف می‌شوند -// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان -// می‌دهند؛ برند و مشخصه ستون‌های جداگانه دارند. +// نام پایه‌ی متریال از نامِ ذخیره‌شده function materialBaseName(name: string, brand: string, specs: string[]): string { let base = (name || "").trim(); if (!base) return ""; @@ -60,9 +58,7 @@ function materialBaseName(name: string, brand: string, specs: string[]): string return base || (name || "").trim(); } -// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند) -// + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها) -// نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7) +// مقادیر مجزای category/brand/spec داخل تاپیک export function materialOptions( materials: Material[], topic: string, @@ -75,7 +71,6 @@ export function materialOptions( const cat = item.cat.trim(); const brand = item.brand.trim(); - // مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => { const out = new Set(); allItems.forEach((it) => { @@ -94,7 +89,6 @@ export function materialOptions( } if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat)); if (field === "brand") { - // همه‌ی برندها از همه‌ی materials + برندهای ثبت‌شده در بخش برندها (بک‌اند) const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean)); apiBrands.forEach((b) => b && allBrands.add(b)); fromRows((it) => it.brand).forEach((b) => allBrands.add(b)); @@ -110,21 +104,18 @@ export function materialOptions( export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) { const materials = useMaterials(); - // واحدها از بک‌اند خوانده می‌شوند + واحدهای واردشده در سطرهای قبلی const [unitOptions, setUnitOptions] = useState(() => { const all = new Set(); items.forEach((it) => { if (it.unit) all.add(it.unit); }); return [...all]; }); - // واحدهای موجود (دیتابیس) + واحدهای تایپ‌شده در سطرهای دیگر + const allUnits = useMemo(() => { const out = new Set(unitOptions); items.forEach((it) => { if (it.unit) out.add(it.unit); }); return [...out]; }, [items, unitOptions]); - // نام‌های پایه‌ی یکتای همه‌ی متریال‌های کاتالوگ سراسری (فقط خود متریال، - // بدون برند/مشخصه — آن‌ها ستون‌های خودشان را دارند) const allMaterialNames = useMemo(() => { const set = new Set(); materials.forEach((m) => { @@ -134,7 +125,6 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember return [...set].sort((a, b) => a.localeCompare(b, "fa")); }, [materials]); - // برندهای ثبت‌شده در بخش برندها (بک‌اند) — به‌علاوه‌ی برندهای متریال‌ها const [brandOptions, setBrandOptions] = useState([]); useEffect(() => { @@ -146,11 +136,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); }); return [...merged]; })) - .catch(() => { /* بک‌اند در دسترس نیست */ }); + .catch(() => {}); void api .brandsList() .then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean))) - .catch(() => { /* بک‌اند در دسترس نیست */ }); + .catch(() => {}); return () => { alive = false; }; }, []); @@ -163,14 +153,12 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }; const add = () => { - // سطر جدید — مقدارها خالی؛ پیشنهادها از همه‌ی سطرهای قبلی می‌آیند onItemsChange([ ...items, { tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null }, ]); }; - // یادآوری یک فیلد تایپ‌شده برای پیشنهادهای آینده (داخل تاپیک) const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => { if (!selectedTopic) return; let rec: MaterialRec; @@ -198,10 +186,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember return (

- ۲ + ۲ اقلام سفارش

-
قلم است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} /> +
قلم است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} />
متریال @@ -215,12 +203,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
{items.map((it) => ( -
+
patch(it.tempId, { cat: x.label })} - // پیشنهادها: همه‌ی متریال‌های کاتالوگ سراسری با نام واقعی‌شان - // (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse می‌کند items={allMaterialNames} allowAdd addSuffix="به عنوان متریال جدید" @@ -263,30 +249,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember patch(it.tempId, { unit: x.label })} - // متنِ خودِ همین سطر از پیشنهادها حذف می‌شود؛ وگرنه متن تایپ‌شده - // «تطبیق دقیق» پیدا می‌کرد و گزینه‌ی «به عنوان واحد جدید» هرگز ظاهر نمی‌شد items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))} allowAdd addSuffix="به عنوان واحد جدید" placeholder="واحد" onInputChange={(v) => patch(it.tempId, { unit: v })} onAdd={(v) => { - // انتخاب گزینه‌ی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛ - // نامِ تایپ‌شده هم روی همان قلم اعمال می‌شود (onInputChange بالا) const name = v.trim(); if (!name) return; void apiV26.catalog .createUnit(name) .then(() => { - // بعد از ثبت، واحد برای سطرهای بعدی هم پیشنهاد شود setUnitOptions((prev) => prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name], ); }) - .catch(() => { - // اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه - // خطا دهد، هنگام ذخیره‌ی پیشنهاد دوباره resolve می‌شود — خطای بلوک‌کننده نیست - }); + .catch(() => {}); }} inputClassName="!text-[12px] !h-9" className="min-w-0" @@ -315,16 +293,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember placeholder="قیمت کل" showWords /> -
))}
-
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Wizard/Step5Review.tsx b/frontend/src/pages/Wizard/Step5Review.tsx index b53edc3..1b5fc57 100644 --- a/frontend/src/pages/Wizard/Step5Review.tsx +++ b/frontend/src/pages/Wizard/Step5Review.tsx @@ -2,7 +2,6 @@ import { Paperclip } from "lucide-react"; import { faNum } from "../../lib/formatters"; import { groupCommas } from "../../lib/numutil"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; -import { FileUpload } from "../../components/FileUpload"; export interface Step5Props { quoteDate: string | null; @@ -52,7 +51,7 @@ export function Step5Review({ return (

- ۵ + ۵ تاریخ، یادداشت و بازبینی نهایی

@@ -78,19 +77,43 @@ export function Step5Review({ />
- + {proforma ? ( -
- - - {proforma.name} - -
) : ( - + /* دکمه آپلود مینیمال با حاشیه خط‌چین ملایم */ + )}
@@ -114,4 +137,4 @@ export function Step5Review({
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx index 111aee8..edc12f5 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -73,18 +73,15 @@ export function WizardPage() { const [assignment, setAssignment] = useState>({}); const [quoteDate, setQuoteDate] = useState(() => { - // تاریخ پیشنهاد: همین الان (ساعت و دقیقه‌ی فعلی) — نه نیمه‌شب return new Date().toISOString(); }); const [expiryDate, setExpiryDate] = useState(null); const [notes, setNotes] = useState(""); const [proforma, setProforma] = useState(null); - // دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه) const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]); const [confirmOpen, setConfirmOpen] = useState(false); // ---------- prefill از لینک‌ها (?topic=&supplier=) ---------- - // وقتی suppliers هنوز لود نشده، دوباره اجرا می‌شود useEffect(() => { const t = params.get("topic"); if (!t) return; @@ -102,23 +99,17 @@ export function WizardPage() { }, [params, suppliers, topics]); // ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- - // مراحل تکمیل‌شده را بر اساس داده‌های فعلی مشخص می‌کند const completedSteps = useMemo(() => { const s = new Set(); - // مرحله ۱: تاپیک انتخاب شده + نام تأمین‌کننده پر شده if (selectedTopic && supplierName.trim()) s.add(1); - // مرحله ۲: حداقل یک قلم با تعداد و قیمت if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2); - // مرحله ۳: حداقل یک termsheet با مایل‌ستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیش‌فرض 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]); @@ -200,144 +191,133 @@ export function WizardPage() { }, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]); // ---------- ذخیره ---------- - // تابع ذخیره‌ی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ) const doSave = async () => { - // خطای بخش — با دکمه‌ی «رفع خطا» که به همان بخش می‌برد - const sectionError = (step: number, title: string, err: unknown) => { - const msg = err instanceof Error ? err.message : "خطای ناشناخته"; - toast.error(`${title}: ${msg}`, { - action: { label: "رفع خطا", onClick: () => goToStep(step) }, - }); - }; - // مشخص کردن بخش خطا از روی متن پیام - const guessStep = (msg: string): number => { - if (/تأمین|تامین|supplier/i.test(msg)) return 1; - if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2; - if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3; - if (/پروژه|project/i.test(msg)) return 4; - return 5; - }; + const sectionError = (step: number, title: string, err: unknown) => { + const msg = err instanceof Error ? err.message : "خطای ناشناخته"; + toast.error(`${title}: ${msg}`, { + action: { label: "رفع خطا", onClick: () => goToStep(step) }, + }); + }; + const guessStep = (msg: string): number => { + if (/تأمین|تامین|supplier/i.test(msg)) return 1; + if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2; + if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3; + if (/پروژه|project/i.test(msg)) return 4; + return 5; + }; - // تأمین‌کننده - 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 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 = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید"; + let material = materials.find((m) => normalize(m.name) === normalize(wantName)); + if (!material) { + material = materials.find( + (m) => + normalize(m.category) === normalize(cat) && + normalize(m.brand) === normalize(brand) && + specsKey(m.specs) === specsKey(specs), + ); + } + let materialId: string; + if (material) { + materialId = material.id; + } else { + try { + const created = await addMaterial.mutateAsync({ + id: nextId("m", materials), + name: wantName, + topic: selectedTopic, + category: cat, + brand, + specs, + base_unit: unit, + }); + materialId = created.id; + } catch (err) { + sectionError(2, "خطا در بخش اقلام (متریال)", 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 = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید"; - let material = materials.find((m) => normalize(m.name) === normalize(wantName)); - if (!material) { - // جستجوی قبلی (برند و مشخصه‌ی ساختارمند در همین نشست) - material = materials.find( - (m) => - normalize(m.category) === normalize(cat) && - normalize(m.brand) === normalize(brand) && - specsKey(m.specs) === specsKey(specs), - ); - } - let materialId: string; - if (material) { - materialId = material.id; - } else { - try { - const created = await addMaterial.mutateAsync({ - id: nextId("m", materials), - name: wantName, - topic: selectedTopic, - category: cat, - brand, - specs, - base_unit: unit, - }); - materialId = created.id; // UUID واقعی از بک‌اند - } 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); - - // termsheetها - 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); - - // اتصال اقلام به termsheetها - 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] : []; + 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 projSet = new Set(); - collected.forEach(({ li, tempId }) => { - const list = assignment[tempId] || []; - li.projects = list.map((p) => ({ id: "", name: p })); - list.forEach((p) => projSet.add(p)); - }); - const projectsList = [...projSet]; + const lineItems = collected.map((c) => c.li); + + const termsheets = cards + .map((card, i) => ({ + id: "new-ts-" + (i + 1), + label: "گزینه " + faNum(i + 1), + milestones: card.milestones.filter((m) => m.when_iso || m.amount != null), + status: "offered" as const, + })) + .filter((t) => t.milestones.length); + + collected.forEach(({ li, tempId }) => { + const ids = cards + .map((card, i) => + effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "", + ) + .filter(Boolean); + const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid)); + li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : []; + }); + + const projSet = new Set(); + collected.forEach(({ li, tempId }) => { + const list = assignment[tempId] || []; + li.projects = list.map((p) => ({ id: "", name: p })); + list.forEach((p) => projSet.add(p)); + }); + const projectsList = [...projSet]; const quoteIso = quoteDate || new Date().toISOString(); const quote: Quote = { @@ -361,7 +341,6 @@ export function WizardPage() { try { const created = await createQuote.mutateAsync(quote); - // آپلود پیش‌فاکتور به MinIO بعد از ساخت پیشنهاد if (proforma) { try { await api.files.upload(created.id, "proforma", proforma); @@ -379,7 +358,6 @@ export function WizardPage() { } }; - // ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز می‌شود const save = () => { const incomplete: { label: string; step: number }[] = []; if (!completedSteps.has(1)) incomplete.push({ label: "تأمین‌کننده", step: 1 }); @@ -475,19 +453,53 @@ export function WizardPage() {
)} - {/* پاورقی ویزارد */} -
- {currentStep > 1 && ( - - )} -
- - انصراف - - {currentStep < TOTAL_STEPS && } - {currentStep === TOTAL_STEPS && } + {/* پاورقی ویزارد — طراحی بازسازی‌شده با ناوبری استاندارد */} +
+ {/* دکمه مرحله قبلی */} +
+ {currentStep > 1 && ( + + )} +
+ + {/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */} +
+ + انصراف + + + {currentStep < TOTAL_STEPS && ( + + )} + + {currentStep === TOTAL_STEPS && ( + + )} +
{/* دیالوگ ذخیره با فیلدهای خالی */} @@ -539,4 +551,4 @@ export function WizardPage() {
); -} +} \ No newline at end of file