Compare commits
2
Commits
e637d80b76
...
prod_v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a4a29431 | ||
|
|
d2b9af5278 |
@@ -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";
|
import { fmtNum } from "../lib/formatters";
|
||||||
|
|
||||||
|
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
|
||||||
|
|
||||||
export interface StatsBarProps {
|
export interface StatsBarProps {
|
||||||
total: number;
|
total: number;
|
||||||
offered: number;
|
offered: number;
|
||||||
accepted: 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: {
|
const stats: {
|
||||||
|
key: StatusFilter;
|
||||||
label: string;
|
label: string;
|
||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -18,6 +30,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
|
key: "all",
|
||||||
label: "کل پیشنهادها",
|
label: "کل پیشنهادها",
|
||||||
shortLabel: "کل",
|
shortLabel: "کل",
|
||||||
value: fmtNum(total),
|
value: fmtNum(total),
|
||||||
@@ -25,6 +38,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "offered",
|
||||||
label: "پیشنهاد باز",
|
label: "پیشنهاد باز",
|
||||||
shortLabel: "باز",
|
shortLabel: "باز",
|
||||||
value: fmtNum(offered),
|
value: fmtNum(offered),
|
||||||
@@ -32,6 +46,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "accepted",
|
||||||
label: "پذیرفتهشده",
|
label: "پذیرفتهشده",
|
||||||
shortLabel: "پذیرفته",
|
shortLabel: "پذیرفته",
|
||||||
value: fmtNum(accepted),
|
value: fmtNum(accepted),
|
||||||
@@ -39,48 +54,69 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "میانگین زمان تحویل",
|
key: "arrived",
|
||||||
shortLabel: "میانگین تحویل",
|
label: "تحویلشده به کارگاه",
|
||||||
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime),
|
shortLabel: "تحویل",
|
||||||
unit: avgLeadTime != null ? "روز" : undefined,
|
value: fmtNum(arrived),
|
||||||
color: "text-green",
|
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 (
|
return (
|
||||||
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => {
|
||||||
<div
|
const isClickable = Boolean(onFilterChange);
|
||||||
key={s.label}
|
const isActive = activeFilter === s.key;
|
||||||
className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right"
|
|
||||||
>
|
|
||||||
{/* عنوان کارت */}
|
|
||||||
<div className="flex items-center justify-center sm:justify-between">
|
|
||||||
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
|
|
||||||
<span className="sm:hidden">{s.shortLabel}</span>
|
|
||||||
<span className="hidden sm:inline">{s.label}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* آیکون در دسکتاپ */}
|
return (
|
||||||
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
|
|
||||||
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* مقدار عددی به همراه واحد روز */}
|
|
||||||
<div
|
<div
|
||||||
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
key={s.label}
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span>{s.value}</span>
|
{/* عنوان کارت */}
|
||||||
{s.unit && (
|
<div className="flex items-center justify-center sm:justify-between">
|
||||||
<span className="text-[10px] font-medium text-muted sm:text-xs">
|
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
|
||||||
{s.unit}
|
<span className="sm:hidden">{s.shortLabel}</span>
|
||||||
|
<span className="hidden sm:inline">{s.label}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
{/* آیکون در دسکتاپ */}
|
||||||
|
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
|
||||||
|
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* مقدار عددی */}
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
||||||
|
>
|
||||||
|
<span>{s.value}</span>
|
||||||
|
{s.unit && (
|
||||||
|
<span className="text-[10px] font-medium text-muted sm:text-xs">
|
||||||
|
{s.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -288,8 +288,13 @@ export function filterQuotes(
|
|||||||
"پیشنهاد شده": "offered",
|
"پیشنهاد شده": "offered",
|
||||||
"پذیرفته شده": "accepted",
|
"پذیرفته شده": "accepted",
|
||||||
"رسیده": "arrived",
|
"رسیده": "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) => {
|
return store.quotes.filter((quote) => {
|
||||||
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
||||||
@@ -313,11 +318,16 @@ export function filterQuotes(
|
|||||||
export function isExpired(q: Quote): boolean {
|
export function isExpired(q: Quote): boolean {
|
||||||
return (
|
return (
|
||||||
q.status === "offered" &&
|
q.status === "offered" &&
|
||||||
|
!quoteHasArrivedOrAccepted(q) &&
|
||||||
!!q.expiry_date &&
|
!!q.expiry_date &&
|
||||||
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quoteHasArrivedOrAccepted(q: Quote): boolean {
|
||||||
|
return q.status === "accepted" || q.status === "arrived";
|
||||||
|
}
|
||||||
|
|
||||||
// آمادهسازی نمایش ترمشیت در حالت expanded
|
// آمادهسازی نمایش ترمشیت در حالت expanded
|
||||||
export function termSheetBlocks(
|
export function termSheetBlocks(
|
||||||
store: Store,
|
store: Store,
|
||||||
@@ -347,4 +357,4 @@ export function termSheetBlocks(
|
|||||||
});
|
});
|
||||||
return { ts, isAccepted, isUsed, itemsDesc, milestones };
|
return { ts, isAccepted, isUsed, itemsDesc, milestones };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { filterQuotes } from "../../lib/quoteUtils";
|
import { filterQuotes } from "../../lib/quoteUtils";
|
||||||
import { daysBetween } from "../../lib/formatters";
|
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
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 { FilterToolbar, type Filters } from "./FilterToolbar";
|
||||||
import { LedgerTable } from "./LedgerTable";
|
import { LedgerTable } from "./LedgerTable";
|
||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
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)));
|
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
||||||
}, [store, filters, sortDir]);
|
}, [store, filters, sortDir]);
|
||||||
|
|
||||||
|
// شمارش تفکیکی پیشنهادها بر اساس پایپلاین خرید
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = quotes.length;
|
const total = quotes.length;
|
||||||
const offered = quotes.filter((q) => q.status === "offered").length;
|
const offered = quotes.filter((q) => q.status === "offered").length;
|
||||||
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
||||||
const leadTimes = quotes
|
const arrived = quotes.filter((q) => q.status === "arrived").length;
|
||||||
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
|
return { total, offered, accepted, arrived };
|
||||||
.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 };
|
|
||||||
}, [quotes]);
|
}, [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 categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
||||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||||
|
|
||||||
@@ -119,7 +131,9 @@ export function DashboardPage() {
|
|||||||
total={stats.total}
|
total={stats.total}
|
||||||
offered={stats.offered}
|
offered={stats.offered}
|
||||||
accepted={stats.accepted}
|
accepted={stats.accepted}
|
||||||
avgLeadTime={stats.avgLead}
|
arrived={stats.arrived}
|
||||||
|
activeFilter={activeStatusFilter}
|
||||||
|
onFilterChange={handleStatusFilterChange}
|
||||||
/>
|
/>
|
||||||
<FilterToolbar
|
<FilterToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
|
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -10,8 +9,33 @@ export interface Filters {
|
|||||||
search: string;
|
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<string, string> = {
|
||||||
|
offered: "پیشنهاد باز",
|
||||||
|
accepted: "پذیرفتهشده",
|
||||||
|
arrived: "تحویلشده",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TO_KEY: Record<string, string> = {
|
||||||
|
"پیشنهاد باز": "offered",
|
||||||
|
"پذیرفتهشده": "accepted",
|
||||||
|
"تحویلشده": "arrived",
|
||||||
|
"پیشنهاد شده": "offered",
|
||||||
|
"پذیرفته شده": "accepted",
|
||||||
|
"رسیده": "arrived",
|
||||||
|
"باز": "offered",
|
||||||
|
"پذیرفته": "accepted",
|
||||||
|
"تحویل": "arrived",
|
||||||
|
offered: "offered",
|
||||||
|
accepted: "accepted",
|
||||||
|
arrived: "arrived",
|
||||||
|
};
|
||||||
|
|
||||||
export interface FilterToolbarProps {
|
export interface FilterToolbarProps {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
@@ -40,6 +64,14 @@ export function FilterToolbar({
|
|||||||
onChange({ ...filters, category: "", supplier: "", status: "" });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
||||||
@@ -148,12 +180,12 @@ export function FilterToolbar({
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={filters.status}
|
value={statusDisplayValue}
|
||||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||||
items={STATUS_ITEMS}
|
items={STATUS_OPTIONS}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
placeholder="همه وضعیتها"
|
placeholder="همه وضعیتها"
|
||||||
onInputChange={(v) => set({ status: v })}
|
onInputChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,12 +229,12 @@ export function FilterToolbar({
|
|||||||
<div className="field min-w-[150px] flex-1">
|
<div className="field min-w-[150px] flex-1">
|
||||||
<label>وضعیت</label>
|
<label>وضعیت</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={filters.status}
|
value={statusDisplayValue}
|
||||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||||
items={STATUS_ITEMS}
|
items={STATUS_OPTIONS}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
placeholder="همه"
|
placeholder="همه"
|
||||||
onInputChange={(v) => set({ status: v })}
|
onInputChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field min-w-[200px] flex-1">
|
<div className="field min-w-[200px] flex-1">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { nextId } from "../../lib/api";
|
|||||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { topicByName } from "../../lib/topics";
|
import { topicByName } from "../../lib/topics";
|
||||||
|
import { daysBetween } from "../../lib/formatters";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
@@ -15,7 +16,6 @@ import { materialLabel } from "../../lib/quoteUtils";
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||||
// (سلسلهمراتب دامنه: دسته → تأمینکننده؛ در v2.6 دسته = تاپیک)
|
|
||||||
export function CategoryPage() {
|
export function CategoryPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
const { category: catParam = "" } = useParams();
|
const { category: catParam = "" } = useParams();
|
||||||
@@ -36,12 +36,29 @@ export function CategoryPage() {
|
|||||||
contact: "",
|
contact: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// همهی تأمینکنندهها برای انتخاب سریع (task: کومبوکس کششده)
|
// همهی تأمینکنندهها برای انتخاب سریع
|
||||||
const supplierOptions = useMemo(
|
const supplierOptions = useMemo(
|
||||||
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
||||||
[suppliers],
|
[suppliers],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// محاسبه میانگین زمان تحویل اختصاصی هر تأمینکننده بر اساس سفارشهای تحویلشده
|
||||||
|
const supLeadTimes = useMemo(() => {
|
||||||
|
const map = new Map<string, number | null>();
|
||||||
|
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 <Navigate to="/suppliers" replace />;
|
if (!cat) return <Navigate to="/suppliers" replace />;
|
||||||
|
|
||||||
const sups = suppliersOfCategory(cat, cat, suppliers);
|
const sups = suppliersOfCategory(cat, cat, suppliers);
|
||||||
@@ -69,7 +86,6 @@ export function CategoryPage() {
|
|||||||
toast.error("نام تأمینکننده را وارد کنید.");
|
toast.error("نام تأمینکننده را وارد کنید.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// تأمینکننده دقیقاً زیر همین دسته ثبت میشود (۱ دسته به ازای هر تأمینکننده)
|
|
||||||
const s: Supplier = {
|
const s: Supplier = {
|
||||||
id: nextId("s", suppliers),
|
id: nextId("s", suppliers),
|
||||||
topic: cat,
|
topic: cat,
|
||||||
@@ -101,7 +117,15 @@ export function CategoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{sups.map((s) => (
|
{sups.map((s) => (
|
||||||
<SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
<SupplierCard
|
||||||
|
key={s.id}
|
||||||
|
s={s}
|
||||||
|
topic={cat}
|
||||||
|
deletable
|
||||||
|
onDelete={removeSup}
|
||||||
|
materials={supMats.get(s.id) || []}
|
||||||
|
avgLeadTime={supLeadTimes.get(s.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -161,4 +185,4 @@ export function CategoryPage() {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Link } from "react-router-dom";
|
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 type { Material, Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
materials
|
materials
|
||||||
@@ -19,32 +21,44 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// کارت تأمینکننده
|
// کارت تأمینکننده همراه با برچسب میانگین زمان تحویل
|
||||||
export function SupplierCard({
|
export function SupplierCard({
|
||||||
s,
|
s,
|
||||||
topic,
|
topic,
|
||||||
deletable,
|
deletable,
|
||||||
onDelete,
|
onDelete,
|
||||||
materials = [],
|
materials = [],
|
||||||
|
avgLeadTime,
|
||||||
}: {
|
}: {
|
||||||
s: Supplier;
|
s: Supplier;
|
||||||
topic: string;
|
topic: string;
|
||||||
deletable?: boolean;
|
deletable?: boolean;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
// متریالهای خریداریشده از این تأمینکننده (بهجای دستهبندی)
|
|
||||||
materials?: string[];
|
materials?: string[];
|
||||||
|
avgLeadTime?: number | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="sup-card">
|
<div className="sup-card">
|
||||||
<div className="sc-head">
|
<div className="sc-head flex items-start justify-between gap-2">
|
||||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
|
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
{deletable && onDelete && (
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
{avgLeadTime != null && (
|
||||||
×
|
<span
|
||||||
</button>
|
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
|
||||||
)}
|
title="میانگین زمان تحویل سفارشها"
|
||||||
|
>
|
||||||
|
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
|
||||||
|
<span>{fmtNum(avgLeadTime)} روز</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deletable && onDelete && (
|
||||||
|
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sc-meta">
|
<div className="sc-meta">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
Reference in New Issue
Block a user