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 && ( + + )} +