diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index a801e1c..5356d3f 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -1,10 +1,10 @@ import json import uuid -from datetime import datetime -from typing import Any +from datetime import datetime, timedelta +from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update as sql_update +from sqlalchemy import select, update as sql_update, func, case, distinct, and_, extract from pydantic import BaseModel from app.database import get_db from app.models.user import User @@ -12,7 +12,7 @@ from app.models.system_setting import SystemSetting from app.models.cad_file import CadFile, ProcessingStatus from app.models.output_type import OutputType as OutputTypeModel from app.schemas.user import UserOut, UserUpdate, UserCreate -from app.utils.auth import require_global_admin, hash_password +from app.utils.auth import require_global_admin, get_current_user, hash_password router = APIRouter(prefix="/admin", tags=["admin"]) @@ -882,3 +882,211 @@ async def purge_render_media( "message": f"Purged {deleted_db} still/turntable asset(s), freed {round(freed_bytes / 1024 / 1024, 1)} MB", } + +# ── Dashboard Stats ────────────────────────────────────────────────────────── + +class RenderThroughputStats(BaseModel): + completed_today: int + completed_this_week: int + completed_this_month: int + failed_today: int + failed_this_week: int + failed_this_month: int + avg_render_time_s: Optional[float] + median_render_time_s: Optional[float] + + +class MaterialCoverageStats(BaseModel): + total_unique_materials: int + mapped_materials: int + unmapped_materials: int + coverage_pct: float + library_material_count: int + alias_count: int + + +class ProductStatsOverview(BaseModel): + total_products: int + with_step_files: int + without_step_files: int + step_coverage_pct: float + + +class OrderStatusBreakdown(BaseModel): + draft: int + submitted: int + processing: int + completed: int + rejected: int + total: int + + +class DashboardStatsResponse(BaseModel): + render_throughput: RenderThroughputStats + material_coverage: MaterialCoverageStats + product_stats: ProductStatsOverview + order_status: OrderStatusBreakdown + + +@router.get("/dashboard-stats", response_model=DashboardStatsResponse) +async def get_dashboard_stats( + _user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> DashboardStatsResponse: + """Aggregate stats for the dashboard: render throughput, material coverage, product and order stats.""" + from app.domains.orders.models import Order, OrderStatus, OrderLine + from app.domains.products.models import Product + from app.domains.materials.models import Material, MaterialAlias + + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = today_start - timedelta(days=today_start.weekday()) + month_start = today_start.replace(day=1) + + # ── Render throughput ───────────────────────────────────────────────── + def _count_renders(status_val: str, since: datetime): + return select(func.count(OrderLine.id)).where( + OrderLine.render_status == status_val, + OrderLine.render_completed_at >= since, + ) + + completed_today = (await db.execute(_count_renders("completed", today_start))).scalar() or 0 + completed_week = (await db.execute(_count_renders("completed", week_start))).scalar() or 0 + completed_month = (await db.execute(_count_renders("completed", month_start))).scalar() or 0 + failed_today = (await db.execute(_count_renders("failed", today_start))).scalar() or 0 + failed_week = (await db.execute(_count_renders("failed", week_start))).scalar() or 0 + failed_month = (await db.execute(_count_renders("failed", month_start))).scalar() or 0 + + # Average and median render time (for completed renders with both timestamps) + render_duration = extract( + "epoch", + OrderLine.render_completed_at - OrderLine.render_started_at, + ) + avg_result = await db.execute( + select(func.avg(render_duration)).where( + OrderLine.render_status == "completed", + OrderLine.render_started_at.isnot(None), + OrderLine.render_completed_at.isnot(None), + ) + ) + avg_render_s = avg_result.scalar() + avg_render_s = round(avg_render_s, 1) if avg_render_s is not None else None + + # Median via percentile_cont + median_result = await db.execute( + select( + func.percentile_cont(0.5).within_group(render_duration) + ).where( + OrderLine.render_status == "completed", + OrderLine.render_started_at.isnot(None), + OrderLine.render_completed_at.isnot(None), + ) + ) + median_render_s = median_result.scalar() + median_render_s = round(median_render_s, 1) if median_render_s is not None else None + + render_throughput = RenderThroughputStats( + completed_today=completed_today, + completed_this_week=completed_week, + completed_this_month=completed_month, + failed_today=failed_today, + failed_this_week=failed_week, + failed_this_month=failed_month, + avg_render_time_s=avg_render_s, + median_render_time_s=median_render_s, + ) + + # ── Material coverage ───────────────────────────────────────────────── + # Unique material names referenced in products' cad_part_materials + # Each product.cad_part_materials is a JSONB array of {part_name, material} + # We collect all distinct material names from products + product_rows = await db.execute( + select(Product.cad_part_materials).where(Product.cad_part_materials.isnot(None)) + ) + all_mat_names: set[str] = set() + for (cpm,) in product_rows: + if isinstance(cpm, list): + for entry in cpm: + if isinstance(entry, dict) and entry.get("material"): + all_mat_names.add(entry["material"]) + + # Library materials (name starts with SCHAEFFLER_) + lib_count_result = await db.execute( + select(func.count(Material.id)).where(Material.name.like("SCHAEFFLER_%")) + ) + library_material_count = lib_count_result.scalar() or 0 + + # All known material names (from Material table) + known_mat_result = await db.execute(select(Material.name)) + known_names = {row[0] for row in known_mat_result} + + # All aliases + alias_result = await db.execute(select(MaterialAlias.alias)) + known_aliases = {row[0] for row in alias_result} + + alias_count_result = await db.execute(select(func.count(MaterialAlias.id))) + alias_count = alias_count_result.scalar() or 0 + + # A material from a product is "mapped" if it exists in Material table or has an alias + mapped = 0 + for mat_name in all_mat_names: + if mat_name in known_names or mat_name in known_aliases: + mapped += 1 + + total_unique = len(all_mat_names) + unmapped = total_unique - mapped + coverage_pct = round((mapped / total_unique * 100) if total_unique > 0 else 100.0, 1) + + material_coverage = MaterialCoverageStats( + total_unique_materials=total_unique, + mapped_materials=mapped, + unmapped_materials=unmapped, + coverage_pct=coverage_pct, + library_material_count=library_material_count, + alias_count=alias_count, + ) + + # ── Product stats ───────────────────────────────────────────────────── + total_products_result = await db.execute(select(func.count(Product.id))) + total_products = total_products_result.scalar() or 0 + + with_step_result = await db.execute( + select(func.count(Product.id)).where(Product.cad_file_id.isnot(None)) + ) + with_step = with_step_result.scalar() or 0 + without_step = total_products - with_step + step_pct = round((with_step / total_products * 100) if total_products > 0 else 0.0, 1) + + product_stats = ProductStatsOverview( + total_products=total_products, + with_step_files=with_step, + without_step_files=without_step, + step_coverage_pct=step_pct, + ) + + # ── Order status breakdown ──────────────────────────────────────────── + order_counts = await db.execute( + select(Order.status, func.count(Order.id)).group_by(Order.status) + ) + status_map: dict[str, int] = {} + for row_status, count in order_counts: + status_map[row_status.value if hasattr(row_status, "value") else str(row_status)] = count + + order_total = sum(status_map.values()) + + order_status = OrderStatusBreakdown( + draft=status_map.get("draft", 0), + submitted=status_map.get("submitted", 0), + processing=status_map.get("processing", 0), + completed=status_map.get("completed", 0), + rejected=status_map.get("rejected", 0), + total=order_total, + ) + + return DashboardStatsResponse( + render_throughput=render_throughput, + material_coverage=material_coverage, + product_stats=product_stats, + order_status=order_status, + ) + diff --git a/backend/app/api/routers/products.py b/backend/app/api/routers/products.py index 1f85b6d..8c0c420 100644 --- a/backend/app/api/routers/products.py +++ b/backend/app/api/routers/products.py @@ -34,6 +34,118 @@ from app.models.user import User router = APIRouter(prefix="/products", tags=["products"]) +class BatchDeleteRequest(BaseModel): + product_ids: list[uuid.UUID] + hard: bool = False + + +@router.post("/batch-delete", status_code=status.HTTP_200_OK) +async def batch_delete_products( + body: BatchDeleteRequest, + user: User = Depends(require_admin_or_pm), + db: AsyncSession = Depends(get_db), +): + """Delete or deactivate multiple products at once. + + When hard=False (default), products are soft-deleted (is_active=False). + When hard=True, products and their related data are permanently removed + using the same cleanup logic as the single-product delete endpoint. + """ + from sqlalchemy import delete as sql_delete + + if not body.product_ids: + return {"deleted": 0, "not_found": 0} + + # Deduplicate + product_ids = list(set(body.product_ids)) + + # Load all products + result = await db.execute( + select(Product).where(Product.id.in_(product_ids)) + ) + products_found = {p.id: p for p in result.scalars().all()} + not_found = len(product_ids) - len(products_found) + + if not products_found: + return {"deleted": 0, "not_found": not_found} + + if not body.hard: + # Soft delete: deactivate all found products + for product in products_found.values(): + product.is_active = False + await db.commit() + return {"deleted": len(products_found), "not_found": not_found} + + # Hard delete: reuse single-delete cleanup logic per product + from app.domains.media.models import MediaAsset + from app.core.storage import get_storage + + all_storage_keys: list[str] = [] + all_result_paths: list[str] = [] + + for pid, product in products_found.items(): + # 1. Collect storage keys from MediaAssets + media_result = await db.execute( + select(MediaAsset.storage_key).where(MediaAsset.product_id == pid) + ) + all_storage_keys.extend(row[0] for row in media_result.all() if row[0]) + + # 2. Collect render result paths from order lines + ol_result = await db.execute( + select(OrderLine.result_path).where( + OrderLine.product_id == pid, + OrderLine.result_path.isnot(None), + ) + ) + all_result_paths.extend(row[0] for row in ol_result.all() if row[0]) + + # 3. Check if CadFile is orphaned + cad_file_id = product.cad_file_id + orphan_cad = False + if cad_file_id: + other_count = await db.execute( + select(func.count(Product.id)).where( + Product.cad_file_id == cad_file_id, + Product.id.notin_([p for p in products_found]), + ) + ) + orphan_cad = (other_count.scalar() or 0) == 0 + + # 4. Delete order_lines + await db.execute(sql_delete(OrderLine).where(OrderLine.product_id == pid)) + + # 5. Delete orphaned CadFile + if orphan_cad and cad_file_id: + cad_media_result = await db.execute( + select(MediaAsset.storage_key).where(MediaAsset.cad_file_id == cad_file_id) + ) + all_storage_keys.extend(row[0] for row in cad_media_result.all() if row[0]) + product.cad_file_id = None + await db.flush() + await db.execute(sql_delete(CadFile).where(CadFile.id == cad_file_id)) + + # 6. Delete product (cascades MediaAsset + ProductRenderPosition) + await db.delete(product) + + await db.commit() + + # 7. Clean up storage files (best-effort, after commit) + storage = get_storage() + for key in all_storage_keys: + try: + storage.delete(key) + except Exception: + pass + for path in all_result_paths: + try: + if os.path.isfile(path): + os.unlink(path) + except Exception: + pass + + return {"deleted": len(products_found), "not_found": not_found} + + def _best_render_url(product: Product, priority: list[str]) -> str | None: """Walk the priority list and return the first available render URL. diff --git a/backend/app/data/material_alias_seeds.py b/backend/app/data/material_alias_seeds.py index ec3f620..803f605 100644 --- a/backend/app/data/material_alias_seeds.py +++ b/backend/app/data/material_alias_seeds.py @@ -4,10 +4,14 @@ Each entry maps a SCHAEFFLER library material name to its known aliases: - German description (Col A from Materialmapping) - Intermediate identifier (Col B, e.g. "Steel_black_oxided--Stahl_brueniert") - Schaeffler code as string (e.g. "10102") +- German variants (singular/plural, abbreviations, industry terms, DIN/EN standards) +- English equivalents commonly used in German engineering contexts """ MATERIAL_ALIAS_SEEDS: list[dict] = [ + # ===================================================================== # --- 01 Metals --- + # ===================================================================== { "material_name": "SCHAEFFLER_010101_Steel-Bare", "aliases": [ @@ -19,6 +23,46 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Steel", "Stahl, gänzend", "10101", + "Stähle", + "Stahl blank", + "Stahl, blank", + "Stahl glänzend", + "Stahl konserviert", + "Blanker Stahl", + "Blankstahl", + "Blank-Stahl", + "Glanzstahl", + "Stahl unbehandelt", + "Stahl natur", + "Stahl roh", + "Rohstahl", + "Lagerstahl", + "Wälzlagerstahl", + "Bearing Steel", + "Chromstahl", + "Chrom-Stahl", + "100Cr6", + "100 Cr 6", + "C45", + "C 45", + "C60", + "42CrMo4", + "St37", + "St 37", + "S235", + "S355", + "1.3505", + "1.0503", + "Einsatzstahl", + "Vergütungsstahl", + "Federstahl", + "Automatenstahl", + "Bare Steel", + "Plain Steel", + "Bright Steel", + "Polished Steel", + "Conserved Steel", + "Carbon Steel", ], }, { @@ -27,6 +71,26 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Stahl, brüniert", "Steel_black_oxided--Stahl_brueniert", "10102", + "Stahl brüniert", + "Brünierstahl", + "Brünierter Stahl", + "Stahl brueniert", + "Brünierung", + "Stahl schwarz-brüniert", + "Stahl schwarzbrüniert", + "Stahl, schwarz brüniert", + "Stahl, oxidiert", + "Stahl oxidiert", + "Stahl, schwarz oxidiert", + "Schwarzoxid", + "Schwarz oxidiert", + "Schwarz-Oxid", + "Burnished Steel", + "Black Oxide Steel", + "Blackened Steel", + "Black oxided", + "Steel burnished", + "Steel, burnished", ], }, { @@ -37,6 +101,32 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "MU-Stahl, Zinnüberzug", "MX-Stahl, Zinnüberzug", "10103", + "Stahl verzinkt", + "Verzinkter Stahl", + "Verzinkung", + "Galvanisierter Stahl", + "Stahl, galvanisiert", + "Stahl galvanisiert", + "Stahl, feuerverzinkt", + "Stahl feuerverzinkt", + "Feuerverzinkter Stahl", + "Stahl, elektrolytisch verzinkt", + "Stahl, Zink", + "Stahl Zinküberzug", + "Stahl, Zinküberzug", + "Zinkbeschichtung", + "Zinkschicht", + "Zinküberzug", + "Stahl galv. verzinkt", + "Stahl, galv. verzinkt", + "MU-Stahl", + "MX-Stahl", + "Galvanized Steel", + "Zinc Plated Steel", + "Zinc Coated Steel", + "Steel galvanized", + "Steel, galvanized", + "Hot-dip galvanized", ], }, { @@ -46,6 +136,36 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Guss", "Steel_cast--Stahl_Guss", "10104", + "Stahlguss", + "Stahl-Guss", + "Stahl, Guss", + "Stahl gegossen", + "Gegossener Stahl", + "Gussstahl", + "Guss-Stahl", + "Grauguss", + "Grau-Guss", + "Gusseisen", + "Guss-Eisen", + "Eisenguss", + "Sphäroguss", + "Kugelgraphitguss", + "Kokillenguss", + "Sandguss", + "Feinguss", + "Stahl, Körnung", + "Stahl mit Körnung", + "Stahl gekörnt", + "GJS", + "GJL", + "GG25", + "GG 25", + "Cast Steel", + "Cast Iron", + "Steel cast", + "Steel, cast", + "Casted Steel", + "Grey Cast Iron", ], }, { @@ -54,6 +174,33 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Stahlblech", "Steel_sheet--Stahlblech", "10105", + "Stahl-Blech", + "Stahl Blech", + "Stahlbleche", + "Blech", + "Blechstahl", + "Feinblech", + "Fein-Blech", + "Grobblech", + "Grob-Blech", + "Stahlplatte", + "Stahl-Platte", + "Stahl, Blech", + "Stahl, Platte", + "Tiefziehblech", + "Kaltband", + "Warmband", + "Bandstahl", + "Band-Stahl", + "Flachstahl", + "DC01", + "DC04", + "DX51", + "Steel Sheet", + "Steel Plate", + "Sheet Steel", + "Sheet Metal", + "Flat Steel", ], }, { @@ -62,6 +209,42 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Niro", "Steel_stainless--Niro", "10201", + "Edelstahl", + "Edel-Stahl", + "Nirosta", + "Niro-Stahl", + "Nirostahl", + "Rostfreier Stahl", + "Rostfrei", + "Stahl, rostfrei", + "Stahl rostfrei", + "Stahl, nichtrostend", + "Nichtrostender Stahl", + "V2A", + "V4A", + "VA-Stahl", + "VA Stahl", + "Edelstahl rostfrei", + "Inox", + "Chromnickelstahl", + "Chrom-Nickel-Stahl", + "CrNi-Stahl", + "X5CrNi18-10", + "X2CrNiMo17-12-2", + "X20Cr13", + "X 20 Cr 13", + "1.4301", + "1.4404", + "1.4571", + "AISI 304", + "AISI 316", + "AISI304", + "AISI316", + "Stainless Steel", + "Stainless", + "SS Steel", + "Inox Steel", + "Corrosion Resistant Steel", ], }, { @@ -70,6 +253,28 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Zinnüberzug", "Tin--Zinn", "10301", + "Zinn", + "Zinn-Überzug", + "Zinnbeschichtung", + "Zinn-Beschichtung", + "Zinnschicht", + "Verzinnung", + "Verzinnt", + "Stahl, verzinnt", + "Stahl verzinnt", + "Verzinnter Stahl", + "Weißblech", + "Weissblech", + "Weiss-Blech", + "Weiß-Blech", + "Zinnauflage", + "Zinnlegierung", + "Sn", + "Tin", + "Tin Coating", + "Tin Plated", + "Tinplate", + "Tin-plated Steel", ], }, { @@ -78,6 +283,39 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Aluminium", "Aluminium--Aluminium", "10401", + "Alu", + "Alulegierung", + "Alu-Legierung", + "Aluminium-Legierung", + "Aluminiumlegierung", + "Aluminiumguss", + "Aluminium-Guss", + "Alu-Guss", + "Aluguss", + "Leichtmetall", + "Leicht-Metall", + "Aluminium blank", + "Aluminium, blank", + "Aluminium natur", + "Aluminium, natur", + "Aluminium eloxiert", + "Aluminium, eloxiert", + "Eloxal", + "Al", + "AlMgSi", + "AlCuMg", + "AlZnMg", + "EN AW-6060", + "EN AW-6082", + "EN AW-7075", + "3.3206", + "3.1325", + "Aluminum", + "Aluminum alloy", + "Aluminium Alloy", + "Alu alloy", + "Light Metal", + "Anodized Aluminium", ], }, { @@ -86,6 +324,30 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Messing", "Brass--Messing", "10501", + "Messinglegierung", + "Messing-Legierung", + "Gelbguss", + "Gelb-Guss", + "Rotguss", + "Rot-Guss", + "Messing blank", + "Messing, blank", + "Messing poliert", + "Messing, poliert", + "Messing natur", + "Messingguss", + "Messing-Guss", + "CuZn", + "CuZn37", + "CuZn39Pb3", + "Ms58", + "Ms 58", + "Ms63", + "2.0321", + "Brass", + "Brass alloy", + "Yellow Brass", + "Polished Brass", ], }, { @@ -95,15 +357,60 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Bronze", "Bronze--Bronze", "10601", + "Bronzelegierung", + "Bronze-Legierung", + "Zinnbronze", + "Zinn-Bronze", + "Aluminiumbronze", + "Aluminium-Bronze", + "Phosphorbronze", + "Phosphor-Bronze", + "Rotbronze", + "Rot-Bronze", + "Gleitbronze", + "Gleit-Bronze", + "Gleitlagerbronze", + "Lagerbronze", + "Lager-Bronze", + "Sinterbronze", + "Sinter-Bronze", + "Bronze blank", + "Bronze, blank", + "CuSn", + "CuSn8", + "CuSn12", + "CuAl10Ni5Fe4", + "Rg7", + "2.1030", + "MU-B", + "Bronze alloy", + "Phosphor Bronze", + "Tin Bronze", + "Bearing Bronze", ], }, + # ===================================================================== # --- 02 Coatings --- + # ===================================================================== { "material_name": "SCHAEFFLER_020101_Durotect-Blue", "aliases": [ "Stahl, Durotect CMT", "Durotect_CMT--Durotect_CMT", "20101", + "Durotect CMT", + "Durotect-CMT", + "DurotectCMT", + "Durotect blau", + "Durotect, blau", + "Durotect Blue", + "Stahl Durotect CMT", + "Stahl mit Durotect CMT", + "CMT-Beschichtung", + "CMT Beschichtung", + "Durotect CMT Beschichtung", + "Durotect CMT Coating", + "Blue Durotect", ], }, { @@ -113,6 +420,18 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Stahl; Durotect M", "Durotect_M--Durotect_M", "20102", + "Durotect M", + "Durotect-M", + "DurotectM", + "Durotect schwarz", + "Durotect, schwarz", + "Durotect Black", + "Stahl Durotect M", + "Stahl mit Durotect M", + "Durotect M Beschichtung", + "M-Beschichtung", + "Durotect M Coating", + "Black Durotect", ], }, { @@ -121,15 +440,56 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Stahl, schwarz", "Steel_coated_black--Stahl_beschichtet_schwarz", "20201", + "Stahl schwarz", + "Schwarzer Stahl", + "Stahl, beschichtet schwarz", + "Stahl beschichtet schwarz", + "Schwarz beschichteter Stahl", + "Schwarzbeschichtung", + "Schwarz-Beschichtung", + "Stahl, schwarz lackiert", + "Stahl schwarz lackiert", + "Schwarz lackiert", + "Schwarzlack", + "Stahl, schwarz beschichtet", + "Stahl schwarz beschichtet", + "Pulverbeschichtung schwarz", + "Pulverbeschichtet schwarz", + "KTL schwarz", + "KTL-Beschichtung", + "Coated Black", + "Black Coated Steel", + "Black Steel", + "Black Coating", + "Powder Coated Black", ], }, + # ===================================================================== # --- 03 Non-metals --- + # ===================================================================== { "material_name": "SCHAEFFLER_030101_Elastomer-Brown", "aliases": [ "Elastomer, braun", "Elastomer_brown--Elastomer_braun", "30101", + "Elastomer braun", + "Braunes Elastomer", + "Gummi, braun", + "Gummi braun", + "Brauner Gummi", + "Dichtung, braun", + "Dichtung braun", + "Kautschuk, braun", + "Kautschuk braun", + "FKM braun", + "FKM, braun", + "FPM braun", + "Viton braun", + "Viton, braun", + "Brown Elastomer", + "Brown Rubber", + "Brown Seal", ], }, { @@ -138,6 +498,23 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Elastomer, grün", "Elastomer_green--Elastomer_gruen", "30102", + "Elastomer grün", + "Elastomer gruen", + "Grünes Elastomer", + "Gummi, grün", + "Gummi grün", + "Grüner Gummi", + "Dichtung, grün", + "Dichtung grün", + "Kautschuk, grün", + "Kautschuk grün", + "FKM grün", + "FKM, grün", + "HNBR grün", + "HNBR, grün", + "Green Elastomer", + "Green Rubber", + "Green Seal", ], }, { @@ -148,6 +525,35 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "TPU, schwarz", "NBR, schwarz", "30103", + "Elastomer schwarz", + "Schwarzes Elastomer", + "Gummi, schwarz", + "Gummi schwarz", + "Schwarzer Gummi", + "Dichtung, schwarz", + "Dichtung schwarz", + "Kautschuk, schwarz", + "Kautschuk schwarz", + "Gummidichtung", + "Gummi-Dichtung", + "FKM schwarz", + "FKM, schwarz", + "NBR schwarz", + "HNBR schwarz", + "HNBR, schwarz", + "EPDM schwarz", + "EPDM, schwarz", + "ACM schwarz", + "Simmerring", + "Wellendichtring", + "O-Ring", + "O-Ring schwarz", + "TPU schwarz", + "Black Elastomer", + "Black Rubber", + "Black Seal", + "Rubber seal", + "Elastomer_black--Elastomer_schwarz", ], }, { @@ -156,6 +562,26 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Kunststoff, braun", "Plastic_brown--Kunststoff_braun", "30201", + "Kunststoff braun", + "Brauner Kunststoff", + "Plastik, braun", + "Plastik braun", + "Polymer, braun", + "Polymer braun", + "Käfig, braun", + "Käfig braun", + "Lagerkäfig braun", + "PA braun", + "PA, braun", + "PA66 braun", + "PA66, braun", + "Polyamid braun", + "Polyamid, braun", + "PEEK braun", + "PEEK, braun", + "Brown Plastic", + "Brown Polymer", + "Brown Cage", ], }, { @@ -164,6 +590,25 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Kunststoff, grün", "Plastic_green--Kunststoff_gruen", "30202", + "Kunststoff grün", + "Kunststoff gruen", + "Grüner Kunststoff", + "Plastik, grün", + "Plastik grün", + "Polymer, grün", + "Polymer grün", + "Käfig, grün", + "Käfig grün", + "Lagerkäfig grün", + "PA grün", + "PA, grün", + "PA66 grün", + "PA66, grün", + "Polyamid grün", + "Polyamid, grün", + "Green Plastic", + "Green Polymer", + "Green Cage", ], }, { @@ -172,6 +617,28 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Kunststoff, schwarz", "Plastic_black--Kunststoff_schwarz", "30203", + "Kunststoff schwarz", + "Schwarzer Kunststoff", + "Plastik, schwarz", + "Plastik schwarz", + "Polymer, schwarz", + "Polymer schwarz", + "Käfig, schwarz", + "Käfig schwarz", + "Lagerkäfig schwarz", + "PA schwarz", + "PA, schwarz", + "PA66 schwarz", + "PA66, schwarz", + "Polyamid schwarz", + "Polyamid, schwarz", + "POM schwarz", + "POM, schwarz", + "PBT schwarz", + "PBT, schwarz", + "Black Plastic", + "Black Polymer", + "Black Cage", ], }, { @@ -180,6 +647,24 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Kunststoff, blau", "Plastic_blue--Kunststoff_blau", "30204", + "Kunststoff blau", + "Blauer Kunststoff", + "Plastik, blau", + "Plastik blau", + "Polymer, blau", + "Polymer blau", + "Käfig, blau", + "Käfig blau", + "Lagerkäfig blau", + "PA blau", + "PA, blau", + "PA66 blau", + "PA66, blau", + "Polyamid blau", + "Polyamid, blau", + "Blue Plastic", + "Blue Polymer", + "Blue Cage", ], }, { @@ -188,6 +673,32 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Kunststoff, weiß", "Plastic_white--Kunststoff_weiss", "30205", + "Kunststoff weiss", + "Kunststoff weiß", + "Kunststoff, weiss", + "Weißer Kunststoff", + "Weisser Kunststoff", + "Plastik, weiß", + "Plastik, weiss", + "Plastik weiß", + "Plastik weiss", + "Polymer, weiß", + "Polymer, weiss", + "Polymer weiß", + "Käfig, weiß", + "Käfig weiß", + "Lagerkäfig weiß", + "PA weiß", + "PA, weiß", + "PA66 weiß", + "POM weiß", + "POM, weiß", + "PTFE weiß", + "PTFE, weiß", + "Teflon weiß", + "White Plastic", + "White Polymer", + "White Cage", ], }, { @@ -196,6 +707,34 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Kunststoff, durchsichtig", "Plastic_clear--Kunststoff_durchsichtig", "30301", + "Kunststoff durchsichtig", + "Kunststoff, transparent", + "Kunststoff transparent", + "Transparenter Kunststoff", + "Durchsichtiger Kunststoff", + "Klarer Kunststoff", + "Kunststoff, klar", + "Kunststoff klar", + "Plastik, durchsichtig", + "Plastik durchsichtig", + "Plastik, transparent", + "Plastik transparent", + "Polymer, transparent", + "Polymer transparent", + "Acrylglas", + "Acryl-Glas", + "PMMA", + "PMMA transparent", + "PMMA, transparent", + "Polycarbonat", + "Polycarbonat transparent", + "PC transparent", + "PC, transparent", + "Plexiglas", + "Clear Plastic", + "Transparent Plastic", + "Clear Polymer", + "Acrylic", ], }, { @@ -203,6 +742,30 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "aliases": [ "Plastic_translucent_white--Kunststoff_transluzent_weiss", "30302", + "Kunststoff, transluzent weiß", + "Kunststoff transluzent weiß", + "Kunststoff, transluzent weiss", + "Kunststoff transluzent weiss", + "Kunststoff, milchig", + "Kunststoff milchig", + "Milchiger Kunststoff", + "Transluzenter Kunststoff", + "Kunststoff, milchig weiß", + "Kunststoff milchig weiß", + "Plastik, transluzent", + "Plastik transluzent", + "Halbdurchsichtig", + "Kunststoff, halbdurchsichtig", + "Kunststoff, halbtransparent", + "PMMA milchig", + "PMMA, milchig", + "Opalweiß", + "Opal weiß", + "Translucent White Plastic", + "Translucent White", + "Milky White Plastic", + "Frosted Plastic", + "Opal White", ], }, { @@ -211,6 +774,27 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "TPU, blau", "Elastomer_blue--Elastomer_blau", "30401", + "TPU blau", + "Blaues TPU", + "Elastomer, blau", + "Elastomer blau", + "Blaues Elastomer", + "Gummi, blau", + "Gummi blau", + "Blauer Gummi", + "Dichtung, blau", + "Dichtung blau", + "Polyurethan blau", + "Polyurethan, blau", + "PU blau", + "PU, blau", + "TPU-Dichtung", + "TPU Dichtung", + "Blue TPU", + "Blue Elastomer", + "Blue Rubber", + "Blue Seal", + "TPU blue", ], }, { @@ -219,15 +803,51 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Keramik, schwarz", "Ceramics_black--Keramik_schwarz", "30501", + "Keramik schwarz", + "Schwarze Keramik", + "Keramik", + "Technische Keramik", + "Technische Keramik, schwarz", + "Oxidkeramik", + "Oxid-Keramik", + "Siliziumnitrid", + "Siliciumnitrid", + "Si3N4", + "Zirkonoxid", + "ZrO2", + "Aluminiumoxid", + "Al2O3", + "Keramikkugel", + "Keramik-Kugel", + "Keramikrolle", + "Keramik-Rolle", + "Keramik-Wälzkörper", + "Black Ceramic", + "Ceramic", + "Ceramic Black", + "Silicon Nitride", + "Technical Ceramic", + "Ceramic bearing", ], }, + # ===================================================================== # --- 04 Compounds --- + # ===================================================================== { "material_name": "SCHAEFFLER_040101_E40", "aliases": [ "E40", "E40--E40", "40101", + "E 40", + "E-40", + "Compound E40", + "Verbundwerkstoff E40", + "Verbundwerkstoff E 40", + "Gleitlager E40", + "Gleitlagerwerkstoff E40", + "E40 Compound", + "E40 Bearing", ], }, { @@ -236,6 +856,15 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "E50", "E50--E50", "40102", + "E 50", + "E-50", + "Compound E50", + "Verbundwerkstoff E50", + "Verbundwerkstoff E 50", + "Gleitlager E50", + "Gleitlagerwerkstoff E50", + "E50 Compound", + "E50 Bearing", ], }, { @@ -244,15 +873,37 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "Elgoglide", "Elgoglide--Elgoglide", "40201", + "Elgo-Glide", + "Elgo Glide", + "Elgoglide-Beschichtung", + "Elgoglide Beschichtung", + "Elgoglide-Gleitlager", + "Elgoglide Gleitlager", + "Elgoglide Gleitschicht", + "Elgoglide-Gleitschicht", + "Elgoglide Coating", + "Elgoglide Bearing", ], }, { "material_name": "SCHAEFFLER_040202_Elgotex", "aliases": [ "Elgotex, schwarz", - "ELGOTEX, schwarz", "Elgotex--Elgotex", "40202", + "Elgotex", + "Elgo-Tex", + "Elgo Tex", + "Elgotex schwarz", + "Elgotex-Beschichtung", + "Elgotex Beschichtung", + "Elgotex-Gleitlager", + "Elgotex Gleitlager", + "Elgotex Gleitschicht", + "Elgotex-Gleitschicht", + "Elgotex Coating", + "Elgotex Bearing", + "Elgotex Black", ], }, { @@ -261,6 +912,24 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "PTFE-Compound, Niro-Verbund", "PTFE_compound_stainless_steel_composite--PTFE_Compound_Niro_Verbund", "40301", + "PTFE Niro Verbund", + "PTFE-Niro-Verbund", + "PTFE Compound Niro", + "PTFE-Compound Niro", + "PTFE Niro Compound", + "PTFE mit Niro", + "PTFE Edelstahl Verbund", + "PTFE-Edelstahl-Verbund", + "PTFE Edelstahl Compound", + "PTFE-Compound, Edelstahl", + "Teflon Niro", + "Teflon-Niro", + "Teflon Edelstahl", + "PTFE Verbund Niro", + "PTFE Stainless Compound", + "PTFE Niro Composite", + "PTFE Stainless Steel Composite", + "Teflon Stainless", ], }, { @@ -269,6 +938,27 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "PTFE-Folie", "PTFE_film--PTFE_Folie", "40302", + "PTFE Folie", + "Teflonfolie", + "Teflon-Folie", + "Teflon Folie", + "PTFE-Film", + "PTFE Film", + "PTFE-Band", + "PTFE Band", + "Teflonband", + "Teflon-Band", + "PTFE-Dichtband", + "PTFE Dichtband", + "PTFE dünn", + "PTFE, dünn", + "PTFE-Gleitfolie", + "PTFE Gleitfolie", + "PTFE Foil", + "Teflon Foil", + "Teflon Film", + "PTFE Tape", + "PTFE Sheet", ], }, { @@ -277,6 +967,23 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "PTFE-Verbund, schwarz", "PTFE_compound_black--PTFE_Verbund_schwarz", "40303", + "PTFE Verbund schwarz", + "PTFE-Compound schwarz", + "PTFE Compound schwarz", + "PTFE-Compound, schwarz", + "PTFE Compound, schwarz", + "Schwarzer PTFE-Verbund", + "PTFE schwarz", + "PTFE, schwarz", + "Teflon schwarz", + "Teflon, schwarz", + "Teflon-Verbund schwarz", + "Schwarzes PTFE", + "PTFE-Gleitschicht schwarz", + "PTFE Compound Black", + "Black PTFE Compound", + "Black PTFE", + "Black Teflon", ], }, { @@ -285,6 +992,25 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "PTFE-Verbundwerkstoff", "PTFE_composite_material_orange--PTFE_Verbundwerkstoff_orange", "40304", + "PTFE Verbundwerkstoff", + "PTFE-Verbundwerkstoff, orange", + "PTFE Verbundwerkstoff orange", + "PTFE-Compound orange", + "PTFE Compound orange", + "PTFE-Compound, orange", + "PTFE Compound, orange", + "PTFE-Verbund orange", + "PTFE Verbund orange", + "PTFE-Verbund, orange", + "Oranges PTFE", + "PTFE orange", + "PTFE, orange", + "Teflon orange", + "Teflon, orange", + "PTFE-Gleitschicht orange", + "Orange PTFE Compound", + "Orange PTFE", + "PTFE Composite Orange", ], }, { @@ -293,6 +1019,31 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [ "GFK+PTFE Verbundwerkstoff, schwarz", "GFK_PTFE_compound--GFK_PTFE_Verbundwerkstoff", "40305", + "GFK PTFE Verbundwerkstoff", + "GFK-PTFE-Verbundwerkstoff", + "GFK+PTFE Verbundwerkstoff", + "GFK PTFE Compound", + "GFK-PTFE-Compound", + "GFK+PTFE Compound", + "GFK+PTFE", + "GFK-PTFE", + "GFK PTFE", + "Glasfaser PTFE", + "Glasfaser-PTFE", + "Glasfaser-PTFE-Verbund", + "Glasfaserverstärktes PTFE", + "GFK Teflon", + "GFK-Teflon", + "Glasfaserverstärkt", + "GFK-Verbundwerkstoff", + "GFK Verbundwerkstoff", + "GFK+PTFE, schwarz", + "GFK-PTFE, schwarz", + "GFK PTFE schwarz", + "Glass Fiber PTFE Compound", + "Fiberglass PTFE", + "GRP PTFE Compound", + "Glass Reinforced PTFE", ], }, ] diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index d778eb0..d2d6f3b 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -16,6 +16,8 @@ export type WidgetType = | 'TopProducts' | 'OrdersByUser' | 'RenderBackendStats' + | 'RenderThroughput' + | 'MaterialCoverage' export interface WidgetPosition { col: number @@ -64,3 +66,53 @@ export async function updateTenantDefaultDashboard( ) return data.widgets } + +// ── Dashboard Stats ────────────────────────────────────────────────────────── + +export interface RenderThroughputStats { + completed_today: number + completed_this_week: number + completed_this_month: number + failed_today: number + failed_this_week: number + failed_this_month: number + avg_render_time_s: number | null + median_render_time_s: number | null +} + +export interface MaterialCoverageStats { + total_unique_materials: number + mapped_materials: number + unmapped_materials: number + coverage_pct: number + library_material_count: number + alias_count: number +} + +export interface ProductStatsOverview { + total_products: number + with_step_files: number + without_step_files: number + step_coverage_pct: number +} + +export interface OrderStatusBreakdown { + draft: number + submitted: number + processing: number + completed: number + rejected: number + total: number +} + +export interface DashboardStats { + render_throughput: RenderThroughputStats + material_coverage: MaterialCoverageStats + product_stats: ProductStatsOverview + order_status: OrderStatusBreakdown +} + +export async function getDashboardStats(): Promise { + const { data } = await api.get('/admin/dashboard-stats') + return data +} diff --git a/frontend/src/api/products.ts b/frontend/src/api/products.ts index e56906e..c327632 100644 --- a/frontend/src/api/products.ts +++ b/frontend/src/api/products.ts @@ -109,6 +109,17 @@ export async function deleteProduct(id: string, hard = false): Promise { await api.delete(`/products/${id}`, { params: hard ? { hard: true } : undefined }) } +export async function batchDeleteProducts( + productIds: string[], + hard = false, +): Promise<{ deleted: number; not_found: number }> { + const res = await api.post<{ deleted: number; not_found: number }>( + '/products/batch-delete', + { product_ids: productIds, hard }, + ) + return res.data +} + export interface ProductCadUploadResponse { cad_file_id: string original_name: string diff --git a/frontend/src/components/dashboard/DashboardCustomizeModal.tsx b/frontend/src/components/dashboard/DashboardCustomizeModal.tsx index 2ab2f54..1baffd2 100644 --- a/frontend/src/components/dashboard/DashboardCustomizeModal.tsx +++ b/frontend/src/components/dashboard/DashboardCustomizeModal.tsx @@ -21,6 +21,8 @@ const WIDGET_LABELS: Record = { TopProducts: 'Top 10 Products', OrdersByUser: 'Orders by User', RenderBackendStats: 'Render Backend Stats', + RenderThroughput: 'Render Throughput', + MaterialCoverage: 'Material & Product Coverage', } const OPERATIONAL_TYPES: WidgetType[] = [ @@ -29,6 +31,8 @@ const OPERATIONAL_TYPES: WidgetType[] = [ 'RecentRenders', 'CostOverview', 'WorkerStatus', + 'RenderThroughput', + 'MaterialCoverage', ] const ANALYTICS_TYPES: WidgetType[] = [ diff --git a/frontend/src/components/dashboard/DashboardGrid.tsx b/frontend/src/components/dashboard/DashboardGrid.tsx index 694d730..5c699bf 100644 --- a/frontend/src/components/dashboard/DashboardGrid.tsx +++ b/frontend/src/components/dashboard/DashboardGrid.tsx @@ -25,6 +25,8 @@ import OutputTypeUsageWidget from './widgets/OutputTypeUsageWidget' import TopProductsWidget from './widgets/TopProductsWidget' import OrdersByUserWidget from './widgets/OrdersByUserWidget' import RenderBackendStatsWidget from './widgets/RenderBackendStatsWidget' +import RenderThroughputWidget from './widgets/RenderThroughputWidget' +import MaterialCoverageWidget from './widgets/MaterialCoverageWidget' const WIDGET_META: Record = { ProductionStats: { title: 'Production Stats', icon: }, @@ -42,6 +44,8 @@ const WIDGET_META: Record TopProducts: { title: 'Top 10 Products', icon: }, OrdersByUser: { title: 'Orders by User', icon: }, RenderBackendStats: { title: 'Render Backend Stats', icon: }, + RenderThroughput: { title: 'Render Throughput', icon: }, + MaterialCoverage: { title: 'Material & Product Coverage', icon: }, } // Analytics widget types that need a timeframe @@ -77,6 +81,8 @@ function WidgetBody({ type }: { type: WidgetType }) { case 'TopProducts': return case 'OrdersByUser': return case 'RenderBackendStats': return + case 'RenderThroughput': return + case 'MaterialCoverage': return default: return

