diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 062973e..597f585 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -384,11 +384,12 @@ async def ensure_v26(engine: AsyncEngine) -> None: "ON CONFLICT (from_unit, to_unit) DO NOTHING", c, ) - # v2.5/v2.6 category decoupling from materials: drop trigger, function, and column. + # v2.5/v2.6 category decoupling from materials: drop the old supplier-derived + # trigger/function. The `materials.category_id` COLUMN is NOT dropped here + # anymore — v2.8 re-introduces it (topic-backfilled) for the dashboard's + # Category→Material filter cascade (see ensure_dashboard_schema below). await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()") - await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey") - await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id") await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement(""" @@ -558,3 +559,70 @@ async def ensure_project_transfers_table(engine: AsyncEngine) -> None: await conn.execute(text(stmt)) logger.info("project_transfers table ensured") +# --------------------------------------------------------------------------- +# v2.8 — MVP dashboard schema: materials.category_id + reporting indexes +# The dashboard's «What» filter cascade (Category → Material → Brand → Specs) +# needs a real materials.category_id link. v2.6 had deliberately dropped the +# old supplier-derived column; this is a fresh, topic-backfilled link +# (nullable — a material may stay uncategorized). The rest is B-tree index +# coverage for the aggregation queries in services/dashboard_service.py. +# Runs AFTER ensure_v26 so the 8 fixed categories always exist for the +# backfill. Every statement is idempotent. +# --------------------------------------------------------------------------- + +_DASHBOARD_SCHEMA_DDL = [ + # Fresh column (inline FK names the constraint materials_category_id_fkey, + # matching the DO-block guard below for volumes that predate v2.8). + """ + ALTER TABLE materials ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL + """, + # Backfill from the legacy Persian topic text (NULL stays NULL — the UI + # groups those under «بدون دسته»). + """ + UPDATE materials SET category_id = mc.id + FROM material_categories mc + WHERE materials.topic = mc.name AND materials.category_id IS NULL + """, + # FK for the (rare) volume that already had the column without it. + """ + DO $$ BEGIN + ALTER TABLE materials ADD CONSTRAINT materials_category_id_fkey + FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + """, + "CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(category_id)", + # Drill-down / PM scoping: every dashboard query filters through it. + "CREATE INDEX IF NOT EXISTS idx_warehouses_project ON warehouses(project_id)", + # Daily flow + daily control (date-range aggregation). + "CREATE INDEX IF NOT EXISTS idx_inventory_tx_date ON inventory_transactions(transaction_date)", + # Type slices (withdrawal / return / waste) inside a date range. + "CREATE INDEX IF NOT EXISTS idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date)", + # Funnel cumulative counts + lead-time average (partial — tiny & hot). + "CREATE INDEX IF NOT EXISTS idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL", + "CREATE INDEX IF NOT EXISTS idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL", + # Funnel per-status counts (existing composite leads with supplier_id). + "CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status)", + # Project-density workload grouping. + "CREATE INDEX IF NOT EXISTS idx_item_decisions_project ON item_decisions(project_id)", + # Receipt-level arrival reporting. + "CREATE INDEX IF NOT EXISTS idx_item_receipts_received ON item_receipts(received_at)", + # «What»-filtered stock scans across warehouses (existing composites lead + # with warehouse_id, useless for a bare material/brand filter). + "CREATE INDEX IF NOT EXISTS idx_inventory_material_brand ON inventory(material_id, brand_id)", +] + + +async def ensure_dashboard_schema(engine: AsyncEngine) -> None: + """v2.8: materials.category_id (+ backfill) + dashboard reporting indexes. + + Statement-per-transaction like ensure_v26: one failure can never block + the rest of the upgrade. Safe to call on every startup. + """ + for ddl in _DASHBOARD_SCHEMA_DDL: + try: + async with engine.begin() as conn: + await conn.execute(text(ddl)) + except Exception: # pragma: no cover - pre-existing data conflicts + first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100] + logger.exception("dashboard schema statement skipped (continuing): %s", first_line) + logger.info("dashboard schema ensured (materials.category_id + reporting indexes)") diff --git a/backend/app/main.py b/backend/app/main.py index be34a9d..af88579 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,7 @@ from app.db.init_db import ( ensure_attachment_table, ensure_audit_table, ensure_bot_drafts_table, + ensure_dashboard_schema, ensure_drop_category_tables, ensure_dashboard_tables, ensure_material_topic_column, @@ -109,6 +110,7 @@ async def lifespan(_app: FastAPI): await ensure_quote_projects(engine) await ensure_v26(engine) await ensure_rbac_schema(engine) + await ensure_dashboard_schema(engine) await ensure_audit_table(engine) await ensure_drop_category_tables(engine) await ensure_bot_drafts_table(engine) diff --git a/backend/app/models/catalog.py b/backend/app/models/catalog.py index d0cd78e..efa1dcf 100644 --- a/backend/app/models/catalog.py +++ b/backend/app/models/catalog.py @@ -130,13 +130,18 @@ class CategoryAttribute(Base): class Material(Base): __tablename__ = "materials" - # v2.6/v2.7: name is GLOBALLY unique; decoupled from categories. + # v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the + # category taxonomy for the dashboard filter cascade (nullable — backfill + # from the legacy `topic` text runs in init_db.ensure_dashboard_schema). __table_args__ = (UniqueConstraint("name", name="uq_material_name"),) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = _uuid_pk() name: Mapped[str] = mapped_column(Text, nullable=False) topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy + category_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("material_categories.id", ondelete="SET NULL") + ) canonical_unit_id: Mapped[int] = mapped_column( ForeignKey("units.id", ondelete="RESTRICT"), nullable=False ) @@ -147,6 +152,7 @@ class Material(Base): ) canonical_unit: Mapped["Unit"] = relationship(lazy="raise") + category: Mapped["MaterialCategory | None"] = relationship(lazy="raise") attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( back_populates="material", lazy="raise", passive_deletes=True ) diff --git a/backend/app/routers/pm.py b/backend/app/routers/pm.py index b075ffb..256e44b 100644 --- a/backend/app/routers/pm.py +++ b/backend/app/routers/pm.py @@ -38,13 +38,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_optional_user from app.config import settings from app.db.engine import get_db -from app.models.catalog import BrandMaterial, Material, Unit +from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.procurement import ( ItemDecision, ItemReceipt, ItemReceiptAllocation, LineItem, + LineItemSpec, Project, Quote, Supplier, @@ -182,16 +183,24 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str: async def pm_on_the_way( ctx: ArrivalScope = Depends(arrivals_scope), db: AsyncSession = Depends(get_db), + material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"), + brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"), ): stmt = ( - select(ItemDecision, LineItem, Quote, Material, Supplier, Project) + select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand) .join(LineItem, LineItem.id == ItemDecision.line_item_id) .join(Quote, Quote.id == LineItem.quote_id) .join(Material, Material.id == LineItem.material_id) .join(Supplier, Supplier.id == Quote.supplier_id) .outerjoin(Project, Project.id == ItemDecision.project_id) + .outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id) + .outerjoin(Brand, Brand.id == BrandMaterial.brand_id) .where(ItemDecision.status == "chosen") ) + if material_id is not None: + stmt = stmt.where(Material.id == material_id) + if brand_id is not None: + stmt = stmt.where(BrandMaterial.brand_id == brand_id) if ctx.role == "pm": allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else []) if not allowed: @@ -211,8 +220,29 @@ async def pm_on_the_way( # unit / category lookups for display unit_ids = {d.LineItem.unit_id for d in decisions} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} + # frozen spec snapshot per line item (key = attribute label) + li_ids = [d.LineItem.id for d in decisions] + specs_by_line: dict = {} + if li_ids: + spec_rows = ( + await db.execute( + select(LineItemSpec, AttributeDefinition) + .join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id) + .where(LineItemSpec.line_item_id.in_(li_ids)) + ) + ).all() + for spec, ad in spec_rows: + value = spec.value_string + if value is None and spec.value_number is not None: + value = str(spec.value_number) + if value is None and spec.value_boolean is not None: + value = str(spec.value_boolean) + if value is None and spec.value_date is not None: + value = spec.value_date.isoformat() + if value is not None: + specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value out = [] - for d, li, q, m, sup, proj in decisions: + for d, li, q, m, sup, proj, bm, br in decisions: received = received_map.get(d.id) out.append({ "item_decision_id": str(d.id), @@ -224,6 +254,8 @@ async def pm_on_the_way( "project": {"id": str(proj.id), "name": proj.name} if proj else None, "decided_at": to_iso(d.decided_at), "material": {"id": str(m.id), "name": m.name}, + "brand": {"id": str(br.id), "name": br.name} if br else None, + "specs": specs_by_line.get(li.id, {}), "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "ordered_qty": str(li.quantity), diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 8b18746..fda2b94 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -1100,6 +1100,17 @@ async def quote_stats( *conditions, ) ) + # Procurement funnel (cumulative, not the current-state counts above): + # a quote counts as accepted/arrived if it EVER reached that stage. + funnel_row = ( + await db.execute( + select( + func.count().label("offered_total"), + func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"), + func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"), + ).where(*conditions) + ) + ).one() return { "total": sum(counts.values()), "offered": counts["offered"], @@ -1108,6 +1119,11 @@ async def quote_stats( "rejected": counts["rejected"], "expired": counts["expired"], "average_lead_time_days": int(round(float(avg))) if avg is not None else None, + "funnel": { + "offered_total": funnel_row.offered_total, + "accepted_total": funnel_row.accepted_total, + "arrived_total": funnel_row.arrived_total, + }, } diff --git a/backend/tools/seed.py b/backend/tools/seed.py index 35c7237..d3f5abe 100644 --- a/backend/tools/seed.py +++ b/backend/tools/seed.py @@ -262,8 +262,11 @@ async def run() -> None: material_by_name: dict[str, Material] = {} brand_material_by_material: dict[str, object] = {} for name, cat_slug, unit_code, brand_name in MATERIALS: + cat = await get_category(db, cat_slug) material = Material( name=name, + topic=cat.name, + category_id=cat.id, # v2.8: dashboard Category→Material cascade canonical_unit_id=unit_ids[unit_code], status="active", ) @@ -441,6 +444,86 @@ async def run() -> None: )) await db.flush() + # ---- MVP dashboard demo ledger ----------------------------------- + # Multi-day history so every widget has real data to show: + # - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d) + # - withdrawals/returns/waste over the last ~12 days → flow series + # - tickets dated TODAY (performed_by names) → daily control + # Quantities are hand-checked to keep every row >= 0. + + async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem: + mat = material_by_name[MATERIALS[mat_idx][0]] + wh = warehouse_rows[project_idx][wh_idx] + inv = await db.scalar(select(InventoryItem).where( + InventoryItem.warehouse_id == wh.id, + InventoryItem.material_id == mat.id, + InventoryItem.specs_hash == "default", + )) + if inv is None: + inv = InventoryItem( + warehouse_id=wh.id, material_id=mat.id, brand_id=None, + quantity=Decimal("0"), unit_id=mat.canonical_unit_id, + specs={}, specs_hash="default", + ) + db.add(inv) + await db.flush() + return inv + + async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int, + days_ago: int, performed_by: str, note: str | None = None) -> None: + inv.quantity += Decimal(str(qty)) + db.add(InventoryTransaction( + inventory_id=inv.id, + warehouse_id=wh.id, + transaction_type=tx_type, + quantity=Decimal(str(qty)), + performed_by=performed_by, + note=note or "تاریخچه دمو داشبورد", + transaction_date=d(days_ago), + )) + + wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2] + + # dead stock: cable + switches bought long ago, untouched since + cable = await ensure_inv(0, 0, 0) + await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو") + keys = await ensure_inv(1, 0, 0) + await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو") + + # cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی) + cement = await ensure_inv(4, 2, 0) + await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی") + await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی") + await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد") + await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست") + await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی") + await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی") + await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز") + + blocks = await ensure_inv(5, 2, 1) + await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی") + await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست") + await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست") + await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی") + await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی") + + pe16_a = await ensure_inv(6, 1, 0) + await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق") + await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه") + await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق") + + pe16_b = await ensure_inv(6, 1, 1) + await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق") + await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق") + await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق") + + pe25 = await ensure_inv(7, 1, 0) + await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق") + await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق") + await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق") + + await db.flush() + await db.commit() # summary diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f7d6e03..24ca741 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "react-dom": "^19.2.8", "react-hook-form": "^7.85.0", "react-router-dom": "^7.18.2", + "recharts": "^3.10.1", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", @@ -1366,6 +1367,32 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", @@ -1714,6 +1741,18 @@ "node": ">=18" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -2003,6 +2042,69 @@ "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -2033,6 +2135,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", @@ -2127,6 +2235,133 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2155,6 +2390,24 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2201,6 +2454,25 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/immer": { + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz", + "integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2645,6 +2917,36 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -2752,6 +3054,57 @@ } } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", @@ -2884,6 +3237,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2970,6 +3329,37 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 399a320..d062b2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "react-dom": "^19.2.8", "react-hook-form": "^7.85.0", "react-router-dom": "^7.18.2", + "recharts": "^3.10.1", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", diff --git a/frontend/src/components/layout/MobileTabBar.tsx b/frontend/src/components/layout/MobileTabBar.tsx index 665553e..529420f 100644 --- a/frontend/src/components/layout/MobileTabBar.tsx +++ b/frontend/src/components/layout/MobileTabBar.tsx @@ -101,6 +101,7 @@ export function MobileTabBar() { if (pathname === "/wizard" || pathname.startsWith("/review")) return null; const onOfficePage = pathname === "/" || + pathname === "/dashboard" || pathname === "/projects" || pathname === "/units" || pathname.startsWith("/suppliers"); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7d4bbf0..6f3cf41 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -19,23 +19,39 @@ import { useAuthStore } from "../store/auth"; import type { Attachment, ConfirmedReceipt, + DashboardFilterOptions, + DailyControlData, + DeadStockItem, + DrilldownData, + FlowDay, + InTransitRow, InventoryRow, InventoryTransaction, Material, - PendingArrival, Project, Quote, + QuoteStats, StandardConversion, Store, Supplier, - TransactionType, - Warehouse, ProjectTransfer, ProjectTransferCreateIn, - ProjectTransferUpdateIn, ProjectTransferReceiveIn, + ProjectTransferUpdateIn, + TransactionType, + Warehouse, + WorkloadProject, } from "./types"; +// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد +// (type alias — نه interface — تا به Record قابل انتساب باشد) +export type DashboardFilterParams = { + category_id?: string | null; + material_id?: string | null; + brand_id?: string | null; + specs_hash?: string | null; +}; + const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; const PAGE_SIZE = 100; @@ -1887,8 +1903,12 @@ export const apiV26 = { pm: { // پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند - onTheWay: async (projectId?: string | null): Promise => - getData(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"), + onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise => { + const qs = new URLSearchParams( + cleanParams({ project_id: projectId ?? activeProjectId(), ...params }), + ).toString(); + return getData(`/pm/on-the-way${qs ? `?${qs}` : ""}`); + }, confirmReceipt: async (body: { item_decision_id: string; received_qty: number; allocations: { warehouse_id: string; quantity: number }[]; @@ -1899,6 +1919,27 @@ export const apiV26 = { getData(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"), }, + // ---------- داشبورد MVP (v2.8) — تجمیع‌های سمت سرور، فقط‌خواندنی ---------- + // نقش/پروژه سمت بک‌اند از JWT (یا هدرهای dev) حل می‌شود؛ PM به پروژه‌های + // خودش محدود است و ادمین/مدیر تدارکات کل سازمان را می‌بیند. + dashboard: { + filters: async (): Promise => + getData("/dashboard/filters"), + drilldown: async (params: DashboardFilterParams = {}): Promise => + getData(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`), + deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> => + getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`), + flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> => + getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`), + dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise => + getData(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`), + workload: async (): Promise<{ projects: WorkloadProject[] }> => + getData<{ projects: WorkloadProject[] }>("/dashboard/procurement/workload"), + // KPI زمان تأمین + قیف تدارکات (endpoint آماری موجود /quotes/stats) + quoteStats: async (): Promise => + getData("/quotes/stats"), + }, + // ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه): // متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند. catalog: { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 6ebd37e..b7838c1 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -195,6 +195,12 @@ export interface PendingArrival { state: "on_the_way" | "partially_received" | "arrived"; } +// v2.6.1 — برند/مشخصه‌های فrozen قلم (اضافه‌شده به /pm/on-the-way برای داشبورد) +export interface InTransitRow extends PendingArrival { + brand: { id: string; name: string } | null; + specs: Record; +} + export interface ConfirmedReceipt { receipt_id: string; quote_id: string; @@ -208,6 +214,132 @@ export interface ConfirmedReceipt { allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[]; } +// ============================================================ +// v2.8 — داشبورد MVP (انبار + بازرگانی) +// ============================================================ + +// — فیلتر سراسری «چه چیزی» — +export interface FilterSpecOption { + hash: string; + specs: Record; +} + +export interface FilterMaterialOption { + id: string; + name: string; + unit: { id: number; code: string; name_fa: string | null } | null; + brands: { id: string; name: string }[]; + specs: FilterSpecOption[]; +} + +export interface FilterCategoryOption { + id: string | null; // null = «بدون دسته» + name: string; + slug: string | null; + materials: FilterMaterialOption[]; +} + +export interface DashboardFilterOptions { + categories: FilterCategoryOption[]; +} + +// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) — +export type QtyByUnit = Record; + +export interface DrilldownWarehouse { + id: string; + name: string; + qty_by_unit: QtyByUnit; + rows: number; +} + +export interface DrilldownProject { + id: string; + name: string; + qty_by_unit: QtyByUnit; + rows: number; + warehouses: DrilldownWarehouse[]; +} + +export interface DrilldownData { + units: Record; + total: { qty_by_unit: QtyByUnit; rows: number }; + projects: DrilldownProject[]; +} + +// — جدول موجودی راکد — +export interface DeadStockItem { + inventory_id: string; + material: string; + brand: string | null; + specs: Record; + warehouse: string; + project: string; + quantity: number; + unit: { code: string; name_fa: string | null }; + last_activity: string | null; + idle_days: number | null; +} + +// — سری زمانی جریان روزانه — +export interface FlowDay { + date: string; // ISO (YYYY-MM-DD) + inbound: number; + outbound: number; + waste: number; + returns: number; + balance: number; +} + +// — کنترل روزانه — +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; + }[]; +} + +// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) — +export interface QuoteStats { + total: number; + offered: number; + accepted: number; + arrived: number; + rejected: number; + expired: number; + average_lead_time_days: number | null; + funnel: { + offered_total: number; + accepted_total: number; + arrived_total: number; + }; +} + +// — بار تدارکات روی پروژه‌ها (Project Density) — +export interface WorkloadProject { + id: string | null; + name: string; + offered_quotes: number; + chosen_items: number; + open_orders: number; + ordered_qty: number; + received_qty: number; +} + export interface ProjectTransfer { id: string; from_project: { id: string; name: string }; @@ -255,4 +387,3 @@ export interface ProjectTransferReceiveIn { received_at?: string | null; note?: string | null; } - diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index d01e4a5..3506f5e 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import { Users, FolderKanban, Ruler, Plus } from "lucide-react"; +import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react"; import { useMaterials, useProjects, @@ -94,6 +94,15 @@ export function DashboardPage() { menu={} actions={
+ {/* داشبورد تحلیلی — دکمه‌ی مستقل، هم‌سبک با «پروژه‌ها» و «ضرایب تبدیل» */} + + + داشبورد + + تأمین‌کننده‌ها diff --git a/sql/migration.sql b/sql/migration.sql index 0da27c6..692d3fd 100644 --- a/sql/migration.sql +++ b/sql/migration.sql @@ -165,13 +165,15 @@ CREATE TABLE category_attributes ( ); -- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ---------- --- Materials are global, category-free entities. name is GLOBALLY --- unique (uq_material_name). topic is a legacy display column no longer --- written by the app. +-- Materials are global entities. name is GLOBALLY unique (uq_material_name). +-- topic is a legacy display column; category_id (v2.8) is the dashboard's +-- Category→Material cascade link — backfilled from topic at API startup +-- (init_db.ensure_dashboard_schema; NULL = «بدون دسته»). CREATE TABLE materials ( id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, name TEXT NOT NULL, topic TEXT, -- LEGACY display column + category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL, canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT, status material_status NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -481,6 +483,23 @@ CREATE INDEX idx_category_attrs_category ON category_attributes(category_id); CREATE INDEX idx_category_attrs_attr_def ON category_attributes(attribute_definition_id); CREATE INDEX idx_line_item_specs_attr_def ON line_item_specs(attribute_definition_id); +-- ============================================================================ +-- MVP DASHBOARD INDEXES (v2.8 — aggregation queries in dashboard_service) +-- (Fresh installs; existing volumes get the same set idempotently via +-- init_db.ensure_dashboard_schema.) +-- ============================================================================ + +CREATE INDEX idx_materials_category ON materials(category_id); +CREATE INDEX idx_warehouses_project ON warehouses(project_id); +CREATE INDEX idx_inventory_tx_date ON inventory_transactions(transaction_date); +CREATE INDEX idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date); +CREATE INDEX idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL; +CREATE INDEX idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL; +CREATE INDEX idx_quotes_status ON quotes(status); +CREATE INDEX idx_item_decisions_project ON item_decisions(project_id); +CREATE INDEX idx_item_receipts_received ON item_receipts(received_at); +CREATE INDEX idx_inventory_material_brand ON inventory(material_id, brand_id); + -- ============================================================================ -- QUOTE ATTACHMENTS (invoice / proforma files — bytes live in MinIO) -- ============================================================================