Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692343423c | ||
|
|
eedb821e3b | ||
|
|
3d16c435d0 | ||
|
|
34b987398a |
@@ -96,13 +96,11 @@ async def dashboard_scope(
|
||||
|
||||
|
||||
def _what_filters(
|
||||
category_id: uuid.UUID | None,
|
||||
material_id: uuid.UUID | None,
|
||||
brand_id: uuid.UUID | None,
|
||||
specs_hash: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"category_id": category_id,
|
||||
"material_id": material_id,
|
||||
"brand_id": brand_id,
|
||||
"specs_hash": specs_hash,
|
||||
@@ -111,7 +109,7 @@ def _what_filters(
|
||||
|
||||
@router.get("/filters")
|
||||
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||
"""Cascading options for the global filter bar (Category→Material→Brand→Specs)."""
|
||||
"""Options for the global filter bar (Material→Brand→Specs)."""
|
||||
return await dashboard_service.filter_options(db)
|
||||
|
||||
|
||||
@@ -119,7 +117,6 @@ async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||
async def inventory_drilldown(
|
||||
scope: DashboardScope = Depends(dashboard_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
category_id: uuid.UUID | None = Query(default=None),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
@@ -129,7 +126,7 @@ async def inventory_drilldown(
|
||||
db,
|
||||
project_ids=scope.project_ids,
|
||||
project_id=scope.project_id,
|
||||
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
||||
**_what_filters(material_id, brand_id, specs_hash),
|
||||
)
|
||||
|
||||
|
||||
@@ -139,7 +136,6 @@ 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),
|
||||
category_id: uuid.UUID | None = Query(default=None),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
@@ -151,7 +147,7 @@ async def dead_stock(
|
||||
project_id=scope.project_id,
|
||||
idle_days=idle_days,
|
||||
limit=limit,
|
||||
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
||||
**_what_filters(material_id, brand_id, specs_hash),
|
||||
)
|
||||
|
||||
|
||||
@@ -162,7 +158,6 @@ async def inventory_flow(
|
||||
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),
|
||||
category_id: uuid.UUID | None = Query(default=None),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
@@ -182,7 +177,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(category_id, material_id, brand_id, specs_hash),
|
||||
**_what_filters(material_id, brand_id, specs_hash),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Read-only service. Every function receives the resolved scope (PM → the list
|
||||
of projects they manage; office roles → project_ids=None = whole
|
||||
organization) plus an optional explicit project filter, and the optional
|
||||
«What» filters (category_id / material_id / brand_id / specs_hash).
|
||||
«What» filters (material_id / brand_id / specs_hash).
|
||||
|
||||
Quantities are NEVER mixed across units: inventory rows live in the
|
||||
material's canonical unit, so stock aggregates carry their unit code and the
|
||||
@@ -19,8 +19,9 @@ from datetime import datetime, time, timedelta, timezone
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
|
||||
from app.models.catalog import Brand, BrandMaterial, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -43,10 +44,8 @@ OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
|
||||
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
|
||||
|
||||
|
||||
def _stock_filters(stmt, *, category_id=None, material_id=None, brand_id=None, specs_hash=None):
|
||||
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
|
||||
"""«What» filters for a statement that already joins inventory→material."""
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(Material.category_id == category_id)
|
||||
if material_id is not None:
|
||||
stmt = stmt.where(InventoryItem.material_id == material_id)
|
||||
if brand_id is not None:
|
||||
@@ -74,23 +73,15 @@ def _qty(value) -> float:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def filter_options(db: AsyncSession) -> dict:
|
||||
"""Cascading options for the global filter bar.
|
||||
"""Options for the global filter bar (Material → Brand → Specs).
|
||||
|
||||
Categories/materials come from the catalog (active only). Brand options
|
||||
per material are the UNION of the catalog links (brand_materials — every
|
||||
brand defined for that material) and the distinct brands actually present
|
||||
in stock, so the cascade is complete even for materials whose current
|
||||
stock rows carry no brand yet. Spec options come from stock rows
|
||||
(inventory.specs_hash), since specs are a stock-level dimension.
|
||||
Materials without a category land in the synthetic «بدون دسته» bucket.
|
||||
Materials come from the catalog (active only). Brand options per material
|
||||
are the UNION of the catalog links (brand_materials — every brand defined
|
||||
for that material) and the distinct brands actually present in stock, so
|
||||
the cascade is complete even for materials whose current stock rows carry
|
||||
no brand yet. Spec options come from stock rows (inventory.specs_hash),
|
||||
since specs are a stock-level dimension.
|
||||
"""
|
||||
categories = (
|
||||
await db.execute(
|
||||
select(MaterialCategory)
|
||||
.where(MaterialCategory.is_active.is_(True))
|
||||
.order_by(MaterialCategory.name)
|
||||
)
|
||||
).scalars().all()
|
||||
materials = (
|
||||
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
|
||||
).scalars().all()
|
||||
@@ -148,18 +139,8 @@ async def filter_options(db: AsyncSession) -> dict:
|
||||
],
|
||||
}
|
||||
|
||||
buckets: dict[uuid.UUID | None, list] = {c.id: [] for c in categories}
|
||||
buckets.setdefault(None, [])
|
||||
for m in materials:
|
||||
buckets.setdefault(m.category_id, []).append(material_entry(m))
|
||||
|
||||
out = [
|
||||
{"id": str(c.id), "name": c.name, "slug": c.slug, "materials": buckets.get(c.id, [])}
|
||||
for c in categories
|
||||
]
|
||||
if buckets.get(None):
|
||||
out.append({"id": None, "name": "بدون دسته", "slug": None, "materials": buckets[None]})
|
||||
return {"data": {"categories": out}}
|
||||
# فهرست تخت متریالها — سرآغاز آبشار سهفیلتری (متریال → برند → مشخصات)
|
||||
return {"data": {"materials": [material_entry(m) for m in materials]}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -171,69 +152,164 @@ async def inventory_drilldown(
|
||||
*,
|
||||
project_ids,
|
||||
project_id,
|
||||
category_id=None,
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
) -> dict:
|
||||
"""Stock quantity as a three-level tree; each aggregate keyed by unit code
|
||||
so incompatible units never merge into one number."""
|
||||
stmt = (
|
||||
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
|
||||
|
||||
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
|
||||
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
|
||||
warehouse, unit) stock aggregation. Active catalog materials without any
|
||||
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
|
||||
(labelled with their canonical unit) instead of disappearing in an inner
|
||||
join.
|
||||
"""
|
||||
# تجمیع دفتر انبار بهازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
|
||||
# راست LEFT JOIN؛ محدودیت دامنه (پروژههای مجاز) همینجا اعمال میشود تا
|
||||
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
|
||||
stock = (
|
||||
select(
|
||||
Project.id,
|
||||
Project.name,
|
||||
Warehouse.id,
|
||||
Warehouse.name,
|
||||
Unit.code,
|
||||
Unit.name_fa,
|
||||
InventoryItem.material_id.label("material_id"),
|
||||
InventoryItem.brand_id.label("brand_id"),
|
||||
InventoryItem.specs_hash.label("specs_hash"),
|
||||
InventoryItem.specs.label("specs"),
|
||||
Project.id.label("pid"),
|
||||
Project.name.label("pname"),
|
||||
Warehouse.id.label("wid"),
|
||||
Warehouse.name.label("wname"),
|
||||
Unit.code.label("ucode"),
|
||||
Unit.name_fa.label("uname_fa"),
|
||||
func.sum(InventoryItem.quantity).label("qty"),
|
||||
func.count().label("rows"),
|
||||
)
|
||||
.select_from(InventoryItem)
|
||||
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||
.join(Project, Project.id == Warehouse.project_id)
|
||||
.join(Material, Material.id == InventoryItem.material_id)
|
||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||
.where(InventoryItem.quantity > 0)
|
||||
.group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
|
||||
.order_by(Project.name, Warehouse.name)
|
||||
.group_by(
|
||||
InventoryItem.material_id,
|
||||
InventoryItem.brand_id,
|
||||
InventoryItem.specs_hash,
|
||||
InventoryItem.specs,
|
||||
Project.id,
|
||||
Project.name,
|
||||
Warehouse.id,
|
||||
Warehouse.name,
|
||||
Unit.code,
|
||||
Unit.name_fa,
|
||||
)
|
||||
)
|
||||
stmt = _stock_filters(
|
||||
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
|
||||
stock_sq = stock.subquery()
|
||||
|
||||
# واحد کاتالوگی ماده — برچسب واحدِ ردیفهای صفر («۰ عدد»)
|
||||
canon_unit = aliased(Unit)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Material.id.label("mid"),
|
||||
Material.name.label("mname"),
|
||||
Brand.name.label("bname"),
|
||||
stock_sq.c.specs,
|
||||
stock_sq.c.pid,
|
||||
stock_sq.c.pname,
|
||||
stock_sq.c.wid,
|
||||
stock_sq.c.wname,
|
||||
stock_sq.c.ucode,
|
||||
stock_sq.c.uname_fa,
|
||||
func.coalesce(stock_sq.c.qty, 0).label("qty"),
|
||||
func.coalesce(stock_sq.c.rows, 0).label("rows"),
|
||||
canon_unit.code.label("canon_ucode"),
|
||||
canon_unit.name_fa.label("canon_uname_fa"),
|
||||
)
|
||||
.select_from(Material)
|
||||
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
|
||||
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
|
||||
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
|
||||
.where(Material.status == "active")
|
||||
)
|
||||
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
||||
if material_id is not None:
|
||||
stmt = stmt.where(Material.id == material_id)
|
||||
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
|
||||
# موجودی روی محور میمانند (رفتار قبلی دستنخورده)
|
||||
if brand_id is not None:
|
||||
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
|
||||
if specs_hash:
|
||||
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
|
||||
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
units_legend: dict[str, str] = {}
|
||||
total_by_unit: dict[str, float] = {}
|
||||
total_rows = 0
|
||||
projects_tree: dict[uuid.UUID, dict] = {}
|
||||
for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows:
|
||||
|
||||
materials_tree: dict[str, dict] = {}
|
||||
|
||||
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
|
||||
q = _qty(qty)
|
||||
units_legend[ucode] = uname_fa
|
||||
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
||||
|
||||
# افسانهی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
|
||||
if ucode is not None:
|
||||
units_legend[ucode] = uname_fa
|
||||
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
||||
elif canon_ucode is not None:
|
||||
units_legend.setdefault(canon_ucode, canon_uname_fa)
|
||||
total_by_unit.setdefault(canon_ucode, 0.0)
|
||||
total_rows += row_count
|
||||
proj = projects_tree.setdefault(
|
||||
|
||||
# Build full material name
|
||||
parts = [mname]
|
||||
if bname:
|
||||
parts.append(bname)
|
||||
if specs:
|
||||
parts.extend(str(v) for v in specs.values() if v)
|
||||
full_name = " ".join(parts)
|
||||
# Use full_name + unit as unique key for material level, or just full_name
|
||||
mat_key = f"{mid}_{full_name}"
|
||||
|
||||
mat = materials_tree.setdefault(
|
||||
mat_key,
|
||||
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
|
||||
)
|
||||
if ucode is not None:
|
||||
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||
elif canon_ucode is not None:
|
||||
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
|
||||
mat["rows"] += row_count
|
||||
|
||||
# مواد بدون هیچ ردیف موجودی: فقط روی محور میمانند (بدون پروژه/انبار)
|
||||
if pid is None:
|
||||
continue
|
||||
|
||||
proj = mat["_projects"].setdefault(
|
||||
pid,
|
||||
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
|
||||
)
|
||||
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||
proj["rows"] += row_count
|
||||
|
||||
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
|
||||
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||
wh["rows"] += row_count
|
||||
|
||||
projects = []
|
||||
for proj in projects_tree.values():
|
||||
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
|
||||
projects.append({**proj, "warehouses": warehouses})
|
||||
projects.sort(key=lambda p: p["name"])
|
||||
materials = []
|
||||
for mat in materials_tree.values():
|
||||
projects_list = []
|
||||
for proj in mat.pop("_projects").values():
|
||||
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
|
||||
projects_list.append({**proj, "warehouses": warehouses})
|
||||
projects_list.sort(key=lambda p: p["name"])
|
||||
materials.append({**mat, "projects": projects_list})
|
||||
|
||||
materials.sort(key=lambda m: m["name"])
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"units": units_legend,
|
||||
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
|
||||
"projects": projects,
|
||||
"materials": materials,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +324,6 @@ async def dead_stock(
|
||||
project_ids,
|
||||
project_id,
|
||||
idle_days: int = 60,
|
||||
category_id=None,
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
@@ -293,7 +368,7 @@ async def dead_stock(
|
||||
.limit(limit)
|
||||
)
|
||||
stmt = _stock_filters(
|
||||
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||
)
|
||||
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
@@ -328,7 +403,6 @@ async def daily_flow(
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
warehouse_id=None,
|
||||
category_id=None,
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
@@ -355,7 +429,7 @@ async def daily_flow(
|
||||
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||
if warehouse_id is not None:
|
||||
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||
what_conditions = [Material.category_id == category_id] if category_id is not None else []
|
||||
what_conditions = []
|
||||
if material_id is not None:
|
||||
what_conditions.append(InventoryItem.material_id == material_id)
|
||||
if brand_id is not None:
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface ComboboxProps {
|
||||
value?: string;
|
||||
onChange?: (item: ComboboxItem) => void;
|
||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||
// شناسهی آیتمهایی که همیشه در ابتدای فهرست میمانند و با جستجوی کاربر
|
||||
// فیلتر نمیشوند (مثل گزینهی «همهی …» در فیلترهای آبشاری داشبورد)
|
||||
pinnedIds?: string[];
|
||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||
addSuffix?: string; // متن بعد از +
|
||||
@@ -50,6 +53,7 @@ export function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
pinnedIds,
|
||||
allowAdd = true,
|
||||
addPrefix,
|
||||
addSuffix = "به عنوان گزینه جدید",
|
||||
@@ -88,21 +92,37 @@ export function Combobox({
|
||||
return all.filter((x) => normalize(x.label).includes(q));
|
||||
}, [all, q]);
|
||||
|
||||
// آیتمهای pinشده همیشه در ابتدای فهرست میمانند و با جستجو حذف نمیشوند؛
|
||||
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام میشود.
|
||||
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
|
||||
const pin = pinnedIds ?? [];
|
||||
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
|
||||
const pinned = pin
|
||||
.map((id) => all.find((x) => x.id === id))
|
||||
.filter((x): x is ComboboxItem => x !== undefined);
|
||||
const pinnedSet = new Set(pinned.map((x) => x.id));
|
||||
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
|
||||
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
|
||||
}, [all, filtered, pinnedIds]);
|
||||
|
||||
const showAdd =
|
||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
||||
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
|
||||
|
||||
const showClear = Boolean(onClear) && !disabled && text.length > 0;
|
||||
|
||||
const listCount = filtered.length + (showAdd ? 1 : 0);
|
||||
const listCount = visible.length + (showAdd ? 1 : 0);
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
const pick = (it: ComboboxItem) => {
|
||||
setText(it.label);
|
||||
// آیتم با شناسهی خالی یعنی «انتخاب نشده» (گزینهی «همهی …»)؛ فیلد به
|
||||
// placeholder برمیگردد تا متن/دکمهی × برای مقدار پاکشده باقی نماند.
|
||||
const nextText = it.id ? it.label : "";
|
||||
setText(nextText);
|
||||
setOpen(false);
|
||||
setHighlight(-1);
|
||||
onChange?.(it);
|
||||
onInputChange?.(it.label);
|
||||
onInputChange?.(nextText);
|
||||
};
|
||||
|
||||
const addCurrent = () => {
|
||||
@@ -127,9 +147,9 @@ export function Combobox({
|
||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (highlight >= 0 && highlight < filtered.length) {
|
||||
pick(filtered[highlight]);
|
||||
} else if (highlight === filtered.length && showAdd) {
|
||||
if (highlight >= 0 && highlight < visible.length) {
|
||||
pick(visible[highlight]);
|
||||
} else if (highlight === visible.length && showAdd) {
|
||||
addCurrent();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -197,13 +217,14 @@ export function Combobox({
|
||||
{listCount === 0 && (
|
||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||
)}
|
||||
{filtered.map((it, i) => (
|
||||
{visible.map((it, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={it.id + "|" + i}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||
i === highlight && "bg-slate-bg",
|
||||
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // حفظ فوکوس input
|
||||
|
||||
@@ -1,49 +1,96 @@
|
||||
// ============================================================
|
||||
// فیلتر سراسری «چه چیزی» — آبشار: دستهبندی → متریال → برند → مشخصات
|
||||
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
|
||||
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
||||
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
||||
// دسته → متریالهای همان دسته
|
||||
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
||||
// متریال → مشخصات فنی موجودِ همان متریال
|
||||
// هر فیلد دکمهی «×» دارد که فقط همان سطح (و زیرمجموعهاش) را پاک میکند.
|
||||
// هر فیلد «×» مخصوص خودش را دارد و گزینهی «همهی …» در ابتدای هر فهرست
|
||||
// همان سطح (و سطوح پاییندست) را پاک میکند؛ دکمهی «حذف فیلترها» کل استور را
|
||||
// یکجا به نمای «کل سازمان» برمیگرداند.
|
||||
// سه فیلتر در یک ردیفِ وسطچین با عرض یکسان مینشینند (موبایل: تکستونی).
|
||||
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Filter } from "lucide-react";
|
||||
import { Combobox } from "../Combobox";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import { Combobox, type ComboboxItem } from "../Combobox";
|
||||
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// گزینهی «همه» — انتخابی که همان سطح و سطوح پاییندستش را پاک میکند.
|
||||
// شناسهی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمیشود.
|
||||
const ALL_OPTION_ID = "";
|
||||
// این شناسه همیشه در ابتدای فهرست میماند و با جستجوی کاربر فیلتر نمیشود
|
||||
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
|
||||
|
||||
// برچسبهای ثابت UI — placeholder هر فیلد و گزینهی «همه»اش یک متن مشترک دارند
|
||||
const LABELS = {
|
||||
allMaterials: "همهی متریالها",
|
||||
allBrands: "همهی برندها",
|
||||
allSpecs: "همهی مشخصات",
|
||||
materialFirst: "ابتدا متریال را انتخاب کنید",
|
||||
clearAll: "حذف فیلترها",
|
||||
wholeOrg: "نمای کل سازمان",
|
||||
cascade: "متریال ← برند ← مشخصات فنی",
|
||||
} as const;
|
||||
|
||||
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
|
||||
|
||||
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیشفرض)»
|
||||
function specLabel(specs: Record<string, string> | null): string {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
||||
}
|
||||
|
||||
export function DashboardFilterBar({ className }: { className?: string }) {
|
||||
const filterQuery = useDashboardFilterOptions();
|
||||
const state = useDashboardFilter();
|
||||
|
||||
const categories = filterQuery.data?.categories ?? [];
|
||||
|
||||
// متریالهای سطح فعلی: اگر دسته انتخاب شده باشد فقط متریالهای همان دسته
|
||||
const materialOptions = useMemo(() => {
|
||||
const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? ""));
|
||||
const list = cat ? cat.materials : categories.flatMap((c) => c.materials);
|
||||
// حذف متریال تکراری وقتی «همهی دستهها» فعال است
|
||||
// فهرست متریالها مستقیماً از کاتالوگ سراسری میآید؛ فقط یکتاسازی شناسهها
|
||||
// انجام میشود تا هیچ گزینهی تکراری در فهرست نباشد.
|
||||
const materials = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
|
||||
}, [categories, state.categoryId]);
|
||||
return (filterQuery.data?.materials ?? []).filter((m) =>
|
||||
seen.has(m.id) ? false : (seen.add(m.id), true),
|
||||
);
|
||||
}, [filterQuery.data]);
|
||||
|
||||
const selectedMaterial = useMemo(
|
||||
() => materialOptions.find((m) => m.id === state.materialId) ?? null,
|
||||
[materialOptions, state.materialId],
|
||||
() => materials.find((m) => m.id === state.materialId) ?? null,
|
||||
[materials, state.materialId],
|
||||
);
|
||||
|
||||
const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
|
||||
const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
|
||||
|
||||
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیشفرض)»
|
||||
const specLabel = (specs: Record<string, string> | null) => {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
||||
};
|
||||
// انتخاب از فهرست؛ گزینهی «همه» همان سطح را پاک میکند (آبشار خودکار)
|
||||
const pick =
|
||||
(set: (id: string | null, label: string | null) => void) =>
|
||||
(item: ComboboxItem) =>
|
||||
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
|
||||
|
||||
// فهرست هر فیلد با گزینهی «همه» در ابتدا
|
||||
const materialItems = useMemo<ComboboxItem[]>(
|
||||
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
|
||||
[materials],
|
||||
);
|
||||
|
||||
const brandItems = useMemo<ComboboxItem[]>(
|
||||
() => [
|
||||
allOption(LABELS.allBrands),
|
||||
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
|
||||
],
|
||||
[selectedMaterial],
|
||||
);
|
||||
|
||||
const specItems = useMemo<ComboboxItem[]>(
|
||||
() => [
|
||||
allOption(LABELS.allSpecs),
|
||||
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
|
||||
],
|
||||
[selectedMaterial],
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -56,61 +103,61 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
|
||||
<p className="text-[10.5px] text-muted">دستهبندی ← متریال ← برند ← مشخصات فنی</p>
|
||||
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
|
||||
</div>
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.reset}
|
||||
className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
|
||||
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
|
||||
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
|
||||
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
|
||||
>
|
||||
حذف فیلترها ({faNum(activeCount)})
|
||||
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
|
||||
{LABELS.clearAll}
|
||||
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
|
||||
{faNum(activeCount)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
{/* ۱ — دستهبندی */}
|
||||
<Combobox
|
||||
value={state.categoryLabel ?? ""}
|
||||
placeholder="همهی دستهها"
|
||||
allowAdd={false}
|
||||
items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
|
||||
onClear={() => state.setCategory(null, null)}
|
||||
onChange={(item) => state.setCategory(item.id, item.label)}
|
||||
/>
|
||||
|
||||
{/* ۲ — متریال (وابسته به دستهی انتخابشده) */}
|
||||
{/* سه فیلتر با عرض یکسان، وسطچین و متقارن؛ روی موبایل تکستونی میشود */}
|
||||
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
|
||||
{/* ۱ — متریال (سرآغاز آبشار) */}
|
||||
<Combobox
|
||||
value={state.materialLabel ?? ""}
|
||||
placeholder="همهی متریالها"
|
||||
placeholder={LABELS.allMaterials}
|
||||
allowAdd={false}
|
||||
disabled={materialOptions.length === 0}
|
||||
items={materialOptions.map((m) => ({ id: m.id, label: m.name }))}
|
||||
disabled={materials.length === 0}
|
||||
items={materialItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setMaterial(null, null)}
|
||||
onChange={(item) => state.setMaterial(item.id, item.label)}
|
||||
onChange={pick(state.setMaterial)}
|
||||
/>
|
||||
|
||||
{/* ۳ — برند (فقط برندهای همان متریال) */}
|
||||
{/* ۲ — برند (فقط برندهای همان متریال) */}
|
||||
<Combobox
|
||||
value={state.brandLabel ?? ""}
|
||||
placeholder={selectedMaterial ? "همهی برندها" : "ابتدا متریال را انتخاب کنید"}
|
||||
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
||||
items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))}
|
||||
items={brandItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setBrand(null, null)}
|
||||
onChange={(item) => state.setBrand(item.id, item.label)}
|
||||
onChange={pick(state.setBrand)}
|
||||
/>
|
||||
|
||||
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||
<Combobox
|
||||
value={state.specsLabel ?? ""}
|
||||
placeholder={selectedMaterial ? "همهی مشخصات" : "ابتدا متریال را انتخاب کنید"}
|
||||
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
||||
items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))}
|
||||
items={specItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setSpecs(null, null)}
|
||||
onChange={(item) => state.setSpecs(item.id, item.label)}
|
||||
onChange={pick(state.setSpecs)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
// ============================================================
|
||||
// ویجت ۳ — سری زمانی جریان روزانه
|
||||
// ستونهای روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایهی
|
||||
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
|
||||
// بهعلاوهی خط «مانده» (بالانس تجمعی). تاریخها شمسی، اعداد فارسی.
|
||||
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
|
||||
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
|
||||
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
|
||||
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
|
||||
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
@@ -19,57 +22,234 @@ import {
|
||||
} from "recharts";
|
||||
import { useDailyFlow } from "../../hooks/useDashboardData";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import {
|
||||
CHART_COLORS,
|
||||
ChartCard,
|
||||
ChartEmpty,
|
||||
ChartLoading,
|
||||
ChartTooltip,
|
||||
faAxisTick,
|
||||
} from "./chartShared";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
||||
|
||||
interface Row {
|
||||
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
|
||||
const FLOW_COLORS = {
|
||||
inbound: "#059669", // ورود به انبار — زمردی
|
||||
returns: "#0d9488", // برگشت از سایت — تیل
|
||||
siteOut: "#f59e0b", // خروج به سایت — کهربایی
|
||||
waste: "#dc2626", // ضایعات و اتلاف — قرمز
|
||||
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
|
||||
balance: "#0f172a", // خط مانده — سرمهای
|
||||
} as const;
|
||||
|
||||
// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX)
|
||||
const LABELS = {
|
||||
title: "جریان روزانه موجودی",
|
||||
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
|
||||
inbound: "ورود به انبار",
|
||||
returns: "برگشت از سایت",
|
||||
siteOut: "خروج به سایت",
|
||||
waste: "ضایعات و اتلاف",
|
||||
other: "سایر حرکات (انتقال/شمارش)",
|
||||
balance: "مانده",
|
||||
opening: "مانده ابتدای روز",
|
||||
net: "تغییر خالص روز",
|
||||
closing: "مانده پایان روز",
|
||||
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||
} as const;
|
||||
|
||||
// سگمنت شناور: [پایین، بالا] — همان چیزی که <Bar> بهعنوان مقدار محدوده میخواند
|
||||
type Range = [number, number];
|
||||
|
||||
// یک ردیف زمانی — سگمنتها بهصورت پراپرتیهای مسطح مینشینند تا dataKey هر
|
||||
// <Bar> مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن).
|
||||
interface FlowRow {
|
||||
date: string;
|
||||
ورود: number;
|
||||
برگشت: number;
|
||||
"خروج سایت": number;
|
||||
"ضایعات و اتلاف": number;
|
||||
مانده: number;
|
||||
_total: number;
|
||||
balance: number; // خط پلهای مانده = مانده پایان روز
|
||||
opening: number;
|
||||
closing: number;
|
||||
net: number;
|
||||
inbound: number;
|
||||
returns: number;
|
||||
siteOut: number;
|
||||
waste: number;
|
||||
other: number;
|
||||
hasMovement: boolean;
|
||||
segInbound: Range;
|
||||
segReturns: Range;
|
||||
segSiteOut: Range;
|
||||
segWaste: Range;
|
||||
segOther: Range;
|
||||
}
|
||||
|
||||
// گرد کردن تا ۴ رقم اعشار — مثل بقیهی تجمیعهای داشبورد
|
||||
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 FlowTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: readonly { payload?: FlowRow }[];
|
||||
}) {
|
||||
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 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"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowChart() {
|
||||
const query = useDailyFlow();
|
||||
|
||||
const rows: Row[] = useMemo(
|
||||
() =>
|
||||
(query.data?.days ?? []).map((d) => ({
|
||||
date: d.date,
|
||||
ورود: d.inbound,
|
||||
برگشت: d.returns,
|
||||
"خروج سایت": Math.max(0, d.outbound - d.waste),
|
||||
"ضایعات و اتلاف": d.waste,
|
||||
مانده: d.balance,
|
||||
_total: d.inbound + d.outbound + d.returns,
|
||||
})),
|
||||
[query.data],
|
||||
);
|
||||
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار میشود ———
|
||||
const rows: FlowRow[] = 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);
|
||||
const net = round4(closing - opening);
|
||||
// «سایر حرکات» = باقیماندهی تراز (انتقال/شمارش/یافتشده)؛ معمولاً صفر
|
||||
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
|
||||
|
||||
const hasAnyData = rows.some((r) => r._total > 0);
|
||||
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);
|
||||
|
||||
running = closing;
|
||||
return {
|
||||
date: d.date,
|
||||
balance: closing,
|
||||
opening,
|
||||
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,
|
||||
};
|
||||
});
|
||||
}, [query.data]);
|
||||
|
||||
// ——— دامنهی محور 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);
|
||||
});
|
||||
});
|
||||
if (values.length === 0) return [0, 1] as const;
|
||||
const lo = Math.min(...values);
|
||||
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]);
|
||||
|
||||
const hasMovement = rows.some((r) => r.hasMovement);
|
||||
const hasOther = rows.some((r) => r.other !== 0);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="جریان روزانه موجودی"
|
||||
sub="ورود، خروج (با لایهی قرمز ضایعات)، برگشت از سایت و ماندهی تجمعی — ۳۰ روز اخیر"
|
||||
>
|
||||
<ChartCard title={LABELS.title} sub={LABELS.sub}>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !hasAnyData ? (
|
||||
<ChartEmpty message="در این بازه جابهجایی موجودی ثبت نشده است" />
|
||||
) : !hasMovement ? (
|
||||
<ChartEmpty message={LABELS.empty} />
|
||||
) : (
|
||||
<div dir="ltr" className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ top: 12, right: 8, left: 8, bottom: 4 }}>
|
||||
<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"
|
||||
@@ -80,6 +260,7 @@ export function FlowChart() {
|
||||
minTickGap={18}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[domainMin, domainMax]}
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
@@ -88,11 +269,7 @@ export function FlowChart() {
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
|
||||
/>
|
||||
}
|
||||
content={<FlowTooltip />}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||
@@ -106,17 +283,62 @@ export function FlowChart() {
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
{/* ستونهای کنار هم: ورود و برگشت */}
|
||||
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||
{/* ستون خروج: دو لایهی سته — بخش قرمز ضایعات داخل ستون خروج */}
|
||||
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} 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}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="مانده"
|
||||
stroke={CHART_COLORS.balance}
|
||||
strokeWidth={1.5}
|
||||
type="stepAfter"
|
||||
dataKey="balance"
|
||||
name={LABELS.balance}
|
||||
stroke={FLOW_COLORS.balance}
|
||||
strokeWidth={1.75}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
|
||||
@@ -46,7 +46,6 @@ import type {
|
||||
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
||||
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
||||
export type DashboardFilterParams = {
|
||||
category_id?: string | null;
|
||||
material_id?: string | null;
|
||||
brand_id?: string | null;
|
||||
specs_hash?: string | null;
|
||||
|
||||
@@ -232,15 +232,8 @@ export interface FilterMaterialOption {
|
||||
specs: FilterSpecOption[];
|
||||
}
|
||||
|
||||
export interface FilterCategoryOption {
|
||||
id: string | null; // null = «بدون دسته»
|
||||
name: string;
|
||||
slug: string | null;
|
||||
materials: FilterMaterialOption[];
|
||||
}
|
||||
|
||||
export interface DashboardFilterOptions {
|
||||
categories: FilterCategoryOption[];
|
||||
materials: FilterMaterialOption[];
|
||||
}
|
||||
|
||||
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
||||
@@ -261,10 +254,18 @@ export interface DrilldownProject {
|
||||
warehouses: DrilldownWarehouse[];
|
||||
}
|
||||
|
||||
export interface DrilldownMaterial {
|
||||
id: string;
|
||||
name: string;
|
||||
qty_by_unit: QtyByUnit;
|
||||
rows: number;
|
||||
projects: DrilldownProject[];
|
||||
}
|
||||
|
||||
export interface DrilldownData {
|
||||
units: Record<string, string>;
|
||||
total: { qty_by_unit: QtyByUnit; rows: number };
|
||||
projects: DrilldownProject[];
|
||||
materials: DrilldownMaterial[];
|
||||
}
|
||||
|
||||
// — جدول موجودی راکد —
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
// ============================================================
|
||||
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
||||
// دستهبندی → متریال → برند → مشخصات فنی
|
||||
// متریال → برند → مشخصات فنی
|
||||
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
||||
// ============================================================
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface DashboardFilterState {
|
||||
categoryId: string | null; // null = همهی دستهها
|
||||
materialId: string | null;
|
||||
materialId: string | null; // null = همهی متریالها
|
||||
brandId: string | null;
|
||||
specsHash: string | null;
|
||||
// برچسبهای انتخابشده برای نمایش چیپ فعال
|
||||
categoryLabel: string | null;
|
||||
materialLabel: string | null;
|
||||
brandLabel: string | null;
|
||||
specsLabel: string | null;
|
||||
|
||||
setCategory: (id: string | null, label: string | null) => void;
|
||||
setMaterial: (id: string | null, label: string | null) => void;
|
||||
setBrand: (id: string | null, label: string | null) => void;
|
||||
setSpecs: (hash: string | null, label: string | null) => void;
|
||||
@@ -25,11 +22,9 @@ export interface DashboardFilterState {
|
||||
}
|
||||
|
||||
const INITIAL = {
|
||||
categoryId: null,
|
||||
materialId: null,
|
||||
brandId: null,
|
||||
specsHash: null,
|
||||
categoryLabel: null,
|
||||
materialLabel: null,
|
||||
brandLabel: null,
|
||||
specsLabel: null,
|
||||
@@ -37,18 +32,7 @@ const INITIAL = {
|
||||
|
||||
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
||||
...INITIAL,
|
||||
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند
|
||||
setCategory: (id, label) =>
|
||||
set({
|
||||
categoryId: id,
|
||||
categoryLabel: label,
|
||||
materialId: null,
|
||||
materialLabel: null,
|
||||
brandId: null,
|
||||
brandLabel: null,
|
||||
specsHash: null,
|
||||
specsLabel: null,
|
||||
}),
|
||||
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند (متریال → برند → مشخصات)
|
||||
setMaterial: (id, label) =>
|
||||
set({
|
||||
materialId: id,
|
||||
@@ -72,7 +56,6 @@ export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
||||
// پارامترهای آماده برای endpointهای داشبورد
|
||||
export function dashboardFilterParams(s: DashboardFilterState) {
|
||||
return {
|
||||
category_id: s.categoryId,
|
||||
material_id: s.materialId,
|
||||
brand_id: s.brandId,
|
||||
specs_hash: s.specsHash,
|
||||
|
||||
Reference in New Issue
Block a user