diff --git a/frontend/src/lib/quoteUtils.ts b/frontend/src/lib/quoteUtils.ts index 22d9c8f..e03da0f 100644 --- a/frontend/src/lib/quoteUtils.ts +++ b/frontend/src/lib/quoteUtils.ts @@ -62,7 +62,7 @@ export function formatMaterialTitle( const normName = normalize(rawName); if (normName.startsWith(normBrand)) { const rest = rawName.slice(rawBrand.length).trim(); - rawName = rest.replace(/^[.\s·\-]+/, "").trim(); + rawName = rest.replace(/^[.\s·-]+/, "").trim(); } } @@ -291,13 +291,22 @@ export function filterQuotes( const STATUS_BY_LABEL: Record = { "پیشنهاد شده": "offered", + "در انتظار تایید": "offered", + "در انتظار تأیید": "offered", "پذیرفته شده": "accepted", + "در حال خرید": "accepted", "رسیده": "arrived", + "تکمیل شده": "arrived", "باز": "offered", "پذیرفته": "accepted", + "تکمیل": "arrived", "offered": "offered", "accepted": "accepted", "arrived": "arrived", + "rejected": "rejected", + "رد شده": "rejected", + "expired": "expired", + "منقضی شده": "expired", }; const stKey = STATUS_BY_LABEL[stVal] || stVal; @@ -309,9 +318,10 @@ export function filterQuotes( const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id)); if (q) { + const projectsText = `${(quote.projects || []).join(" ")} ${quote.project || ""}`; const hay = - `${supObj.name} ${quote.project || ""} ` + - matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); + `${quote.id} PR-${quote.id} ${supObj.name} ${projectsText} ` + + matInfo.map((m) => `${m.name} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); if (!hay.toLowerCase().includes(q)) return false; } return true; diff --git a/frontend/src/pages/Dashboard/AcceptModal.tsx b/frontend/src/pages/Dashboard/AcceptModal.tsx index df384e0..f401c88 100644 --- a/frontend/src/pages/Dashboard/AcceptModal.tsx +++ b/frontend/src/pages/Dashboard/AcceptModal.tsx @@ -235,9 +235,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { {/* هدر ثابت و هم‌تراز */}
- - پذیرش پیشنهاد — {sup.name} - +
+ + پذیرش پیشنهاد — {sup.name} + + + کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`} + +

شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید. diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index d01e4a5..012b5b6 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -132,20 +132,23 @@ export function DashboardPage() { /> - +

+ +
setSortDir((d) => (d === "desc" ? "asc" : "desc"))} + stats={stats} /> {/* هدر */} - ویرایش پیشنهاد خرید +
+ ویرایش پیشنهاد خرید + {q && ( + + کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`} + + )} +
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
diff --git a/frontend/src/pages/Dashboard/FilterToolbar.tsx b/frontend/src/pages/Dashboard/FilterToolbar.tsx index e12b0ca..c7007e5 100644 --- a/frontend/src/pages/Dashboard/FilterToolbar.tsx +++ b/frontend/src/pages/Dashboard/FilterToolbar.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; -import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react"; +import { Search, SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react"; import { Combobox } from "../../components/Combobox"; +import { fmtNum } from "../../lib/formatters"; +import { cn } from "../../lib/utils"; export interface Filters { supplier: string; @@ -8,29 +10,33 @@ export interface Filters { search: string; } -// گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه +// گزینه‌های استاندارد وضعیت برای دراپ‌داون فیلترهای پیشرفته const STATUS_OPTIONS = [ - { id: "offered", label: "پیشنهاد باز" }, - { id: "accepted", label: "پذیرفته‌شده" }, - { id: "arrived", label: "تحویل‌شده" }, + { id: "offered", label: "در انتظار تایید" }, + { id: "accepted", label: "در حال خرید" }, + { id: "arrived", label: "تکمیل شده" }, ]; const STATUS_MAP: Record = { - offered: "پیشنهاد باز", - accepted: "پذیرفته‌شده", - arrived: "تحویل‌شده", + offered: "در انتظار تایید", + accepted: "در حال خرید", + arrived: "تکمیل شده", }; const STATUS_TO_KEY: Record = { + "در انتظار تایید": "offered", + "در انتظار تأیید": "offered", "پیشنهاد باز": "offered", - "پذیرفته‌شده": "accepted", - "تحویل‌شده": "arrived", "پیشنهاد شده": "offered", + "در حال خرید": "accepted", + "پذیرفته‌شده": "accepted", "پذیرفته شده": "accepted", + "تکمیل شده": "arrived", + "تحویل‌شده": "arrived", "رسیده": "arrived", "باز": "offered", "پذیرفته": "accepted", - "تحویل": "arrived", + "تکمیل": "arrived", offered: "offered", accepted: "accepted", arrived: "arrived", @@ -42,6 +48,7 @@ export interface FilterToolbarProps { suppliers: { id: string; name: string }[]; sortDir: "desc" | "asc"; onToggleSort: () => void; + stats?: { total: number; offered: number; accepted: number; arrived: number }; } export function FilterToolbar({ @@ -50,97 +57,179 @@ export function FilterToolbar({ suppliers, sortDir, onToggleSort, + stats, }: FilterToolbarProps) { const [isOpen, setIsOpen] = useState(false); const set = (patch: Partial) => onChange({ ...filters, ...patch }); - // شمارش تعداد فیلترهای فعال به جز سرچ - const activeFilterCount = [filters.supplier, filters.status].filter(Boolean).length; + // شمارش تعداد فیلترهای فعال به جز سرچ و تب وضعیت + const activeFilterCount = [filters.supplier].filter(Boolean).length; - const clearFilters = () => { - onChange({ ...filters, supplier: "", status: "" }); + const clearSupplierFilter = () => { + set({ supplier: "" }); }; - // تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس - const statusDisplayValue = STATUS_MAP[filters.status] || filters.status; + const clearAllFilters = () => { + onChange({ supplier: "", status: "", search: "" }); + }; + + const normalizedStatus = STATUS_TO_KEY[filters.status] ?? filters.status ?? ""; + const statusDisplayValue = STATUS_MAP[normalizedStatus] || filters.status; const handleStatusChange = (val: string) => { const key = STATUS_TO_KEY[val] || val; set({ status: key }); }; + const STATUS_TABS: { + key: string; + label: string; + countKey?: keyof NonNullable; + }[] = [ + { key: "", label: "همه", countKey: "total" }, + { key: "offered", label: "در انتظار تایید", countKey: "offered" }, + { key: "accepted", label: "در حال خرید", countKey: "accepted" }, + { key: "arrived", label: "تکمیل شده", countKey: "arrived" }, + ]; + return ( - <> - {/* ===== نمای موبایل (صفحه‌نمایش‌های زیر md) ===== */} -
- {/* اینپوت جستجوی متنی */} +
+ {/* ===== ردیف جستجو و کنترل‌های فیلتر (تک‌ردیف افقی فشرده) ===== */} +
+ {/* فیلد جستجوی متنی پروژه / کالا / تأمین‌کننده */}
+
+ +
set({ search: e.target.value })} /> + {filters.search && ( + + )}
- {/* دکمه فیلترها با بج تعداد انتخاب‌شده */} + {/* دکمه باز کردن فیلترهای پیشرفته (تأمین‌کننده و ...) */} - {/* دکمه تغییر جهت سورت */} + {/* دکمه تغییر جهت مرتب‌سازی */}
- {/* باتم‌شیت کشویی فیلترها برای موبایل */} + {/* ===== تب‌های اسکرول‌افقی چیپ وضعیت (Filter Chips) ===== */} +
+ {STATUS_TABS.map((tab) => { + const isActive = (!normalizedStatus && !tab.key) || normalizedStatus === tab.key; + const count = stats && tab.countKey ? stats[tab.countKey] : null; + + return ( + + ); + })} + + {/* برچسب چیپ تأمین‌کننده در صورت فعال بودن با امکان حذف سریع */} + {filters.supplier && ( + + تأمین‌کننده: {filters.supplier} + + + )} +
+ + {/* ===== باتم‌شیت کشویی فیلترهای تکمیلی (موبایل و تبلت) ===== */} {isOpen && ( -
+
setIsOpen(false)} aria-hidden="true" /> -
+
{/* هدر کشو */}
- فیلترهای پیشرفته + فیلترهای تکمیلی
- {activeFilterCount > 0 && ( + {(filters.supplier || filters.status || filters.search) && ( )}
{/* دراپ‌داون‌ها داخل کشو */} -
+
- + set({ supplier: it.label })} @@ -163,7 +252,7 @@ export function FilterToolbar({
- + handleStatusChange(it.label)} @@ -179,61 +268,13 @@ export function FilterToolbar({
)} - - {/* ===== نمای دسکتاپ (md به بالا) ===== */} -
-
- - set({ supplier: it.label })} - items={suppliers} - allowAdd={false} - placeholder="همه" - onInputChange={(v) => set({ supplier: v })} - /> -
-
- - handleStatusChange(it.label)} - items={STATUS_OPTIONS} - allowAdd={false} - placeholder="همه" - onInputChange={handleStatusChange} - /> -
-
- - set({ search: e.target.value })} - /> -
-
- - -
-
- +
); } \ No newline at end of file diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index 882d134..e0560cf 100644 --- a/frontend/src/pages/Dashboard/QuoteCard.tsx +++ b/frontend/src/pages/Dashboard/QuoteCard.tsx @@ -1,19 +1,29 @@ import { Link } from "react-router-dom"; -import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard, Check } from "lucide-react"; +import { + Building2, + Boxes, + Calendar, + Hash, + CreditCard, + ChevronDown, + Pencil, + Package, + TriangleAlert, + AlarmClock, +} from "lucide-react"; import type { Quote, Store } from "../../lib/types"; import { isExpired, materialById, - materialLabel, + formatMaterialTitle, projectCell, quoteTotal, supplierById, termsheetSummary, - unitWarning, } from "../../lib/quoteUtils"; -import { STATUS_LABEL } from "../../lib/constants"; -import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters"; -import { Badge } from "../../components/ui/badge"; +import { topicByName } from "../../lib/topics"; +import { fmtDate, fmtNum } from "../../lib/formatters"; +import { groupCommas } from "../../lib/numutil"; import { cn } from "../../lib/utils"; import { QuoteDetail } from "./QuoteDetail"; @@ -27,318 +37,270 @@ export interface QuoteCardProps { canWrite?: boolean; } -function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { - return ( -
-
{label}
-
{children}
-
- ); -} - -export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) { +export function QuoteCard({ + store, + q, + expanded, + onToggleExpand, + onAccept, + onEdit, + canWrite = true, +}: QuoteCardProps) { const sup = supplierById(store.suppliers, q.supplier_id); const expired = isExpired(q); const total = quoteTotal(q); - const pc = priceCell(total); const ts = termsheetSummary(store, q); - const unitWarns = unitWarning(store, q); - // ستون / دکمه پذیرش - let acceptedCell: React.ReactNode; - if (q.status === "offered") { - acceptedCell = canWrite ? ( - - ) : ( - در انتظار پذیرش - ); - } else if (q.accepted_date) { - acceptedCell = {fmtDateTime(q.accepted_date)}; - } else { - acceptedCell = —; - } - - // وضعیت رسید کالا 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); - const fulfilledItems = activeItems.filter( - (li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity, - ).length; - const anyReceived = receivedSum > 0; - const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived; + const primaryLineItem = activeItems[0]; + const primaryMaterial = primaryLineItem + ? materialById(store.materials, primaryLineItem.material_id) + : null; - // نشان پیشرفت تکمیل - let fulfillmentBadge: React.ReactNode = null; - if (q.status === "accepted" || q.status === "arrived") { - if (fullyDelivered || q.status === "arrived") { - fulfillmentBadge = ( - - ✓ تکمیل - - ); - } else if (anyReceived) { - fulfillmentBadge = ( - - رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)} - - ); - } else { - fulfillmentBadge = ( - - ⏳ در راه - - ); - } + // عنوان اصلی کالا / متریال + let itemTitle = "پیشنهاد بدون قلم"; + if (activeItems.length === 1 && primaryMaterial) { + itemTitle = formatMaterialTitle(primaryMaterial); + } else if (activeItems.length > 1 && primaryMaterial) { + itemTitle = `${formatMaterialTitle(primaryMaterial)} (+${fmtNum(activeItems.length - 1)} قلم دیگر)`; } - let arrivedCell: React.ReactNode; - const lastReceiptAt = activeItems.reduce((acc, li) => { - if (!li.arrived_date) return acc; - const t = new Date(li.arrived_date).getTime(); - if (Number.isNaN(t)) return acc; - return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc; - }, null); + // آیکون دسته‌بندی + const topicName = primaryMaterial?.topic || sup.topic; + const topic = topicByName(topicName, store.topics); + const categoryIcon = topic?.icon; - if (q.status === "accepted") { - if (q.arrived_date) { - arrivedCell = {fmtDateTime(q.arrived_date)}; - } else if (receivedSum > 0) { - arrivedCell = ( - - {fmtNum(receivedSum)} از {fmtNum(orderedSum)} - - ); - } else { - arrivedCell = در انتظار انبار; - } + // محاسبه مجموع مقدار و واحدها + let quantityDisplay = "—"; + if (activeItems.length === 1 && primaryLineItem) { + quantityDisplay = + primaryLineItem.quantity != null + ? `${fmtNum(primaryLineItem.quantity)} ${primaryLineItem.unit || ""}` + : "—"; + } else if (activeItems.length > 1) { + const totalQty = activeItems.reduce((acc, li) => acc + (li.quantity || 0), 0); + const uniqueUnits = [...new Set(activeItems.map((li) => li.unit).filter(Boolean))].join("، "); + quantityDisplay = `${fmtNum(totalQty)} ${uniqueUnits || "واحد"} (${fmtNum(activeItems.length)} قلم)`; + } + + // نام پروژه + const projectName = + q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q); + + // بج وضعیت با کدگذاری رنگی معنایی (Semantic Color Coding) + let statusBadge: React.ReactNode; + if (expired) { + statusBadge = ( + + + منقضی شده + + ); } else if (q.status === "arrived") { - arrivedCell = ( - - {fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))} + statusBadge = ( + + ✓ تکمیل شده + + ); + } else if (q.status === "accepted") { + const fulfilledItems = activeItems.filter( + (li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity, + ).length; + const anyReceived = activeItems.some((li) => (li.received_qty || 0) > 0); + + statusBadge = ( + + {anyReceived ? `در حال خرید (${fmtNum(fulfilledItems)}/${fmtNum(activeItems.length)})` : "در حال خرید"} + + ); + } else if (q.status === "rejected") { + statusBadge = ( + + رد شده ); } else { - arrivedCell = —; + // offered / پیش‌فرض در انتظار تایید + statusBadge = ( + + در انتظار تایید + + ); } return (
- {/* سربرگ کارت — با کلیک روی فضای خالی اکسپند می‌شود */} -
- - {sup.name.slice(0, 1)} - - -
- {/* خط ۱: فقط خود اسم تأمین‌کننده لینک است */} -
- e.stopPropagation()} - className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]" - > - {sup.name} - + {/* ===== سربرگ کارت (Header: راست عنوان و آیکون / چپ وضعیت) ===== */} +
+ {/* بخش راست (RTL): آیکون دسته‌بندی + نام کالا + تأمین‌کننده */} +
+
+ {categoryIcon ? ( + + ) : ( + + )}
- {/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */} -
- {sup.location && ( - - {sup.location} - - )} +
+

+ {itemTitle} +

- {q.is_official && ( - + e.stopPropagation()} + className="font-semibold text-muted hover:text-accent truncate no-underline hover:underline transition-colors" > - - فاکتور رسمی - - )} + 🏢 {sup.name} + + + {sup.location && · {sup.location}} + + {q.is_official && ( + + + فاکتور رسمی + + )} +
-
- {fulfillmentBadge ?? ( - - {STATUS_LABEL[q.status]} - - )} + {/* بخش چپ: بج وضعیت */} +
{statusBadge}
+
- {/* دکمه ویرایش */} - {canWrite && ( - - )} + {/* ===== بدنه کارت (Body: گرید دو ستونه فشرده متادیتا) ===== */} +
+
+ {/* ۱. پروژه */} +
+ +
+ پروژه + + {projectName} + +
+
- {/* دکمه فلش اکسپند در هدر */} + {/* ۲. مقدار */} +
+ +
+ مقدار + + {quantityDisplay} + +
+
+ + {/* ۳. تاریخ */} +
+ +
+ تاریخ درخواست + + {fmtDate(q.quote_date)} + +
+
+ + {/* ۴. کد پیگیری */} +
+ +
+ کد پیگیری + + {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`} + +
+
+
+ + {/* سطر قیمت و شرایط پرداخت (در صورت وجود) */} + {total > 0 && ( +
+
+ + مبلغ کل: + + {groupCommas(total, true)} تومان + +
+ + {ts.title !== "—" && ( + + شرایط: {ts.title} {ts.sub ? `(${ts.sub})` : ""} + + )} +
+ )} +
+ + {/* ===== فوتر کارت (Footer: اکشن‌های استاندارد با تاچ تارگت حداقل ۴۰px) ===== */} +
+ {/* دکمه اصلی مشاهده / بستن جزئیات */} + + + {/* دکمه‌های عملیاتی برای کاربران دارای دسترسی نوشتن */} + {canWrite && q.status === "offered" && ( -
+ )} + + {canWrite && ( + + )}
- {/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */} -
- {/* ۱. پروژه */} - -
- - - {q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)} - -
-
- - {/* ۲. اقلام */} - -
- {activeItems.length > 0 ? ( - activeItems.map((li) => { - const m = materialById(store.materials, li.material_id); - return ( - - {materialLabel(m)} - - ); - }) - ) : ( - — - )} -
- - {unitWarns.map((w) => ( - e.stopPropagation()} - className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]" - title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`} - > - - واحد جدید: {w.unit} - - ))} -
- - {/* ۳. مجموع مبلغ */} - - {pc ? ( -
-
- {pc.num} تومان -
-
({pc.words})
-
- ) : ( - "—" - )} -
- - {/* ۴. شرایط معامله */} - - {ts.title !== "—" ? ( -
-
- {ts.title} -
- {ts.sub &&
{ts.sub}
} -
- ) : ( - — - )} -
-
- - {/* تاریخ‌ها و اکشن‌ها */} -
- - - تاریخ: - {fmtDate(q.quote_date)} - - - - - اعتبار: - - {fmtDate(q.expiry_date)} - - {expired && } - - - - پذیرش: - {acceptedCell} - - - - رسید: - {arrivedCell} - -
- - {/* دکمه تمام‌عرض توضیحات بیشتر */} - - - {/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته می‌شود */} + {/* ===== بخش جزئیات بازشونده (Expanded Detail) ===== */} {expanded && ( -
e.stopPropagation()}> +
e.stopPropagation()}>
)} diff --git a/frontend/src/pages/Dashboard/QuoteDetail.tsx b/frontend/src/pages/Dashboard/QuoteDetail.tsx index a064444..8b40238 100644 --- a/frontend/src/pages/Dashboard/QuoteDetail.tsx +++ b/frontend/src/pages/Dashboard/QuoteDetail.tsx @@ -1,5 +1,16 @@ import { useEffect, useState } from "react"; -import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react"; +import { + Package, + CreditCard, + CalendarDays, + Paperclip, + Building2, + Ban, + Hash, + Copy, + Check, +} from "lucide-react"; +import { toast } from "sonner"; import type { Attachment, Quote, Store } from "../../lib/types"; import { api } from "../../lib/api"; import { @@ -13,6 +24,7 @@ import { groupCommas } from "../../lib/numutil"; import { FlowDiagram, type FlowRefs } from "./FlowDiagram"; import { itemProjects } from "../../lib/quoteUtils"; import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal"; +import { cn } from "../../lib/utils"; export interface QuoteDetailProps { store: Store; @@ -24,6 +36,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) { const [flowEl, setFlowEl] = useState(null); const [flowRefs] = useState(() => ({ items: new Map(), termsheets: new Map() })); const [bump, setBump] = useState(0); + const [copied, setCopied] = useState(false); + + const trackingCode = q.id.startsWith("PR-") ? q.id : `PR-${q.id}`; + + const handleCopyCode = async () => { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(trackingCode); + } else { + const ta = document.createElement("textarea"); + ta.value = trackingCode; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + setCopied(true); + toast.success("کد پیگیری با موفقیت کپی شد"); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("کپی کد پیگیری با خطا مواجه شد"); + } + }; // پیش‌نمایش پیوست‌ها: کلیک روی نام فایل، فایل را نشان می‌دهد (نه دانلود). // بایت‌ها با توکن گرفته می‌شوند چون /