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(
material_id: uuid.UUID | None,
material_id: str | None,
brand_id: uuid.UUID | None,
specs_hash: str | None,
spec_id: str | None = None,
) -> 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 {
"material_id": material_id,
"material_id": resolved_material,
"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(
scope: DashboardScope = Depends(dashboard_scope),
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),
specs_hash: str | None = Query(default=None),
spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"),
):
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
return await dashboard_service.inventory_drilldown(
db,
project_ids=scope.project_ids,
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),
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
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),
specs_hash: str | None = Query(default=None),
spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"),
):
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازه‌ی رکود."""
return await dashboard_service.dead_stock(
@@ -147,7 +164,7 @@ async def dead_stock(
project_id=scope.project_id,
idle_days=idle_days,
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),
date_from: 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),
material_id: uuid.UUID | None = Query(default=None),
warehouse_id: uuid.UUID | None = Query(default=None, description="اختیاری — بدون آن همه‌ی انبارها"),
material_id: str | None = Query(default=None, description="UUID ماده یا شناسه‌ی مشخصه"),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"),
):
"""Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده."""
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_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
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).
balance = running sum of EVERY signed movement (transfers/found/stock
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):
return (
def _with_joins(sel, *, include_brand: bool = False):
# ترتیب join‌ها مهم است: برند فقط بعد از join شدن inventory قابل ارجاع است
stmt = (
sel.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id)
)
if include_brand:
stmt = stmt.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
return stmt
scope_conditions = []
if project_id is not None:
@@ -437,18 +447,38 @@ async def daily_flow(
if 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(
_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(
InventoryTransaction.transaction_date >= date_from,
InventoryTransaction.transaction_date < date_to,
*scope_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()
@@ -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]] = {}
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
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 = []
running = opening
@@ -485,10 +552,11 @@ async def daily_flow(
"waste": waste,
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
"balance": running,
"breakdown": breakdown_by_day.get(current, []),
})
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) و تولتیپ تحلیلی کامل روز.
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
// کشیده می‌شود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
// روی مسیر واقعی مانده می‌نشینند، پس ستون دقیقاً پله‌ی خط stepAfter را می‌سازد.
//
// قواعد کسب‌وکار:
// • جمع‌زدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بی‌معناست؛ پس
// بدون انتخاب متریال، نمودار رندر نمی‌شود و به‌جایش انتخاب سریع متریال می‌آید.
// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) می‌تواند چند «گروه» (مشخصات + برند)
// داشته باشد؛ هر گروه یک پله‌ی روشن‌تر از خانواده‌ی رنگ همان دسته می‌گیرد و
// سگمنت‌ها یک ستون مشترک با جداکننده‌ی سفید ۱px می‌سازند.
// • همه‌ی موجودیت‌ها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق می‌شوند.
// ============================================================
import { useMemo } from "react";
@@ -13,151 +18,247 @@ import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useDailyFlow } from "../../hooks/useDashboardData";
import { Package } from "lucide-react";
import { useDailyFlow, useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { isoToJalaliStr } from "../../lib/jalali";
import { fmtNum } from "../../lib/formatters";
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types";
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
const FLOW_COLORS = {
inbound: "#059669", // ورود به انبار — زمردی
returns: "#0d9488", // برگشت از سایت — تیل
siteOut: "#f59e0b", // خروج به سایت — کهربایی
waste: "#dc2626", // ضایعات و اتلاف — قرمز
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
balance: "#0f172a", // خط مانده — سرمهای
} as const;
// ——— چیدمان ———
const FLOW_STACK_ID = "flow-waterfall";
const MAX_BAR_SIZE = 20;
const BAR_CATEGORY_GAP = "18%";
const DOMAIN_PAD_RATIO = 0.1; // ۱۰٪ سرصفحه بالا/پایین دامنه
const OTHER_RESIDUAL_KEY = "__adjust__";
const CATEGORY_ORDER: FlowCategory[] = ["inflow", "returns", "siteOut", "waste", "other"];
// خانواده‌ی رنگی هر دسته (HSL پایه) + جهت علامت در مسیر مانده
const CATEGORY_META: Record<
FlowCategory,
{ label: string; sign: 1 | -1; base: { h: number; s: number; l: number } }
> = {
inflow: { label: "ورود به انبار", sign: 1, base: { h: 160, s: 84, l: 39 } }, // زمردی
returns: { label: "برگشت از سایت", sign: 1, base: { h: 175, s: 77, l: 41 } }, // تیل
siteOut: { label: "خروج به سایت", sign: -1, base: { h: 35, s: 92, l: 47 } }, // کهربایی
waste: { label: "ضایعات و اتلاف", sign: -1, base: { h: 0, s: 72, l: 51 } }, // قرمز
other: { label: "سایر حرکات", sign: 1, base: { h: 215, s: 16, l: 55 } }, // خاکستری
};
// تنالیته‌ی گروه‌های یک دسته: هر گروه (مشخصات+برند) یک پله روشن‌تر
const SHADE_STEP_L = 9;
const SHADE_MAX_L = 78;
function categoryColor(category: FlowCategory, shadeIndex: number): string {
const { h, s, l } = CATEGORY_META[category].base;
const lightness = Math.min(SHADE_MAX_L, l + shadeIndex * SHADE_STEP_L);
return `hsl(${h} ${s}% ${lightness}%)`;
}
// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX)
const LABELS = {
title: "جریان روزانه موجودی",
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
inbound: "ورود به انبار",
returns: "برگشت از سایت",
siteOut: "خروج به سایت",
waste: "ضایعات و اتلاف",
other: "سایر حرکات (انتقال/شمارش)",
balance: "مانده",
needMaterial:
"برای مشاهده‌ی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.",
quickPick: "انتخاب سریع متریال",
empty: "در این بازه جابه‌جایی موجودی ثبت نشده است",
opening: "مانده ابتدای روز",
net: "تغییر خالص روز",
closing: "مانده پایان روز",
empty: "در این بازه جابه‌جایی موجودی ثبت نشده است",
unit: "واحد",
brand: "برند",
warehouse: "انبار",
noSpecs: "بدون مشخصه",
aggregate: "کل دسته",
adjustment: "تعدیل حساب",
shadeHint: "طیف روشن‌تر هر رنگ = برند/مشخصات دیگر در همان روز",
error: "دریافت داده‌ی جریان روزانه ناموفق بود",
errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.",
} as const;
// سگمنت شناور: [پایین، بالا] — همان چیزی که <Bar> به‌عنوان مقدار محدوده می‌خواند
type Range = [number, number];
// ——— مدل داده ———
type Range = [number, number]; // [پایین، بالا] — سگمنت شناور <Bar>
interface CatItem {
key: string; // هویت گروه: مشخصات|برند
specLabel: string;
brand: string | null;
warehouse: string;
quantity: number; // چهار دسته: قدر مطلق — «سایر»: علامت‌دار
}
interface CatGroup {
key: string;
label: string;
brand: string | null;
quantity: number; // مجموع گروه (برای «سایر» علامت‌دار)
items: CatItem[]; // ریزردیف‌ها برای تولتیپ
}
type CatMap = Record<FlowCategory, CatGroup[]>;
// یک ردیف زمانی — سگمنتها به‌صورت پراپرتی‌های مسطح می‌نشینند تا dataKey هر
// <Bar> مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن).
interface FlowRow {
date: string;
balance: number; // خط پله‌ای مانده = مانده پایان روز
opening: number;
closing: number;
net: number;
inbound: number;
returns: number;
siteOut: number;
waste: number;
other: number;
balance: number;
groups: CatMap; // متادیتای تولتیپ (dataKey نیست)
hasMovement: boolean;
segInbound: Range;
segReturns: Range;
segSiteOut: Range;
segWaste: Range;
segOther: Range;
// سگمنت‌ها با کلیدهای مسطح seg_<category>_<shadeIndex>
[segmentKey: string]: unknown;
}
// گرد کردن تا ۴ رقم اعشار — مثل بقیه‌ی تجمیع‌های داشبورد
const round4 = (v: number) => Math.round(v * 10000) / 10000;
// حاشیه‌ی دامنه‌ی محور Y (نسبت به بازه‌ی داده) تا لبه‌ها به قاب نچسبند
const DOMAIN_PAD_RATIO = 0.04;
const MAX_BAR_SIZE = 20; // حداکثر عرض ستون (px) — عرض واقعی از باند محور می‌آید
const BAR_CATEGORY_GAP = "18%";
// همه‌ی سگمنت‌ها یک ستون مشترک می‌سازند (نه چند ستون باریک کنار هم)؛ چون
// مقدار هر سگمنت [پایین، بالا] است، Recharts آن را بدون انبارش (stacking)
// دقیقاً در همان ارتفاع شناور رسم می‌کند.
const FLOW_STACK_ID = "flow-waterfall";
// عدد با علامت برای «تغییر خالص» — ارقام فارسی
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
// تولتیپ تحلیلی روز — مانده ابتدا/پایان، اجزای روز، تغییر خالص
// خطای شبکه/سرور باید صریح دیده شود — هرگز نباید «داده‌ای ثبت نشده» تلقّی شود
function FlowError() {
return (
<div
dir="rtl"
className="flex h-40 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-red/30 bg-red-bg/40 px-4 text-center"
>
<span className="text-[12px] font-bold text-red">{LABELS.error}</span>
<span className="max-w-md text-[11px] leading-5 text-muted">{LABELS.errorHint}</span>
</div>
);
}
// برچسب مشخصات فنی: «کلید: مقدار، …» یا «بدون مشخصه»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs ?? {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : LABELS.noSpecs;
}
// ——— تولتیپ تحلیلی RTL ———
function FlowTooltip({
active,
payload,
unitLabel,
materialName,
}: {
active?: boolean;
payload?: readonly { payload?: FlowRow }[];
unitLabel: string;
materialName: string;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload;
if (!row) return null;
/** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */
const lines: { label: string; value: number; className: string; signed?: boolean }[] = [
{ label: LABELS.opening, value: row.opening, className: "text-slate-600" },
{ label: LABELS.inbound, value: row.inbound, className: "text-slate-700" },
{ label: LABELS.returns, value: row.returns, className: "text-slate-700" },
{ label: LABELS.siteOut, value: -row.siteOut, className: "text-slate-700" },
{ label: LABELS.waste, value: -row.waste, className: "text-slate-700" },
];
if (row.other !== 0) {
lines.push({ label: LABELS.other, value: row.other, className: "text-slate-700" });
}
const withUnit = (value: string) => (unitLabel ? `${value} ${unitLabel}` : value);
const netTone =
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
return (
<div
dir="rtl"
className="min-w-max rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md"
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[380px] sm:whitespace-nowrap"
>
<div className="whitespace-nowrap text-xs font-bold text-slate-800">
{isoToJalaliStr(row.date) ?? row.date}
</div>
<div className="my-2 border-b border-slate-100" />
<div className="flex flex-col gap-1.5">
{lines.map((line) => (
<div key={line.label} className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] text-slate-500 sm:text-xs">
{line.label}
</span>
<span
dir="ltr"
className={`whitespace-nowrap text-[11px] font-medium tabular-nums sm:text-xs ${line.className}`}
>
{line.value === 0 ? fmtNum(0) : signed(line.value)}
</span>
</div>
))}
<div className="my-0.5 border-b border-slate-100" />
<div className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
{LABELS.net}
{/* سربرگ: تاریخ + متریال + نشان واحد */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-bold text-slate-800">
{isoToJalaliStr(row.date) ?? row.date}
</span>
{materialName && <span className="text-[11px] text-slate-500">{materialName}</span>}
{unitLabel && (
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500">
{unitLabel}
</span>
<span
dir="ltr"
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
>
{signed(row.net)}
)}
</div>
<div className="my-2 border-b border-slate-100" />
{/* مانده ابتدای روز */}
<div className="flex items-center justify-between gap-6">
<span className="text-[11px] text-slate-500 sm:text-xs">{LABELS.opening}</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(fmtNum(row.opening))}
</span>
</div>
<div className="my-2 border-b border-slate-100" />
{/* دسته‌ها + ریزردیف مشخصات/برند/انبار */}
<div className="flex flex-col gap-2">
{CATEGORY_ORDER.map((category) => {
const groups = row.groups[category] ?? [];
if (groups.length === 0) return null;
const total = round4(
groups.reduce((sum, group) => sum + group.quantity, 0) *
CATEGORY_META[category].sign,
);
if (total === 0) return null;
return (
<div key={category} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-6">
<span className="flex items-center gap-2 text-[11px] font-semibold text-slate-700 sm:text-xs">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: categoryColor(category, 0) }}
/>
{CATEGORY_META[category].label}
</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(signed(total))}
</span>
</div>
{groups.map((group) => (
<div key={group.key} className="flex flex-col">
{group.items.map((item) => (
<div
key={`${group.key}|${item.warehouse}|${item.specLabel}`}
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-0.5 border-s border-slate-100 ps-3 text-[10.5px] text-slate-500 sm:text-[11px]"
>
<span className="flex flex-wrap items-center gap-1">
<span aria-hidden>↳</span>
<span>{item.specLabel}</span>
{item.brand && (
<>
<span className="text-slate-300">|</span>
<span>
{LABELS.brand}: {item.brand}
</span>
</>
)}
{item.warehouse && (
<span className="text-slate-400">
({LABELS.warehouse}: {item.warehouse})
</span>
)}
</span>
<span dir="ltr" className="font-medium tabular-nums text-slate-600">
{withUnit(signed(round4(item.quantity * CATEGORY_META[category].sign)))}
</span>
</div>
))}
</div>
))}
</div>
);
})}
</div>
<div className="my-2 border-b border-slate-100" />
{/* جمع‌بندی */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-6">
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.net}</span>
<span dir="ltr" className={`text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}>
{withUnit(signed(row.net))}
</span>
</div>
<div className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
{LABELS.closing}
</span>
<span
dir="ltr"
className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs"
>
{fmtNum(row.closing)}
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.closing}</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(fmtNum(row.closing))}
</span>
</div>
</div>
@@ -165,65 +266,188 @@ function FlowTooltip({
);
}
// ——— نرمال‌سازی یک روز: گروه‌بندی ریزردیف‌ها بر اساس (دسته، مشخصات+برند) ———
function dayGroups(day: FlowDay): CatMap {
const groups: CatMap = { inflow: [], returns: [], siteOut: [], waste: [], other: [] };
const insert = (category: FlowCategory, item: CatItem) => {
const existing = groups[category].find((group) => group.key === item.key);
if (existing) {
existing.quantity = round4(existing.quantity + item.quantity);
existing.items.push(item);
} else {
groups[category].push({
key: item.key,
label: item.specLabel,
brand: item.brand,
quantity: item.quantity,
items: [item],
});
}
};
const breakdown: FlowBreakdownItem[] = day.breakdown ?? [];
if (breakdown.length > 0) {
breakdown.forEach((item) => {
// چهار سبد نمایشی همیشه مثبت نشان داده می‌شوند؛ «سایر» علامت دفتر را نگه می‌دارد
const quantity = item.category === "other" ? item.quantity : Math.abs(item.quantity);
if (quantity === 0) return;
insert(item.category, {
key: `${JSON.stringify(item.specs ?? {})}|${item.brand ?? ""}`,
specLabel: specLabel(item.specs),
brand: item.brand,
warehouse: item.warehouse,
quantity: round4(quantity),
});
});
} else {
// سازگاری عقب‌رو: اگر شکست چندبعدی در payload نباشد، یک گروهِ کلِ دسته ساخته می‌شود
const aggregates: [FlowCategory, number][] = [
["inflow", day.inbound],
["returns", day.returns],
["siteOut", Math.max(0, day.outbound - day.waste)],
["waste", day.waste],
];
aggregates.forEach(([category, quantity]) => {
if (quantity > 0) {
insert(category, {
key: `${category}-all`,
specLabel: LABELS.aggregate,
brand: null,
warehouse: "",
quantity: round4(quantity),
});
}
});
}
return groups;
}
export function FlowChart() {
const query = useDailyFlow();
const filter = useDashboardFilter();
// بدون متریال، کوئری اصلاً اجرا نمی‌شود (جلوگیری از تجمیع واحدهای ناسازگار)
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId));
const options = useDashboardFilterOptions();
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار می‌شود ———
const rows: FlowRow[] = useMemo(() => {
const materials = options.data?.materials ?? [];
const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null;
const materialName = selectedMaterial?.name ?? filter.materialLabel ?? "";
const unitLabel =
query.data?.unit?.name_fa ??
query.data?.unit?.code ??
selectedMaterial?.unit?.name_fa ??
selectedMaterial?.unit?.code ??
"";
// ——— هم‌سازمان‌دهی روزها + سگمنت‌های شناور ———
const { rows, groupCounts, hasMovement } = useMemo(() => {
const days = query.data?.days ?? [];
let running = query.data?.opening_balance ?? 0;
return days.map((d) => {
const opening = running;
const closing = d.balance;
const waste = Math.max(0, d.waste);
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته می‌شود
const siteOut = Math.max(0, d.outbound - waste);
let opening = query.data?.opening_balance ?? 0;
// گذر اول: نرمال‌سازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند)
const normalized = days.map((day) => {
const closing = day.balance;
const net = round4(closing - opening);
// «سایر حرکات» = باقی‌مانده‌ی تراز (انتقال/شمارش/یافت‌شده)؛ معمولاً صفر
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
const groups = dayGroups(day);
const categorized = round4(
CATEGORY_ORDER.reduce((sum, category) => {
const categoryTotal = groups[category].reduce(
(groupSum, group) => groupSum + group.quantity,
0,
);
return sum + categoryTotal * CATEGORY_META[category].sign;
}, 0),
);
const residual = round4(net - categorized);
if (residual !== 0) {
groups.other.push({
key: OTHER_RESIDUAL_KEY,
label: LABELS.adjustment,
brand: null,
quantity: residual,
items: [],
});
}
const normalizedDay = { day, opening, closing, net, groups };
opening = closing;
return normalizedDay;
});
let cursor = opening;
// هر سگمنت = [کمترین، بیشترین] از مقادیر قبل و بعد از اعمال همان جزء
const segment = (delta: number): Range => {
const from = cursor;
cursor = round4(cursor + delta);
return [Math.min(from, cursor), Math.max(from, cursor)];
};
const segInbound = segment(d.inbound);
const segReturns = segment(d.returns);
const segSiteOut = segment(-siteOut);
const segWaste = segment(-waste);
const segOther = segment(other);
// ترتیب گروه‌ها (و در نتیجه پله‌ی رنگ) در کل بازه ثابت می‌ماند
const totalByCategory = new Map<FlowCategory, Map<string, number>>();
normalized.forEach(({ groups }) => {
CATEGORY_ORDER.forEach((category) => {
const perCategory = totalByCategory.get(category) ?? new Map<string, number>();
groups[category].forEach((group) => {
perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity)));
});
totalByCategory.set(category, perCategory);
});
});
running = closing;
return {
date: d.date,
balance: closing,
opening,
const orderByCategory = new Map<FlowCategory, string[]>();
const groupCounts: Record<FlowCategory, number> = {
inflow: 0,
returns: 0,
siteOut: 0,
waste: 0,
other: 0,
};
CATEGORY_ORDER.forEach((category) => {
const orderedKeys = [...(totalByCategory.get(category) ?? new Map<string, number>()).entries()]
.sort((a, b) => b[1] - a[1])
.map(([key]) => key);
orderByCategory.set(category, orderedKeys);
groupCounts[category] = orderedKeys.length;
});
// گذر دوم: هر گروه یک سگمنت [پایین، بالا] روی مسیر مانده می‌گیرد
const rows: FlowRow[] = normalized.map(({ day, opening: dayOpening, closing, net, groups }) => {
const row: FlowRow = {
date: day.date,
opening: dayOpening,
closing,
net,
inbound: d.inbound,
returns: d.returns,
siteOut,
waste,
other,
hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0,
segInbound,
segReturns,
segSiteOut,
segWaste,
segOther,
balance: closing,
groups,
hasMovement: false,
};
let cursor = dayOpening;
CATEGORY_ORDER.forEach((category) => {
const orderedKeys = orderByCategory.get(category) ?? [];
const sign = CATEGORY_META[category].sign;
orderedKeys.forEach((key, index) => {
const group = groups[category].find((item) => item.key === key);
let range: Range = [cursor, cursor];
if (group && group.quantity !== 0) {
const from = cursor;
cursor = round4(cursor + group.quantity * sign);
range = [Math.min(from, cursor), Math.max(from, cursor)];
row.hasMovement = true;
}
row[`seg_${category}_${index}`] = range;
});
});
return row;
});
return {
rows,
groupCounts,
hasMovement: rows.some((row) => row.hasMovement || row.net !== 0),
};
}, [query.data]);
// ——— دامنه‌ی محور Y از خود داده (نه از صفر) تا ستون‌ها و خط در کادر جا شوند ———
// ——— دامنه‌ی محور Y با ۱۰٪ سرصفحه ———
const [domainMin, domainMax] = useMemo(() => {
const values: number[] = [];
rows.forEach((r) => {
values.push(r.balance, r.opening, r.closing);
[r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => {
values.push(lo, hi);
rows.forEach((row) => {
values.push(row.balance, row.opening, row.closing);
CATEGORY_ORDER.forEach((category) => {
const count = groupCounts[category] ?? 0;
for (let index = 0; index < count; index += 1) {
const range = row[`seg_${category}_${index}`] as Range | undefined;
if (range) values.push(range[0], range[1]);
}
});
});
if (values.length === 0) return [0, 1] as const;
@@ -231,120 +455,158 @@ export function FlowChart() {
const hi = Math.max(...values);
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
}, [rows]);
}, [rows, groupCounts]);
const hasMovement = rows.some((r) => r.hasMovement);
const hasOther = rows.some((r) => r.other !== 0);
const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0);
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
// ——— دروازه‌ی تک‌متریالی: بدون متریال، نمودار رندر نمی‌شود ———
if (!filter.materialId) {
return (
<ChartCard title={LABELS.title}>
{options.isError ? (
<FlowError />
) : options.isLoading ? (
<ChartLoading />
) : (
<div
dir="rtl"
className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-line bg-slate-bg/40 px-4 py-7 text-center"
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-bg text-accent">
<Package aria-hidden className="h-5 w-5" />
</span>
<p className="max-w-md text-[12.5px] leading-6 text-muted">{LABELS.needMaterial}</p>
{materials.length > 0 && (
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] font-bold text-slate-500">{LABELS.quickPick}</span>
<div className="flex max-w-xl flex-wrap justify-center gap-2">
{materials.map((material) => {
const materialUnit =
material.unit?.name_fa ?? material.unit?.code ?? "";
return (
<button
key={material.id}
type="button"
onClick={() => filter.setMaterial(material.id, material.name)}
title={material.name}
className="inline-flex max-w-[16rem] cursor-pointer items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-[11.5px] font-semibold text-ink shadow-sm transition-colors hover:border-accent hover:text-accent"
>
<span className="truncate">{material.name}</span>
{materialUnit && (
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
{materialUnit}
</span>
)}
</button>
);
})}
</div>
</div>
)}
</div>
)}
</ChartCard>
);
}
return (
<ChartCard title={LABELS.title} sub={LABELS.sub}>
<ChartCard
title={LABELS.title}
sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
>
{query.isLoading ? (
<ChartLoading />
) : query.isError ? (
<FlowError />
) : !hasMovement ? (
<ChartEmpty message={LABELS.empty} />
) : (
<div dir="ltr" className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={rows}
barCategoryGap={BAR_CATEGORY_GAP}
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
minTickGap={18}
/>
<YAxis
domain={[domainMin, domainMax]}
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={<FlowTooltip />}
/>
<Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: unknown) => (
<span dir="rtl" className="text-[11px] text-muted">
{String(value)}
</span>
<div dir="rtl" className="flex flex-col gap-1">
{/* راهنمای رنگ دسته‌ها (به‌جای Legend پیش‌فرض؛ سری‌های تنالیته معنا ندارند) */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
{visibleCategories.map((category) => (
<span key={category} className="inline-flex items-center gap-1">
<span
className="h-2 w-2 rounded-full"
style={{ background: categoryColor(category, 0) }}
/>
{CATEGORY_META[category].label}
</span>
))}
{hasMultipleShades && (
<span className="text-[10px] text-slate-400">{LABELS.shadeHint}</span>
)}
</div>
<div dir="ltr" className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={rows}
barCategoryGap={BAR_CATEGORY_GAP}
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
minTickGap={18}
/>
<YAxis
domain={[domainMin, domainMax]}
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={unitLabel ? 70 : 56}
label={
unitLabel
? {
value: unitLabel,
angle: -90,
position: "insideLeft",
style: { fontSize: 10, fill: "#64748b" },
}
: undefined
}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={<FlowTooltip unitLabel={unitLabel} materialName={materialName} />}
/>
{/* سگمنت‌های شناور: هر گروه (مشخصات+برند) یک پله از خانواده‌ی رنگ
دسته، با جداکننده‌ی سفید ۱px و یک ستون مشترک */}
{CATEGORY_ORDER.map((category) =>
Array.from({ length: groupCounts[category] ?? 0 }, (_, index) => (
<Bar
key={`${category}-${index}`}
dataKey={`seg_${category}_${index}`}
fill={categoryColor(category, index)}
stroke="#ffffff"
strokeWidth={1}
stackId={FLOW_STACK_ID}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
)),
)}
/>
{/* سگمنت‌های شناور آبشاری — هر جزء دقیقاً روی مسیر مانده می‌نشیند */}
<Bar
dataKey="segInbound"
name={LABELS.inbound}
fill={FLOW_COLORS.inbound}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segReturns"
name={LABELS.returns}
fill={FLOW_COLORS.returns}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segSiteOut"
name={LABELS.siteOut}
fill={FLOW_COLORS.siteOut}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segWaste"
name={LABELS.waste}
fill={FLOW_COLORS.waste}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
{/* فقط وقتی حرکتی خارج از چهار سبد ثبت شده باشد نمایش داده می‌شود */}
{hasOther && (
<Bar
dataKey="segOther"
name={LABELS.other}
fill={FLOW_COLORS.other}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
{/* خط پله‌ای مانده — هم‌تراز هندسی با سرِ ستون‌های شناور */}
<Line
type="stepAfter"
dataKey="balance"
name={LABELS.closing}
stroke="#0f172a"
strokeWidth={1.75}
strokeDasharray="5 3"
dot={false}
isAnimationActive={false}
/>
)}
{/* خط پله‌ای مانده — هم‌تراز هندسی با سرِ ستون‌های شناور */}
<Line
type="stepAfter"
dataKey="balance"
name={LABELS.balance}
stroke={FLOW_COLORS.balance}
strokeWidth={1.75}
strokeDasharray="5 3"
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
)}
</ChartCard>
+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();
return useQuery({
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
enabled,
});
}
+3 -3
View File
@@ -23,7 +23,7 @@ import type {
DailyControlData,
DeadStockItem,
DrilldownData,
FlowDay,
FlowData,
InTransitRow,
InventoryRow,
InventoryTransaction,
@@ -1928,8 +1928,8 @@ export const apiV26 = {
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
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))}`),
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> =>
getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<FlowData> =>
getData<FlowData>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
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 {
date: string; // ISO (YYYY-MM-DD)
inbound: number;
@@ -290,6 +304,14 @@ export interface FlowDay {
waste: number;
returns: number;
balance: number;
breakdown?: FlowBreakdownItem[];
}
export interface FlowData {
opening_balance: number;
// واحد کاتالوگی متریال انتخاب‌شده (فقط وقتی یک متریال فیلتر شده باشد)
unit: { code: string; name_fa: string | null } | null;
days: FlowDay[];
}
// — کنترل روزانه —