update FlowChart
This commit is contained in:
@@ -96,14 +96,29 @@ async def dashboard_scope(
|
|||||||
|
|
||||||
|
|
||||||
def _what_filters(
|
def _what_filters(
|
||||||
material_id: uuid.UUID | None,
|
material_id: str | None,
|
||||||
brand_id: uuid.UUID | None,
|
brand_id: uuid.UUID | None,
|
||||||
specs_hash: str | None,
|
specs_hash: str | None,
|
||||||
|
spec_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""دامنهی «چه چیزی» را به پارامترهای سرویس تبدیل میکند.
|
||||||
|
|
||||||
|
شناسهی ماده اگر UUID معتبر باشد → دامنهی همان ماده (همهی مشخصات آن).
|
||||||
|
اگر مقدار ارسالی UUID نباشد (یعنی شناسهی مشخصه فرستاده شده) یا spec_id
|
||||||
|
صریح بیاید → دامنه به همان مشخصه محدود میشود (specs_hash).
|
||||||
|
"""
|
||||||
|
resolved_material: uuid.UUID | None = None
|
||||||
|
resolved_specs = specs_hash or spec_id
|
||||||
|
if material_id:
|
||||||
|
try:
|
||||||
|
resolved_material = uuid.UUID(str(material_id))
|
||||||
|
except (ValueError, AttributeError, TypeError):
|
||||||
|
# شناسهی مشخصه (نه UUID ماده) — بهعنوان دامنهی مشخصه استفاده میشود
|
||||||
|
resolved_specs = resolved_specs or str(material_id)
|
||||||
return {
|
return {
|
||||||
"material_id": material_id,
|
"material_id": resolved_material,
|
||||||
"brand_id": brand_id,
|
"brand_id": brand_id,
|
||||||
"specs_hash": specs_hash,
|
"specs_hash": resolved_specs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -117,16 +132,17 @@ async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
|||||||
async def inventory_drilldown(
|
async def inventory_drilldown(
|
||||||
scope: DashboardScope = Depends(dashboard_scope),
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
):
|
):
|
||||||
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
||||||
return await dashboard_service.inventory_drilldown(
|
return await dashboard_service.inventory_drilldown(
|
||||||
db,
|
db,
|
||||||
project_ids=scope.project_ids,
|
project_ids=scope.project_ids,
|
||||||
project_id=scope.project_id,
|
project_id=scope.project_id,
|
||||||
**_what_filters(material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -136,9 +152,10 @@ async def dead_stock(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
||||||
limit: int = Query(default=200, ge=1, le=1000),
|
limit: int = Query(default=200, ge=1, le=1000),
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
):
|
):
|
||||||
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
||||||
return await dashboard_service.dead_stock(
|
return await dashboard_service.dead_stock(
|
||||||
@@ -147,7 +164,7 @@ async def dead_stock(
|
|||||||
project_id=scope.project_id,
|
project_id=scope.project_id,
|
||||||
idle_days=idle_days,
|
idle_days=idle_days,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
**_what_filters(material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -157,10 +174,11 @@ async def inventory_flow(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
||||||
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
||||||
warehouse_id: uuid.UUID | None = Query(default=None),
|
warehouse_id: uuid.UUID | None = Query(default=None, description="اختیاری — بدون آن همهی انبارها"),
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
):
|
):
|
||||||
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
||||||
today = datetime.now(dashboard_service.TEHRAN).date()
|
today = datetime.now(dashboard_service.TEHRAN).date()
|
||||||
@@ -177,7 +195,7 @@ async def inventory_flow(
|
|||||||
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
warehouse_id=warehouse_id,
|
warehouse_id=warehouse_id,
|
||||||
**_what_filters(material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -413,14 +413,24 @@ async def daily_flow(
|
|||||||
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
|
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
|
||||||
balance = running sum of EVERY signed movement (transfers/found/stock
|
balance = running sum of EVERY signed movement (transfers/found/stock
|
||||||
counts included), seeded with the opening balance before `date_from`.
|
counts included), seeded with the opening balance before `date_from`.
|
||||||
|
|
||||||
|
Each day also carries a `breakdown` list — the ledger rows behind that
|
||||||
|
day's aggregates, grouped by (category, transaction type, specs, brand,
|
||||||
|
warehouse) — so the chart can subdivide categories into tonal shades and
|
||||||
|
the tooltip can itemize them. When a single material is selected, the
|
||||||
|
payload also carries that material's canonical `unit`.
|
||||||
"""
|
"""
|
||||||
def _with_joins(sel):
|
def _with_joins(sel, *, include_brand: bool = False):
|
||||||
return (
|
# ترتیب joinها مهم است: برند فقط بعد از join شدن inventory قابل ارجاع است
|
||||||
|
stmt = (
|
||||||
sel.select_from(InventoryTransaction)
|
sel.select_from(InventoryTransaction)
|
||||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||||
.join(Material, Material.id == InventoryItem.material_id)
|
.join(Material, Material.id == InventoryItem.material_id)
|
||||||
)
|
)
|
||||||
|
if include_brand:
|
||||||
|
stmt = stmt.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
scope_conditions = []
|
scope_conditions = []
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
@@ -437,18 +447,38 @@ async def daily_flow(
|
|||||||
if specs_hash:
|
if specs_hash:
|
||||||
what_conditions.append(InventoryItem.specs_hash == specs_hash)
|
what_conditions.append(InventoryItem.specs_hash == specs_hash)
|
||||||
|
|
||||||
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day")
|
# «روز» بر مبنای تقویم تهران است؛ بدون این تبدیل، تراکنشهای بامداد
|
||||||
bucket_rows = (
|
# (۰۰:۰۰ تا ۰۳:۳۰ تهران) به روز قبلِ UTC میافتادند.
|
||||||
|
day_expr = func.date_trunc(
|
||||||
|
"day", func.timezone("Asia/Tehran", InventoryTransaction.transaction_date)
|
||||||
|
).label("day")
|
||||||
|
# ریزتراکنشها بهازای (نوع، مشخصات، برند، انبار) گروه میشوند تا هم تجمیع
|
||||||
|
# روزانه و هم شکست چندبعدی نمودار از یک کوئری واحد بیایند (بدون تکرار رکورد).
|
||||||
|
detail_rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
_with_joins(
|
_with_joins(
|
||||||
select(day_expr, InventoryTransaction.transaction_type, func.sum(InventoryTransaction.quantity))
|
select(
|
||||||
|
day_expr,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
func.sum(InventoryTransaction.quantity),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
InventoryTransaction.transaction_date >= date_from,
|
InventoryTransaction.transaction_date >= date_from,
|
||||||
InventoryTransaction.transaction_date < date_to,
|
InventoryTransaction.transaction_date < date_to,
|
||||||
*scope_conditions,
|
*scope_conditions,
|
||||||
*what_conditions,
|
*what_conditions,
|
||||||
)
|
)
|
||||||
.group_by(day_expr, InventoryTransaction.transaction_type)
|
.group_by(
|
||||||
|
day_expr,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
),
|
||||||
|
include_brand=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
@@ -465,10 +495,47 @@ async def daily_flow(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def category_of(tx_type: str) -> str:
|
||||||
|
if tx_type == "PURCHASE":
|
||||||
|
return "inflow"
|
||||||
|
if tx_type == "RETURN":
|
||||||
|
return "returns"
|
||||||
|
if tx_type == "WITHDRAWAL":
|
||||||
|
return "siteOut"
|
||||||
|
if tx_type in WASTE_TYPES:
|
||||||
|
return "waste"
|
||||||
|
# انتقالها/شمارش/یافتشده — خارج از چهار سبد نمایشی، علامتدار
|
||||||
|
return "other"
|
||||||
|
|
||||||
signed_by_day: dict[date_cls, dict[str, float]] = {}
|
signed_by_day: dict[date_cls, dict[str, float]] = {}
|
||||||
for day, tx_type, signed in bucket_rows:
|
breakdown_by_day: dict[date_cls, list[dict]] = {}
|
||||||
|
for day, tx_type, specs, brand_name, warehouse_name, signed in detail_rows:
|
||||||
day_key = day.date() if isinstance(day, datetime) else day
|
day_key = day.date() if isinstance(day, datetime) else day
|
||||||
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed)
|
tx_type = str(tx_type)
|
||||||
|
qty = _qty(signed)
|
||||||
|
day_types = signed_by_day.setdefault(day_key, {})
|
||||||
|
day_types[tx_type] = round(day_types.get(tx_type, 0.0) + qty, 4)
|
||||||
|
breakdown_by_day.setdefault(day_key, []).append({
|
||||||
|
"category": category_of(tx_type),
|
||||||
|
"transaction_type": tx_type,
|
||||||
|
"specs": specs or None,
|
||||||
|
"brand": brand_name or None,
|
||||||
|
"warehouse": warehouse_name,
|
||||||
|
"quantity": qty,
|
||||||
|
})
|
||||||
|
|
||||||
|
unit = None
|
||||||
|
if material_id is not None:
|
||||||
|
unit_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Unit.code, Unit.name_fa)
|
||||||
|
.select_from(Material)
|
||||||
|
.join(Unit, Unit.id == Material.canonical_unit_id)
|
||||||
|
.where(Material.id == material_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if unit_row is not None:
|
||||||
|
unit = {"code": unit_row[0], "name_fa": unit_row[1]}
|
||||||
|
|
||||||
days = []
|
days = []
|
||||||
running = opening
|
running = opening
|
||||||
@@ -485,10 +552,11 @@ async def daily_flow(
|
|||||||
"waste": waste,
|
"waste": waste,
|
||||||
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
|
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
|
||||||
"balance": running,
|
"balance": running,
|
||||||
|
"breakdown": breakdown_by_day.get(current, []),
|
||||||
})
|
})
|
||||||
current += timedelta(days=1)
|
current += timedelta(days=1)
|
||||||
|
|
||||||
return {"data": {"opening_balance": opening, "days": days}}
|
return {"data": {"opening_balance": opening, "unit": unit, "days": days}}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
|
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور، تکمتریالی)
|
||||||
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||||
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||||
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
|
// روی مسیر واقعی مانده مینشینند، پس ستون دقیقاً پلهی خط stepAfter را میسازد.
|
||||||
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
|
//
|
||||||
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
|
// قواعد کسبوکار:
|
||||||
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
|
// • جمعزدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بیمعناست؛ پس
|
||||||
|
// بدون انتخاب متریال، نمودار رندر نمیشود و بهجایش انتخاب سریع متریال میآید.
|
||||||
|
// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) میتواند چند «گروه» (مشخصات + برند)
|
||||||
|
// داشته باشد؛ هر گروه یک پلهی روشنتر از خانوادهی رنگ همان دسته میگیرد و
|
||||||
|
// سگمنتها یک ستون مشترک با جداکنندهی سفید ۱px میسازند.
|
||||||
|
// • همهی موجودیتها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق میشوند.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -13,151 +18,247 @@ import {
|
|||||||
Bar,
|
Bar,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
Legend,
|
|
||||||
Line,
|
Line,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} 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 { isoToJalaliStr } from "../../lib/jalali";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { fmtNum } from "../../lib/formatters";
|
||||||
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
||||||
|
import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types";
|
||||||
|
|
||||||
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
|
// ——— چیدمان ———
|
||||||
const FLOW_COLORS = {
|
const FLOW_STACK_ID = "flow-waterfall";
|
||||||
inbound: "#059669", // ورود به انبار — زمردی
|
const MAX_BAR_SIZE = 20;
|
||||||
returns: "#0d9488", // برگشت از سایت — تیل
|
const BAR_CATEGORY_GAP = "18%";
|
||||||
siteOut: "#f59e0b", // خروج به سایت — کهربایی
|
const DOMAIN_PAD_RATIO = 0.1; // ۱۰٪ سرصفحه بالا/پایین دامنه
|
||||||
waste: "#dc2626", // ضایعات و اتلاف — قرمز
|
const OTHER_RESIDUAL_KEY = "__adjust__";
|
||||||
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
|
|
||||||
balance: "#0f172a", // خط مانده — سرمهای
|
const CATEGORY_ORDER: FlowCategory[] = ["inflow", "returns", "siteOut", "waste", "other"];
|
||||||
} as const;
|
|
||||||
|
// خانوادهی رنگی هر دسته (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 = {
|
const LABELS = {
|
||||||
title: "جریان روزانه موجودی",
|
title: "جریان روزانه موجودی",
|
||||||
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
|
needMaterial:
|
||||||
inbound: "ورود به انبار",
|
"برای مشاهدهی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.",
|
||||||
returns: "برگشت از سایت",
|
quickPick: "انتخاب سریع متریال",
|
||||||
siteOut: "خروج به سایت",
|
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||||
waste: "ضایعات و اتلاف",
|
|
||||||
other: "سایر حرکات (انتقال/شمارش)",
|
|
||||||
balance: "مانده",
|
|
||||||
opening: "مانده ابتدای روز",
|
opening: "مانده ابتدای روز",
|
||||||
net: "تغییر خالص روز",
|
net: "تغییر خالص روز",
|
||||||
closing: "مانده پایان روز",
|
closing: "مانده پایان روز",
|
||||||
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
unit: "واحد",
|
||||||
|
brand: "برند",
|
||||||
|
warehouse: "انبار",
|
||||||
|
noSpecs: "بدون مشخصه",
|
||||||
|
aggregate: "کل دسته",
|
||||||
|
adjustment: "تعدیل حساب",
|
||||||
|
shadeHint: "طیف روشنتر هر رنگ = برند/مشخصات دیگر در همان روز",
|
||||||
|
error: "دریافت دادهی جریان روزانه ناموفق بود",
|
||||||
|
errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.",
|
||||||
} as const;
|
} 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 {
|
interface FlowRow {
|
||||||
date: string;
|
date: string;
|
||||||
balance: number; // خط پلهای مانده = مانده پایان روز
|
|
||||||
opening: number;
|
opening: number;
|
||||||
closing: number;
|
closing: number;
|
||||||
net: number;
|
net: number;
|
||||||
inbound: number;
|
balance: number;
|
||||||
returns: number;
|
groups: CatMap; // متادیتای تولتیپ (dataKey نیست)
|
||||||
siteOut: number;
|
|
||||||
waste: number;
|
|
||||||
other: number;
|
|
||||||
hasMovement: boolean;
|
hasMovement: boolean;
|
||||||
segInbound: Range;
|
// سگمنتها با کلیدهای مسطح seg_<category>_<shadeIndex>
|
||||||
segReturns: Range;
|
[segmentKey: string]: unknown;
|
||||||
segSiteOut: Range;
|
|
||||||
segWaste: Range;
|
|
||||||
segOther: Range;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// گرد کردن تا ۴ رقم اعشار — مثل بقیهی تجمیعهای داشبورد
|
|
||||||
const round4 = (v: number) => Math.round(v * 10000) / 10000;
|
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))}`;
|
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({
|
function FlowTooltip({
|
||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
|
unitLabel,
|
||||||
|
materialName,
|
||||||
}: {
|
}: {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: readonly { payload?: FlowRow }[];
|
payload?: readonly { payload?: FlowRow }[];
|
||||||
|
unitLabel: string;
|
||||||
|
materialName: string;
|
||||||
}) {
|
}) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
const row = payload[0]?.payload;
|
const row = payload[0]?.payload;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
/** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */
|
const withUnit = (value: string) => (unitLabel ? `${value} ${unitLabel}` : value);
|
||||||
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 netTone =
|
const netTone =
|
||||||
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
|
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
dir="rtl"
|
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 className="flex flex-wrap items-center gap-2">
|
||||||
</div>
|
<span className="text-xs font-bold text-slate-800">
|
||||||
<div className="my-2 border-b border-slate-100" />
|
{isoToJalaliStr(row.date) ?? row.date}
|
||||||
<div className="flex flex-col gap-1.5">
|
</span>
|
||||||
{lines.map((line) => (
|
{materialName && <span className="text-[11px] text-slate-500">{materialName}</span>}
|
||||||
<div key={line.label} className="flex items-center justify-between gap-6">
|
{unitLabel && (
|
||||||
<span className="whitespace-nowrap text-[11px] text-slate-500 sm:text-xs">
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500">
|
||||||
{line.label}
|
{unitLabel}
|
||||||
</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}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
)}
|
||||||
dir="ltr"
|
</div>
|
||||||
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
|
|
||||||
>
|
<div className="my-2 border-b border-slate-100" />
|
||||||
{signed(row.net)}
|
|
||||||
|
{/* مانده ابتدای روز */}
|
||||||
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-6">
|
<div className="flex items-center justify-between gap-6">
|
||||||
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
|
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.closing}</span>
|
||||||
{LABELS.closing}
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
</span>
|
{withUnit(fmtNum(row.closing))}
|
||||||
<span
|
|
||||||
dir="ltr"
|
|
||||||
className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs"
|
|
||||||
>
|
|
||||||
{fmtNum(row.closing)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
export function FlowChart() {
|
||||||
const query = useDailyFlow();
|
const filter = useDashboardFilter();
|
||||||
|
// بدون متریال، کوئری اصلاً اجرا نمیشود (جلوگیری از تجمیع واحدهای ناسازگار)
|
||||||
|
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId));
|
||||||
|
const options = useDashboardFilterOptions();
|
||||||
|
|
||||||
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار میشود ———
|
const materials = options.data?.materials ?? [];
|
||||||
const rows: FlowRow[] = useMemo(() => {
|
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 ?? [];
|
const days = query.data?.days ?? [];
|
||||||
let running = query.data?.opening_balance ?? 0;
|
let opening = query.data?.opening_balance ?? 0;
|
||||||
return days.map((d) => {
|
|
||||||
const opening = running;
|
// گذر اول: نرمالسازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند)
|
||||||
const closing = d.balance;
|
const normalized = days.map((day) => {
|
||||||
const waste = Math.max(0, d.waste);
|
const closing = day.balance;
|
||||||
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته میشود
|
|
||||||
const siteOut = Math.max(0, d.outbound - waste);
|
|
||||||
const net = round4(closing - opening);
|
const net = round4(closing - opening);
|
||||||
// «سایر حرکات» = باقیماندهی تراز (انتقال/شمارش/یافتشده)؛ معمولاً صفر
|
const groups = dayGroups(day);
|
||||||
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
|
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 totalByCategory = new Map<FlowCategory, Map<string, number>>();
|
||||||
const segment = (delta: number): Range => {
|
normalized.forEach(({ groups }) => {
|
||||||
const from = cursor;
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
cursor = round4(cursor + delta);
|
const perCategory = totalByCategory.get(category) ?? new Map<string, number>();
|
||||||
return [Math.min(from, cursor), Math.max(from, cursor)];
|
groups[category].forEach((group) => {
|
||||||
};
|
perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity)));
|
||||||
const segInbound = segment(d.inbound);
|
});
|
||||||
const segReturns = segment(d.returns);
|
totalByCategory.set(category, perCategory);
|
||||||
const segSiteOut = segment(-siteOut);
|
});
|
||||||
const segWaste = segment(-waste);
|
});
|
||||||
const segOther = segment(other);
|
|
||||||
|
|
||||||
running = closing;
|
const orderByCategory = new Map<FlowCategory, string[]>();
|
||||||
return {
|
const groupCounts: Record<FlowCategory, number> = {
|
||||||
date: d.date,
|
inflow: 0,
|
||||||
balance: closing,
|
returns: 0,
|
||||||
opening,
|
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,
|
closing,
|
||||||
net,
|
net,
|
||||||
inbound: d.inbound,
|
balance: closing,
|
||||||
returns: d.returns,
|
groups,
|
||||||
siteOut,
|
hasMovement: false,
|
||||||
waste,
|
|
||||||
other,
|
|
||||||
hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0,
|
|
||||||
segInbound,
|
|
||||||
segReturns,
|
|
||||||
segSiteOut,
|
|
||||||
segWaste,
|
|
||||||
segOther,
|
|
||||||
};
|
};
|
||||||
|
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]);
|
}, [query.data]);
|
||||||
|
|
||||||
// ——— دامنهی محور Y از خود داده (نه از صفر) تا ستونها و خط در کادر جا شوند ———
|
// ——— دامنهی محور Y با ۱۰٪ سرصفحه ———
|
||||||
const [domainMin, domainMax] = useMemo(() => {
|
const [domainMin, domainMax] = useMemo(() => {
|
||||||
const values: number[] = [];
|
const values: number[] = [];
|
||||||
rows.forEach((r) => {
|
rows.forEach((row) => {
|
||||||
values.push(r.balance, r.opening, r.closing);
|
values.push(row.balance, row.opening, row.closing);
|
||||||
[r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => {
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
values.push(lo, hi);
|
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;
|
if (values.length === 0) return [0, 1] as const;
|
||||||
@@ -231,120 +455,158 @@ export function FlowChart() {
|
|||||||
const hi = Math.max(...values);
|
const hi = Math.max(...values);
|
||||||
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
|
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
|
||||||
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
|
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
|
||||||
}, [rows]);
|
}, [rows, groupCounts]);
|
||||||
|
|
||||||
const hasMovement = rows.some((r) => r.hasMovement);
|
const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0);
|
||||||
const hasOther = rows.some((r) => r.other !== 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 (
|
return (
|
||||||
<ChartCard title={LABELS.title} sub={LABELS.sub}>
|
<ChartCard
|
||||||
|
title={LABELS.title}
|
||||||
|
sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
|
||||||
|
>
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<ChartLoading />
|
<ChartLoading />
|
||||||
|
) : query.isError ? (
|
||||||
|
<FlowError />
|
||||||
) : !hasMovement ? (
|
) : !hasMovement ? (
|
||||||
<ChartEmpty message={LABELS.empty} />
|
<ChartEmpty message={LABELS.empty} />
|
||||||
) : (
|
) : (
|
||||||
<div dir="ltr" className="h-72 w-full">
|
<div dir="rtl" className="flex flex-col gap-1">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{/* راهنمای رنگ دستهها (بهجای Legend پیشفرض؛ سریهای تنالیته معنا ندارند) */}
|
||||||
<ComposedChart
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||||
data={rows}
|
{visibleCategories.map((category) => (
|
||||||
barCategoryGap={BAR_CATEGORY_GAP}
|
<span key={category} className="inline-flex items-center gap-1">
|
||||||
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
|
<span
|
||||||
>
|
className="h-2 w-2 rounded-full"
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
style={{ background: categoryColor(category, 0) }}
|
||||||
<XAxis
|
/>
|
||||||
dataKey="date"
|
{CATEGORY_META[category].label}
|
||||||
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
|
</span>
|
||||||
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
))}
|
||||||
tickLine={false}
|
{hasMultipleShades && (
|
||||||
axisLine={{ stroke: "#e5e9f0" }}
|
<span className="text-[10px] text-slate-400">{LABELS.shadeHint}</span>
|
||||||
minTickGap={18}
|
)}
|
||||||
/>
|
</div>
|
||||||
<YAxis
|
|
||||||
domain={[domainMin, domainMax]}
|
<div dir="ltr" className="h-72 w-full">
|
||||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
tickFormatter={faAxisTick}
|
<ComposedChart
|
||||||
tickLine={false}
|
data={rows}
|
||||||
axisLine={false}
|
barCategoryGap={BAR_CATEGORY_GAP}
|
||||||
width={56}
|
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
|
||||||
/>
|
>
|
||||||
<Tooltip
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
<XAxis
|
||||||
content={<FlowTooltip />}
|
dataKey="date"
|
||||||
/>
|
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
|
||||||
<Legend
|
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
tickLine={false}
|
||||||
verticalAlign="top"
|
axisLine={{ stroke: "#e5e9f0" }}
|
||||||
height={28}
|
minTickGap={18}
|
||||||
iconType="circle"
|
/>
|
||||||
iconSize={8}
|
<YAxis
|
||||||
formatter={(value: unknown) => (
|
domain={[domainMin, domainMax]}
|
||||||
<span dir="rtl" className="text-[11px] text-muted">
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
{String(value)}
|
tickFormatter={faAxisTick}
|
||||||
</span>
|
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}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
)}
|
)}
|
||||||
/>
|
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
||||||
{/* سگمنتهای شناور آبشاری — هر جزء دقیقاً روی مسیر مانده مینشیند */}
|
<Line
|
||||||
<Bar
|
type="stepAfter"
|
||||||
dataKey="segInbound"
|
dataKey="balance"
|
||||||
name={LABELS.inbound}
|
name={LABELS.closing}
|
||||||
fill={FLOW_COLORS.inbound}
|
stroke="#0f172a"
|
||||||
stackId={FLOW_STACK_ID}
|
strokeWidth={1.75}
|
||||||
radius={2}
|
strokeDasharray="5 3"
|
||||||
maxBarSize={MAX_BAR_SIZE}
|
dot={false}
|
||||||
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}
|
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
)}
|
</ComposedChart>
|
||||||
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
</ResponsiveContainer>
|
||||||
<Line
|
</div>
|
||||||
type="stepAfter"
|
|
||||||
dataKey="balance"
|
|
||||||
name={LABELS.balance}
|
|
||||||
stroke={FLOW_COLORS.balance}
|
|
||||||
strokeWidth={1.75}
|
|
||||||
strokeDasharray="5 3"
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ export function useDeadStock(idleDays: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ویجت ۳ — سری زمانی جریان روزانه
|
// ویجت ۳ — سری زمانی جریان روزانه
|
||||||
export function useDailyFlow(dateFrom?: string, dateTo?: string) {
|
// فقط با متریال مشخص اجرا میشود: جمع چند متریال با واحدهای متفاوت بیمعناست.
|
||||||
|
export function useDailyFlow(dateFrom?: string, dateTo?: string, enabled = true) {
|
||||||
const params = useFilterParams();
|
const params = useFilterParams();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
||||||
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
DailyControlData,
|
DailyControlData,
|
||||||
DeadStockItem,
|
DeadStockItem,
|
||||||
DrilldownData,
|
DrilldownData,
|
||||||
FlowDay,
|
FlowData,
|
||||||
InTransitRow,
|
InTransitRow,
|
||||||
InventoryRow,
|
InventoryRow,
|
||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
@@ -1928,8 +1928,8 @@ export const apiV26 = {
|
|||||||
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
|
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
|
||||||
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
|
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
|
||||||
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
|
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
|
||||||
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> =>
|
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<FlowData> =>
|
||||||
getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
|
getData<FlowData>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
|
||||||
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
|
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
|
||||||
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
|
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
|
||||||
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
|
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
|
||||||
|
|||||||
@@ -283,6 +283,20 @@ export interface DeadStockItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// — سری زمانی جریان روزانه —
|
// — سری زمانی جریان روزانه —
|
||||||
|
// دستههای نمایشی جریان: ورود، برگشت، خروج به سایت، ضایعات، و سایر حرکات
|
||||||
|
// (انتقال/شمارش/یافتشده که در چهار سبد نمایشی جا نمیگیرند)
|
||||||
|
export type FlowCategory = "inflow" | "returns" | "siteOut" | "waste" | "other";
|
||||||
|
|
||||||
|
// یک ریزردیف دفتر: نوع تراکنش + مشخصات + برند + انبار — پایهی شکست چندبعدی
|
||||||
|
export interface FlowBreakdownItem {
|
||||||
|
category: FlowCategory;
|
||||||
|
transaction_type: string;
|
||||||
|
specs: Record<string, string> | null;
|
||||||
|
brand: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
quantity: number; // علامتدار مطابق دفتر (مثبت/منفی)
|
||||||
|
}
|
||||||
|
|
||||||
export interface FlowDay {
|
export interface FlowDay {
|
||||||
date: string; // ISO (YYYY-MM-DD)
|
date: string; // ISO (YYYY-MM-DD)
|
||||||
inbound: number;
|
inbound: number;
|
||||||
@@ -290,6 +304,14 @@ export interface FlowDay {
|
|||||||
waste: number;
|
waste: number;
|
||||||
returns: number;
|
returns: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
breakdown?: FlowBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowData {
|
||||||
|
opening_balance: number;
|
||||||
|
// واحد کاتالوگی متریال انتخابشده (فقط وقتی یک متریال فیلتر شده باشد)
|
||||||
|
unit: { code: string; name_fa: string | null } | null;
|
||||||
|
days: FlowDay[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// — کنترل روزانه —
|
// — کنترل روزانه —
|
||||||
|
|||||||
Reference in New Issue
Block a user