From d2b9af5278b336c9a49f45d74703a12002326611 Mon Sep 17 00:00:00 2001 From: Behrad Date: Sun, 20 Sep 2026 14:40:27 +0330 Subject: [PATCH 1/2] feat(dashboard): filter quotes by clicking status cards in StatsBar --- frontend/src/components/StatsBar.tsx | 91 +++++++++++++------ frontend/src/lib/quoteUtils.ts | 14 ++- .../src/pages/Dashboard/DashboardPage.tsx | 23 ++++- 3 files changed, 96 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 44a1029..5fd3931 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -1,15 +1,27 @@ import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react"; import { fmtNum } from "../lib/formatters"; +export type StatusFilter = "all" | "offered" | "accepted"; + export interface StatsBarProps { total: number; offered: number; accepted: number; avgLeadTime: number | null; // روز + activeFilter?: StatusFilter; + onFilterChange?: (filter: StatusFilter) => void; } -export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) { +export function StatsBar({ + total, + offered, + accepted, + avgLeadTime, + activeFilter = "all", + onFilterChange, +}: StatsBarProps) { const stats: { + key?: StatusFilter; label: string; shortLabel: string; value: string; @@ -18,6 +30,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp icon: LucideIcon; }[] = [ { + key: "all", label: "کل پیشنهادها", shortLabel: "کل", value: fmtNum(total), @@ -25,6 +38,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp icon: ClipboardList, }, { + key: "offered", label: "پیشنهاد باز", shortLabel: "باز", value: fmtNum(offered), @@ -32,6 +46,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp icon: Inbox, }, { + key: "accepted", label: "پذیرفته‌شده", shortLabel: "پذیرفته", value: fmtNum(accepted), @@ -48,39 +63,61 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp }, ]; + const handleClick = (key?: StatusFilter) => { + if (!key || !onFilterChange) return; + if (key === "all") { + onFilterChange("all"); + } else { + // در صورت کلیک مجدد روی همان کارت، فیلتر ریست شده و به کل برمی‌گردد + onFilterChange(activeFilter === key ? "all" : key); + } + }; + return (
- {stats.map((s) => ( -
- {/* عنوان کارت */} -
- - {s.shortLabel} - {s.label} - + {stats.map((s) => { + const isClickable = Boolean(s.key && onFilterChange); + const isActive = s.key && activeFilter === s.key; - {/* آیکون در دسکتاپ */} - - - -
- - {/* مقدار عددی به همراه واحد روز */} + return (
handleClick(s.key)} + className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${ + isClickable ? "cursor-pointer select-none" : "" + } ${ + isActive + ? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm" + : "border-line bg-panel hover:border-accent/40" + }`} > - {s.value} - {s.unit && ( - - {s.unit} + {/* عنوان کارت */} +
+ + {s.shortLabel} + {s.label} - )} + + {/* آیکون در دسکتاپ */} + + + +
+ + {/* مقدار عددی به همراه واحد روز */} +
+ {s.value} + {s.unit && ( + + {s.unit} + + )} +
-
- ))} + ); + })}
); } \ No newline at end of file diff --git a/frontend/src/lib/quoteUtils.ts b/frontend/src/lib/quoteUtils.ts index 455e351..ca5d8fd 100644 --- a/frontend/src/lib/quoteUtils.ts +++ b/frontend/src/lib/quoteUtils.ts @@ -288,8 +288,13 @@ export function filterQuotes( "پیشنهاد شده": "offered", "پذیرفته شده": "accepted", "رسیده": "arrived", + "باز": "offered", + "پذیرفته": "accepted", + "offered": "offered", + "accepted": "accepted", + "arrived": "arrived", }; - const stKey = STATUS_BY_LABEL[stVal] || ""; + const stKey = STATUS_BY_LABEL[stVal] || stVal; return store.quotes.filter((quote) => { const supObj = supplierById(store.suppliers, quote.supplier_id); @@ -313,11 +318,16 @@ export function filterQuotes( export function isExpired(q: Quote): boolean { return ( q.status === "offered" && + !quoteHasArrivedOrAccepted(q) && !!q.expiry_date && new Date(q.expiry_date) < new Date(new Date().toDateString()) ); } +function quoteHasArrivedOrAccepted(q: Quote): boolean { + return q.status === "accepted" || q.status === "arrived"; +} + // آماده‌سازی نمایش ترم‌شیت در حالت expanded export function termSheetBlocks( store: Store, @@ -347,4 +357,4 @@ export function termSheetBlocks( }); return { ts, isAccepted, isUsed, itemsDesc, milestones }; }); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index 7d88728..ba5fd5a 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -13,7 +13,7 @@ import { useAppStore } from "../../store"; import { filterQuotes } from "../../lib/quoteUtils"; import { daysBetween } from "../../lib/formatters"; import { Header, PageLayout } from "../../components/layout/Header"; -import { StatsBar } from "../../components/StatsBar"; +import { StatsBar, type StatusFilter } from "../../components/StatsBar"; import { FilterToolbar, type Filters } from "./FilterToolbar"; import { LedgerTable } from "./LedgerTable"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; @@ -68,6 +68,21 @@ export function DashboardPage() { return { total, offered, accepted, avgLead }; }, [quotes]); + // تعیین وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status + const activeStatusFilter: StatusFilter = useMemo(() => { + if (filters.status === "offered") return "offered"; + if (filters.status === "accepted") return "accepted"; + return "all"; + }, [filters.status]); + + // تغییر فیلتر وضعیت هنگام کلیک روی کارت‌ها + const handleStatusFilterChange = (status: StatusFilter) => { + setFilters((prev) => ({ + ...prev, + status: status === "all" ? "" : status, + })); + }; + const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]); const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]); @@ -87,7 +102,7 @@ export function DashboardPage() { {/* دکمه پروژه‌ها */} پروژه‌ها @@ -96,7 +111,7 @@ export function DashboardPage() { {/* دکمه ضرایب تبدیل */} ضرایب تبدیل @@ -120,6 +135,8 @@ export function DashboardPage() { offered={stats.offered} accepted={stats.accepted} avgLeadTime={stats.avgLead} + activeFilter={activeStatusFilter} + onFilterChange={handleStatusFilterChange} /> Date: Sun, 20 Sep 2026 15:10:10 +0330 Subject: [PATCH 2/2] feat(procurement): sync status pipeline cards, add supplier lead time badges, and enhance badge contrast --- frontend/src/components/StatsBar.tsx | 31 ++++++----- .../src/pages/Dashboard/DashboardPage.tsx | 21 ++++---- .../src/pages/Dashboard/FilterToolbar.tsx | 54 +++++++++++++++---- frontend/src/pages/Suppliers/CategoryPage.tsx | 34 ++++++++++-- .../src/pages/Suppliers/supplierUtils.tsx | 34 ++++++++---- 5 files changed, 120 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 5fd3931..59b8a2d 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -1,13 +1,13 @@ -import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react"; +import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react"; import { fmtNum } from "../lib/formatters"; -export type StatusFilter = "all" | "offered" | "accepted"; +export type StatusFilter = "all" | "offered" | "accepted" | "arrived"; export interface StatsBarProps { total: number; offered: number; accepted: number; - avgLeadTime: number | null; // روز + arrived: number; activeFilter?: StatusFilter; onFilterChange?: (filter: StatusFilter) => void; } @@ -16,12 +16,12 @@ export function StatsBar({ total, offered, accepted, - avgLeadTime, + arrived, activeFilter = "all", onFilterChange, }: StatsBarProps) { const stats: { - key?: StatusFilter; + key: StatusFilter; label: string; shortLabel: string; value: string; @@ -54,21 +54,20 @@ export function StatsBar({ icon: CheckCircle2, }, { - label: "میانگین زمان تحویل", - shortLabel: "میانگین تحویل", - value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), - unit: avgLeadTime != null ? "روز" : undefined, + key: "arrived", + label: "تحویل‌شده به کارگاه", + shortLabel: "تحویل", + value: fmtNum(arrived), color: "text-green", - icon: Timer, + icon: PackageCheck, }, ]; - const handleClick = (key?: StatusFilter) => { - if (!key || !onFilterChange) return; + const handleClick = (key: StatusFilter) => { + if (!onFilterChange) return; if (key === "all") { onFilterChange("all"); } else { - // در صورت کلیک مجدد روی همان کارت، فیلتر ریست شده و به کل برمی‌گردد onFilterChange(activeFilter === key ? "all" : key); } }; @@ -76,8 +75,8 @@ export function StatsBar({ return (
{stats.map((s) => { - const isClickable = Boolean(s.key && onFilterChange); - const isActive = s.key && activeFilter === s.key; + const isClickable = Boolean(onFilterChange); + const isActive = activeFilter === s.key; return (
- {/* مقدار عددی به همراه واحد روز */} + {/* مقدار عددی */}
diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index ba5fd5a..c612fc5 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -11,7 +11,6 @@ import { } from "../../hooks"; import { useAppStore } from "../../store"; import { filterQuotes } from "../../lib/quoteUtils"; -import { daysBetween } from "../../lib/formatters"; import { Header, PageLayout } from "../../components/layout/Header"; import { StatsBar, type StatusFilter } from "../../components/StatsBar"; import { FilterToolbar, type Filters } from "./FilterToolbar"; @@ -56,26 +55,24 @@ export function DashboardPage() { return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b))); }, [store, filters, sortDir]); + // شمارش تفکیکی پیشنهادها بر اساس پایپ‌لاین خرید const stats = useMemo(() => { const total = quotes.length; const offered = quotes.filter((q) => q.status === "offered").length; const accepted = quotes.filter((q) => q.status === "accepted").length; - const leadTimes = quotes - .filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date) - .map((q) => daysBetween(q.arrived_date, q.accepted_date)) - .filter((x): x is number => x != null); - const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null; - return { total, offered, accepted, avgLead }; + const arrived = quotes.filter((q) => q.status === "arrived").length; + return { total, offered, accepted, arrived }; }, [quotes]); - // تعیین وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status + // وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status const activeStatusFilter: StatusFilter = useMemo(() => { if (filters.status === "offered") return "offered"; if (filters.status === "accepted") return "accepted"; + if (filters.status === "arrived") return "arrived"; return "all"; }, [filters.status]); - // تغییر فیلتر وضعیت هنگام کلیک روی کارت‌ها + // تغییر فیلتر وضعیت هنگام کلیک روی هر یک از ۴ کارت const handleStatusFilterChange = (status: StatusFilter) => { setFilters((prev) => ({ ...prev, @@ -102,7 +99,7 @@ export function DashboardPage() { {/* دکمه پروژه‌ها */} پروژه‌ها @@ -111,7 +108,7 @@ export function DashboardPage() { {/* دکمه ضرایب تبدیل */} ضرایب تبدیل @@ -134,7 +131,7 @@ export function DashboardPage() { total={stats.total} offered={stats.offered} accepted={stats.accepted} - avgLeadTime={stats.avgLead} + arrived={stats.arrived} activeFilter={activeStatusFilter} onFilterChange={handleStatusFilterChange} /> diff --git a/frontend/src/pages/Dashboard/FilterToolbar.tsx b/frontend/src/pages/Dashboard/FilterToolbar.tsx index d697556..d333df7 100644 --- a/frontend/src/pages/Dashboard/FilterToolbar.tsx +++ b/frontend/src/pages/Dashboard/FilterToolbar.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react"; import { Combobox } from "../../components/Combobox"; -import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants"; export interface Filters { category: string; @@ -10,8 +9,33 @@ export interface Filters { search: string; } -// لیست وضعیت‌ها از dictionary واحد (constants) — نه آرایه‌ی تکراری -const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]); +// گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه +const STATUS_OPTIONS = [ + { id: "offered", label: "پیشنهاد باز" }, + { id: "accepted", label: "پذیرفته‌شده" }, + { id: "arrived", label: "تحویل‌شده" }, +]; + +const STATUS_MAP: Record = { + offered: "پیشنهاد باز", + accepted: "پذیرفته‌شده", + arrived: "تحویل‌شده", +}; + +const STATUS_TO_KEY: Record = { + "پیشنهاد باز": "offered", + "پذیرفته‌شده": "accepted", + "تحویل‌شده": "arrived", + "پیشنهاد شده": "offered", + "پذیرفته شده": "accepted", + "رسیده": "arrived", + "باز": "offered", + "پذیرفته": "accepted", + "تحویل": "arrived", + offered: "offered", + accepted: "accepted", + arrived: "arrived", +}; export interface FilterToolbarProps { filters: Filters; @@ -40,6 +64,14 @@ export function FilterToolbar({ onChange({ ...filters, category: "", supplier: "", status: "" }); }; + // تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس + const statusDisplayValue = STATUS_MAP[filters.status] || filters.status; + + const handleStatusChange = (val: string) => { + const key = STATUS_TO_KEY[val] || val; + set({ status: key }); + }; + return ( <> {/* ===== نمای موبایل (صفحه‌نمایش‌های زیر md) ===== */} @@ -148,12 +180,12 @@ export function FilterToolbar({
set({ status: it.label })} - items={STATUS_ITEMS} + value={statusDisplayValue} + onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)} + items={STATUS_OPTIONS} allowAdd={false} placeholder="همه وضعیت‌ها" - onInputChange={(v) => set({ status: v })} + onInputChange={handleStatusChange} />
@@ -197,12 +229,12 @@ export function FilterToolbar({
set({ status: it.label })} - items={STATUS_ITEMS} + value={statusDisplayValue} + onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)} + items={STATUS_OPTIONS} allowAdd={false} placeholder="همه" - onInputChange={(v) => set({ status: v })} + onInputChange={handleStatusChange} />
diff --git a/frontend/src/pages/Suppliers/CategoryPage.tsx b/frontend/src/pages/Suppliers/CategoryPage.tsx index 21c3f74..e22320e 100644 --- a/frontend/src/pages/Suppliers/CategoryPage.tsx +++ b/frontend/src/pages/Suppliers/CategoryPage.tsx @@ -7,6 +7,7 @@ import { nextId } from "../../lib/api"; import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks"; import { useAppStore } from "../../store"; import { topicByName } from "../../lib/topics"; +import { daysBetween } from "../../lib/formatters"; import { Header, PageLayout } from "../../components/layout/Header"; import { EmptyState } from "../../components/EmptyState"; import { Combobox } from "../../components/Combobox"; @@ -15,7 +16,6 @@ import { materialLabel } from "../../lib/quoteUtils"; import { Button } from "../../components/ui/button"; // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده -// (سلسله‌مراتب دامنه: دسته → تأمین‌کننده؛ در v2.6 دسته = تاپیک) export function CategoryPage() { useStoreData(); const { category: catParam = "" } = useParams(); @@ -36,12 +36,29 @@ export function CategoryPage() { contact: "", }); - // همه‌ی تأمین‌کننده‌ها برای انتخاب سریع (task: کومبوکس کش‌شده) + // همه‌ی تأمین‌کننده‌ها برای انتخاب سریع const supplierOptions = useMemo( () => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers], ); + // محاسبه میانگین زمان تحویل اختصاصی هر تأمین‌کننده بر اساس سفارش‌های تحویل‌شده + const supLeadTimes = useMemo(() => { + const map = new Map(); + suppliers.forEach((s) => { + const supQuotes = quotes.filter( + (q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date + ); + const times = supQuotes + .map((q) => daysBetween(q.arrived_date, q.accepted_date)) + .filter((x): x is number => x != null); + + const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null; + map.set(s.id, avg); + }); + return map; + }, [quotes, suppliers]); + if (!cat) return ; const sups = suppliersOfCategory(cat, cat, suppliers); @@ -69,7 +86,6 @@ export function CategoryPage() { toast.error("نام تأمین‌کننده را وارد کنید."); return; } - // تأمین‌کننده دقیقاً زیر همین دسته ثبت می‌شود (۱ دسته به ازای هر تأمین‌کننده) const s: Supplier = { id: nextId("s", suppliers), topic: cat, @@ -101,7 +117,15 @@ export function CategoryPage() { ) : (
{sups.map((s) => ( - + ))}
)} @@ -161,4 +185,4 @@ export function CategoryPage() {
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Suppliers/supplierUtils.tsx b/frontend/src/pages/Suppliers/supplierUtils.tsx index e52e862..69fae33 100644 --- a/frontend/src/pages/Suppliers/supplierUtils.tsx +++ b/frontend/src/pages/Suppliers/supplierUtils.tsx @@ -1,7 +1,9 @@ import { Link } from "react-router-dom"; -import { MapPin, Phone, User, Plus } from "lucide-react"; +import { MapPin, Phone, User, Plus, Timer } from "lucide-react"; import type { Material, Supplier } from "../../lib/types"; import { normalize } from "../../lib/normalize"; +import { fmtNum } from "../../lib/formatters"; + export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] { const out = new Set(); materials @@ -19,32 +21,44 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl ); } -// کارت تأمین‌کننده +// کارت تأمین‌کننده همراه با برچسب میانگین زمان تحویل export function SupplierCard({ s, topic, deletable, onDelete, materials = [], + avgLeadTime, }: { s: Supplier; topic: string; deletable?: boolean; onDelete?: (id: string) => void; - // متریال‌های خریداری‌شده از این تأمین‌کننده (به‌جای دسته‌بندی) materials?: string[]; + avgLeadTime?: number | null; }) { return (
-
- +
+ {s.name} - {deletable && onDelete && ( - - )} +
+ {avgLeadTime != null && ( + + + {fmtNum(avgLeadTime)} روز + + )} + {deletable && onDelete && ( + + )} +