@@ -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-nor mal 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-[11 px] 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-[10 px] 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 >