diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 8932a03..6e4b450 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -100,12 +100,14 @@ def _what_filters( brand_id: uuid.UUID | None, specs_hash: str | None, spec_id: str | None = None, + material_family: str | None = None, ) -> dict: """دامنه‌ی «چه چیزی» را به پارامترهای سرویس تبدیل می‌کند. - شناسه‌ی ماده اگر UUID معتبر باشد → دامنه‌ی همان ماده (همه‌ی مشخصات آن). - اگر مقدار ارسالی UUID نباشد (یعنی شناسه‌ی مشخصه فرستاده شده) یا spec_id - صریح بیاید → دامنه به همان مشخصه محدود می‌شود (specs_hash). + • شناسه‌ی ماده اگر UUID معتبر باشد → دامنه‌ی همان ماده (همه‌ی مشخصات آن). + • اگر مقدار ارسالی UUID نباشد (یعنی شناسه‌ی مشخصه فرستاده شده) یا spec_id + صریح بیاید → دامنه به همان مشخصه محدود می‌شود (specs_hash). + • material_family → همه‌ی ردیف‌های کاتالوگِ همان خانواده (سرویس حل می‌کند). """ resolved_material: uuid.UUID | None = None resolved_specs = specs_hash or spec_id @@ -119,6 +121,7 @@ def _what_filters( "material_id": resolved_material, "brand_id": brand_id, "specs_hash": resolved_specs, + "material_family": (material_family or "").strip() or None, } @@ -136,13 +139,14 @@ async def inventory_drilldown( brand_id: uuid.UUID | None = Query(default=None), specs_hash: str | None = Query(default=None), spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"), + material_family: 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, spec_id), + **_what_filters(material_id, brand_id, specs_hash, spec_id, material_family), ) @@ -150,12 +154,13 @@ async def inventory_drilldown( async def dead_stock( scope: DashboardScope = Depends(dashboard_scope), db: AsyncSession = Depends(get_db), - idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"), + idle_days: int = Query(default=0, ge=0, le=365, description="آستانه رکود (روز) — ۰ یعنی همه"), limit: int = Query(default=200, ge=1, le=1000), 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="دامنه‌ی یک مشخصه‌ی خاص"), + material_family: str | None = Query(default=None, description="خانواده‌ی متریال (نام پایه)"), ): """Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازه‌ی رکود.""" return await dashboard_service.dead_stock( @@ -164,7 +169,7 @@ async def dead_stock( project_id=scope.project_id, idle_days=idle_days, limit=limit, - **_what_filters(material_id, brand_id, specs_hash, spec_id), + **_what_filters(material_id, brand_id, specs_hash, spec_id, material_family), ) @@ -179,6 +184,7 @@ async def inventory_flow( brand_id: uuid.UUID | None = Query(default=None), specs_hash: str | None = Query(default=None), spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"), + material_family: str | None = Query(default=None, description="خانواده‌ی متریال (نام پایه)"), ): """Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده.""" today = datetime.now(dashboard_service.TEHRAN).date() @@ -195,7 +201,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, spec_id), + **_what_filters(material_id, brand_id, specs_hash, spec_id, material_family), ) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index a9b519d..57ec790 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -31,6 +31,14 @@ from app.models.procurement import ( Quote, QuoteProjects, ) +from app.services.material_naming import ( + material_entry_labels, + material_family_key, + spec_label_from_specs, +) + +# برچسب مشخصه‌ی خالی (ردیف‌های دارای specs_hash پیش‌فرض) +DEFAULT_SPEC_LABEL = "بدون مشخصه (پیش‌فرض)" # Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the # daily widgets means today at this offset. @@ -43,6 +51,21 @@ TEHRAN = timezone(timedelta(hours=3, minutes=30)) OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS") WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS") +# دسته‌های عملیاتی «کنترل روزانه» — تنها تراکنش‌هایی که در فید امروز دیده می‌شوند +OPS_CATEGORIES = ("outbound", "returns", "waste", "purchases") +OPS_TYPES = ("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE") + + +def _ops_category(tx_type: str) -> str: + """نوع تراکنش دفتر → دسته‌ی عملیاتی (خروج/برگشت/ضایعات/پذیرش).""" + if tx_type == "WITHDRAWAL": + return "outbound" + if tx_type == "RETURN": + return "returns" + if tx_type == "PURCHASE": + return "purchases" + return "waste" # DAMAGE / WASTE / LOSS + def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None): """«What» filters for a statement that already joins inventory→material.""" @@ -68,6 +91,21 @@ def _qty(value) -> float: return round(float(value), 4) if value is not None else 0.0 +async def _family_material_ids(db: AsyncSession, family: str) -> list[uuid.UUID]: + """شناسه‌ی همه‌ی ردیف‌های کاتالوگِ یک خانواده. + + خانواده از نام پایه‌ی ماده مشتق می‌شود (material_naming)؛ بنابراین انتخاب + «لوله پلی‌اتیلن» همه‌ی سایزهایش را با هم فیلتر می‌کند — بدون هاردکد نام. + """ + name = (family or "").strip() + if not name: + return [] + rows = ( + await db.execute(select(Material.id, Material.name).where(Material.status == "active")) + ).all() + return [material_id for material_id, material_name in rows if material_family_key(material_name) == name] + + # --------------------------------------------------------------------------- # «What» filter cascade — Category → Material → Brand → Specs # --------------------------------------------------------------------------- @@ -125,22 +163,78 @@ async def filter_options(db: AsyncSession) -> dict: unit = units.get(m.canonical_unit_id) brands = brands_by_material.get(m.id, {}) spec_map = specs_by_material.get(m.id, {}) + labels = material_entry_labels(m.name, None) return { "id": str(m.id), "name": m.name, + # خانواده vs واریانت — تفکیک عمومی از روی نام کاتالوگ + "family_id": labels["family_id"], + "family_name": labels["family_name"], + "variant_label": labels["variant_label"], "unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None, "brands": [ {"id": str(bid), "name": bname} for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "") ], "specs": [ - {"hash": h, "specs": sp} + { + "hash": h, + "specs": sp, + "label": spec_label_from_specs(sp) or DEFAULT_SPEC_LABEL, + } for h, sp in sorted(spec_map.items()) ], } - # فهرست تخت متریال‌ها — سرآغاز آبشار سه‌فیلتری (متریال → برند → مشخصات) - return {"data": {"materials": [material_entry(m) for m in materials]}} + entries = [material_entry(m) for m in materials] + + # خانواده‌ها: گروه‌بندی ردیف‌های کاتالوگ بر اساس نام پایه (بدون هاردکد نام) + family_map: dict[str, dict] = {} + for entry in entries: + family = family_map.setdefault( + entry["family_id"], + { + "id": entry["family_id"], + "name": entry["family_name"], + "unit": entry["unit"], + "brands": {}, + "specs": {}, + "materials": [], + }, + ) + if family["unit"] is None: + family["unit"] = entry["unit"] + family["materials"].append( + { + "id": entry["id"], + "name": entry["name"], + "variant_label": entry["variant_label"], + } + ) + for brand in entry["brands"]: + family["brands"][brand["id"]] = brand["name"] + for spec in entry["specs"]: + family["specs"][spec["hash"]] = spec["label"] + + families = [ + { + "id": family["id"], + "name": family["name"], + "unit": family["unit"], + "brands": [ + {"id": bid, "name": bname} + for bid, bname in sorted(family["brands"].items(), key=lambda kv: kv[1] or "") + ], + "specs": [ + {"hash": spec_hash, "label": label} + for spec_hash, label in sorted(family["specs"].items(), key=lambda kv: kv[1] or "") + ], + "materials": family["materials"], + } + for family in sorted(family_map.values(), key=lambda item: item["name"]) + ] + + return {"data": {"materials": entries, "families": families}} # --------------------------------------------------------------------------- @@ -155,6 +249,7 @@ async def inventory_drilldown( material_id=None, brand_id=None, specs_hash=None, + material_family=None, ) -> dict: """Stock quantity as a three-level tree: Material -> Project -> Warehouse. @@ -212,6 +307,7 @@ async def inventory_drilldown( Material.name.label("mname"), Brand.name.label("bname"), stock_sq.c.specs, + stock_sq.c.specs_hash, stock_sq.c.pid, stock_sq.c.pname, stock_sq.c.wid, @@ -232,6 +328,8 @@ async def inventory_drilldown( # فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند if material_id is not None: stmt = stmt.where(Material.id == material_id) + if material_family: + stmt = stmt.where(Material.id.in_(await _family_material_ids(db, material_family))) # برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان # موجودی روی محور می‌مانند (رفتار قبلی دست‌نخورده) if brand_id is not None: @@ -247,7 +345,7 @@ async def inventory_drilldown( 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: + for mid, mname, bname, specs, specs_hash, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows: q = _qty(qty) # افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر @@ -269,9 +367,25 @@ async def inventory_drilldown( # Use full_name + unit as unique key for material level, or just full_name mat_key = f"{mid}_{full_name}" + # برچسب‌های محور X: خانواده (نام پایه) و مشخصه/واریانت — مشتق از نام کاتالوگ + labels = material_entry_labels(mname, specs) mat = materials_tree.setdefault( mat_key, - {"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}}, + { + "id": mat_key, + "name": full_name, + "material_id": str(mid), + "specs_hash": specs_hash or "", + "family_id": labels["family_id"], + "family_name": labels["family_name"], + "variant_label": labels["variant_label"], + "spec_label": labels["spec_label"], + "spec_detail": labels["spec_detail"], + "brand_name": bname, + "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) @@ -327,6 +441,7 @@ async def dead_stock( material_id=None, brand_id=None, specs_hash=None, + material_family=None, limit: int = 200, ) -> dict: """Stock rows with quantity > 0 and NO ledger activity within `idle_days`. @@ -370,6 +485,10 @@ async def dead_stock( stmt = _stock_filters( stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash ) + if material_family: + stmt = stmt.where( + InventoryItem.material_id.in_(await _family_material_ids(db, material_family)) + ) stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id) rows = (await db.execute(stmt)).all() @@ -406,6 +525,7 @@ async def daily_flow( material_id=None, brand_id=None, specs_hash=None, + material_family=None, ) -> dict: """Per-day buckets from the signed ledger. @@ -440,6 +560,10 @@ async def daily_flow( if warehouse_id is not None: scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id) what_conditions = [] + family_ids: list[uuid.UUID] = [] + if material_family: + family_ids = await _family_material_ids(db, material_family) + what_conditions.append(InventoryItem.material_id.in_(family_ids)) if material_id is not None: what_conditions.append(InventoryItem.material_id == material_id) if brand_id is not None: @@ -525,13 +649,14 @@ async def daily_flow( }) unit = None - if material_id is not None: + unit_material_id = material_id or (family_ids[0] if family_ids else None) + if unit_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) + .where(Material.id == unit_material_id) ) ).first() if unit_row is not None: @@ -571,8 +696,14 @@ async def daily_control( day: date_cls, warehouse_id=None, ) -> dict: - """One day (Tehran calendar): outbound / return / waste / purchase ticket - counts + quantities, the exact performer names, and the operation detail.""" + """One day (Tehran calendar): operational counts per category, the people + involved (warehouse clerk + site recipient) and the transaction feed. + + Quantities are never exposed as a unitless total: every category carries a + per-unit breakdown (`units`) plus the number of distinct stock items + (`items`), so the UI can render «۱۲۲ عدد» for single-unit days and + «چندواحدی (۳ قلم کالا)» for mixed-unit days. + """ day_start = datetime.combine(day, time.min, tzinfo=TEHRAN) day_end = day_start + timedelta(days=1) @@ -584,44 +715,88 @@ async def daily_control( if warehouse_id is not None: scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id) - count_rows = ( + # ۱) شمارش/کمیت هر دسته، تفکیک‌شده بر اساس واحد + تعداد قلم کالای متمایز + category_unit_rows = ( await db.execute( select( InventoryTransaction.transaction_type, + Unit.code, + Unit.name_fa, func.count().label("tickets"), + func.count(func.distinct(InventoryItem.id)).label("items"), func.sum(func.abs(InventoryTransaction.quantity)).label("qty"), ) .select_from(InventoryTransaction) .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) + .join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id) + .join(Unit, Unit.id == InventoryItem.unit_id) .where( InventoryTransaction.transaction_date >= day_start, InventoryTransaction.transaction_date < day_end, + InventoryTransaction.transaction_type.in_(OPS_TYPES), *scope_conditions, ) - .group_by(InventoryTransaction.transaction_type) - ) - ).all() - by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows} - - performers_rows = ( - await db.execute( - select( - InventoryTransaction.performed_by, - func.count().label("tickets"), - func.sum(func.abs(InventoryTransaction.quantity)).label("qty"), - ) - .select_from(InventoryTransaction) - .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) - .where( - InventoryTransaction.transaction_date >= day_start, - InventoryTransaction.transaction_date < day_end, - *scope_conditions, - ) - .group_by(InventoryTransaction.performed_by) - .order_by(func.count().desc()) + .group_by(InventoryTransaction.transaction_type, Unit.code, Unit.name_fa) ) ).all() + categories: dict[str, dict] = { + key: {"tickets": 0, "items": 0, "qty": 0.0, "units": []} for key in OPS_CATEGORIES + } + for tx_type, unit_code, unit_name_fa, tickets, items, qty in category_unit_rows: + bucket = categories[_ops_category(str(tx_type))] + bucket["tickets"] += tickets + bucket["items"] += items + bucket["qty"] = round(bucket["qty"] + _qty(qty), 4) + bucket["units"].append({"code": unit_code, "name_fa": unit_name_fa, "qty": _qty(qty)}) + + # ۲) اشخاص درگیر: ثبت‌کننده‌ی انبار (performed_by) و تحویل‌گیرنده (delivered_to) + async def _people(field, role: str) -> list[dict]: + rows = ( + await db.execute( + select( + field, + InventoryTransaction.transaction_type, + func.count(), + func.sum(func.abs(InventoryTransaction.quantity)), + ) + .select_from(InventoryTransaction) + .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) + .where( + InventoryTransaction.transaction_date >= day_start, + InventoryTransaction.transaction_date < day_end, + InventoryTransaction.transaction_type.in_(OPS_TYPES), + field.isnot(None), + *scope_conditions, + ) + .group_by(field, InventoryTransaction.transaction_type) + ) + ).all() + people: dict[str, dict] = {} + for name, tx_type, count, qty in rows: + clean = (name or "").strip() + if not clean: + continue + person = people.setdefault( + clean, + { + "name": clean, + "role": role, + "tickets": 0, + "qty": 0.0, + "categories": {key: 0 for key in OPS_CATEGORIES}, + }, + ) + person["tickets"] += count + person["qty"] = round(person["qty"] + _qty(qty), 4) + person["categories"][_ops_category(str(tx_type))] += count + return list(people.values()) + + people = await _people(InventoryTransaction.performed_by, "performer") + people += await _people(InventoryTransaction.delivered_to, "recipient") + people.sort(key=lambda p: (p["role"] != "performer", -p["tickets"], p["name"])) + + # ۳) ریز عملیات امروز (همراه با انبار، برند، واحد فارسی و تحویل‌گیرنده) detail_rows = ( await db.execute( select( @@ -630,18 +805,19 @@ async def daily_control( Material.name, InventoryItem.specs, Unit.code, + Unit.name_fa, + Brand.name, ) .select_from(InventoryTransaction) .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) .join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id) .join(Material, Material.id == InventoryItem.material_id) .join(Unit, Unit.id == InventoryItem.unit_id) + .outerjoin(Brand, Brand.id == InventoryItem.brand_id) .where( InventoryTransaction.transaction_date >= day_start, InventoryTransaction.transaction_date < day_end, - InventoryTransaction.transaction_type.in_( - ("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE") - ), + InventoryTransaction.transaction_type.in_(OPS_TYPES), *scope_conditions, ) .order_by(InventoryTransaction.transaction_date.desc()) @@ -649,15 +825,17 @@ async def daily_control( ) ).all() - zero = {"tickets": 0, "qty": 0.0} items = [] - for tx, wh_name, mat_name, specs, ucode in detail_rows: + for tx, wh_name, mat_name, specs, unit_code, unit_name_fa, brand_name in detail_rows: items.append({ "id": str(tx.id), "transaction_type": str(tx.transaction_type), "quantity": _qty(tx.quantity), - "unit_code": ucode, + "unit_code": unit_code, + "unit_name_fa": unit_name_fa, "performed_by": tx.performed_by, + "delivered_to": tx.delivered_to, + "brand": brand_name, "note": tx.note, "warehouse": wh_name, "material": mat_name, @@ -668,18 +846,20 @@ async def daily_control( return { "data": { "date": day.isoformat(), - "outbound": by_type.get("WITHDRAWAL", zero), - "returns": by_type.get("RETURN", zero), - "waste": { - "tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES), - "qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4), - }, - "purchases": by_type.get("PURCHASE", zero), - "performers": [ - {"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)} - for name, tickets, qty in performers_rows - ], + "outbound": categories["outbound"], + "returns": categories["returns"], + "waste": categories["waste"], + "purchases": categories["purchases"], + # همیشه آرایه‌اند (هرگز null/حذف‌شده) تا مصرف‌کننده‌ها امن بمانند + "people": people, "items": items, + # سازگاری عقب‌رو: باندل‌های فرانت که هنوز فقط performers را می‌خوانند + # با API جدید کرش نکنند (تا زمان انتشار باندل تازه) + "performers": [ + {"name": person["name"], "tickets": person["tickets"], "qty": person["qty"]} + for person in people + if person["role"] == "performer" + ], } } diff --git a/backend/app/services/material_naming.py b/backend/app/services/material_naming.py new file mode 100644 index 0000000..27585c2 --- /dev/null +++ b/backend/app/services/material_naming.py @@ -0,0 +1,121 @@ +"""نام‌گذاری متریال — تفکیک «خانواده» از «مشخصه/واریانت» به‌صورت عمومی. + +برخی ردیف‌های کاتالوگ، سایز/گرید را داخل نام ماده دارند +(«بلوک سیمانی ۲۰×۲۰×۴۰»، «لوله پلی‌اتیلن PE100 سایز ۲۵»). این ماژول با قواعد +الگویی (ابعاد NxNxN، کلیدواژه‌های مشخصه، کدهای گرید لاتین) نام را به +«خانواده» و «واریانت» می‌شکند — بدون هیچ هاردکدِ نام محصول مشخصی. +""" +import re + +_DIGIT_MAP = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789") + +# واژه‌هایی که شروع بخش مشخصه را نشان می‌دهند (ابعاد/درجه/تیپ/مدل/…) +_ATTRIBUTE_WORDS = { + "سایز", + "تیپ", + "مدل", + "درجه", + "ضخامت", + "قطر", + "ابعاد", + "طول", + "عرض", + "ارتفاع", + "رنگ", + "وزن", + "ظرفیت", + "شماره", + "کلاس", +} + +# ابعاد ترکیبی: ۲۰×۲۰×۴۰ / ۲×۲٫۵ / 60x60 / 2*2.5 +_DIMENSION_RE = re.compile(r"^\d+(?:[×xX*]\d+(?:[.,٫/]\d+)?)+$") +# کد گرید: PE100 / PN10 / DN50 / A3 / SN4 / ST37 +_GRADE_RE = re.compile(r"^(?:PN|PE|DN|SN|SCH|ST|A|B|C)[-]?\d+$") +_NUMBER_RE = re.compile(r"^\d+(?:[.,٫/]\d+)?$") +_LATIN_WORD_RE = re.compile(r"^[A-Za-z]{2,6}$") + +STANDARD_VARIANT_LABEL = "استاندارد" + + +def normalize_digits(text: str) -> str: + return (text or "").translate(_DIGIT_MAP) + + +def _is_spec_start(tokens: list[str], index: int) -> bool: + token = tokens[index] + normalized = normalize_digits(token) + if normalized in _ATTRIBUTE_WORDS: # «سایز»، «تیپ»، «مدل»، … + return True + if _DIMENSION_RE.match(normalized): # ۲۰×۲۰×۴۰ + return True + if _GRADE_RE.match(token): # PE100 / PN10 / A3 + return True + # کد لاتین کوتاه که پشتش عدد می‌آید: «IPE ۱۴» + if ( + _LATIN_WORD_RE.match(token) + and index + 1 < len(tokens) + and _NUMBER_RE.match(normalize_digits(tokens[index + 1])) + ): + return True + # عددِ تنها در انتهای نام (بدون کلیدواژه): «لوله مسی ۱۵» + if _NUMBER_RE.match(normalized) and index == len(tokens) - 1 and index >= 1: + return True + return False + + +def split_material_name(name: str) -> tuple[str, str | None]: + """«لوله پلی‌اتیلن PE100 سایز ۲۵» → («لوله پلی‌اتیلن», «PE100 سایز ۲۵»).""" + tokens = [token for token in (name or "").split() if token] + for index in range(1, len(tokens)): + if _is_spec_start(tokens, index): + family = " ".join(tokens[:index]).strip() + variant = " ".join(tokens[index:]).strip() + if family and variant: + return family, variant + return " ".join(tokens).strip(), None + + +def material_family(name: str) -> str: + """نام خانواده (بدون واریانت).""" + family, _ = split_material_name(name) + return " ".join(family.split()) + + +def material_family_key(name: str) -> str: + """شناسه‌ی پایدار خانواده — همان نام نرمال‌شده (بدون UUID اختصاصی).""" + return material_family(name) + + +def variant_label(name: str) -> str: + """برچسب واریانت؛ در نبود واریانت، «استاندارد».""" + _, variant = split_material_name(name) + return variant or STANDARD_VARIANT_LABEL + + +def spec_label_from_specs(specs: dict | None) -> str | None: + """برچسب کوتاه مشخصات از JSON موجودی (فقط مقدارها، نه «کلید: مقدار»).""" + if not specs: + return None + values = [str(value).strip() for value in specs.values() if str(value).strip()] + return "، ".join(values) if values else None + + +def material_entry_labels(material_name: str, specs: dict | None) -> dict: + """برچسب‌های نمایشی یک ردیف کاتالوگ: خانواده، واریانت، مشخصه. + + `spec_label` (برچسب محور X) = مقدار مشخصات JSON اگر موجود باشد، وگرنه + واریانتِ استخراج‌شده از نام، وگرنه «استاندارد». + """ + family = material_family(material_name) + variant = variant_label(material_name) + spec_from_json = spec_label_from_specs(specs) + # برچسب محور X: واریانت (سایز/گرید) ترجیح دارد؛ در نبود آن مقدار مشخصات + axis_label = variant if variant != STANDARD_VARIANT_LABEL else (spec_from_json or STANDARD_VARIANT_LABEL) + return { + "family_id": material_family_key(material_name), + "family_name": family, + "variant_label": variant, + "spec_label": axis_label, + "spec_detail": spec_from_json, + } diff --git a/frontend/src/components/dashboard/DailyControlPanel.tsx b/frontend/src/components/dashboard/DailyControlPanel.tsx index a11d28a..2162046 100644 --- a/frontend/src/components/dashboard/DailyControlPanel.tsx +++ b/frontend/src/components/dashboard/DailyControlPanel.tsx @@ -1,176 +1,597 @@ // ============================================================ -// ویجت ۴ — کنترل روزانه (امروز) -// کارت‌های KPI: بلیت‌های خروج امروز، برگشت از سایت، ضایعات و پذیرش -// + فهرست دقیق نام تحویل‌دهنده‌ها/انجام‌دهنده‌ها + ریز عملیات امروز. +// ویجت ۴ — کنترل روزانه (عملیات امروز) +// • کارت‌های KPI: «حواله» (خروج) / «برگشتی» (برگشت) / «ثبت» (ضایعات) / +// «رسید» (پذیرش خرید). کمیت‌ها هرگز بدون واحد جمع نمی‌شوند: با یک واحد، +// «۱۲۲ عدد» و با چند واحد «چندواحدی (۳ قلم کالا)» نمایش داده می‌شود. +// • کارت‌ها و اشخاص، فیلتر فید «ریز عملیات امروز» را روشن/خاموش می‌کنند. +// • هر ردیف فید: نام کامل متریال + مشخصات، کمیت علامت‌دار با واحد فارسی، +// و متادیتای عملیاتی (انبار/برند/ثبت‌کننده/تحویل‌گیرنده) + ساعت تهران. +// • هیچ متن کاربردی بریده نمی‌شود: برش/خط‌چین/عرض محدود در کارت‌ها نیست؛ +// نام‌ها و متادیتا کامل و چندخطی دیده می‌شوند. // هر ۶۰ ثانیه خودکار تازه‌سازی می‌شود (useDailyControl). // ============================================================ +import { useMemo, useState } from "react"; import { ArrowDownToLine, ArrowUpFromLine, PackageOpen, TriangleAlert, Users, + X, type LucideIcon, } from "lucide-react"; import { useDailyControl } from "../../hooks/useDashboardData"; -import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters"; +import { faNum, fmtNum } from "../../lib/formatters"; import { isoToJalaliStr } from "../../lib/jalali"; import { cn } from "../../lib/utils"; import { Badge } from "../ui/badge"; import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared"; +import type { + DailyControlCategoryStats, + DailyControlData, + DailyControlItem, + DailyControlPerson, + DailyControlPersonRole, +} from "../../lib/types"; -const TYPE_FA: Record = { - WITHDRAWAL: "برداشت از انبار", - RETURN: "برگشت از سایت", - DAMAGE: "آسیب‌دیده", - WASTE: "اتلاف", - LOSS: "مفقودی", - PURCHASE: "پذیرش خرید", - TRANSFER_IN: "انتقال ورودی", - TRANSFER_OUT: "انتقال خروجی", - FOUND: "پیدا شده", - STOCK_COUNT: "انبارگردانی", +// دسته‌های عملیاتی امروز — عنوان، واحد شمارش فارسی، آیکون و رنگ‌بندی +type OpsCategory = "outbound" | "returns" | "waste" | "purchases"; + +const OPS_CATEGORY_ORDER: OpsCategory[] = ["outbound", "returns", "waste", "purchases"]; + +const CATEGORY_META: Record< + OpsCategory, + { label: string; countNoun: string; icon: LucideIcon; tone: string } +> = { + outbound: { + label: "خروج به سایت", + countNoun: "حواله", + icon: ArrowUpFromLine, + tone: "bg-blue-bg text-blue-700", + }, + returns: { + label: "برگشت از سایت", + countNoun: "برگشتی", + icon: ArrowDownToLine, + tone: "bg-amber-bg text-amber", + }, + waste: { + label: "ضایعات / اتلاف / مفقودی", + countNoun: "ثبت", + icon: TriangleAlert, + tone: "bg-red-bg text-red", + }, + purchases: { + label: "پذیرش خرید (ورود)", + countNoun: "رسید", + icon: PackageOpen, + tone: "bg-green-bg text-green", + }, }; -const TYPE_STYLE: Record = { - WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]", - RETURN: "bg-amber-bg text-amber", - DAMAGE: "bg-red-bg text-red", - WASTE: "bg-red-bg text-red", - LOSS: "bg-red-bg text-red", - PURCHASE: "bg-green-bg text-green", +// نوع تراکنش → برچسب نشان و دسته‌ی عملیاتی +const TYPE_META: Record = { + WITHDRAWAL: { label: "برداشت از انبار", category: "outbound" }, + RETURN: { label: "برگشت از سایت", category: "returns" }, + DAMAGE: { label: "آسیب‌دیده", category: "waste" }, + WASTE: { label: "اتلاف", category: "waste" }, + LOSS: { label: "مفقودی", category: "waste" }, + PURCHASE: { label: "پذیرش خرید", category: "purchases" }, }; +const PERSON_ROLE_META: Record = { + performer: { label: "ثبت‌کننده انبار", tone: "bg-blue-bg text-blue-700" }, + recipient: { label: "تحویل‌گیرنده کارگاه", tone: "bg-amber-bg text-amber" }, +}; + +// اسکرول‌بار نازک و ظریف برای فهرست‌های داخلی (کلاس‌ها به‌صورت literal) +const SCROLLBAR_CLASSES = + "[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300/70"; + +const LABELS = { + peopleTitle: "اشخاص درگیر در عملیات امروز", + peopleSub: "(ثبت‌کننده / تحویل‌گیرنده)", + feedTitle: "ریز عملیات امروز", + warehouse: "انبار", + brand: "برند", + performer: "ثبت‌کننده", + recipient: "تحویل‌گیرنده", + transaction: "تراکنش", + noActivity: "ثبتی نشده", + multiUnit: "چندواحدی", + itemUnit: "قلم کالا", + noPeople: "امروز عملیاتی ثبت نشده است", + emptyFeed: "هنوز جابه‌جایی‌ای امروز ثبت نشده است", + emptyFiltered: "عملیاتی با این فیلتر امروز ثبت نشده است", + noSpecs: "بدون مشخصه", +} as const; + +// برچسب مشخصات فنی: «کلید: مقدار، …» یا خالی +function specLabel(specs: Record): string { + const parts = Object.entries(specs ?? {}); + return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : ""; +} + +// کمیت با علامت (+/−) و ارقام فارسی +const signedQty = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`; + +// فقط ساعت تهران (ارقام فارسی) — تاریخ در کل ویجت امروز است و تکرار نمی‌شود +function timeOnly(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const text = new Intl.DateTimeFormat("en-GB", { + timeZone: "Asia/Tehran", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(date); + return faNum(text); +} + +// متریک دوم کارت: تک‌واحدی → «۱۲۲ عدد» / چندواحدی → «چندواحدی (۳ قلم کالا)» +function secondaryMetric(stats: DailyControlCategoryStats): string { + if (!stats || stats.tickets === 0) return LABELS.noActivity; + const units = stats.units ?? []; + if (units.length === 1) { + const unit = units[0]; + return `${fmtNum(unit.qty)} ${unit.name_fa ?? unit.code}`; + } + return `${LABELS.multiUnit} (${faNum(stats.items)} ${LABELS.itemUnit})`; +} + +// تفکیک فعالیت هر شخص: «۲ حواله · ۱ رسید» +function personBreakdown(person: DailyControlPerson): string { + return OPS_CATEGORY_ORDER.filter((category) => person.categories[category] > 0) + .map((category) => `${faNum(person.categories[category])} ${CATEGORY_META[category].countNoun}`) + .join(" · "); +} + +// ——— نرمال‌سازی payload ——— +// هر آرایه/عدد/متن با پیش‌فرض امن ساخته می‌شود تا هیچ مصرف‌کننده‌ای هرگز +// `.length` یا `.map` را روی undefined اجرا نکند (پاسخ خالی/قدیمی/ناقص هم امن است). +const toNumber = (value: unknown): number => { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; +const toArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []); +const toText = (value: unknown): string => (typeof value === "string" ? value : ""); +const toNullableText = (value: unknown): string | null => + typeof value === "string" ? value : null; + +const EMPTY_PERSON_CATEGORIES: DailyControlPerson["categories"] = { + outbound: 0, + returns: 0, + waste: 0, + purchases: 0, +}; + +function normalizeUnits(raw: unknown): DailyControlCategoryStats["units"] { + return toArray>(raw).map((unit) => ({ + code: toText(unit?.code), + name_fa: toNullableText(unit?.name_fa), + qty: toNumber(unit?.qty), + })); +} + +function normalizeStats(raw: unknown): DailyControlCategoryStats { + const stats = (raw ?? {}) as Partial; + return { + tickets: toNumber(stats.tickets), + items: toNumber(stats.items), + qty: toNumber(stats.qty), + // همیشه آرایه — منبع کرش قبلی وقتی API فیلد units را نداشت + units: normalizeUnits(stats.units), + }; +} + +function normalizePerson(raw: unknown): DailyControlPerson | null { + const person = (raw ?? {}) as Partial; + const name = toText(person.name).trim(); + if (!name) return null; + const categories = (person.categories ?? EMPTY_PERSON_CATEGORIES) as Partial< + DailyControlPerson["categories"] + >; + return { + name, + role: person.role === "recipient" ? "recipient" : "performer", + tickets: toNumber(person.tickets), + categories: { + outbound: toNumber(categories.outbound), + returns: toNumber(categories.returns), + waste: toNumber(categories.waste), + purchases: toNumber(categories.purchases), + }, + }; +} + +function normalizeItem(raw: unknown): DailyControlItem | null { + const item = (raw ?? {}) as Partial; + const id = toText(item.id); + if (!id) return null; // ردیف بدون شناسه قابل رندر نیست + const specs = + item.specs && typeof item.specs === "object" && !Array.isArray(item.specs) + ? (item.specs as Record) + : {}; + return { + id, + transaction_type: toText(item.transaction_type), + quantity: toNumber(item.quantity), + unit_code: toText(item.unit_code), + unit_name_fa: toNullableText(item.unit_name_fa), + performed_by: toNullableText(item.performed_by), + delivered_to: toNullableText(item.delivered_to), + brand: toNullableText(item.brand), + note: toNullableText(item.note), + warehouse: toText(item.warehouse), + material: toText(item.material), + specs, + transaction_date: toText(item.transaction_date), + }; +} + +interface DailyControlView { + date: string; + categories: Record; + people: DailyControlPerson[]; + items: DailyControlItem[]; +} + +// پاسخ API (نسخه‌ی جدید یا قدیمی) → مدل نمایشیِ کاملاً تضمین‌شده +export function normalizeDailyControl(raw: unknown): DailyControlView { + const data = (raw ?? {}) as Partial & { performers?: unknown }; + + let people = toArray(data.people) + .map(normalizePerson) + .filter((person): person is DailyControlPerson => person !== null); + + // سازگاری با payload قدیمی که فقط performers دارد (بدون نقش/تفکیک دسته) + if (people.length === 0) { + people = toArray<{ name?: unknown; tickets?: unknown }>(data.performers) + .map((person) => + normalizePerson({ + name: person?.name, + role: "performer", + tickets: person?.tickets, + categories: EMPTY_PERSON_CATEGORIES, + }), + ) + .filter((person): person is DailyControlPerson => person !== null); + } + + return { + date: toText(data.date), + categories: { + outbound: normalizeStats(data.outbound), + returns: normalizeStats(data.returns), + waste: normalizeStats(data.waste), + purchases: normalizeStats(data.purchases), + }, + people, + items: toArray(data.items) + .map(normalizeItem) + .filter((item): item is DailyControlItem => item !== null), + }; +} + +type FeedFilter = + | { kind: "category"; value: OpsCategory } + | { kind: "person"; name: string; role: DailyControlPersonRole }; + +// ——— کارت KPI ——— +// نوار فشرده: عنوان و آیکون / متریک اصلی / زیرنویس. در ستون‌های باریک عنوان +// به‌طور طبیعی می‌شکند (بدون برش) و هیچ متنی پنهان نمی‌شود. function KpiCard({ - icon: Icon, - label, - tickets, - qty, - tone, + category, + stats, + active, + onToggle, }: { - icon: LucideIcon; - label: string; - tickets: number; - qty: number; - tone: string; + category: OpsCategory; + stats: DailyControlCategoryStats; + active: boolean; + onToggle: () => void; }) { + const meta = CATEGORY_META[category]; + const Icon = meta.icon; return ( -
- - + + ); +} + +// ——— یک ردیف فید عملیات ——— +// نام کامل متریال و همه‌ی متادیتا (بدون برش متن)؛ کمیت و ساعت در ستون +// جداگانه و nowrap می‌مانند تا هرگز با متن‌های بلند قاطی نشوند. +function FeedRow({ item }: { item: DailyControlItem }) { + const type = TYPE_META[item.transaction_type] ?? { + label: item.transaction_type, + category: "outbound" as OpsCategory, + }; + const specs = specLabel(item.specs); + const unitName = item.unit_name_fa ?? item.unit_code; + return ( +
  • + {/* ردیف ۱: نشان نوع + نام کامل متریال/مشخصات و کمیت */} +
    +
    + + {type.label} + + + {item.material} + {specs && — {specs}} +
    + + {signedQty(item.quantity)} {unitName} +
    -
  • + + {/* ردیف ۲: متادیتای کامل + ساعت */} +
    +
    + + {LABELS.warehouse}: {item.warehouse} + + {item.brand && ( + <> + + • + + + {LABELS.brand}: {item.brand} + + + )} + {item.performed_by && ( + <> + + • + + + {LABELS.performer}: {item.performed_by} + + + )} + {item.delivered_to && ( + <> + + • + + + {LABELS.recipient}: {item.delivered_to} + + + )} +
    + + {timeOnly(item.transaction_date)} + +
    + ); } export function DailyControlPanel() { const query = useDailyControl(); - const d = query.data; + // مدل نمایشیِ نرمال‌شده: هیچ آرایه‌ای undefined نیست (پاسخ خالی/قدیمی هم امن است) + const view = useMemo( + () => (query.data ? normalizeDailyControl(query.data) : null), + [query.data], + ); + const [filter, setFilter] = useState(null); + + const items = useMemo(() => view?.items ?? [], [view]); + + const visibleItems = useMemo(() => { + if (!filter) return items; + if (filter.kind === "category") { + return items.filter( + (item) => TYPE_META[item.transaction_type]?.category === filter.value, + ); + } + return items.filter((item) => + filter.role === "performer" + ? item.performed_by === filter.name + : item.delivered_to === filter.name, + ); + }, [items, filter]); + + const toggleCategory = (category: OpsCategory) => + setFilter((prev) => + prev?.kind === "category" && prev.value === category + ? null + : { kind: "category", value: category }, + ); + + const togglePerson = (person: DailyControlPerson) => + setFilter((prev) => + prev?.kind === "person" && prev.name === person.name && prev.role === person.role + ? null + : { kind: "person", name: person.name, role: person.role }, + ); + + const filterLabel = + filter?.kind === "category" + ? CATEGORY_META[filter.value].label + : filter?.kind === "person" + ? `${filter.name} (${PERSON_ROLE_META[filter.role].label})` + : ""; return ( {query.isLoading ? ( - ) : !d ? ( + ) : query.isError || !view ? ( ) : ( -
    - {/* KPI کارت‌ها */} -
    - - - - +
    + {/* نوار فشرده‌ی KPI — همیشه چهار ستون در دسکتاپ؛ قابل کلیک برای فیلتر فید */} +
    + {OPS_CATEGORY_ORDER.map((category) => ( + toggleCategory(category)} + /> + ))}
    -
    - {/* نام دقیق انجام‌دهنده‌ها */} -
    -

    - - تحویل‌دهنده‌ها / انجام‌دهنده‌های امروز -

    - {d.performers.length === 0 ? ( -

    امروز عملیاتی ثبت نشده است

    +
    + {/* اشخاص درگیر در عملیات امروز — کاشی دو‌ردیفی */} +
    +
    +

    + + {LABELS.peopleTitle} +

    +

    + {LABELS.peopleSub} +

    +
    + {view.people.length === 0 ? ( +

    {LABELS.noPeople}

    ) : ( -
      - {d.performers.map((p, i) => ( -
    • - - {faNum(i + 1)} - - {p.name} - - {fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)} - -
    • - ))} +
        + {view.people.map((person, index) => { + const active = + filter?.kind === "person" && + filter.name === person.name && + filter.role === person.role; + const breakdown = personBreakdown(person); + return ( +
      • + +
      • + ); + })}
      )}
    {/* ریز عملیات امروز */} -
    -

    ریز عملیات امروز

    - {d.items.length === 0 ? ( -

    هنوز جابه‌جایی‌ای امروز ثبت نشده است

    +
    +
    +

    {LABELS.feedTitle}

    + {filter && ( + + )} +
    + {visibleItems.length === 0 ? ( +

    + {items.length === 0 ? LABELS.emptyFeed : LABELS.emptyFiltered} +

    ) : ( -
      - {d.items.map((it) => ( -
    • - - {TYPE_FA[it.transaction_type] ?? it.transaction_type} - - {it.material} - - {fmtNum(it.quantity)} {it.unit_code} - - - {fmtDateTime(it.transaction_date)} - -
    • +
        + {visibleItems.map((item) => ( + ))}
      )} diff --git a/frontend/src/components/dashboard/DashboardFilterBar.tsx b/frontend/src/components/dashboard/DashboardFilterBar.tsx index 8bfd357..dcc30d6 100644 --- a/frontend/src/components/dashboard/DashboardFilterBar.tsx +++ b/frontend/src/components/dashboard/DashboardFilterBar.tsx @@ -1,13 +1,11 @@ // ============================================================ -// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی -// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب -// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند: -// متریال → برندهای همان متریال (کاتالوگ + موجودی) -// متریال → مشخصات فنی موجودِ همان متریال -// هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست -// همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را -// یک‌جا به نمای «کل سازمان» برمی‌گرداند. -// سه فیلتر در یک ردیفِ وسط‌چین با عرض یکسان می‌نشینند (موبایل: تک‌ستونی). +// فیلتر سراسری «چه چیزی» — آبشار: متریال (خانواده) → برند → مشخصات فنی +// • «متریال» فقط خانواده‌های پایه را نشان می‌دهد (بلوک سیمانی، لوله پلی‌اتیلن…) +// نه ردیف‌های دارای سایز/گرید. +// • «مشخصات فنی» پس از انتخاب خانواده، واریانت‌های همان خانواده (سایز/گرید) +// و مشخصه‌های موجودیِ آن خانواده را فهرست می‌کند. +// • همه‌ی گزینه‌ها از API مشتق می‌شوند (بدون هیچ لیست یا نام هاردکدشده). +// • سه فیلتر وسط‌چین و متقارن‌اند؛ روی موبایل تک‌ستونی می‌شوند. // وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود. // ============================================================ @@ -19,18 +17,19 @@ import { useDashboardFilter } from "../../store/dashboardFilter"; import { faNum } from "../../lib/formatters"; import { cn } from "../../lib/utils"; -// گزینه‌ی «همه» — انتخابی که همان سطح و سطوح پایین‌دستش را پاک می‌کند. -// شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمی‌شود. +// گزینه‌ی «همه» — شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) const ALL_OPTION_ID = ""; -// این شناسه همیشه در ابتدای فهرست می‌ماند و با جستجوی کاربر فیلتر نمی‌شود const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID]; -// برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند +// پیشوندهای شناسه‌ی گزینه‌های «مشخصات» — واریانت کاتالوگ یا مشخصه‌ی موجودی +const SPEC_VARIANT_PREFIX = "v:"; +const SPEC_STOCK_PREFIX = "s:"; + const LABELS = { - allMaterials: "همه‌ی متریال‌ها", + allFamilies: "همه‌ی متریال‌ها", allBrands: "همه‌ی برندها", allSpecs: "همه‌ی مشخصات", - materialFirst: "ابتدا متریال را انتخاب کنید", + familyFirst: "ابتدا متریال را انتخاب کنید", clearAll: "حذف فیلترها", wholeOrg: "نمای کل سازمان", cascade: "متریال ← برند ← مشخصات فنی", @@ -38,59 +37,80 @@ const LABELS = { const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label }); -// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)» -function specLabel(specs: Record | 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 materials = useMemo(() => { - const seen = new Set(); - return (filterQuery.data?.materials ?? []).filter((m) => - seen.has(m.id) ? false : (seen.add(m.id), true), - ); - }, [filterQuery.data]); - - const selectedMaterial = useMemo( - () => materials.find((m) => m.id === state.materialId) ?? null, - [materials, state.materialId], + const families = useMemo(() => filterQuery.data?.families ?? [], [filterQuery.data]); + const selectedFamily = useMemo( + () => families.find((family) => family.id === state.materialFamilyId) ?? null, + [families, state.materialFamilyId], ); - const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length; + const activeCount = [state.materialFamilyId, state.brandId, state.spec].filter(Boolean).length; - // انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار) - 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 pickFamily = (item: ComboboxItem) => + item.id === ALL_OPTION_ID + ? state.setMaterialFamily(null, null) + : state.setMaterialFamily(item.id, item.label); - // فهرست هر فیلد با گزینه‌ی «همه» در ابتدا - const materialItems = useMemo( - () => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))], - [materials], + const pickBrand = (item: ComboboxItem) => + item.id === ALL_OPTION_ID + ? state.setBrand(null, null) + : state.setBrand(item.id, item.label); + + // گزینه‌های مشخصات: «v:» برای واریانت و «s:» برای مشخصه‌ی موجودی + const pickSpec = (item: ComboboxItem) => { + if (item.id === ALL_OPTION_ID) { + state.setSpec(null); + return; + } + if (item.id.startsWith(SPEC_VARIANT_PREFIX)) { + state.setSpec({ + kind: "variant", + id: item.id.slice(SPEC_VARIANT_PREFIX.length), + label: item.label, + }); + return; + } + if (item.id.startsWith(SPEC_STOCK_PREFIX)) { + state.setSpec({ + kind: "stockSpec", + id: item.id.slice(SPEC_STOCK_PREFIX.length), + label: item.label, + }); + } + }; + + const familyItems = useMemo( + () => [allOption(LABELS.allFamilies), ...families.map((f) => ({ id: f.id, label: f.name }))], + [families], ); const brandItems = useMemo( () => [ allOption(LABELS.allBrands), - ...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })), + ...(selectedFamily?.brands ?? []).map((brand) => ({ id: brand.id, label: brand.name })), ], - [selectedMaterial], + [selectedFamily], ); - const specItems = useMemo( - () => [ - allOption(LABELS.allSpecs), - ...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })), - ], - [selectedMaterial], - ); + const specItems = useMemo(() => { + if (!selectedFamily) return [allOption(LABELS.allSpecs)]; + // واریانت‌ها (سایز/گرید) — یکتاسازی بر اساس برچسب برای پرهیز از تکرار + const seen = new Set(); + const variants = selectedFamily.materials + .filter((material) => (seen.has(material.variant_label) ? false : (seen.add(material.variant_label), true))) + .map((material) => ({ + id: `${SPEC_VARIANT_PREFIX}${material.id}`, + label: material.variant_label, + })); + const stockSpecs = selectedFamily.specs.map((spec) => ({ + id: `${SPEC_STOCK_PREFIX}${spec.hash}`, + label: spec.label, + })); + return [allOption(LABELS.allSpecs), ...variants, ...stockSpecs]; + }, [selectedFamily]); return (
      - {/* ۱ — متریال (سرآغاز آبشار) */} + {/* ۱ — متریال (خانواده‌ی پایه) */} state.setMaterial(null, null)} - onChange={pick(state.setMaterial)} + onClear={() => state.setMaterialFamily(null, null)} + onChange={pickFamily} /> - {/* ۲ — برند (فقط برندهای همان متریال) */} + {/* ۲ — برند (برندهای همان خانواده) */} state.setBrand(null, null)} - onChange={pick(state.setBrand)} + onChange={pickBrand} /> - {/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */} + {/* ۳ — مشخصات فنی (واریانت‌ها + مشخصه‌های موجودی همان خانواده) */} state.setSpecs(null, null)} - onChange={pick(state.setSpecs)} + onClear={() => state.setSpec(null)} + onChange={pickSpec} />
    diff --git a/frontend/src/components/dashboard/DeadStockTable.tsx b/frontend/src/components/dashboard/DeadStockTable.tsx index cf28559..f05c3f1 100644 --- a/frontend/src/components/dashboard/DeadStockTable.tsx +++ b/frontend/src/components/dashboard/DeadStockTable.tsx @@ -1,41 +1,231 @@ // ============================================================ -// ویجت ۲ — جدول موجودی راکد (سرمایه‌ی خوابیده) -// ردیف‌هایی با موجودی مثبت که هیچ تراکنشی در بازه‌ی رکود نداشته‌اند. -// آستانه‌ی ۶۰/۹۰ روز با چیپ انتخاب می‌شود؛ روزهای رکود رنگی است. +// ویجت ۲ — موجودی راکد (سرمایه‌ی خوابیده) — فقط‌خواندنی +// داشبورد برای پایش و تحلیل است، نه ویرایش: هیچ استپر/آستانه‌ی ردیفی یا +// پنهان‌سازی وجود ندارد. همه‌ی ردیف‌ها با داده‌ی خام API رندر می‌شوند و +// مرتب‌سازی سه‌حالته (صعودی ← نزولی ← پیش‌فرض) روی هدر سه ستون کلیدی است. // ============================================================ -import { useState } from "react"; -import { Snowflake } from "lucide-react"; +import { useMemo, useState } from "react"; +import { ArrowDown, ArrowUp, ArrowUpDown, Snowflake } from "lucide-react"; import { useDeadStock } from "../../hooks/useDashboardData"; -import { fmtNum, faNum } from "../../lib/formatters"; +import { faNum, fmtNum } from "../../lib/formatters"; import { isoToJalaliStr } from "../../lib/jalali"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "../ui/table"; import { cn } from "../../lib/utils"; import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared"; +import type { DeadStockItem } from "../../lib/types"; +// صفر = بدون هیچ آستانه‌ای؛ همه‌ی ردیف‌های موجودی برگردانده می‌شوند +const ALL_ITEMS_IDLE_DAYS = 0; +const MAX_LIST_HEIGHT = "max-h-[450px]"; + +// اسکرول‌بار ظریف (کلاس‌ها به‌صورت literal برای Tailwind) +const SCROLLBAR_CLASSES = + "[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300/70"; + +// شبکه‌ی مشترک ستون‌ها: هویت + سه ستون متریک با عرض ثابت. +// هدر و همه‌ی ردیف‌ها از همین قالب استفاده می‌کنند تا ستون‌ها در کل لیست +// دقیقاً روی هم بیفتند؛ در موبایل هویت بالا و متریک‌ها در سه ستون مساوی +// زیر آن می‌آیند (sm:contents پوسته‌ی متریک را از چیدمان حذف می‌کند). +const ROW_GRID = + "grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_repeat(3,7rem)] sm:items-center sm:gap-4 lg:gap-6"; +const HEADER_GRID = + "grid grid-cols-3 items-center gap-4 px-3.5 pb-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,7rem)] sm:gap-4 lg:gap-6"; +const METRICS_GRID = "grid grid-cols-3 gap-4 sm:contents"; +const METRIC_CELL = "flex min-w-0 flex-col items-center justify-center gap-0.5 text-center"; +const METRIC_LABEL = "whitespace-nowrap text-[9.5px] font-bold leading-4 text-gray-400"; +const METRIC_VALUE = "whitespace-nowrap text-sm font-bold leading-5 tabular-nums"; + +const LABELS = { + sub: "همه‌ی اقلام راکد — برای مرتب‌سازی روی عنوان ستون‌ها کلیک کنید", + qty: "مقدار راکد", + days: "روزهای رکود", + dayUnit: "روز", + lastActivity: "آخرین فعالیت", + brand: "برند", + warehouse: "انبار", + project: "پروژه", + sortHint: "ترتیب: صعودی ← نزولی ← پیش‌فرض", + empty: "موجودی راکدی یافت نشد", + noDate: "—", +} as const; + +// ——— مرتب‌سازی سه‌حالته ——— +type SortKey = "quantity" | "days" | "activity"; +type SortDirection = "asc" | "desc"; + +interface SortState { + key: SortKey; + direction: SortDirection; +} + +const SORT_FIELDS: { key: SortKey; label: string }[] = [ + { key: "quantity", label: LABELS.qty }, + { key: "days", label: LABELS.days }, + { key: "activity", label: LABELS.lastActivity }, +]; + +// برچسب مشخصات: «کلید: مقدار، …» function specLabel(specs: Record): string { const parts = Object.entries(specs || {}); return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : ""; } -function idleSeverity(days: number | null): string { - if (days == null) return "text-muted"; - if (days >= 90) return "text-red"; - if (days >= 60) return "text-amber"; - return "text-muted"; +// شدت رکود نسبت به بیشترین رکود مجموعه (کاملاً داده‌محور) +function severityClass(days: number, maxDays: number): string { + const ratio = maxDays > 0 ? days / maxDays : 0; + if (ratio > 0.66) return "text-red"; + if (ratio > 0.33) return "text-amber"; + return "text-green"; +} + +// مقایسه‌ی امن: ردیف‌های بدون تاریخ همیشه در انتها می‌آیند +function compareActivity(a: DeadStockItem, b: DeadStockItem, direction: 1 | -1): number { + const time = (value: string | null): number | null => { + if (!value) return null; + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : null; + }; + const at = time(a.last_activity); + const bt = time(b.last_activity); + if (at === null && bt === null) return 0; + if (at === null) return 1; + if (bt === null) return -1; + return (at - bt) * direction; +} + +export function sortItems(items: DeadStockItem[], sort: SortState | null): DeadStockItem[] { + // بی‌ترتیب = ترتیب پیش‌فرض دیتابیس (بدون هیچ تغییر) + if (!sort) return items; + const direction = sort.direction === "asc" ? 1 : -1; + const list = [...items]; + list.sort((a, b) => { + if (sort.key === "quantity") return (a.quantity - b.quantity) * direction; + if (sort.key === "days") return ((a.idle_days ?? 0) - (b.idle_days ?? 0)) * direction; + return compareActivity(a, b, direction); + }); + return list; +} + +// چرخه‌ی سه‌حالته‌ی هر ستون: صعودی ← نزولی ← برگشت به پیش‌فرض دیتابیس +export function nextSortState(prev: SortState | null, key: SortKey): SortState | null { + if (!prev || prev.key !== key) return { key, direction: "asc" }; + if (prev.direction === "asc") return { key, direction: "desc" }; + return null; +} + +// ——— هدر ستون تعاملی ——— +// برچسب متریک خودِ دکمه است. نشانگر مرتب‌سازی همیشه دیده می‌شود (نه فقط با +// هاور) تا از نگاه اول مشخص باشد ستون قابل مرتب‌سازی است: حالت غیرفعال با +// فلش دوگانه‌ی خاکستری ملایم و حالت فعال با فلش جهت‌دار و رنگ برند. +function SortHeader({ + field, + sort, + onCycle, +}: { + field: { key: SortKey; label: string }; + sort: SortState | null; + onCycle: (key: SortKey) => void; +}) { + const active = sort?.key === field.key; + return ( + + ); +} + +// ——— یک ردیف فقط‌خواندنی ——— +function DeadStockRow({ item, maxDays }: { item: DeadStockItem; maxDays: number }) { + const specs = specLabel(item.specs); + const unitName = item.unit?.name_fa || item.unit?.code || ""; + const days = item.idle_days ?? 0; + return ( +
  • +
    + {/* هویت */} +
    + + {item.material} + {specs && — {specs}} + + + {item.brand && ( + + {LABELS.brand}: {item.brand} + + )} + + {LABELS.warehouse}: {item.warehouse} + + + {LABELS.project}: {item.project} + + +
    + + {/* سه متریک فقط‌خواندنی — در sm به‌صورت contents داخل شبکه‌ی ردیف + می‌نشینند تا دقیقاً زیر ستون‌های هدر قرار بگیرند */} +
    +
    + {LABELS.qty} + + {fmtNum(item.quantity)} {unitName} + +
    +
    + {LABELS.days} + + {item.idle_days == null ? LABELS.noDate : `${faNum(days)} ${LABELS.dayUnit}`} + +
    +
    + {LABELS.lastActivity} + + {item.last_activity + ? isoToJalaliStr(item.last_activity) ?? LABELS.noDate + : LABELS.noDate} + +
    +
    +
    +
  • + ); } export function DeadStockTable() { - const [idleDays, setIdleDays] = useState<60 | 90>(60); - const query = useDeadStock(idleDays); + const query = useDeadStock(ALL_ITEMS_IDLE_DAYS); const items = query.data?.items ?? []; + const [sort, setSort] = useState(null); + + const maxDays = useMemo( + () => Math.max(1, ...items.map((item) => item.idle_days ?? 0)), + [items], + ); + + const sortedItems = useMemo(() => sortItems(items, sort), [items, sort]); + + const cycleSort = (key: SortKey) => setSort((prev) => nextSortState(prev, key)); return ( } - sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازه‌ی انتخابی نداشته‌اند" - actions={ -
    - {([60, 90] as const).map((d) => ( - - ))} -
    - } + sub={LABELS.sub} > {query.isLoading ? ( ) : items.length === 0 ? ( - + ) : ( - - - - متریال و مشخصات - برند - انبار / پروژه - مقدار راکد - روزهای رکود - آخرین فعالیت - - - - {items.map((it) => { - const specs = specLabel(it.specs); - return ( - - -
    {it.material}
    - {specs &&
    {specs}
    } -
    - {it.brand || "—"} - -
    {it.warehouse}
    -
    {it.project}
    -
    - - {fmtNum(it.quantity)}{" "} - - {it.unit?.name_fa || it.unit?.code} - - - - {it.idle_days == null ? "—" : faNum(it.idle_days)} - - - {it.last_activity ? isoToJalaliStr(it.last_activity) : "—"} - -
    - ); - })} -
    -
    +
    + {/* هدر و ردیف‌ها در یک اسکرول‌کانتینر مشترک‌اند؛ پس عرض محتوا و گاتر + اسکرول‌بار برای هر دو یکسان است و ستون‌های هدر دقیقاً روی ستون‌های + هر ردیف می‌نشینند. هدر sticky است تا هنگام پیمایش دیده شود. */} +
    + {/* ستون هویت در دسکتاپ؛ در موبایل فضای خالی لازم نیست */} + + {SORT_FIELDS.map((field) => ( + + ))} +
    + +
      + {sortedItems.map((item) => ( + + ))} +
    +
    )}
    ); diff --git a/frontend/src/components/dashboard/DrilldownMatrix.tsx b/frontend/src/components/dashboard/DrilldownMatrix.tsx index e6307aa..cf9554e 100644 --- a/frontend/src/components/dashboard/DrilldownMatrix.tsx +++ b/frontend/src/components/dashboard/DrilldownMatrix.tsx @@ -25,13 +25,11 @@ import { ChevronDown, ChevronLeft, MousePointerClick, - Package, RotateCcw, Square, - X, } from "lucide-react"; import { useDrilldown } from "../../hooks/useDashboardData"; -import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter"; +import { useDashboardFilter } from "../../store/dashboardFilter"; import type { DrilldownProject } from "../../lib/types"; import { faNum, fmtNum } from "../../lib/formatters"; import { @@ -63,11 +61,21 @@ interface StackGroup { // می‌نشیند؛ چون dataKey هر مستقیماً از سطح پراپرتی‌های داده خوانده می‌شود. interface StackedBar { id: string; - label: string; + label: string; // نام کامل (تولتیپ) + // برچسب‌های سلسله‌مراتبی محور X (از payload) + axisLabel: string; // Tier 1: مشخصه/واریانت کوتاه + familyId: string; + familyName: string; + specLabel: string; + specDetail: string | null; + variantLabel: string; + specsHash: string; + materialRowId: string; + brandName: string | null; unitLabel: string; total: number; segments: Record; - [segmentKey: string]: number | string | Record; + [segmentKey: string]: number | string | null | Record; } // سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند) @@ -83,11 +91,14 @@ const LABELS = { warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید", drillHint: "برای مشاهده‌ی سهم پروژه‌ها و انبارها روی میله‌ها کلیک کنید", lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید", - removeMaterial: "حذف متریال انتخاب‌شده و بازگشت به نمای متریال‌ها", + removeSpec: "حذف مشخصه و بازگشت به نمای همین خانواده", totalLabel: "مجموع نمایش داده شده", itemsLabel: "اقلام نمایش داده شده", tooltipTotal: "مجموع", tooltipBreakdown: "تفکیک بر اساس", + family: "خانواده", + spec: "مشخصه", + standard: "استاندارد", emptyChart: "موجودی‌ای با این فیلترها یافت نشد", } as const; @@ -184,9 +195,17 @@ function scopeLabel( // بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند. const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px) const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px) -const CHART_HEIGHT = 360; // ارتفاع نمودار (px) -const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (px) -const AXIS_TICK_MARGIN = 15; // فاصله‌ی خط محور تا خط اول برچسب (px) +const CHART_HEIGHT = 360; // ارتفاع ناحیه‌ی نمودار (px) +const AXIS_HEIGHT = 62; // ناحیه‌ی برچسب Tier 1 (مشخصه) — px +const AXIS_TICK_MARGIN = 10; // فاصله‌ی خط محور تا خط اول برچسب (px) +// هندسه‌ی محور سلسله‌مراتبی (Tier 2/3) — هم‌تراز با layout خودِ BarChart +const CHART_MARGIN_LEFT = 8; +const CHART_MARGIN_RIGHT = 12; +const CHART_MARGIN_TOP = 26; +const CHART_MARGIN_BOTTOM = 12; +const Y_AXIS_WIDTH = 56; +const FAMILY_BAR_HEIGHT = 32; // خط سقف خانواده + نام آن (px) +const SCROLLBAR_ALLOWANCE = 18; // فضای اسکرول‌بار افقی const LABEL_LINE_CHARS = 20; // سقف نویسه‌ی هر خط برچسب const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب const LABEL_LINE_HEIGHT = 18; // فاصله‌ی خطوط برچسب (px) @@ -281,13 +300,11 @@ function numericValues(qtyByUnit: Record): Record {/* سربرگ: موجودیت اصلی میله (متریال در نمای کلی، پروژه در نمای drill-down) */}
    - - {String(label ?? bar.label)} - - - {LABELS.tooltipBreakdown} {SEGMENT_META[segmentKind].plural} + {bar.label} + + {SEGMENT_META[segmentKind].plural} + {bar.familyName && ( + <> + • + + {LABELS.family}: {bar.familyName} + + + )} + {bar.specLabel && ( + <> + • + + {LABELS.spec}: {bar.specLabel} + + + )}
    @@ -356,11 +387,6 @@ function CustomTooltip({ ); } -// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و -// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود. -const selectGlobalFilterKey = (s: DashboardFilterState): string => - JSON.stringify([s.materialId, s.brandId, s.specsHash]); - // --- MultiSelect Dropdown Component --- // کنترل چند‌انتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چند‌انتخابیِ breadcrumb // رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را @@ -528,7 +554,13 @@ export function DrilldownMatrix() { // فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن // کورکورانه‌ی انتخاب‌ها، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه // در سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم. - const globalFilterKey = useDashboardFilter(selectGlobalFilterKey); + const filter = useDashboardFilter(); + // اثر انگشت فیلتر سراسری (خانواده + برند + مشخصه) — با هر تغییر، payload نو + const globalFilterKey = JSON.stringify([ + filter.materialFamilyId, + filter.brandId, + filter.spec?.id ?? null, + ]); // ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند // که در نتیجه‌ی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپ‌های فعال می‌بینیم. @@ -582,6 +614,35 @@ export function DrilldownMatrix() { const matchesWarehouse = (wid: string) => selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid); + // برچسب‌های سلسله‌مراتبی هر میله (خانواده/مشخصه/واریانت) — فقط در نمای کلی + const materialMeta = new Map< + string, + { + familyId: string; + familyName: string; + specLabel: string; + specDetail: string | null; + variantLabel: string; + specsHash: string; + materialRowId: string; + brandName: string | null; + } + >(); + if (level === ROOT_LEVEL) { + data.materials.forEach((mat) => { + materialMeta.set(mat.id, { + familyId: mat.family_id ?? mat.name, + familyName: mat.family_name ?? mat.name, + specLabel: mat.spec_label ?? mat.variant_label ?? LABELS.standard, + specDetail: mat.spec_detail ?? null, + variantLabel: mat.variant_label ?? LABELS.standard, + specsHash: mat.specs_hash ?? "", + materialRowId: mat.material_id ?? "", + brandName: mat.brand_name ?? null, + }); + }); + } + interface GroupAcc { name: string; byUnit: Record; @@ -670,9 +731,19 @@ export function DrilldownMatrix() { total += value; groupNames.set(groupId, group.name); } + const meta = materialMeta.get(bar.id); bars.push({ id: bar.id, label: bar.label, + axisLabel: meta ? meta.specLabel : bar.label, + familyId: meta?.familyId ?? "", + familyName: meta?.familyName ?? "", + specLabel: meta?.specLabel ?? "", + specDetail: meta?.specDetail ?? null, + variantLabel: meta?.variantLabel ?? "", + specsHash: meta?.specsHash ?? "", + materialRowId: meta?.materialRowId ?? "", + brandName: meta?.brandName ?? null, unitLabel: unitCode ? data.units[unitCode] || unitCode : "", total, segments, @@ -681,7 +752,40 @@ export function DrilldownMatrix() { ...segments, }); } - bars.sort((a, b) => b.total - a.total); + // Tier1: در یک خانواده، برچسب تکراری با برند تفکیک می‌شود + const labelCounts = new Map(); + bars.forEach((bar) => { + if (!bar.familyId) return; + const key = `${bar.familyId}|${bar.axisLabel}`; + labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1); + }); + bars.forEach((bar) => { + if (!bar.familyId) return; + const key = `${bar.familyId}|${bar.axisLabel}`; + if ((labelCounts.get(key) ?? 0) > 1) { + const suffix = bar.specDetail ?? bar.brandName; + if (suffix) bar.axisLabel = `${bar.axisLabel} · ${suffix}`; + } + }); + + if (level === ROOT_LEVEL) { + // گروه‌بندی پیوسته: خانواده‌ها بر اساس مجموع موجودی، واریانت‌ها داخل هر خانواده + const familyTotals = new Map(); + bars.forEach((bar) => { + familyTotals.set(bar.familyId, (familyTotals.get(bar.familyId) ?? 0) + bar.total); + }); + bars.sort((a, b) => { + if (a.familyId !== b.familyId) { + return ( + (familyTotals.get(b.familyId) ?? 0) - (familyTotals.get(a.familyId) ?? 0) || + a.familyName.localeCompare(b.familyName, "fa") + ); + } + return a.axisLabel.localeCompare(b.axisLabel, "fa", { numeric: true }); + }); + } else { + bars.sort((a, b) => b.total - a.total); + } // فهرست استک‌ها = اجتماع گروه‌های حاضر؛ ترتیب (و بنابراین رنگ‌ها) از مجموع // کمیت‌ها مشتق می‌شود و در حالت تساوی با نام، پایدار می‌ماند. @@ -720,6 +824,28 @@ export function DrilldownMatrix() { // کانتینر (و کف تعیین‌شده) نمی‌شود. const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, bars.length * AXIS_SLOT_PX); + // ——— هندسه‌ی محور سلسله‌مراتبی (Tier 2/3) ——— + // گروه‌های پیوسته‌ی خانواده از ترتیب میله‌ها مشتق می‌شوند + const familyGroups = useMemo(() => { + const groups: { familyId: string; familyName: string; start: number; count: number }[] = []; + if (level !== ROOT_LEVEL) return groups; + bars.forEach((bar, index) => { + const last = groups[groups.length - 1]; + if (last && last.familyId === bar.familyId) last.count += 1; + else + groups.push({ familyId: bar.familyId, familyName: bar.familyName, start: index, count: 1 }); + }); + return groups; + }, [bars, level]); + + const plotLeft = CHART_MARGIN_LEFT + Y_AXIS_WIDTH; + const bandWidth = + bars.length > 0 ? Math.max(0, chartWidth - plotLeft - CHART_MARGIN_RIGHT) / bars.length : 0; + const plotAreaHeight = + CHART_HEIGHT + + (level === ROOT_LEVEL && familyGroups.length > 0 ? FAMILY_BAR_HEIGHT : 0) + + SCROLLBAR_ALLOWANCE; + // ——— قفل هوشمند drill-down ——— // اگر کاربر انبار(های) مشخصی را در دامنه‌ی سراسری فیلتر کرده باشد، عمیق‌تر // رفتن روی نمودار بی‌معناست؛ کلیک بی‌اثر و نشانگر پیش‌فرض می‌شود. @@ -732,18 +858,37 @@ export function DrilldownMatrix() { if (level !== ROOT_LEVEL) return; const bar = (entry as { payload?: StackedBar })?.payload; if (!bar) return; + // همگام‌سازی دوطرفه: انتخاب میله → فیلتر سراسری (خانواده + مشخصه/واریانت) + if (bar.familyId) { + filter.setMaterialFamily(bar.familyId, bar.familyName); + if (bar.specsHash && bar.specsHash !== "default") { + filter.setSpec({ kind: "stockSpec", id: bar.specsHash, label: bar.specLabel }); + } else if (bar.materialRowId) { + filter.setSpec({ kind: "variant", id: bar.materialRowId, label: bar.variantLabel }); + } + } setSelectedMaterial({ id: bar.id, name: bar.label }); }; - // بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک می‌شوند + // بازگشت کامل به «کل سازمان» — فیلتر سراسری، دامنه‌ها و مسیر drill پاک می‌شوند const resetToRoot = () => { setSelectedMaterial(null); setSelectedProjectIds([]); setSelectedWarehouseIds([]); + filter.reset(); }; - // حذف تگ متریال — فقط حالت محلی نمودار صفر می‌شود و دامنه‌های سراسری دست‌نخورده می‌مانند - const clearMaterial = () => setSelectedMaterial(null); + // یک پله بالا: حذف مشخصه (خانواده می‌ماند) و بازگشت نمودار به سطح خانواده + const clearSpecAndDrill = () => { + filter.setSpec(null); + setSelectedMaterial(null); + }; + + // انتخاب خانواده از برچسب محور → متریال = خانواده، مشخصات = «همه» + const selectFamilyGroup = (familyId: string, familyName: string) => { + filter.setMaterialFamily(familyId, familyName); + setSelectedMaterial(null); + }; // دامنه‌ی پروژه فقط دامنه‌ی انبارِ زیرمجموعه‌اش را آزاد می‌کند const handleProjectScopeChange = (ids: string[]) => { @@ -835,34 +980,33 @@ export function DrilldownMatrix() { )} - {/* تگ متریال انتخاب‌شده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف می‌کنند */} - {selectedMaterial && ( + {/* گره‌ی خانواده‌ی متریال (از فیلتر سراسری) — کلیک: حذف مشخصه و یک پله بالا */} + {filter.materialFamilyId && ( <> - - - - + + + )} + + {/* گره‌ی مشخصه (از فیلتر سراسری) — کلیک: حذف مشخصه و بازگشت به خانواده */} + {filter.spec && ( + <> + + )} @@ -889,8 +1033,26 @@ export function DrilldownMatrix() { ref={plotRef} dir="ltr" className="w-full overflow-x-auto overflow-y-hidden outline-none focus:outline-none [&_svg]:!outline-none [&_svg:focus]:!outline-none [&_svg:active]:!outline-none [&_svg:focus-visible]:!outline-none [&_.recharts-surface]:!outline-none [&_g]:!outline-none [&_g:focus]:!outline-none [&_g:focus-visible]:!outline-none [&_*]:!outline-none" - style={{ width: '100%', height: CHART_HEIGHT }} + style={{ width: '100%', height: plotAreaHeight }} > +
    + {/* جداکننده‌های عمودی نقطه‌چین بین خانواده‌ها (فقط نمای کلی) */} + {level === ROOT_LEVEL && + familyGroups.slice(1).map((group) => ( + + ))} {/* بدون لایه‌ی دسترس‌پذیری، Recharts روی خود SVG دیگر tabIndex نمی‌گذارد؛ ولی لایه‌های z-index داخلی‌اش (g[tabindex="-1"]) هنوز فوکوس‌پذیرند و همان‌ها منبع حلقه‌ی فوکوس‌اند — که با سلکتورهای @@ -901,11 +1063,16 @@ export function DrilldownMatrix() { data={bars} stackOffset="expand" accessibilityLayer={false} - margin={{ top: 26, right: 12, left: 8, bottom: 12 }} + margin={{ + top: CHART_MARGIN_TOP, + right: CHART_MARGIN_RIGHT, + left: CHART_MARGIN_LEFT, + bottom: CHART_MARGIN_BOTTOM, + }} > @@ -947,6 +1114,36 @@ export function DrilldownMatrix() { /> ))} + + {/* Tier 2/3: خط سقف خانواده + نام آن، وسط‌چین زیر همان بازه */} + {level === ROOT_LEVEL && familyGroups.length > 0 && ( +
    + {familyGroups.map((group) => { + const left = plotLeft + bandWidth * group.start; + const width = bandWidth * group.count; + return ( +
    +
    + + + +
    +
    + +
    +
    + ); + })} +
    + )} +
    {/* راهنمای رنگ‌ها — از همان سگمنت‌های payload ساخته می‌شود و نام diff --git a/frontend/src/components/dashboard/FlowChart.tsx b/frontend/src/components/dashboard/FlowChart.tsx index 437eb43..5d1d30c 100644 --- a/frontend/src/components/dashboard/FlowChart.tsx +++ b/frontend/src/components/dashboard/FlowChart.tsx @@ -324,18 +324,18 @@ function dayGroups(day: FlowDay): CatMap { export function FlowChart() { const filter = useDashboardFilter(); - // بدون متریال، کوئری اصلاً اجرا نمی‌شود (جلوگیری از تجمیع واحدهای ناسازگار) - const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId)); + // بدون خانواده‌ی متریال، کوئری اصلاً اجرا نمی‌شود (تجمیع واحدهای ناسازگار ممنوع) + const query = useDailyFlow(undefined, undefined, Boolean(filter.materialFamilyId)); const options = useDashboardFilterOptions(); - const materials = options.data?.materials ?? []; - const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null; - const materialName = selectedMaterial?.name ?? filter.materialLabel ?? ""; + const families = options.data?.families ?? []; + const selectedFamily = families.find((family) => family.id === filter.materialFamilyId) ?? null; + const materialName = selectedFamily?.name ?? filter.materialFamilyLabel ?? ""; const unitLabel = query.data?.unit?.name_fa ?? query.data?.unit?.code ?? - selectedMaterial?.unit?.name_fa ?? - selectedMaterial?.unit?.code ?? + selectedFamily?.unit?.name_fa ?? + selectedFamily?.unit?.code ?? ""; // ——— هم‌سازمان‌دهی روزها + سگمنت‌های شناور ——— @@ -461,9 +461,12 @@ export function FlowChart() { const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1); // ——— دروازه‌ی تک‌متریالی: بدون متریال، نمودار رندر نمی‌شود ——— - if (!filter.materialId) { + if (!filter.materialFamilyId) { return ( - + {options.isError ? ( ) : options.isLoading ? ( @@ -471,31 +474,30 @@ export function FlowChart() { ) : (

    {LABELS.needMaterial}

    - {materials.length > 0 && ( + {families.length > 0 && (
    {LABELS.quickPick}
    - {materials.map((material) => { - const materialUnit = - material.unit?.name_fa ?? material.unit?.code ?? ""; + {families.map((family) => { + const familyUnit = family.unit?.name_fa ?? family.unit?.code ?? ""; return ( @@ -512,6 +514,7 @@ export function FlowChart() { return ( @@ -522,9 +525,9 @@ export function FlowChart() { ) : !hasMovement ? ( ) : ( -
    +
    {/* راهنمای رنگ دسته‌ها (به‌جای Legend پیش‌فرض؛ سری‌های تنالیته معنا ندارند) */} -
    +
    {visibleCategories.map((category) => ( -
    +
    قابل انتساب باشد) export type DashboardFilterParams = { + // خانواده‌ی متریال (نام پایه) — سرور به همه‌ی واریانت‌های خانواده map می‌کند + material_family?: string | null; + // واریانت مشخص (ردیف کاتالوگ) برای محدودکردن به یک سایز/گرید material_id?: string | null; brand_id?: string | null; specs_hash?: string | null; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5e59d73..47a7782 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -222,18 +222,35 @@ export interface ConfirmedReceipt { export interface FilterSpecOption { hash: string; specs: Record; + // برچسب کوتاه مشخصه (مقدارها) — برای محور X و فهرست کشویی + label?: string; } export interface FilterMaterialOption { id: string; name: string; + // تفکیک «خانواده» (نام پایه) از «واریانت» (سایز/گرید) — از API + family_id?: string; + family_name?: string; + variant_label?: string; unit: { id: number; code: string; name_fa: string | null } | null; brands: { id: string; name: string }[]; specs: FilterSpecOption[]; } +// خانواده‌ی متریال: چند ردیف کاتالوگ با یک نام پایه (سایزها/گریدها زیرمجموعه) +export interface FilterFamilyOption { + id: string; + name: string; + unit: { id: number; code: string; name_fa: string | null } | null; + brands: { id: string; name: string }[]; + specs: { hash: string; label: string }[]; + materials: { id: string; name: string; variant_label: string }[]; +} + export interface DashboardFilterOptions { materials: FilterMaterialOption[]; + families: FilterFamilyOption[]; } // — ماتریس Drill-Down (کل سازمان → پروژه → انبار) — @@ -257,6 +274,15 @@ export interface DrilldownProject { export interface DrilldownMaterial { id: string; name: string; + // ردیف کاتالوگ + برچسب‌های محور (خانواده/واریانت/مشخصه) و برند + material_id?: string; + specs_hash?: string; + family_id?: string; + family_name?: string; + variant_label?: string; + spec_label?: string; + spec_detail?: string | null; + brand_name?: string | null; qty_by_unit: QtyByUnit; rows: number; projects: DrilldownProject[]; @@ -315,25 +341,53 @@ export interface FlowData { } // — کنترل روزانه — +export interface DailyControlUnitStat { + code: string; + name_fa: string | null; + qty: number; +} + +// آمار هر دسته‌ی عملیاتی: تعداد تراکنش، تعداد قلم کالای متمایز و تفکیک واحدها +export interface DailyControlCategoryStats { + tickets: number; + items: number; + qty: number; + units: DailyControlUnitStat[]; +} + +export type DailyControlPersonRole = "performer" | "recipient"; + +export interface DailyControlPerson { + name: string; + role: DailyControlPersonRole; + tickets: number; + categories: { outbound: number; returns: number; waste: number; purchases: number }; +} + +export interface DailyControlItem { + id: string; + transaction_type: string; + quantity: number; // علامت‌دار مطابق دفتر + unit_code: string; + unit_name_fa: string | null; + performed_by: string | null; + delivered_to: string | null; + brand: string | null; + note: string | null; + warehouse: string; + material: string; + specs: Record; + transaction_date: string; +} + export interface DailyControlData { date: string; - outbound: { tickets: number; qty: number }; - returns: { tickets: number; qty: number }; - waste: { tickets: number; qty: number }; - purchases: { tickets: number; qty: number }; - performers: { name: string; tickets: number; qty: number }[]; - items: { - id: string; - transaction_type: string; - quantity: number; - unit_code: string; - performed_by: string | null; - note: string | null; - warehouse: string; - material: string; - specs: Record; - transaction_date: string; - }[]; + outbound: DailyControlCategoryStats; + returns: DailyControlCategoryStats; + waste: DailyControlCategoryStats; + purchases: DailyControlCategoryStats; + people: DailyControlPerson[]; + items: DailyControlItem[]; } // — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) — diff --git a/frontend/src/pages/Dashboard/MvpDashboardPage.tsx b/frontend/src/pages/Dashboard/MvpDashboardPage.tsx index ac6601f..08474a9 100644 --- a/frontend/src/pages/Dashboard/MvpDashboardPage.tsx +++ b/frontend/src/pages/Dashboard/MvpDashboardPage.tsx @@ -69,7 +69,7 @@ export function MvpDashboardPage() { {/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */} {/* ردیف دوم: سری زمانی + کنترل روزانه */} -
    +
    diff --git a/frontend/src/store/dashboardFilter.ts b/frontend/src/store/dashboardFilter.ts index 12a61ed..171a6bc 100644 --- a/frontend/src/store/dashboardFilter.ts +++ b/frontend/src/store/dashboardFilter.ts @@ -1,63 +1,68 @@ // ============================================================ // Zustand store — فیلتر سراسری «چه چیزی» داشبورد -// متریال → برند → مشخصات فنی +// متریال (خانواده) → برند → مشخصات فنی +// «مشخصات» یکی از دو چیز است: واریانتِ کاتالوگ (سایز/گرید) یا مشخصه‌ی موجودی. // بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود. // ============================================================ import { create } from "zustand"; -export interface DashboardFilterState { - materialId: string | null; // null = همه‌ی متریال‌ها - brandId: string | null; - specsHash: string | null; - // برچسب‌های انتخاب‌شده برای نمایش چیپ فعال - materialLabel: string | null; - brandLabel: string | null; - specsLabel: string | null; +// انتخاب مشخصه: واریانت (ردیف کاتالوگ) یا مشخصه‌ی موجودی (specs_hash) +export type SpecSelection = + | { kind: "variant"; id: string; label: string } + | { kind: "stockSpec"; id: string; label: string }; - setMaterial: (id: string | null, label: string | null) => void; +export interface DashboardFilterState { + // شناسه/نام خانواده (نام پایه) — نه ردیف تکیِ کاتالوگ + materialFamilyId: string | null; + materialFamilyLabel: string | null; + brandId: string | null; + brandLabel: string | null; + spec: SpecSelection | null; + + setMaterialFamily: (id: string | null, label: string | null) => void; setBrand: (id: string | null, label: string | null) => void; - setSpecs: (hash: string | null, label: string | null) => void; + setSpec: (selection: SpecSelection | null) => void; reset: () => void; } const INITIAL = { - materialId: null, + materialFamilyId: null, + materialFamilyLabel: null, brandId: null, - specsHash: null, - materialLabel: null, brandLabel: null, - specsLabel: null, + spec: null, }; export const useDashboardFilter = create((set) => ({ ...INITIAL, - // آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند (متریال → برند → مشخصات) - setMaterial: (id, label) => + // آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند + setMaterialFamily: (id, label) => set({ - materialId: id, - materialLabel: label, + materialFamilyId: id, + materialFamilyLabel: label, brandId: null, brandLabel: null, - specsHash: null, - specsLabel: null, + spec: null, }), setBrand: (id, label) => set({ brandId: id, brandLabel: label, - specsHash: null, - specsLabel: null, + spec: null, }), - setSpecs: (hash, label) => set({ specsHash: hash, specsLabel: label }), + setSpec: (selection) => set({ spec: selection }), reset: () => set({ ...INITIAL }), })); // پارامترهای آماده برای endpointهای داشبورد export function dashboardFilterParams(s: DashboardFilterState) { return { - material_id: s.materialId, + material_family: s.materialFamilyId, + // واریانت = ردیف کاتالوگ همان سایز/گرید + material_id: s.spec?.kind === "variant" ? s.spec.id : null, brand_id: s.brandId, - specs_hash: s.specsHash, + // مشخصه‌ی موجودی + specs_hash: s.spec?.kind === "stockSpec" ? s.spec.id : null, }; }