update FlowChart
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
// ============================================================
|
||||
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
|
||||
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور، تکمتریالی)
|
||||
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
|
||||
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
|
||||
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
|
||||
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
|
||||
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||
// روی مسیر واقعی مانده مینشینند، پس ستون دقیقاً پلهی خط stepAfter را میسازد.
|
||||
//
|
||||
// قواعد کسبوکار:
|
||||
// • جمعزدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بیمعناست؛ پس
|
||||
// بدون انتخاب متریال، نمودار رندر نمیشود و بهجایش انتخاب سریع متریال میآید.
|
||||
// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) میتواند چند «گروه» (مشخصات + برند)
|
||||
// داشته باشد؛ هر گروه یک پلهی روشنتر از خانوادهی رنگ همان دسته میگیرد و
|
||||
// سگمنتها یک ستون مشترک با جداکنندهی سفید ۱px میسازند.
|
||||
// • همهی موجودیتها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق میشوند.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
@@ -13,151 +18,247 @@ import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useDailyFlow } from "../../hooks/useDashboardData";
|
||||
import { Package } from "lucide-react";
|
||||
import { useDailyFlow, useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
||||
import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types";
|
||||
|
||||
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
|
||||
const FLOW_COLORS = {
|
||||
inbound: "#059669", // ورود به انبار — زمردی
|
||||
returns: "#0d9488", // برگشت از سایت — تیل
|
||||
siteOut: "#f59e0b", // خروج به سایت — کهربایی
|
||||
waste: "#dc2626", // ضایعات و اتلاف — قرمز
|
||||
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
|
||||
balance: "#0f172a", // خط مانده — سرمهای
|
||||
} as const;
|
||||
// ——— چیدمان ———
|
||||
const FLOW_STACK_ID = "flow-waterfall";
|
||||
const MAX_BAR_SIZE = 20;
|
||||
const BAR_CATEGORY_GAP = "18%";
|
||||
const DOMAIN_PAD_RATIO = 0.1; // ۱۰٪ سرصفحه بالا/پایین دامنه
|
||||
const OTHER_RESIDUAL_KEY = "__adjust__";
|
||||
|
||||
const CATEGORY_ORDER: FlowCategory[] = ["inflow", "returns", "siteOut", "waste", "other"];
|
||||
|
||||
// خانوادهی رنگی هر دسته (HSL پایه) + جهت علامت در مسیر مانده
|
||||
const CATEGORY_META: Record<
|
||||
FlowCategory,
|
||||
{ label: string; sign: 1 | -1; base: { h: number; s: number; l: number } }
|
||||
> = {
|
||||
inflow: { label: "ورود به انبار", sign: 1, base: { h: 160, s: 84, l: 39 } }, // زمردی
|
||||
returns: { label: "برگشت از سایت", sign: 1, base: { h: 175, s: 77, l: 41 } }, // تیل
|
||||
siteOut: { label: "خروج به سایت", sign: -1, base: { h: 35, s: 92, l: 47 } }, // کهربایی
|
||||
waste: { label: "ضایعات و اتلاف", sign: -1, base: { h: 0, s: 72, l: 51 } }, // قرمز
|
||||
other: { label: "سایر حرکات", sign: 1, base: { h: 215, s: 16, l: 55 } }, // خاکستری
|
||||
};
|
||||
|
||||
// تنالیتهی گروههای یک دسته: هر گروه (مشخصات+برند) یک پله روشنتر
|
||||
const SHADE_STEP_L = 9;
|
||||
const SHADE_MAX_L = 78;
|
||||
function categoryColor(category: FlowCategory, shadeIndex: number): string {
|
||||
const { h, s, l } = CATEGORY_META[category].base;
|
||||
const lightness = Math.min(SHADE_MAX_L, l + shadeIndex * SHADE_STEP_L);
|
||||
return `hsl(${h} ${s}% ${lightness}%)`;
|
||||
}
|
||||
|
||||
// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX)
|
||||
const LABELS = {
|
||||
title: "جریان روزانه موجودی",
|
||||
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
|
||||
inbound: "ورود به انبار",
|
||||
returns: "برگشت از سایت",
|
||||
siteOut: "خروج به سایت",
|
||||
waste: "ضایعات و اتلاف",
|
||||
other: "سایر حرکات (انتقال/شمارش)",
|
||||
balance: "مانده",
|
||||
needMaterial:
|
||||
"برای مشاهدهی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.",
|
||||
quickPick: "انتخاب سریع متریال",
|
||||
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||
opening: "مانده ابتدای روز",
|
||||
net: "تغییر خالص روز",
|
||||
closing: "مانده پایان روز",
|
||||
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||
unit: "واحد",
|
||||
brand: "برند",
|
||||
warehouse: "انبار",
|
||||
noSpecs: "بدون مشخصه",
|
||||
aggregate: "کل دسته",
|
||||
adjustment: "تعدیل حساب",
|
||||
shadeHint: "طیف روشنتر هر رنگ = برند/مشخصات دیگر در همان روز",
|
||||
error: "دریافت دادهی جریان روزانه ناموفق بود",
|
||||
errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.",
|
||||
} as const;
|
||||
|
||||
// سگمنت شناور: [پایین، بالا] — همان چیزی که <Bar> بهعنوان مقدار محدوده میخواند
|
||||
type Range = [number, number];
|
||||
// ——— مدل داده ———
|
||||
type Range = [number, number]; // [پایین، بالا] — سگمنت شناور <Bar>
|
||||
|
||||
interface CatItem {
|
||||
key: string; // هویت گروه: مشخصات|برند
|
||||
specLabel: string;
|
||||
brand: string | null;
|
||||
warehouse: string;
|
||||
quantity: number; // چهار دسته: قدر مطلق — «سایر»: علامتدار
|
||||
}
|
||||
|
||||
interface CatGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
brand: string | null;
|
||||
quantity: number; // مجموع گروه (برای «سایر» علامتدار)
|
||||
items: CatItem[]; // ریزردیفها برای تولتیپ
|
||||
}
|
||||
|
||||
type CatMap = Record<FlowCategory, CatGroup[]>;
|
||||
|
||||
// یک ردیف زمانی — سگمنتها بهصورت پراپرتیهای مسطح مینشینند تا dataKey هر
|
||||
// <Bar> مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن).
|
||||
interface FlowRow {
|
||||
date: string;
|
||||
balance: number; // خط پلهای مانده = مانده پایان روز
|
||||
opening: number;
|
||||
closing: number;
|
||||
net: number;
|
||||
inbound: number;
|
||||
returns: number;
|
||||
siteOut: number;
|
||||
waste: number;
|
||||
other: number;
|
||||
balance: number;
|
||||
groups: CatMap; // متادیتای تولتیپ (dataKey نیست)
|
||||
hasMovement: boolean;
|
||||
segInbound: Range;
|
||||
segReturns: Range;
|
||||
segSiteOut: Range;
|
||||
segWaste: Range;
|
||||
segOther: Range;
|
||||
// سگمنتها با کلیدهای مسطح seg_<category>_<shadeIndex>
|
||||
[segmentKey: string]: unknown;
|
||||
}
|
||||
|
||||
// گرد کردن تا ۴ رقم اعشار — مثل بقیهی تجمیعهای داشبورد
|
||||
const round4 = (v: number) => Math.round(v * 10000) / 10000;
|
||||
|
||||
// حاشیهی دامنهی محور Y (نسبت به بازهی داده) تا لبهها به قاب نچسبند
|
||||
const DOMAIN_PAD_RATIO = 0.04;
|
||||
const MAX_BAR_SIZE = 20; // حداکثر عرض ستون (px) — عرض واقعی از باند محور میآید
|
||||
const BAR_CATEGORY_GAP = "18%";
|
||||
// همهی سگمنتها یک ستون مشترک میسازند (نه چند ستون باریک کنار هم)؛ چون
|
||||
// مقدار هر سگمنت [پایین، بالا] است، Recharts آن را بدون انبارش (stacking)
|
||||
// دقیقاً در همان ارتفاع شناور رسم میکند.
|
||||
const FLOW_STACK_ID = "flow-waterfall";
|
||||
|
||||
// عدد با علامت برای «تغییر خالص» — ارقام فارسی
|
||||
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
|
||||
|
||||
// تولتیپ تحلیلی روز — مانده ابتدا/پایان، اجزای روز، تغییر خالص
|
||||
// خطای شبکه/سرور باید صریح دیده شود — هرگز نباید «دادهای ثبت نشده» تلقّی شود
|
||||
function FlowError() {
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="flex h-40 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-red/30 bg-red-bg/40 px-4 text-center"
|
||||
>
|
||||
<span className="text-[12px] font-bold text-red">{LABELS.error}</span>
|
||||
<span className="max-w-md text-[11px] leading-5 text-muted">{LABELS.errorHint}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// برچسب مشخصات فنی: «کلید: مقدار، …» یا «بدون مشخصه»
|
||||
function specLabel(specs: Record<string, string> | null): string {
|
||||
const parts = Object.entries(specs ?? {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : LABELS.noSpecs;
|
||||
}
|
||||
|
||||
// ——— تولتیپ تحلیلی RTL ———
|
||||
function FlowTooltip({
|
||||
active,
|
||||
payload,
|
||||
unitLabel,
|
||||
materialName,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: readonly { payload?: FlowRow }[];
|
||||
unitLabel: string;
|
||||
materialName: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0]?.payload;
|
||||
if (!row) return null;
|
||||
/** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */
|
||||
const lines: { label: string; value: number; className: string; signed?: boolean }[] = [
|
||||
{ label: LABELS.opening, value: row.opening, className: "text-slate-600" },
|
||||
{ label: LABELS.inbound, value: row.inbound, className: "text-slate-700" },
|
||||
{ label: LABELS.returns, value: row.returns, className: "text-slate-700" },
|
||||
{ label: LABELS.siteOut, value: -row.siteOut, className: "text-slate-700" },
|
||||
{ label: LABELS.waste, value: -row.waste, className: "text-slate-700" },
|
||||
];
|
||||
if (row.other !== 0) {
|
||||
lines.push({ label: LABELS.other, value: row.other, className: "text-slate-700" });
|
||||
}
|
||||
const withUnit = (value: string) => (unitLabel ? `${value} ${unitLabel}` : value);
|
||||
const netTone =
|
||||
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="min-w-max rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md"
|
||||
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[380px] sm:whitespace-nowrap"
|
||||
>
|
||||
<div className="whitespace-nowrap text-xs font-bold text-slate-800">
|
||||
{isoToJalaliStr(row.date) ?? row.date}
|
||||
</div>
|
||||
<div className="my-2 border-b border-slate-100" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{lines.map((line) => (
|
||||
<div key={line.label} className="flex items-center justify-between gap-6">
|
||||
<span className="whitespace-nowrap text-[11px] text-slate-500 sm:text-xs">
|
||||
{line.label}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`whitespace-nowrap text-[11px] font-medium tabular-nums sm:text-xs ${line.className}`}
|
||||
>
|
||||
{line.value === 0 ? fmtNum(0) : signed(line.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-0.5 border-b border-slate-100" />
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
|
||||
{LABELS.net}
|
||||
{/* سربرگ: تاریخ + متریال + نشان واحد */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
{isoToJalaliStr(row.date) ?? row.date}
|
||||
</span>
|
||||
{materialName && <span className="text-[11px] text-slate-500">{materialName}</span>}
|
||||
{unitLabel && (
|
||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500">
|
||||
{unitLabel}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
|
||||
>
|
||||
{signed(row.net)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="my-2 border-b border-slate-100" />
|
||||
|
||||
{/* مانده ابتدای روز */}
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="text-[11px] text-slate-500 sm:text-xs">{LABELS.opening}</span>
|
||||
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||
{withUnit(fmtNum(row.opening))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="my-2 border-b border-slate-100" />
|
||||
|
||||
{/* دستهها + ریزردیف مشخصات/برند/انبار */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const groups = row.groups[category] ?? [];
|
||||
if (groups.length === 0) return null;
|
||||
const total = round4(
|
||||
groups.reduce((sum, group) => sum + group.quantity, 0) *
|
||||
CATEGORY_META[category].sign,
|
||||
);
|
||||
if (total === 0) return null;
|
||||
return (
|
||||
<div key={category} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="flex items-center gap-2 text-[11px] font-semibold text-slate-700 sm:text-xs">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ background: categoryColor(category, 0) }}
|
||||
/>
|
||||
{CATEGORY_META[category].label}
|
||||
</span>
|
||||
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||
{withUnit(signed(total))}
|
||||
</span>
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<div key={group.key} className="flex flex-col">
|
||||
{group.items.map((item) => (
|
||||
<div
|
||||
key={`${group.key}|${item.warehouse}|${item.specLabel}`}
|
||||
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-0.5 border-s border-slate-100 ps-3 text-[10.5px] text-slate-500 sm:text-[11px]"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<span aria-hidden>↳</span>
|
||||
<span>{item.specLabel}</span>
|
||||
{item.brand && (
|
||||
<>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span>
|
||||
{LABELS.brand}: {item.brand}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.warehouse && (
|
||||
<span className="text-slate-400">
|
||||
({LABELS.warehouse}: {item.warehouse})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span dir="ltr" className="font-medium tabular-nums text-slate-600">
|
||||
{withUnit(signed(round4(item.quantity * CATEGORY_META[category].sign)))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="my-2 border-b border-slate-100" />
|
||||
|
||||
{/* جمعبندی */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.net}</span>
|
||||
<span dir="ltr" className={`text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}>
|
||||
{withUnit(signed(row.net))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
|
||||
{LABELS.closing}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs"
|
||||
>
|
||||
{fmtNum(row.closing)}
|
||||
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.closing}</span>
|
||||
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||
{withUnit(fmtNum(row.closing))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,65 +266,188 @@ function FlowTooltip({
|
||||
);
|
||||
}
|
||||
|
||||
// ——— نرمالسازی یک روز: گروهبندی ریزردیفها بر اساس (دسته، مشخصات+برند) ———
|
||||
function dayGroups(day: FlowDay): CatMap {
|
||||
const groups: CatMap = { inflow: [], returns: [], siteOut: [], waste: [], other: [] };
|
||||
const insert = (category: FlowCategory, item: CatItem) => {
|
||||
const existing = groups[category].find((group) => group.key === item.key);
|
||||
if (existing) {
|
||||
existing.quantity = round4(existing.quantity + item.quantity);
|
||||
existing.items.push(item);
|
||||
} else {
|
||||
groups[category].push({
|
||||
key: item.key,
|
||||
label: item.specLabel,
|
||||
brand: item.brand,
|
||||
quantity: item.quantity,
|
||||
items: [item],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const breakdown: FlowBreakdownItem[] = day.breakdown ?? [];
|
||||
if (breakdown.length > 0) {
|
||||
breakdown.forEach((item) => {
|
||||
// چهار سبد نمایشی همیشه مثبت نشان داده میشوند؛ «سایر» علامت دفتر را نگه میدارد
|
||||
const quantity = item.category === "other" ? item.quantity : Math.abs(item.quantity);
|
||||
if (quantity === 0) return;
|
||||
insert(item.category, {
|
||||
key: `${JSON.stringify(item.specs ?? {})}|${item.brand ?? ""}`,
|
||||
specLabel: specLabel(item.specs),
|
||||
brand: item.brand,
|
||||
warehouse: item.warehouse,
|
||||
quantity: round4(quantity),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// سازگاری عقبرو: اگر شکست چندبعدی در payload نباشد، یک گروهِ کلِ دسته ساخته میشود
|
||||
const aggregates: [FlowCategory, number][] = [
|
||||
["inflow", day.inbound],
|
||||
["returns", day.returns],
|
||||
["siteOut", Math.max(0, day.outbound - day.waste)],
|
||||
["waste", day.waste],
|
||||
];
|
||||
aggregates.forEach(([category, quantity]) => {
|
||||
if (quantity > 0) {
|
||||
insert(category, {
|
||||
key: `${category}-all`,
|
||||
specLabel: LABELS.aggregate,
|
||||
brand: null,
|
||||
warehouse: "",
|
||||
quantity: round4(quantity),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function FlowChart() {
|
||||
const query = useDailyFlow();
|
||||
const filter = useDashboardFilter();
|
||||
// بدون متریال، کوئری اصلاً اجرا نمیشود (جلوگیری از تجمیع واحدهای ناسازگار)
|
||||
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId));
|
||||
const options = useDashboardFilterOptions();
|
||||
|
||||
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار میشود ———
|
||||
const rows: FlowRow[] = useMemo(() => {
|
||||
const materials = options.data?.materials ?? [];
|
||||
const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null;
|
||||
const materialName = selectedMaterial?.name ?? filter.materialLabel ?? "";
|
||||
const unitLabel =
|
||||
query.data?.unit?.name_fa ??
|
||||
query.data?.unit?.code ??
|
||||
selectedMaterial?.unit?.name_fa ??
|
||||
selectedMaterial?.unit?.code ??
|
||||
"";
|
||||
|
||||
// ——— همسازماندهی روزها + سگمنتهای شناور ———
|
||||
const { rows, groupCounts, hasMovement } = useMemo(() => {
|
||||
const days = query.data?.days ?? [];
|
||||
let running = query.data?.opening_balance ?? 0;
|
||||
return days.map((d) => {
|
||||
const opening = running;
|
||||
const closing = d.balance;
|
||||
const waste = Math.max(0, d.waste);
|
||||
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته میشود
|
||||
const siteOut = Math.max(0, d.outbound - waste);
|
||||
let opening = query.data?.opening_balance ?? 0;
|
||||
|
||||
// گذر اول: نرمالسازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند)
|
||||
const normalized = days.map((day) => {
|
||||
const closing = day.balance;
|
||||
const net = round4(closing - opening);
|
||||
// «سایر حرکات» = باقیماندهی تراز (انتقال/شمارش/یافتشده)؛ معمولاً صفر
|
||||
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
|
||||
const groups = dayGroups(day);
|
||||
const categorized = round4(
|
||||
CATEGORY_ORDER.reduce((sum, category) => {
|
||||
const categoryTotal = groups[category].reduce(
|
||||
(groupSum, group) => groupSum + group.quantity,
|
||||
0,
|
||||
);
|
||||
return sum + categoryTotal * CATEGORY_META[category].sign;
|
||||
}, 0),
|
||||
);
|
||||
const residual = round4(net - categorized);
|
||||
if (residual !== 0) {
|
||||
groups.other.push({
|
||||
key: OTHER_RESIDUAL_KEY,
|
||||
label: LABELS.adjustment,
|
||||
brand: null,
|
||||
quantity: residual,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
const normalizedDay = { day, opening, closing, net, groups };
|
||||
opening = closing;
|
||||
return normalizedDay;
|
||||
});
|
||||
|
||||
let cursor = opening;
|
||||
// هر سگمنت = [کمترین، بیشترین] از مقادیر قبل و بعد از اعمال همان جزء
|
||||
const segment = (delta: number): Range => {
|
||||
const from = cursor;
|
||||
cursor = round4(cursor + delta);
|
||||
return [Math.min(from, cursor), Math.max(from, cursor)];
|
||||
};
|
||||
const segInbound = segment(d.inbound);
|
||||
const segReturns = segment(d.returns);
|
||||
const segSiteOut = segment(-siteOut);
|
||||
const segWaste = segment(-waste);
|
||||
const segOther = segment(other);
|
||||
// ترتیب گروهها (و در نتیجه پلهی رنگ) در کل بازه ثابت میماند
|
||||
const totalByCategory = new Map<FlowCategory, Map<string, number>>();
|
||||
normalized.forEach(({ groups }) => {
|
||||
CATEGORY_ORDER.forEach((category) => {
|
||||
const perCategory = totalByCategory.get(category) ?? new Map<string, number>();
|
||||
groups[category].forEach((group) => {
|
||||
perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity)));
|
||||
});
|
||||
totalByCategory.set(category, perCategory);
|
||||
});
|
||||
});
|
||||
|
||||
running = closing;
|
||||
return {
|
||||
date: d.date,
|
||||
balance: closing,
|
||||
opening,
|
||||
const orderByCategory = new Map<FlowCategory, string[]>();
|
||||
const groupCounts: Record<FlowCategory, number> = {
|
||||
inflow: 0,
|
||||
returns: 0,
|
||||
siteOut: 0,
|
||||
waste: 0,
|
||||
other: 0,
|
||||
};
|
||||
CATEGORY_ORDER.forEach((category) => {
|
||||
const orderedKeys = [...(totalByCategory.get(category) ?? new Map<string, number>()).entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([key]) => key);
|
||||
orderByCategory.set(category, orderedKeys);
|
||||
groupCounts[category] = orderedKeys.length;
|
||||
});
|
||||
|
||||
// گذر دوم: هر گروه یک سگمنت [پایین، بالا] روی مسیر مانده میگیرد
|
||||
const rows: FlowRow[] = normalized.map(({ day, opening: dayOpening, closing, net, groups }) => {
|
||||
const row: FlowRow = {
|
||||
date: day.date,
|
||||
opening: dayOpening,
|
||||
closing,
|
||||
net,
|
||||
inbound: d.inbound,
|
||||
returns: d.returns,
|
||||
siteOut,
|
||||
waste,
|
||||
other,
|
||||
hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0,
|
||||
segInbound,
|
||||
segReturns,
|
||||
segSiteOut,
|
||||
segWaste,
|
||||
segOther,
|
||||
balance: closing,
|
||||
groups,
|
||||
hasMovement: false,
|
||||
};
|
||||
let cursor = dayOpening;
|
||||
CATEGORY_ORDER.forEach((category) => {
|
||||
const orderedKeys = orderByCategory.get(category) ?? [];
|
||||
const sign = CATEGORY_META[category].sign;
|
||||
orderedKeys.forEach((key, index) => {
|
||||
const group = groups[category].find((item) => item.key === key);
|
||||
let range: Range = [cursor, cursor];
|
||||
if (group && group.quantity !== 0) {
|
||||
const from = cursor;
|
||||
cursor = round4(cursor + group.quantity * sign);
|
||||
range = [Math.min(from, cursor), Math.max(from, cursor)];
|
||||
row.hasMovement = true;
|
||||
}
|
||||
row[`seg_${category}_${index}`] = range;
|
||||
});
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
groupCounts,
|
||||
hasMovement: rows.some((row) => row.hasMovement || row.net !== 0),
|
||||
};
|
||||
}, [query.data]);
|
||||
|
||||
// ——— دامنهی محور Y از خود داده (نه از صفر) تا ستونها و خط در کادر جا شوند ———
|
||||
// ——— دامنهی محور Y با ۱۰٪ سرصفحه ———
|
||||
const [domainMin, domainMax] = useMemo(() => {
|
||||
const values: number[] = [];
|
||||
rows.forEach((r) => {
|
||||
values.push(r.balance, r.opening, r.closing);
|
||||
[r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => {
|
||||
values.push(lo, hi);
|
||||
rows.forEach((row) => {
|
||||
values.push(row.balance, row.opening, row.closing);
|
||||
CATEGORY_ORDER.forEach((category) => {
|
||||
const count = groupCounts[category] ?? 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const range = row[`seg_${category}_${index}`] as Range | undefined;
|
||||
if (range) values.push(range[0], range[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (values.length === 0) return [0, 1] as const;
|
||||
@@ -231,120 +455,158 @@ export function FlowChart() {
|
||||
const hi = Math.max(...values);
|
||||
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
|
||||
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
|
||||
}, [rows]);
|
||||
}, [rows, groupCounts]);
|
||||
|
||||
const hasMovement = rows.some((r) => r.hasMovement);
|
||||
const hasOther = rows.some((r) => r.other !== 0);
|
||||
const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0);
|
||||
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
|
||||
|
||||
// ——— دروازهی تکمتریالی: بدون متریال، نمودار رندر نمیشود ———
|
||||
if (!filter.materialId) {
|
||||
return (
|
||||
<ChartCard title={LABELS.title}>
|
||||
{options.isError ? (
|
||||
<FlowError />
|
||||
) : options.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-line bg-slate-bg/40 px-4 py-7 text-center"
|
||||
>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-bg text-accent">
|
||||
<Package aria-hidden className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="max-w-md text-[12.5px] leading-6 text-muted">{LABELS.needMaterial}</p>
|
||||
{materials.length > 0 && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-[11px] font-bold text-slate-500">{LABELS.quickPick}</span>
|
||||
<div className="flex max-w-xl flex-wrap justify-center gap-2">
|
||||
{materials.map((material) => {
|
||||
const materialUnit =
|
||||
material.unit?.name_fa ?? material.unit?.code ?? "";
|
||||
return (
|
||||
<button
|
||||
key={material.id}
|
||||
type="button"
|
||||
onClick={() => filter.setMaterial(material.id, material.name)}
|
||||
title={material.name}
|
||||
className="inline-flex max-w-[16rem] cursor-pointer items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-[11.5px] font-semibold text-ink shadow-sm transition-colors hover:border-accent hover:text-accent"
|
||||
>
|
||||
<span className="truncate">{material.name}</span>
|
||||
{materialUnit && (
|
||||
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
|
||||
{materialUnit}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard title={LABELS.title} sub={LABELS.sub}>
|
||||
<ChartCard
|
||||
title={LABELS.title}
|
||||
sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : query.isError ? (
|
||||
<FlowError />
|
||||
) : !hasMovement ? (
|
||||
<ChartEmpty message={LABELS.empty} />
|
||||
) : (
|
||||
<div dir="ltr" className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={rows}
|
||||
barCategoryGap={BAR_CATEGORY_GAP}
|
||||
margin={{ top: 12, right: 12, 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
|
||||
domain={[domainMin, domainMax]}
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||
content={<FlowTooltip />}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||
verticalAlign="top"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value: unknown) => (
|
||||
<span dir="rtl" className="text-[11px] text-muted">
|
||||
{String(value)}
|
||||
</span>
|
||||
<div dir="rtl" className="flex flex-col gap-1">
|
||||
{/* راهنمای رنگ دستهها (بهجای Legend پیشفرض؛ سریهای تنالیته معنا ندارند) */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||
{visibleCategories.map((category) => (
|
||||
<span key={category} className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ background: categoryColor(category, 0) }}
|
||||
/>
|
||||
{CATEGORY_META[category].label}
|
||||
</span>
|
||||
))}
|
||||
{hasMultipleShades && (
|
||||
<span className="text-[10px] text-slate-400">{LABELS.shadeHint}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div dir="ltr" className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={rows}
|
||||
barCategoryGap={BAR_CATEGORY_GAP}
|
||||
margin={{ top: 12, right: 12, 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
|
||||
domain={[domainMin, domainMax]}
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={unitLabel ? 70 : 56}
|
||||
label={
|
||||
unitLabel
|
||||
? {
|
||||
value: unitLabel,
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
style: { fontSize: 10, fill: "#64748b" },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||
content={<FlowTooltip unitLabel={unitLabel} materialName={materialName} />}
|
||||
/>
|
||||
{/* سگمنتهای شناور: هر گروه (مشخصات+برند) یک پله از خانوادهی رنگ
|
||||
دسته، با جداکنندهی سفید ۱px و یک ستون مشترک */}
|
||||
{CATEGORY_ORDER.map((category) =>
|
||||
Array.from({ length: groupCounts[category] ?? 0 }, (_, index) => (
|
||||
<Bar
|
||||
key={`${category}-${index}`}
|
||||
dataKey={`seg_${category}_${index}`}
|
||||
fill={categoryColor(category, index)}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
stackId={FLOW_STACK_ID}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
/>
|
||||
{/* سگمنتهای شناور آبشاری — هر جزء دقیقاً روی مسیر مانده مینشیند */}
|
||||
<Bar
|
||||
dataKey="segInbound"
|
||||
name={LABELS.inbound}
|
||||
fill={FLOW_COLORS.inbound}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="segReturns"
|
||||
name={LABELS.returns}
|
||||
fill={FLOW_COLORS.returns}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="segSiteOut"
|
||||
name={LABELS.siteOut}
|
||||
fill={FLOW_COLORS.siteOut}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="segWaste"
|
||||
name={LABELS.waste}
|
||||
fill={FLOW_COLORS.waste}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{/* فقط وقتی حرکتی خارج از چهار سبد ثبت شده باشد نمایش داده میشود */}
|
||||
{hasOther && (
|
||||
<Bar
|
||||
dataKey="segOther"
|
||||
name={LABELS.other}
|
||||
fill={FLOW_COLORS.other}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="balance"
|
||||
name={LABELS.closing}
|
||||
stroke="#0f172a"
|
||||
strokeWidth={1.75}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="balance"
|
||||
name={LABELS.balance}
|
||||
stroke={FLOW_COLORS.balance}
|
||||
strokeWidth={1.75}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
Reference in New Issue
Block a user