chore: restore MVP dashboard components from backup
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// ============================================================
|
||||
// ویجت ۴ — کنترل روزانه (امروز)
|
||||
// کارتهای KPI: بلیتهای خروج امروز، برگشت از سایت، ضایعات و پذیرش
|
||||
// + فهرست دقیق نام تحویلدهندهها/انجامدهندهها + ریز عملیات امروز.
|
||||
// هر ۶۰ ثانیه خودکار تازهسازی میشود (useDailyControl).
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
PackageOpen,
|
||||
TriangleAlert,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useDailyControl } from "../../hooks/useDashboardData";
|
||||
import { faNum, fmtDateTime } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||
|
||||
const TYPE_FA: Record<string, string> = {
|
||||
WITHDRAWAL: "برداشت از انبار",
|
||||
RETURN: "برگشت از سایت",
|
||||
DAMAGE: "آسیبدیده",
|
||||
WASTE: "اتلاف",
|
||||
LOSS: "مفقودی",
|
||||
PURCHASE: "پذیرش خرید",
|
||||
TRANSFER_IN: "انتقال ورودی",
|
||||
TRANSFER_OUT: "انتقال خروجی",
|
||||
FOUND: "پیدا شده",
|
||||
STOCK_COUNT: "انبارگردانی",
|
||||
};
|
||||
|
||||
const TYPE_STYLE: Record<string, string> = {
|
||||
WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]",
|
||||
RETURN: "bg-amber-bg text-amber",
|
||||
DAMAGE: "bg-red-bg text-red",
|
||||
WASTE: "bg-red-bg text-red",
|
||||
LOSS: "bg-red-bg text-red",
|
||||
PURCHASE: "bg-green-bg text-green",
|
||||
};
|
||||
|
||||
function KpiCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
tickets,
|
||||
qty,
|
||||
tone,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
tickets: number;
|
||||
qty: number;
|
||||
tone: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5">
|
||||
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", tone)}>
|
||||
<Icon className="h-4.5 w-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-muted">{label}</div>
|
||||
<div className="flex items-baseline gap-1.5 tabular-nums">
|
||||
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
|
||||
<span className="text-[10.5px] text-muted">بلیت</span>
|
||||
<span className="text-[11px] text-muted">·</span>
|
||||
<span className="text-[12px] font-bold">{faNum(qty.toLocaleString("en-US"))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DailyControlPanel() {
|
||||
const query = useDailyControl();
|
||||
const d = query.data;
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="کنترل روزانه — عملیات امروز"
|
||||
sub={d ? `تاریخ ${isoToJalaliStr(d.date) ?? d.date} — هر دقیقه بهروزرسانی میشود` : "هر دقیقه بهروزرسانی میشود"}
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !d ? (
|
||||
<ChartEmpty message="دادهی امروز در دسترس نیست" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* KPI کارتها */}
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
icon={ArrowUpFromLine}
|
||||
label="خروج به سایت (برداشت)"
|
||||
tickets={d.outbound.tickets}
|
||||
qty={d.outbound.qty}
|
||||
tone="bg-blue-bg text-[#1d4ed8]"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={ArrowDownToLine}
|
||||
label="برگشت از سایت"
|
||||
tickets={d.returns.tickets}
|
||||
qty={d.returns.qty}
|
||||
tone="bg-amber-bg text-amber"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={TriangleAlert}
|
||||
label="ضایعات / اتلاف / مفقودی"
|
||||
tickets={d.waste.tickets}
|
||||
qty={d.waste.qty}
|
||||
tone="bg-red-bg text-red"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={PackageOpen}
|
||||
label="پذیرش خرید (ورود)"
|
||||
tickets={d.purchases.tickets}
|
||||
qty={d.purchases.qty}
|
||||
tone="bg-green-bg text-green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-5">
|
||||
{/* نام دقیق انجامدهندهها */}
|
||||
<div className="rounded-xl border border-line p-3 lg:col-span-2">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[12.5px] font-extrabold">
|
||||
<Users className="h-4 w-4 text-muted" />
|
||||
تحویلدهندهها / انجامدهندههای امروز
|
||||
</h3>
|
||||
{d.performers.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px] text-muted">امروز عملیاتی ثبت نشده است</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-line">
|
||||
{d.performers.map((p, i) => (
|
||||
<li key={i} className="flex items-center gap-2 py-1.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-bg text-[11px] font-extrabold text-accent">
|
||||
{faNum(i + 1)}
|
||||
</span>
|
||||
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
|
||||
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
|
||||
{faNum(p.tickets)} بلیت · {faNum(p.qty.toLocaleString("en-US"))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ریز عملیات امروز */}
|
||||
<div className="rounded-xl border border-line p-3 lg:col-span-3">
|
||||
<h3 className="mb-2 text-[12.5px] font-extrabold">ریز عملیات امروز</h3>
|
||||
{d.items.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px] text-muted">هنوز جابهجاییای امروز ثبت نشده است</p>
|
||||
) : (
|
||||
<ul className="max-h-56 flex flex-col divide-y divide-line overflow-y-auto pe-1">
|
||||
{d.items.map((it) => (
|
||||
<li key={it.id} className="flex items-center gap-2 py-1.5">
|
||||
<Badge
|
||||
className={cn(
|
||||
"shrink-0 px-2 py-0 text-[10px]",
|
||||
TYPE_STYLE[it.transaction_type] ?? "bg-slate-bg text-muted",
|
||||
)}
|
||||
>
|
||||
{TYPE_FA[it.transaction_type] ?? it.transaction_type}
|
||||
</Badge>
|
||||
<span className="truncate text-[12px] font-bold">{it.material}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted">
|
||||
{faNum(it.quantity.toLocaleString("en-US"))} {it.unit_code}
|
||||
</span>
|
||||
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
|
||||
{fmtDateTime(it.transaction_date)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// ============================================================
|
||||
// فیلتر سراسری «چه چیزی» — آبشار: دستهبندی → متریال → برند → مشخصات
|
||||
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
||||
// هر سطح، سطحهای پاییندست را بازنشانی میکند. وضعیت در Zustand
|
||||
// نگه داشته میشود تا با جابهجایی بین تب انبار/بازرگانی از دست نرود.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import { Combobox } from "../Combobox";
|
||||
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const CLEAR_ID = "__clear__";
|
||||
|
||||
export function DashboardFilterBar({ className }: { className?: string }) {
|
||||
const filterQuery = useDashboardFilterOptions();
|
||||
const state = useDashboardFilter();
|
||||
|
||||
const categories = filterQuery.data?.categories ?? [];
|
||||
|
||||
// متریالهای سطح فعلی: اگر دسته انتخاب شده باشد فقط متریالهای همان دسته
|
||||
const materialOptions = useMemo(() => {
|
||||
const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? ""));
|
||||
const list = cat ? cat.materials : categories.flatMap((c) => c.materials);
|
||||
// حذف متریال تکراری وقتی «همهی دستهها» فعال است
|
||||
const seen = new Set<string>();
|
||||
return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
|
||||
}, [categories, state.categoryId]);
|
||||
|
||||
const selectedMaterial = useMemo(
|
||||
() => materialOptions.find((m) => m.id === state.materialId) ?? null,
|
||||
[materialOptions, state.materialId],
|
||||
);
|
||||
|
||||
const activeChips = [
|
||||
state.categoryLabel && { key: "category", label: state.categoryLabel },
|
||||
state.materialLabel && { key: "material", label: state.materialLabel },
|
||||
state.brandLabel && { key: "brand", label: state.brandLabel },
|
||||
state.specsLabel && { key: "specs", label: state.specsLabel },
|
||||
].filter(Boolean) as { key: string; label: string }[];
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-2xl border border-line bg-panel p-3 sm:p-4",
|
||||
className,
|
||||
)}
|
||||
aria-label="فیلتر سراسری متریال"
|
||||
>
|
||||
<div className="mb-2.5 flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-bg text-accent">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
|
||||
<p className="text-[10.5px] text-muted">دستهبندی ← متریال ← برند ← مشخصات فنی</p>
|
||||
</div>
|
||||
{activeChips.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.reset}
|
||||
className="ms-auto inline-flex items-center gap-1 rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
حذف همهی فیلترها
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
{/* ۱ — دستهبندی */}
|
||||
<Combobox
|
||||
value={state.categoryLabel ?? ""}
|
||||
placeholder={state.categoryId ? state.categoryLabel ?? "دستهبندی" : "همهی دستهها"}
|
||||
allowAdd={false}
|
||||
items={[
|
||||
{ id: CLEAR_ID, label: "همهی دستهها" },
|
||||
...categories.map((c) => ({ id: c.id ?? CLEAR_ID, label: c.name })),
|
||||
]}
|
||||
onChange={(item) => {
|
||||
if (item.id === CLEAR_ID) {
|
||||
state.setCategory(null, null);
|
||||
} else if (item.id === state.categoryId) {
|
||||
state.setCategory(null, null); // کلیک دوباره = پاک کردن
|
||||
} else {
|
||||
state.setCategory(item.id, item.label);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ۲ — متریال (وابسته به دسته) */}
|
||||
<Combobox
|
||||
value={state.materialLabel ?? ""}
|
||||
placeholder={state.materialId ? state.materialLabel ?? "متریال" : "همهی متریالها"}
|
||||
allowAdd={false}
|
||||
disabled={materialOptions.length === 0}
|
||||
items={[
|
||||
{ id: CLEAR_ID, label: "همهی متریالها" },
|
||||
...materialOptions.map((m) => ({ id: m.id, label: m.name })),
|
||||
]}
|
||||
onChange={(item) => {
|
||||
if (item.id === CLEAR_ID) {
|
||||
state.setMaterial(null, null);
|
||||
} else if (item.id === state.materialId) {
|
||||
state.setMaterial(null, null);
|
||||
} else {
|
||||
state.setMaterial(item.id, item.label);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ۳ — برند (از موجودی واقعیِ همان متریال) */}
|
||||
<Combobox
|
||||
value={state.brandLabel ?? ""}
|
||||
placeholder={state.brandId ? state.brandLabel ?? "برند" : "همهی برندها"}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
||||
items={[
|
||||
{ id: CLEAR_ID, label: "همهی برندها" },
|
||||
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
|
||||
]}
|
||||
onChange={(item) => {
|
||||
if (item.id === CLEAR_ID) {
|
||||
state.setBrand(null, null);
|
||||
} else if (item.id === state.brandId) {
|
||||
state.setBrand(null, null);
|
||||
} else {
|
||||
state.setBrand(item.id, item.label);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ۴ — مشخصات فنی (specs_hash از موجودی واقعی) */}
|
||||
<Combobox
|
||||
value={state.specsLabel ?? ""}
|
||||
placeholder={state.specsHash ? state.specsLabel ?? "مشخصات" : "همهی مشخصات"}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
||||
items={[
|
||||
{ id: CLEAR_ID, label: "همهی مشخصات" },
|
||||
...(selectedMaterial?.specs ?? []).map((s) => {
|
||||
const parts = Object.entries(s.specs || {});
|
||||
const label = parts.length
|
||||
? parts.map(([k, v]) => `${k}: ${v}`).join("، ")
|
||||
: "بدون مشخصه (پیشفرض)";
|
||||
return { id: s.hash, label };
|
||||
}),
|
||||
]}
|
||||
onChange={(item) => {
|
||||
if (item.id === CLEAR_ID) {
|
||||
state.setSpecs(null, null);
|
||||
} else if (item.id === state.specsHash) {
|
||||
state.setSpecs(null, null);
|
||||
} else {
|
||||
state.setSpecs(item.id, item.label);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* چیپهای فیلتر فعال — بازخورد فوری انتخابها */}
|
||||
{activeChips.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{activeChips.map((chip) => (
|
||||
<span
|
||||
key={chip.key}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-accent-bg px-2.5 py-0.5 text-[11px] font-bold text-accent"
|
||||
>
|
||||
{chip.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// ============================================================
|
||||
// ویجت ۲ — جدول موجودی راکد (سرمایهی خوابیده)
|
||||
// ردیفهایی با موجودی مثبت که هیچ تراکنشی در بازهی رکود نداشتهاند.
|
||||
// آستانهی ۶۰/۹۰ روز با چیپ انتخاب میشود؛ روزهای رکود رنگی است.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { Snowflake } from "lucide-react";
|
||||
import { useDeadStock } from "../../hooks/useDashboardData";
|
||||
import { fmtNum, faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||
|
||||
function specLabel(specs: Record<string, string>): string {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||
}
|
||||
|
||||
function idleSeverity(days: number | null): string {
|
||||
if (days == null) return "text-muted";
|
||||
if (days >= 90) return "text-red";
|
||||
if (days >= 60) return "text-amber";
|
||||
return "text-muted";
|
||||
}
|
||||
|
||||
export function DeadStockTable() {
|
||||
const [idleDays, setIdleDays] = useState<60 | 90>(60);
|
||||
const query = useDeadStock(idleDays);
|
||||
const items = query.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Snowflake className="h-4 w-4 text-[#0ea5e9]" />
|
||||
موجودی راکد — سرمایهی خوابیده
|
||||
</span>
|
||||
}
|
||||
sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازهی انتخابی نداشتهاند"
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
{([60, 90] as const).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => setIdleDays(d)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-[11px] font-bold transition-colors",
|
||||
idleDays === d ? "bg-accent text-white" : "bg-slate-bg text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{faNum(d)} روز و بیشتر
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : items.length === 0 ? (
|
||||
<ChartEmpty message={`موجودی راکدی با آستانهی ${faNum(idleDays)} روز وجود ندارد`} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>متریال و مشخصات</TableHead>
|
||||
<TableHead>برند</TableHead>
|
||||
<TableHead>انبار / پروژه</TableHead>
|
||||
<TableHead className="text-end">مقدار راکد</TableHead>
|
||||
<TableHead className="text-end">روزهای رکود</TableHead>
|
||||
<TableHead>آخرین فعالیت</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((it) => {
|
||||
const specs = specLabel(it.specs);
|
||||
return (
|
||||
<TableRow key={it.inventory_id}>
|
||||
<TableCell>
|
||||
<div className="text-[13px] font-bold">{it.material}</div>
|
||||
{specs && <div className="text-[11px] text-muted">{specs}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted">{it.brand || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-[12px]">{it.warehouse}</div>
|
||||
<div className="text-[10.5px] text-muted">{it.project}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{fmtNum(it.quantity)}{" "}
|
||||
<span className="text-[10.5px] font-normal text-muted">
|
||||
{it.unit?.name_fa || it.unit?.code}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end font-extrabold tabular-nums", idleSeverity(it.idle_days))}>
|
||||
{it.idle_days == null ? "—" : faNum(it.idle_days)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] text-muted">
|
||||
{it.last_activity ? isoToJalaliStr(it.last_activity) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// ============================================================
|
||||
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
|
||||
// نمودار ستونی تعاملی: کلیک روی هر ستون پروژه، به انبارهای همان
|
||||
// پروژه رفته و مسیر (breadcrumb) بهروز میشود — سبک Power BI.
|
||||
// مقادیر هرگز بین واحدهای ناسازگار مخلوط نمیشوند: واحد فعال با
|
||||
// چیپهای بالای نمودار انتخاب میشود.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useDrilldown } from "../../hooks/useDashboardData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import {
|
||||
CHART_COLORS,
|
||||
ChartCard,
|
||||
ChartEmpty,
|
||||
ChartLoading,
|
||||
ChartTooltip,
|
||||
UnitPicker,
|
||||
faAxisTick,
|
||||
} from "./chartShared";
|
||||
|
||||
interface Slice {
|
||||
id: string | null;
|
||||
name: string;
|
||||
value: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export function DrilldownMatrix() {
|
||||
const query = useDrilldown();
|
||||
const [unit, setUnit] = useState<string | null>(null);
|
||||
const [project, setProject] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const data = query.data;
|
||||
|
||||
// واحد فعال: انتخاب کاربر یا بزرگترین مجموع (پایدار بین رندرها)
|
||||
const activeUnit = useMemo(() => {
|
||||
if (unit && data?.units?.[unit]) return unit;
|
||||
const entries = Object.entries(data?.total?.qty_by_unit ?? {});
|
||||
if (!entries.length) return null;
|
||||
return entries.sort((a, b) => b[1] - a[1])[0][0];
|
||||
}, [unit, data]);
|
||||
|
||||
const slices: Slice[] = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const pick = (q: Record<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0);
|
||||
if (project) {
|
||||
const p = data.projects.find((x) => x.id === project.id);
|
||||
return (p?.warehouses ?? []).map((w) => ({ id: w.id, name: w.name, value: pick(w.qty_by_unit), rows: w.rows }));
|
||||
}
|
||||
return data.projects.map((p) => ({ id: p.id, name: p.name, value: pick(p.qty_by_unit), rows: p.rows }));
|
||||
}, [data, project, activeUnit]);
|
||||
|
||||
const unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : "";
|
||||
const levelTotal = slices.reduce((s, x) => s + x.value, 0);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
|
||||
sub={
|
||||
project
|
||||
? `انبارهای پروژه «${project.name}» — برای بازگشت روی مسیر کلیک کنید`
|
||||
: "سهم هر پروژه از کل سازمان — روی هر ستون کلیک کنید تا انبارهای آن پروژه را ببینید"
|
||||
}
|
||||
actions={
|
||||
data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} /> : null
|
||||
}
|
||||
>
|
||||
{/* مسیر Drill-Down */}
|
||||
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProject(null)}
|
||||
className={`rounded-md px-2 py-0.5 font-bold transition-colors ${
|
||||
project ? "text-muted hover:bg-slate-bg hover:text-ink" : "bg-accent-bg text-accent"
|
||||
}`}
|
||||
>
|
||||
کل سازمان
|
||||
</button>
|
||||
{project && (
|
||||
<>
|
||||
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
|
||||
<span className="rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="text-muted">← انبار</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ms-auto text-[11px] tabular-nums text-muted">
|
||||
جمع سطح فعال: {faNum(levelTotal.toLocaleString("en-US"))} {unitLabel}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !slices.length || levelTotal === 0 ? (
|
||||
<ChartEmpty message="موجودیای با این فیلترها یافت نشد" />
|
||||
) : (
|
||||
<div dir="ltr" className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={slices} margin={{ top: 24, right: 8, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e9f0" }}
|
||||
interval={0}
|
||||
height={44}
|
||||
angle={slices.length > 4 ? -18 : 0}
|
||||
textAnchor={slices.length > 4 ? "end" : "middle"}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => String(l)}
|
||||
valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} ${unitLabel}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
name="موجودی"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={64}
|
||||
isAnimationActive={false}
|
||||
onClick={(entry: unknown) => {
|
||||
// Recharts ورودی ستون را در آرگومان اول میدهد
|
||||
const payload = (entry as { payload?: Slice })?.payload;
|
||||
if (!project && payload?.id) {
|
||||
setProject({ id: payload.id, name: payload.name });
|
||||
}
|
||||
}}
|
||||
className={project ? "" : "cursor-pointer"}
|
||||
>
|
||||
{slices.map((s) => (
|
||||
<Cell
|
||||
key={s.id ?? s.name}
|
||||
fill={project ? CHART_COLORS.accent : "#818cf8"}
|
||||
fillOpacity={project ? 1 : 0.9}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// ============================================================
|
||||
// ویجت ۳ — سری زمانی جریان روزانه
|
||||
// ستونهای روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایهی
|
||||
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
|
||||
// بهعلاوهی خط «مانده» (بالانس تجمعی). تاریخها شمسی، اعداد فارسی.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useDailyFlow } from "../../hooks/useDashboardData";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import {
|
||||
CHART_COLORS,
|
||||
ChartCard,
|
||||
ChartEmpty,
|
||||
ChartLoading,
|
||||
ChartTooltip,
|
||||
faAxisTick,
|
||||
} from "./chartShared";
|
||||
|
||||
interface Row {
|
||||
date: string;
|
||||
ورود: number;
|
||||
برگشت: number;
|
||||
"خروج سایت": number;
|
||||
"ضایعات و اتلاف": number;
|
||||
مانده: number;
|
||||
_total: number;
|
||||
}
|
||||
|
||||
export function FlowChart() {
|
||||
const query = useDailyFlow();
|
||||
|
||||
const rows: Row[] = useMemo(
|
||||
() =>
|
||||
(query.data?.days ?? []).map((d) => ({
|
||||
date: d.date,
|
||||
ورود: d.inbound,
|
||||
برگشت: d.returns,
|
||||
"خروج سایت": Math.max(0, d.outbound - d.waste),
|
||||
"ضایعات و اتلاف": d.waste,
|
||||
مانده: d.balance,
|
||||
_total: d.inbound + d.outbound + d.returns,
|
||||
})),
|
||||
[query.data],
|
||||
);
|
||||
|
||||
const hasAnyData = rows.some((r) => r._total > 0);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="جریان روزانه موجودی"
|
||||
sub="ورود، خروج (با لایهی قرمز ضایعات)، برگشت از سایت و ماندهی تجمعی — ۳۰ روز اخیر"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !hasAnyData ? (
|
||||
<ChartEmpty message="در این بازه جابهجایی موجودی ثبت نشده است" />
|
||||
) : (
|
||||
<div dir="ltr" className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ top: 12, right: 8, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
|
||||
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e9f0" }}
|
||||
minTickGap={18}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value: unknown) => (
|
||||
<span dir="rtl" className="text-[11px] text-muted">
|
||||
{String(value)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
{/* ستونهای کنار هم: ورود و برگشت */}
|
||||
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||
{/* ستون خروج: دو لایهی سته — بخش قرمز ضایعات داخل ستون خروج */}
|
||||
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="مانده"
|
||||
stroke={CHART_COLORS.balance}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// ============================================================
|
||||
// ویجت ۶ — شبکهی وضعیت سفارشهای در راه
|
||||
// جدول کمی سفارشهای باز: متریال و تأمینکننده | سفارششده | رسیده
|
||||
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
|
||||
// داده از /pm/on-the-way (با برچسب برند و مشخصات و فیلتر سراسری).
|
||||
// ============================================================
|
||||
|
||||
import { Truck } from "lucide-react";
|
||||
import { useInTransit } from "../../hooks/useDashboardData";
|
||||
import type { InTransitRow } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||
|
||||
const STATE_META: Record<
|
||||
InTransitRow["state"],
|
||||
{ label: string; cls: string; bar: string }
|
||||
> = {
|
||||
on_the_way: { label: "در راه", cls: "bg-amber-bg text-amber", bar: "bg-amber" },
|
||||
partially_received: { label: "تحویل جزئی", cls: "bg-blue-bg text-[#1d4ed8]", bar: "bg-[#2563eb]" },
|
||||
arrived: { label: "تحویل کامل", cls: "bg-green-bg text-green", bar: "bg-green" },
|
||||
};
|
||||
|
||||
function specLabel(specs: Record<string, string>): string {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||
}
|
||||
|
||||
function num(v: string): number {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export function InTransitGrid() {
|
||||
const query = useInTransit();
|
||||
const rows = query.data ?? [];
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Truck className="h-4 w-4 text-muted" />
|
||||
سفارشهای در راه — وضعیت تحویل
|
||||
</span>
|
||||
}
|
||||
sub="اقلام پذیرفتهشدهی خرید تا رسیدن کامل به انبار"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : rows.length === 0 ? (
|
||||
<ChartEmpty message="سفارش بازی در جریان نیست" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>متریال / تأمینکننده</TableHead>
|
||||
<TableHead className="text-end">سفارششده</TableHead>
|
||||
<TableHead className="text-end">رسیده تاکنون</TableHead>
|
||||
<TableHead className="min-w-[140px]">پیشرفت</TableHead>
|
||||
<TableHead>وضعیت</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => {
|
||||
const ordered = num(r.ordered_qty);
|
||||
const received = num(r.received_qty);
|
||||
const pct = ordered > 0 ? Math.min(100, Math.round((received / ordered) * 100)) : 0;
|
||||
const meta = STATE_META[r.state] ?? STATE_META.on_the_way;
|
||||
const specs = specLabel(r.specs);
|
||||
return (
|
||||
<TableRow key={r.item_decision_id}>
|
||||
<TableCell>
|
||||
<div className="text-[13px] font-bold">{r.material.name}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{r.supplier.name}
|
||||
{r.brand ? ` · ${r.brand.name}` : ""}
|
||||
</div>
|
||||
{specs && <div className="text-[10.5px] text-muted">{specs}</div>}
|
||||
{r.project && (
|
||||
<div className="mt-0.5 text-[10.5px] text-accent">{r.project.name}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{faNum(ordered.toLocaleString("en-US"))}
|
||||
<div className="text-[10px] font-normal text-muted">
|
||||
{r.unit?.name_fa || r.unit?.code}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{faNum(received.toLocaleString("en-US"))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-bg">
|
||||
<div
|
||||
className={cn("h-full rounded-full", meta.bar)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10.5px] tabular-nums text-muted">
|
||||
{faNum(pct)}٪
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={cn("px-2 py-0.5 text-[10.5px]", meta.cls)}>{meta.label}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// ============================================================
|
||||
// ویجت ۵ — KPI زمان تأمین (Lead Time)
|
||||
// میانگین روزهای «پذیرش رسمی سفارش → رسیدن فیزیکی به انبار»
|
||||
// از /quotes/stats (میانگین arrived_date − accepted_date).
|
||||
// ============================================================
|
||||
|
||||
import { Timer } from "lucide-react";
|
||||
import { useQuoteStats } from "../../hooks/useDashboardData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { ChartCard, ChartLoading } from "./chartShared";
|
||||
|
||||
export function LeadTimeKpiCard() {
|
||||
const query = useQuoteStats();
|
||||
const days = query.data?.average_lead_time_days;
|
||||
|
||||
return (
|
||||
<ChartCard title="میانگین زمان تأمین" sub="از پذیرش رسمی سفارش تا رسیدن فیزیکی به انبار">
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-bg text-accent">
|
||||
<Timer className="h-7 w-7" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[38px] font-extrabold leading-none tabular-nums text-ink">
|
||||
{days == null ? "—" : faNum(days)}
|
||||
</span>
|
||||
<span className="text-[14px] font-bold text-muted">روز</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted">
|
||||
{days == null
|
||||
? "هنوز سفارشی به مرحلهی رسیدن نرسیده است"
|
||||
: `بر پایهی سفارشهای رسیدهشده به انبار`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// ============================================================
|
||||
// ویجت ۷ — قیف تدارکات
|
||||
// تبدیل تجمعی: کل درخواستها (پیشنهادشده) → پذیرفتهشده → رسیده به انبار
|
||||
// از /quotes/stats.funnel (شمارش تجمعی بر پایهی تاریخهای واقعی).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { useQuoteStats } from "../../hooks/useDashboardData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared";
|
||||
|
||||
interface Step {
|
||||
name: string;
|
||||
value: number;
|
||||
fill: string;
|
||||
conversion: number | null;
|
||||
}
|
||||
|
||||
export function ProcurementFunnel() {
|
||||
const query = useQuoteStats();
|
||||
|
||||
const steps: Step[] = useMemo(() => {
|
||||
const f = query.data?.funnel;
|
||||
if (!f) return [];
|
||||
const offered = f.offered_total;
|
||||
return [
|
||||
{
|
||||
name: "کل درخواستها (پیشنهادشده)",
|
||||
value: f.offered_total,
|
||||
fill: CHART_COLORS.accent,
|
||||
conversion: null,
|
||||
},
|
||||
{
|
||||
name: "پذیرفتهشده",
|
||||
value: f.accepted_total,
|
||||
fill: CHART_COLORS.blue,
|
||||
conversion: offered > 0 ? Math.round((f.accepted_total / offered) * 100) : 0,
|
||||
},
|
||||
{
|
||||
name: "رسیده به انبار",
|
||||
value: f.arrived_total,
|
||||
fill: CHART_COLORS.green,
|
||||
conversion: offered > 0 ? Math.round((f.arrived_total / offered) * 100) : 0,
|
||||
},
|
||||
];
|
||||
}, [query.data]);
|
||||
|
||||
const total = steps[0]?.value ?? 0;
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="قیف تدارکات"
|
||||
sub="تبدیل درخواستها به سفارش پذیرفتهشده و رسیدن کالا به انبار"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : total === 0 ? (
|
||||
<ChartEmpty message="هنوز درخواستی ثبت نشده است" />
|
||||
) : (
|
||||
<div className="grid items-center gap-3 sm:grid-cols-5">
|
||||
<div dir="ltr" className="h-56 sm:col-span-3">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<FunnelChart>
|
||||
<Tooltip
|
||||
content={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => String(l)}
|
||||
valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} درخواست`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Funnel dataKey="value" data={steps} isAnimationActive={false}>
|
||||
{/* مقدار هر مرحله — با dataKey صریح تا Recharts مقدار درست بدهد */}
|
||||
<LabelList
|
||||
dataKey="value"
|
||||
position="right"
|
||||
fill="#0f172a"
|
||||
stroke="none"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
formatter={(v: unknown) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? faNum(n.toLocaleString("en-US")) : "";
|
||||
}}
|
||||
/>
|
||||
{/* نام مرحله داخل نوار */}
|
||||
<LabelList
|
||||
dataKey="name"
|
||||
position="left"
|
||||
fill="#ffffff"
|
||||
stroke="none"
|
||||
fontSize={11}
|
||||
formatter={(v: unknown) => (v == null ? "" : String(v))}
|
||||
/>
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* مراحل قیف + درصد تبدیل */}
|
||||
<ul className="flex flex-col gap-2 sm:col-span-2">
|
||||
{steps.map((s) => (
|
||||
<li key={s.name} className="rounded-xl border border-line px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} />
|
||||
<span className="text-[12px] font-bold">{s.name}</span>
|
||||
<span className="ms-auto text-[15px] font-extrabold tabular-nums">
|
||||
{faNum(s.value.toLocaleString("en-US"))}
|
||||
</span>
|
||||
</div>
|
||||
{s.conversion != null && (
|
||||
<div className="mt-0.5 text-[10.5px] text-muted">
|
||||
{faNum(s.conversion)}٪ از کل درخواستها
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// ============================================================
|
||||
// ویجت ۸ — تراکم پروژهها (Project Density)
|
||||
// نمودار میلهای افقی: توزیع بار تدارکات روی پروژههای فعال —
|
||||
// سفارشهای باز (ستهشده با پیشنهادهای باز).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useProcurementWorkload } from "../../hooks/useDashboardData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import {
|
||||
CHART_COLORS,
|
||||
ChartCard,
|
||||
ChartEmpty,
|
||||
ChartLoading,
|
||||
ChartTooltip,
|
||||
faAxisTick,
|
||||
} from "./chartShared";
|
||||
|
||||
export function ProjectDensityChart() {
|
||||
const query = useProcurementWorkload();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const projects = query.data?.projects ?? [];
|
||||
return projects
|
||||
.filter((p) => p.open_orders > 0 || p.offered_quotes > 0 || p.chosen_items > 0)
|
||||
.map((p) => ({
|
||||
name: p.name,
|
||||
"سفارش باز (در راه)": p.open_orders,
|
||||
"پیشنهاد باز": p.offered_quotes,
|
||||
_ordered: p.ordered_qty,
|
||||
_received: p.received_qty,
|
||||
}))
|
||||
.sort((a, b) => (b["سفارش باز (در راه)"] + b["پیشنهاد باز"]) - (a["سفارش باز (در راه)"] + a["پیشنهاد باز"]));
|
||||
}, [query.data]);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="تراکم بار تدارکات روی پروژهها"
|
||||
sub="تعداد سفارشهای باز و پیشنهادهای در جریان به تفکیک پروژه"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : rows.length === 0 ? (
|
||||
<ChartEmpty message="بار تدارکاتی فعالی روی پروژهها وجود ندارد" />
|
||||
) : (
|
||||
<div dir="ltr" className="w-full" style={{ height: Math.max(180, rows.length * 56 + 60) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={rows}
|
||||
layout="vertical"
|
||||
margin={{ top: 8, right: 16, left: 8, bottom: 4 }}
|
||||
barCategoryGap="28%"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={150}
|
||||
tick={{ fontSize: 11, fill: "#0f172a", fontFamily: "Vazirmatn" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => String(l)}
|
||||
valueFormatter={(v) => `${faNum(v)} مورد`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={26}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value: unknown) => (
|
||||
<span dir="rtl" className="text-[11px] text-muted">
|
||||
{String(value)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="سفارش باز (در راه)"
|
||||
stackId="load"
|
||||
fill={CHART_COLORS.blue}
|
||||
maxBarSize={22}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="پیشنهاد باز"
|
||||
stackId="load"
|
||||
fill={CHART_COLORS.amber}
|
||||
radius={[0, 4, 4, 0]}
|
||||
maxBarSize={22}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// ============================================================
|
||||
// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ RTL
|
||||
// همهی نمودارهای داشبورد از این قالب استفاده میکنند تا خروجی
|
||||
// Recharts فارسی و هماهنگ با دیزاینسیستم (Tailwind @theme) باشد.
|
||||
// ============================================================
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// پالت معنایی نمودارها — همراستا با توکنهای @theme در globals.css
|
||||
export const CHART_COLORS = {
|
||||
inbound: "#16a34a", // ورود = سبز
|
||||
outbound: "#2563eb", // خروج = آبی
|
||||
waste: "#dc2626", // ضایعات/اتلاف/مفقودی = قرمز (لایهی داخل ستون خروج)
|
||||
returns: "#d97706", // برگشت از سایت = کهربایی
|
||||
balance: "#0f172a", // مانده = خط سرمهای نقطهچین
|
||||
accent: "#4f46e5", // ایندیگو — رنگ اصلی سیستم
|
||||
amber: "#d97706",
|
||||
green: "#16a34a",
|
||||
red: "#dc2626",
|
||||
blue: "#2563eb",
|
||||
} as const;
|
||||
|
||||
// عدد → رقم فارسی با جداکنندهی هزارگان (تیک محورها)
|
||||
export function faAxisTick(value: unknown): string {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return "";
|
||||
if (Number.isInteger(n) && Math.abs(n) < 1000) return faNum(n);
|
||||
return groupCommas(n, true);
|
||||
}
|
||||
|
||||
// ISO (YYYY-MM-DD) → تاریخ شمسی کوتاه برای محور X
|
||||
export function jaAxisDate(iso: unknown): string {
|
||||
const j = isoToJalaliStr(typeof iso === "string" ? iso : String(iso ?? ""));
|
||||
return j || String(iso ?? "");
|
||||
}
|
||||
|
||||
// ——— تولتیپ سفارشی RTL — اعداد فارسی و نامهای فارسی سریها ———
|
||||
interface TooltipEntry {
|
||||
name?: string | number;
|
||||
value?: string | number;
|
||||
color?: string;
|
||||
dataKey?: string | number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipEntry[];
|
||||
label?: string | number;
|
||||
labelFormatter?: (label: string | number) => ReactNode;
|
||||
valueFormatter?: (value: number, name: string) => string;
|
||||
}
|
||||
|
||||
export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter }: TooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
// ردیفهای صفر را از تولتیپ حذف میکنیم — شلوغیِ بیمعنا (اصل Power BI)
|
||||
const rows = payload.filter((p) => {
|
||||
const v = typeof p.value === "number" ? p.value : Number(p.value);
|
||||
return Number.isFinite(v) && v !== 0;
|
||||
});
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
{label !== undefined && label !== "" && (
|
||||
<div className="mb-1 text-[11px] font-bold text-muted">
|
||||
{labelFormatter ? labelFormatter(label) : String(label)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{rows.map((p, i) => {
|
||||
const v = typeof p.value === "number" ? p.value : Number(p.value);
|
||||
const name = String(p.name ?? p.dataKey ?? "");
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 text-[12px] leading-5">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: p.color }} />
|
||||
<span className="text-muted">{name}</span>
|
||||
<span className="ms-auto font-bold tabular-nums text-ink">
|
||||
{valueFormatter ? valueFormatter(v, name) : groupCommas(v, true)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ——— کارت نگهدارندهی ویجت — عنوان + توضیح + اکشن ———
|
||||
export function ChartCard({
|
||||
title,
|
||||
sub,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
sub?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("rounded-2xl border border-line bg-panel p-4", className)}>
|
||||
<header className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[14px] font-extrabold leading-6">{title}</h2>
|
||||
{sub && <p className="text-[11px] text-muted">{sub}</p>}
|
||||
</div>
|
||||
{actions && <div className="ms-auto flex items-center gap-1.5">{actions}</div>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ——— حالت خالی/خطای ویجت ———
|
||||
export function ChartEmpty({ message = "دادهای برای نمایش وجود ندارد" }: { message?: string }) {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center rounded-xl border border-dashed border-line text-[12px] text-muted">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartLoading() {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center text-[12px] text-muted">
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ——— چیپ انتخاب واحد (وقتی مجموعهای با واحدهای ناسازگار نمایش داده میشود) ———
|
||||
export function UnitPicker({
|
||||
units,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
units: Record<string, string>;
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
}) {
|
||||
const codes = Object.keys(units);
|
||||
if (codes.length <= 1) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{codes.map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => onChange(code)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-[11px] font-semibold transition-colors",
|
||||
value === code
|
||||
? "bg-accent text-white"
|
||||
: "bg-slate-bg text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{units[code] || code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// ============================================================
|
||||
// هوکهای TanStack Query برای داشبورد MVP
|
||||
// همهی ویجتها مستقیم به API واقعی وصلاند — بدون هیچ دادهی ساختگی.
|
||||
// کلیدهای کوئری شامل پارامترهای فیلتر «چه چیزی» هستند تا با هر
|
||||
// تغییر فیلتر، فقط ویجتهای همان تب دوباره خوانده شوند.
|
||||
// ============================================================
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiV26 } from "../lib/api";
|
||||
import type { DashboardFilterParams } from "../lib/api";
|
||||
import { dashboardFilterParams, useDashboardFilter } from "../store/dashboardFilter";
|
||||
|
||||
function useFilterParams(): DashboardFilterParams {
|
||||
const state = useDashboardFilter();
|
||||
return dashboardFilterParams(state);
|
||||
}
|
||||
|
||||
// گزینههای آبشاری فیلتر سراسری (دسته → متریال → برند → مشخصات)
|
||||
export function useDashboardFilterOptions() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "filters"],
|
||||
queryFn: () => apiV26.dashboard.filters(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
|
||||
export function useDrilldown() {
|
||||
const params = useFilterParams();
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "drilldown", params],
|
||||
queryFn: () => apiV26.dashboard.drilldown(params),
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۲ — موجودی راکد (dead stock)
|
||||
export function useDeadStock(idleDays: number) {
|
||||
const params = useFilterParams();
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "dead-stock", idleDays, params],
|
||||
queryFn: () => apiV26.dashboard.deadStock({ ...params, idle_days: idleDays }),
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۳ — سری زمانی جریان روزانه
|
||||
export function useDailyFlow(dateFrom?: string, dateTo?: string) {
|
||||
const params = useFilterParams();
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
||||
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازهسازی میشود
|
||||
export function useDailyControl(date?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "daily-control", date],
|
||||
queryFn: () => apiV26.dashboard.dailyControl(date ? { date } : {}),
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۵ + ۷ — KPI زمان تأمین و قیف تدارکات (/quotes/stats)
|
||||
export function useQuoteStats() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "quote-stats"],
|
||||
queryFn: () => apiV26.dashboard.quoteStats(),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۶ — کالاهای در راه (با فیلتر متریال/برند سراسری)
|
||||
export function useInTransit() {
|
||||
const state = useDashboardFilter();
|
||||
const params = dashboardFilterParams(state);
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "in-transit", params],
|
||||
queryFn: () =>
|
||||
apiV26.pm.onTheWay(null, { material_id: params.material_id, brand_id: params.brand_id }),
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۸ — بار تدارکات روی پروژهها
|
||||
export function useProcurementWorkload() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "workload"],
|
||||
queryFn: () => apiV26.dashboard.workload(),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// ============================================================
|
||||
// داشبورد MVP — نمای «انبار» و نمای «بازرگانی و لجستیک»
|
||||
// فیلتر سراسری «چه چیزی» بالای هر دو نما (Zustand — با جابهجایی
|
||||
// بین تبها حفظ میشود). تمام ویجتها مستقیم به API واقعی وصلاند.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { BarChart3, Warehouse } from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { DashboardFilterBar } from "../../components/dashboard/DashboardFilterBar";
|
||||
import { DrilldownMatrix } from "../../components/dashboard/DrilldownMatrix";
|
||||
import { FlowChart } from "../../components/dashboard/FlowChart";
|
||||
import { DeadStockTable } from "../../components/dashboard/DeadStockTable";
|
||||
import { DailyControlPanel } from "../../components/dashboard/DailyControlPanel";
|
||||
import { LeadTimeKpiCard } from "../../components/dashboard/LeadTimeKpiCard";
|
||||
import { InTransitGrid } from "../../components/dashboard/InTransitGrid";
|
||||
import { ProcurementFunnel } from "../../components/dashboard/ProcurementFunnel";
|
||||
import { ProjectDensityChart } from "../../components/dashboard/ProjectDensityChart";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type Tab = "warehouse" | "procurement";
|
||||
|
||||
const TABS: { key: Tab; label: string; icon: typeof Warehouse }[] = [
|
||||
{ key: "warehouse", label: "داشبورد انبار", icon: Warehouse },
|
||||
{ key: "procurement", label: "داشبورد بازرگانی و لجستیک", icon: BarChart3 },
|
||||
];
|
||||
|
||||
export function MvpDashboardPage() {
|
||||
const [tab, setTab] = useState<Tab>("warehouse");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-20 md:pb-6">
|
||||
<Header
|
||||
title="داشبورد تحلیلی"
|
||||
sub="انبار و تدارکات — فیلتر جنس روی همهی ویجتها اعمال میشود"
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
actions={
|
||||
/* تبهای نما — سگمنتکنترل هماهنگ با دیزاینسیستم */
|
||||
<div className="flex items-center gap-1 rounded-xl border border-line bg-slate-bg p-1">
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setTab(key)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-bold transition-all",
|
||||
tab === key
|
||||
? "bg-panel text-accent shadow-sm"
|
||||
: "text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||
{/* فیلتر سراسری «چه چیزی» — مشترک بین دو نما */}
|
||||
<DashboardFilterBar />
|
||||
|
||||
{tab === "warehouse" ? (
|
||||
/* ——— نمای A: انبار ——— */
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
|
||||
<DrilldownMatrix />
|
||||
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<FlowChart />
|
||||
<DailyControlPanel />
|
||||
</div>
|
||||
{/* ردیف سوم: موجودی راکد */}
|
||||
<DeadStockTable />
|
||||
</div>
|
||||
) : (
|
||||
/* ——— نمای B: بازرگانی و لجستیک ——— */
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* KPI + قیف */}
|
||||
<div className="grid gap-3 xl:grid-cols-3">
|
||||
<LeadTimeKpiCard />
|
||||
<div className="xl:col-span-2">
|
||||
<ProcurementFunnel />
|
||||
</div>
|
||||
</div>
|
||||
{/* تراکم پروژهها + سفارشهای در راه */}
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<ProjectDensityChart />
|
||||
<InTransitGrid />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// ============================================================
|
||||
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
||||
// دستهبندی → متریال → برند → مشخصات فنی
|
||||
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
||||
// ============================================================
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface DashboardFilterState {
|
||||
categoryId: string | null; // null = همهی دستهها
|
||||
materialId: string | null;
|
||||
brandId: string | null;
|
||||
specsHash: string | null;
|
||||
// برچسبهای انتخابشده برای نمایش چیپ فعال
|
||||
categoryLabel: string | null;
|
||||
materialLabel: string | null;
|
||||
brandLabel: string | null;
|
||||
specsLabel: string | null;
|
||||
|
||||
setCategory: (id: string | null, label: string | null) => void;
|
||||
setMaterial: (id: string | null, label: string | null) => void;
|
||||
setBrand: (id: string | null, label: string | null) => void;
|
||||
setSpecs: (hash: string | null, label: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const INITIAL = {
|
||||
categoryId: null,
|
||||
materialId: null,
|
||||
brandId: null,
|
||||
specsHash: null,
|
||||
categoryLabel: null,
|
||||
materialLabel: null,
|
||||
brandLabel: null,
|
||||
specsLabel: null,
|
||||
};
|
||||
|
||||
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
||||
...INITIAL,
|
||||
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند
|
||||
setCategory: (id, label) =>
|
||||
set({
|
||||
categoryId: id,
|
||||
categoryLabel: label,
|
||||
materialId: null,
|
||||
materialLabel: null,
|
||||
brandId: null,
|
||||
brandLabel: null,
|
||||
specsHash: null,
|
||||
specsLabel: null,
|
||||
}),
|
||||
setMaterial: (id, label) =>
|
||||
set({
|
||||
materialId: id,
|
||||
materialLabel: label,
|
||||
brandId: null,
|
||||
brandLabel: null,
|
||||
specsHash: null,
|
||||
specsLabel: null,
|
||||
}),
|
||||
setBrand: (id, label) =>
|
||||
set({
|
||||
brandId: id,
|
||||
brandLabel: label,
|
||||
specsHash: null,
|
||||
specsLabel: null,
|
||||
}),
|
||||
setSpecs: (hash, label) => set({ specsHash: hash, specsLabel: label }),
|
||||
reset: () => set({ ...INITIAL }),
|
||||
}));
|
||||
|
||||
// پارامترهای آماده برای endpointهای داشبورد
|
||||
export function dashboardFilterParams(s: DashboardFilterState) {
|
||||
return {
|
||||
category_id: s.categoryId,
|
||||
material_id: s.materialId,
|
||||
brand_id: s.brandId,
|
||||
specs_hash: s.specsHash,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user