Unknown widget

} } diff --git a/frontend/src/components/dashboard/widgets/MaterialCoverageWidget.tsx b/frontend/src/components/dashboard/widgets/MaterialCoverageWidget.tsx new file mode 100644 index 0000000..d3d9384 --- /dev/null +++ b/frontend/src/components/dashboard/widgets/MaterialCoverageWidget.tsx @@ -0,0 +1,138 @@ +import { useQuery } from '@tanstack/react-query' +import { Layers, Package, FileBox, AlertTriangle, CheckCircle2, BookOpen, Link2 } from 'lucide-react' +import { getDashboardStats } from '../../../api/dashboard' + +function Skeleton() { + return ( +
+
+
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+ ) +} + +function ProgressBar({ pct, color }: { pct: number; color: string }) { + return ( +
+
+
+ ) +} + +export default function MaterialCoverageWidget() { + const { data, isLoading, error } = useQuery({ + queryKey: ['dashboard-stats'], + queryFn: getDashboardStats, + staleTime: 30_000, + retry: 1, + }) + + if (isLoading) return + if (error) return

Failed to load material coverage

+ if (!data) return null + + const mc = data.material_coverage + const ps = data.product_stats + const os = data.order_status + + return ( +
+ {/* Material coverage bar */} +
+
+ Material Coverage + {mc.coverage_pct}% +
+ = 90 ? '#22c55e' : mc.coverage_pct >= 70 ? '#eab308' : '#ef4444'} /> +
+ {mc.mapped_materials} mapped + {mc.unmapped_materials > 0 && ( + + + {mc.unmapped_materials} unmapped + + )} +
+
+ + {/* STEP coverage bar */} +
+
+ STEP File Coverage + {ps.step_coverage_pct}% +
+ = 90 ? '#22c55e' : ps.step_coverage_pct >= 70 ? '#eab308' : '#ef4444'} /> +
+ {ps.with_step_files} / {ps.total_products} products +
+
+ + {/* Stats grid */} +
+ } label="Total Materials" value={mc.total_unique_materials} /> + } label="Library Materials" value={mc.library_material_count} /> + } label="Aliases" value={mc.alias_count} /> + } label="Products" value={ps.total_products} /> +
+ + {/* Order status summary */} +
+

