update FlowChart

This commit is contained in:
fatemeh sane
2026-10-20 21:05:37 +03:30
parent 692343423c
commit 41f93acec5
6 changed files with 644 additions and 272 deletions
+28 -10
View File
@@ -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),
) )
+77 -9
View File
@@ -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}}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+511 -249
View File
@@ -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>
+3 -1
View File
@@ -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,
}); });
} }
+3 -3
View File
@@ -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[] }> =>
+22
View File
@@ -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[];
} }
// — کنترل روزانه — // — کنترل روزانه —