feat(dashboard): filter quotes by clicking status cards in StatsBar

This commit is contained in:
Behrad
2026-09-20 14:40:27 +03:30
parent 3c7ae80b02
commit d2b9af5278
3 changed files with 96 additions and 32 deletions
+64 -27
View File
@@ -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 (
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
{stats.map((s) => (
<div
key={s.label}
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>
{stats.map((s) => {
const isClickable = Boolean(s.key && onFilterChange);
const isActive = s.key && activeFilter === s.key;
{/* آیکون در دسکتاپ */}
<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>
{/* مقدار عددی به همراه واحد روز */}
return (
<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 && (
<span className="text-[10px] font-medium text-muted sm:text-xs">
{s.unit}
{/* عنوان کارت */}
<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>
)}
{/* آیکون در دسکتاپ */}
<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>
))}
);
})}
</section>
);
}
+12 -2
View File
@@ -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 };
});
}
}
+20 -3
View File
@@ -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() {
{/* دکمه پروژه‌ها */}
<Link
to="/projects"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<FolderKanban className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>پروژه‌ها</span>
@@ -96,7 +111,7 @@ export function DashboardPage() {
{/* دکمه ضرایب تبدیل */}
<Link
to="/units"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>ضرایب تبدیل</span>
@@ -120,6 +135,8 @@ export function DashboardPage() {
offered={stats.offered}
accepted={stats.accepted}
avgLeadTime={stats.avgLead}
activeFilter={activeStatusFilter}
onFilterChange={handleStatusFilterChange}
/>
<FilterToolbar
filters={filters}