Orders ({os.total})

+
+ {os.completed > 0 && } + {os.processing > 0 && } + {os.submitted > 0 && } + {os.draft > 0 && } + {os.rejected > 0 && } +
+
+ {os.completed > 0 && } + {os.processing > 0 && } + {os.submitted > 0 && } + {os.draft > 0 && } + {os.rejected > 0 && } +
+
+
+ ) +} + +function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) { + return ( +
+
{icon}
+

{value}

+

{label}

+
+ ) +} + +function StatusSlice({ count, total, color, label }: { count: number; total: number; color: string; label: string }) { + const pct = total > 0 ? (count / total) * 100 : 0 + return ( +
0 ? '4px' : '0' }} + /> + ) +} + +function LegendItem({ color, label, count }: { color: string; label: string; count: number }) { + return ( + + + {label} ({count}) + + ) +} diff --git a/frontend/src/components/dashboard/widgets/RenderThroughputWidget.tsx b/frontend/src/components/dashboard/widgets/RenderThroughputWidget.tsx new file mode 100644 index 0000000..6c41c74 --- /dev/null +++ b/frontend/src/components/dashboard/widgets/RenderThroughputWidget.tsx @@ -0,0 +1,85 @@ +import { useQuery } from '@tanstack/react-query' +import { CheckCircle2, XCircle, Clock, CalendarDays, CalendarRange, Timer } from 'lucide-react' +import { getDashboardStats } from '../../../api/dashboard' + +function Skeleton() { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ) +} + +function formatTime(seconds: number | null): string { + if (seconds == null) return '--' + if (seconds < 60) return `${seconds.toFixed(1)}s` + const m = Math.floor(seconds / 60) + const s = Math.round(seconds % 60) + return `${m}m ${s}s` +} + +export default function RenderThroughputWidget() { + const { data, isLoading, error } = useQuery({ + queryKey: ['dashboard-stats'], + queryFn: getDashboardStats, + staleTime: 30_000, + retry: 1, + }) + + if (isLoading) return + if (error) return

Failed to load render throughput

+ if (!data) return null + + const t = data.render_throughput + + const periods = [ + { label: 'Today', completed: t.completed_today, failed: t.failed_today, icon: }, + { label: 'This Week', completed: t.completed_this_week, failed: t.failed_this_week, icon: }, + { label: 'This Month', completed: t.completed_this_month, failed: t.failed_this_month, icon: }, + ] + + return ( +
+ {periods.map(({ label, completed, failed, icon }) => ( +
+
+ {icon} + {label} +
+
+ + + {completed} + + {failed > 0 && ( + + + {failed} + + )} +
+
+ ))} + + {/* Render time stats */} +
+
+ + Avg / Median +
+ + {formatTime(t.avg_render_time_s)} / {formatTime(t.median_render_time_s)} + +
+
+ ) +} diff --git a/frontend/src/pages/ProductLibrary.tsx b/frontend/src/pages/ProductLibrary.tsx index f04f55f..d8b145a 100644 --- a/frontend/src/pages/ProductLibrary.tsx +++ b/frontend/src/pages/ProductLibrary.tsx @@ -1,14 +1,14 @@ import { useState, useEffect, useMemo } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { Library, Search, Box, CheckCircle2, Clock, AlertTriangle, LayoutGrid, List, Trash2, X, ArrowUpDown, + CheckSquare, Square, EyeOff, Loader2, } from 'lucide-react' import { toast } from 'sonner' -import { listProducts, deleteProduct } from '../api/products' +import { listProducts, batchDeleteProducts } from '../api/products' import type { Product } from '../api/products' -import ConfirmModal from '../components/ConfirmModal' const CATEGORY_LABELS: Record = { TRB: 'TRB', @@ -123,7 +123,6 @@ export default function ProductLibraryPage() { const [sortBy, setSortBy] = useState<'pim_id' | 'name' | 'status'>('pim_id') const [view, setView] = useState<'grid' | 'table'>('grid') const [selected, setSelected] = useState>(new Set()) - const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} }) // Debounce search input (300ms) useEffect(() => { @@ -169,31 +168,41 @@ export default function ProductLibraryPage() { setSelected(allSelected ? new Set() : new Set(products.map((p) => p.id))) } - // ── Bulk delete ──────────────────────────────────────────────────────── - const deleteMut = useMutation({ - mutationFn: async (ids: string[]) => { - await Promise.all(ids.map((id) => deleteProduct(id, true))) - }, - onSuccess: (_, ids) => { - toast.success(`${ids.length} product${ids.length > 1 ? 's' : ''} deleted`) - setSelected(new Set()) - qc.invalidateQueries({ queryKey: ['products'] }) - }, - onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'), - }) + // ── Bulk actions ────────────────────────────────────────────────────── + const [batchBusy, setBatchBusy] = useState(false) + const [confirmDeleteHard, setConfirmDeleteHard] = useState(false) - const handleDeleteSelected = () => { + const handleDeactivateSelected = async () => { const ids = [...selected] if (!ids.length) return - setConfirmState({ - open: true, - title: `Delete ${ids.length} product${ids.length > 1 ? 's' : ''}`, - message: 'This cannot be undone.', - onConfirm: () => { - deleteMut.mutate(ids) - setConfirmState((s) => ({ ...s, open: false })) - }, - }) + setBatchBusy(true) + try { + const result = await batchDeleteProducts(ids, false) + toast.success(`Deactivated ${result.deleted} product${result.deleted !== 1 ? 's' : ''}`) + setSelected(new Set()) + qc.invalidateQueries({ queryKey: ['products'] }) + } catch (e: any) { + toast.error(e.response?.data?.detail || 'Deactivate failed') + } finally { + setBatchBusy(false) + } + } + + const handleHardDeleteSelected = async () => { + const ids = [...selected] + if (!ids.length) return + setBatchBusy(true) + try { + const result = await batchDeleteProducts(ids, true) + toast.success(`Permanently deleted ${result.deleted} product${result.deleted !== 1 ? 's' : ''}`) + setSelected(new Set()) + setConfirmDeleteHard(false) + qc.invalidateQueries({ queryKey: ['products'] }) + } catch (e: any) { + toast.error(e.response?.data?.detail || 'Delete failed') + } finally { + setBatchBusy(false) + } } return ( @@ -204,9 +213,21 @@ export default function ProductLibraryPage() {

Product Library

-

- {products ? `${products.length} products` : isLoading ? 'Loading…' : '0 products'} -

+
+ {products ? `${products.length} products` : isLoading ? 'Loading\u2026' : '0 products'} + {products && products.length > 0 && ( + + )} + {selected.size > 0 && ( + {selected.size} selected + )} +
@@ -433,35 +454,63 @@ export default function ProductLibraryPage() {
)} - setConfirmState((s) => ({ ...s, open: false }))} - /> - {/* ── Floating action bar ───────────────────────────────────────── */} + {/* ── Floating selection action bar (MediaBrowser pattern) ───── */} {selected.size > 0 && (
- - {selected.size} selected + + {selected.size} product{selected.size !== 1 ? 's' : ''} selected +
+
+ {confirmDeleteHard ? ( +
+ + Delete {selected.size} permanently? + + + +
+ ) : ( + + )} +