diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 44a1029..59b8a2d 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 { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react"; import { fmtNum } from "../lib/formatters"; +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; } -export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) { +export function StatsBar({ + total, + offered, + accepted, + arrived, + 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), @@ -39,48 +54,69 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp 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 (!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(onFilterChange); + const isActive = 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 9cc0da9..49cac01 100644 --- a/frontend/src/lib/quoteUtils.ts +++ b/frontend/src/lib/quoteUtils.ts @@ -286,8 +286,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); @@ -311,11 +316,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, @@ -345,4 +355,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..c612fc5 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -11,9 +11,8 @@ 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 } 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"; @@ -56,18 +55,31 @@ 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 + 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, + 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]); @@ -119,7 +131,9 @@ export function DashboardPage() { total={stats.total} offered={stats.offered} accepted={stats.accepted} - avgLeadTime={stats.avgLead} + arrived={stats.arrived} + activeFilter={activeStatusFilter} + onFilterChange={handleStatusFilterChange} /> 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 && ( + + )} +