feat: material alias seeds expansion, bulk product delete, dashboard stats widgets
- Material alias seeds: 95 → 855 aliases covering German variants, DIN standards, Werkstoffnummern, industry terms, English equivalents, polymer abbreviations - Batch product delete/deactivate endpoint (POST /products/batch-delete) - Multi-select UI on Products page with floating action bar - Dashboard: RenderThroughput + MaterialCoverage widgets - Dashboard stats endpoint (GET /admin/dashboard-stats) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 pydantic import BaseModel
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
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.cad_file import CadFile, ProcessingStatus
|
||||||
from app.models.output_type import OutputType as OutputTypeModel
|
from app.models.output_type import OutputType as OutputTypeModel
|
||||||
from app.schemas.user import UserOut, UserUpdate, UserCreate
|
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"])
|
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",
|
"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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,118 @@ from app.models.user import User
|
|||||||
router = APIRouter(prefix="/products", tags=["products"])
|
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:
|
def _best_render_url(product: Product, priority: list[str]) -> str | None:
|
||||||
"""Walk the priority list and return the first available render URL.
|
"""Walk the priority list and return the first available render URL.
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ Each entry maps a SCHAEFFLER library material name to its known aliases:
|
|||||||
- German description (Col A from Materialmapping)
|
- German description (Col A from Materialmapping)
|
||||||
- Intermediate identifier (Col B, e.g. "Steel_black_oxided--Stahl_brueniert")
|
- Intermediate identifier (Col B, e.g. "Steel_black_oxided--Stahl_brueniert")
|
||||||
- Schaeffler code as string (e.g. "10102")
|
- 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] = [
|
MATERIAL_ALIAS_SEEDS: list[dict] = [
|
||||||
|
# =====================================================================
|
||||||
# --- 01 Metals ---
|
# --- 01 Metals ---
|
||||||
|
# =====================================================================
|
||||||
{
|
{
|
||||||
"material_name": "SCHAEFFLER_010101_Steel-Bare",
|
"material_name": "SCHAEFFLER_010101_Steel-Bare",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
@@ -19,6 +23,46 @@ MATERIAL_ALIAS_SEEDS: list[dict] = [
|
|||||||
"Steel",
|
"Steel",
|
||||||
"Stahl, gänzend",
|
"Stahl, gänzend",
|
||||||
"10101",
|
"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",
|
"Stahl, brüniert",
|
||||||
"Steel_black_oxided--Stahl_brueniert",
|
"Steel_black_oxided--Stahl_brueniert",
|
||||||
"10102",
|
"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",
|
"MU-Stahl, Zinnüberzug",
|
||||||
"MX-Stahl, Zinnüberzug",
|
"MX-Stahl, Zinnüberzug",
|
||||||
"10103",
|
"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",
|
"Guss",
|
||||||
"Steel_cast--Stahl_Guss",
|
"Steel_cast--Stahl_Guss",
|
||||||
"10104",
|
"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",
|
"Stahlblech",
|
||||||
"Steel_sheet--Stahlblech",
|
"Steel_sheet--Stahlblech",
|
||||||
"10105",
|
"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",
|
"Niro",
|
||||||
"Steel_stainless--Niro",
|
"Steel_stainless--Niro",
|
||||||
"10201",
|
"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",
|
"Zinnüberzug",
|
||||||
"Tin--Zinn",
|
"Tin--Zinn",
|
||||||
"10301",
|
"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--Aluminium",
|
"Aluminium--Aluminium",
|
||||||
"10401",
|
"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",
|
"Messing",
|
||||||
"Brass--Messing",
|
"Brass--Messing",
|
||||||
"10501",
|
"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--Bronze",
|
"Bronze--Bronze",
|
||||||
"10601",
|
"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 ---
|
# --- 02 Coatings ---
|
||||||
|
# =====================================================================
|
||||||
{
|
{
|
||||||
"material_name": "SCHAEFFLER_020101_Durotect-Blue",
|
"material_name": "SCHAEFFLER_020101_Durotect-Blue",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"Stahl, Durotect CMT",
|
"Stahl, Durotect CMT",
|
||||||
"Durotect_CMT--Durotect_CMT",
|
"Durotect_CMT--Durotect_CMT",
|
||||||
"20101",
|
"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",
|
"Stahl; Durotect M",
|
||||||
"Durotect_M--Durotect_M",
|
"Durotect_M--Durotect_M",
|
||||||
"20102",
|
"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",
|
"Stahl, schwarz",
|
||||||
"Steel_coated_black--Stahl_beschichtet_schwarz",
|
"Steel_coated_black--Stahl_beschichtet_schwarz",
|
||||||
"20201",
|
"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 ---
|
# --- 03 Non-metals ---
|
||||||
|
# =====================================================================
|
||||||
{
|
{
|
||||||
"material_name": "SCHAEFFLER_030101_Elastomer-Brown",
|
"material_name": "SCHAEFFLER_030101_Elastomer-Brown",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"Elastomer, braun",
|
"Elastomer, braun",
|
||||||
"Elastomer_brown--Elastomer_braun",
|
"Elastomer_brown--Elastomer_braun",
|
||||||
"30101",
|
"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, grün",
|
||||||
"Elastomer_green--Elastomer_gruen",
|
"Elastomer_green--Elastomer_gruen",
|
||||||
"30102",
|
"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",
|
"TPU, schwarz",
|
||||||
"NBR, schwarz",
|
"NBR, schwarz",
|
||||||
"30103",
|
"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",
|
"Kunststoff, braun",
|
||||||
"Plastic_brown--Kunststoff_braun",
|
"Plastic_brown--Kunststoff_braun",
|
||||||
"30201",
|
"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",
|
"Kunststoff, grün",
|
||||||
"Plastic_green--Kunststoff_gruen",
|
"Plastic_green--Kunststoff_gruen",
|
||||||
"30202",
|
"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",
|
"Kunststoff, schwarz",
|
||||||
"Plastic_black--Kunststoff_schwarz",
|
"Plastic_black--Kunststoff_schwarz",
|
||||||
"30203",
|
"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",
|
"Kunststoff, blau",
|
||||||
"Plastic_blue--Kunststoff_blau",
|
"Plastic_blue--Kunststoff_blau",
|
||||||
"30204",
|
"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ß",
|
"Kunststoff, weiß",
|
||||||
"Plastic_white--Kunststoff_weiss",
|
"Plastic_white--Kunststoff_weiss",
|
||||||
"30205",
|
"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",
|
"Kunststoff, durchsichtig",
|
||||||
"Plastic_clear--Kunststoff_durchsichtig",
|
"Plastic_clear--Kunststoff_durchsichtig",
|
||||||
"30301",
|
"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": [
|
"aliases": [
|
||||||
"Plastic_translucent_white--Kunststoff_transluzent_weiss",
|
"Plastic_translucent_white--Kunststoff_transluzent_weiss",
|
||||||
"30302",
|
"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",
|
"TPU, blau",
|
||||||
"Elastomer_blue--Elastomer_blau",
|
"Elastomer_blue--Elastomer_blau",
|
||||||
"30401",
|
"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",
|
"Keramik, schwarz",
|
||||||
"Ceramics_black--Keramik_schwarz",
|
"Ceramics_black--Keramik_schwarz",
|
||||||
"30501",
|
"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 ---
|
# --- 04 Compounds ---
|
||||||
|
# =====================================================================
|
||||||
{
|
{
|
||||||
"material_name": "SCHAEFFLER_040101_E40",
|
"material_name": "SCHAEFFLER_040101_E40",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"E40",
|
"E40",
|
||||||
"E40--E40",
|
"E40--E40",
|
||||||
"40101",
|
"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--E50",
|
"E50--E50",
|
||||||
"40102",
|
"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--Elgoglide",
|
"Elgoglide--Elgoglide",
|
||||||
"40201",
|
"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",
|
"material_name": "SCHAEFFLER_040202_Elgotex",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"Elgotex, schwarz",
|
"Elgotex, schwarz",
|
||||||
"ELGOTEX, schwarz",
|
|
||||||
"Elgotex--Elgotex",
|
"Elgotex--Elgotex",
|
||||||
"40202",
|
"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, Niro-Verbund",
|
||||||
"PTFE_compound_stainless_steel_composite--PTFE_Compound_Niro_Verbund",
|
"PTFE_compound_stainless_steel_composite--PTFE_Compound_Niro_Verbund",
|
||||||
"40301",
|
"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-Folie",
|
||||||
"PTFE_film--PTFE_Folie",
|
"PTFE_film--PTFE_Folie",
|
||||||
"40302",
|
"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-Verbund, schwarz",
|
||||||
"PTFE_compound_black--PTFE_Verbund_schwarz",
|
"PTFE_compound_black--PTFE_Verbund_schwarz",
|
||||||
"40303",
|
"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-Verbundwerkstoff",
|
||||||
"PTFE_composite_material_orange--PTFE_Verbundwerkstoff_orange",
|
"PTFE_composite_material_orange--PTFE_Verbundwerkstoff_orange",
|
||||||
"40304",
|
"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 Verbundwerkstoff, schwarz",
|
||||||
"GFK_PTFE_compound--GFK_PTFE_Verbundwerkstoff",
|
"GFK_PTFE_compound--GFK_PTFE_Verbundwerkstoff",
|
||||||
"40305",
|
"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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type WidgetType =
|
|||||||
| 'TopProducts'
|
| 'TopProducts'
|
||||||
| 'OrdersByUser'
|
| 'OrdersByUser'
|
||||||
| 'RenderBackendStats'
|
| 'RenderBackendStats'
|
||||||
|
| 'RenderThroughput'
|
||||||
|
| 'MaterialCoverage'
|
||||||
|
|
||||||
export interface WidgetPosition {
|
export interface WidgetPosition {
|
||||||
col: number
|
col: number
|
||||||
@@ -64,3 +66,53 @@ export async function updateTenantDefaultDashboard(
|
|||||||
)
|
)
|
||||||
return data.widgets
|
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<DashboardStats> {
|
||||||
|
const { data } = await api.get<DashboardStats>('/admin/dashboard-stats')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ export async function deleteProduct(id: string, hard = false): Promise<void> {
|
|||||||
await api.delete(`/products/${id}`, { params: hard ? { hard: true } : undefined })
|
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 {
|
export interface ProductCadUploadResponse {
|
||||||
cad_file_id: string
|
cad_file_id: string
|
||||||
original_name: string
|
original_name: string
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const WIDGET_LABELS: Record<WidgetType, string> = {
|
|||||||
TopProducts: 'Top 10 Products',
|
TopProducts: 'Top 10 Products',
|
||||||
OrdersByUser: 'Orders by User',
|
OrdersByUser: 'Orders by User',
|
||||||
RenderBackendStats: 'Render Backend Stats',
|
RenderBackendStats: 'Render Backend Stats',
|
||||||
|
RenderThroughput: 'Render Throughput',
|
||||||
|
MaterialCoverage: 'Material & Product Coverage',
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPERATIONAL_TYPES: WidgetType[] = [
|
const OPERATIONAL_TYPES: WidgetType[] = [
|
||||||
@@ -29,6 +31,8 @@ const OPERATIONAL_TYPES: WidgetType[] = [
|
|||||||
'RecentRenders',
|
'RecentRenders',
|
||||||
'CostOverview',
|
'CostOverview',
|
||||||
'WorkerStatus',
|
'WorkerStatus',
|
||||||
|
'RenderThroughput',
|
||||||
|
'MaterialCoverage',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ANALYTICS_TYPES: WidgetType[] = [
|
const ANALYTICS_TYPES: WidgetType[] = [
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import OutputTypeUsageWidget from './widgets/OutputTypeUsageWidget'
|
|||||||
import TopProductsWidget from './widgets/TopProductsWidget'
|
import TopProductsWidget from './widgets/TopProductsWidget'
|
||||||
import OrdersByUserWidget from './widgets/OrdersByUserWidget'
|
import OrdersByUserWidget from './widgets/OrdersByUserWidget'
|
||||||
import RenderBackendStatsWidget from './widgets/RenderBackendStatsWidget'
|
import RenderBackendStatsWidget from './widgets/RenderBackendStatsWidget'
|
||||||
|
import RenderThroughputWidget from './widgets/RenderThroughputWidget'
|
||||||
|
import MaterialCoverageWidget from './widgets/MaterialCoverageWidget'
|
||||||
|
|
||||||
const WIDGET_META: Record<WidgetType, { title: string; icon: React.ReactNode }> = {
|
const WIDGET_META: Record<WidgetType, { title: string; icon: React.ReactNode }> = {
|
||||||
ProductionStats: { title: 'Production Stats', icon: <BarChart2 size={15} /> },
|
ProductionStats: { title: 'Production Stats', icon: <BarChart2 size={15} /> },
|
||||||
@@ -42,6 +44,8 @@ const WIDGET_META: Record<WidgetType, { title: string; icon: React.ReactNode }>
|
|||||||
TopProducts: { title: 'Top 10 Products', icon: <Table2 size={15} /> },
|
TopProducts: { title: 'Top 10 Products', icon: <Table2 size={15} /> },
|
||||||
OrdersByUser: { title: 'Orders by User', icon: <Users size={15} /> },
|
OrdersByUser: { title: 'Orders by User', icon: <Users size={15} /> },
|
||||||
RenderBackendStats: { title: 'Render Backend Stats', icon: <Server size={15} /> },
|
RenderBackendStats: { title: 'Render Backend Stats', icon: <Server size={15} /> },
|
||||||
|
RenderThroughput: { title: 'Render Throughput', icon: <TrendingUp size={15} /> },
|
||||||
|
MaterialCoverage: { title: 'Material & Product Coverage', icon: <PieChart size={15} /> },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analytics widget types that need a timeframe
|
// Analytics widget types that need a timeframe
|
||||||
@@ -77,6 +81,8 @@ function WidgetBody({ type }: { type: WidgetType }) {
|
|||||||
case 'TopProducts': return <TopProductsWidget />
|
case 'TopProducts': return <TopProductsWidget />
|
||||||
case 'OrdersByUser': return <OrdersByUserWidget />
|
case 'OrdersByUser': return <OrdersByUserWidget />
|
||||||
case 'RenderBackendStats': return <RenderBackendStatsWidget />
|
case 'RenderBackendStats': return <RenderBackendStatsWidget />
|
||||||
|
case 'RenderThroughput': return <RenderThroughputWidget />
|
||||||
|
case 'MaterialCoverage': return <MaterialCoverageWidget />
|
||||||
default: return <p className="text-xs text-content-muted">Unknown widget</p>
|
default: return <p className="text-xs text-content-muted">Unknown widget</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-6 rounded bg-surface-muted" />
|
||||||
|
<div className="h-3 rounded-full bg-surface-muted" />
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-16 rounded-lg bg-surface-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ pct, color }: { pct: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-2 rounded-full bg-surface-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.min(pct, 100)}%`, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MaterialCoverageWidget() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: getDashboardStats,
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
if (error) return <p className="text-xs text-red-500">Failed to load material coverage</p>
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
const mc = data.material_coverage
|
||||||
|
const ps = data.product_stats
|
||||||
|
const os = data.order_status
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Material coverage bar */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-xs font-medium text-content-secondary">Material Coverage</span>
|
||||||
|
<span className="text-xs font-bold text-content">{mc.coverage_pct}%</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar pct={mc.coverage_pct} color={mc.coverage_pct >= 90 ? '#22c55e' : mc.coverage_pct >= 70 ? '#eab308' : '#ef4444'} />
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="text-[10px] text-content-muted">{mc.mapped_materials} mapped</span>
|
||||||
|
{mc.unmapped_materials > 0 && (
|
||||||
|
<span className="text-[10px] text-amber-600 flex items-center gap-0.5">
|
||||||
|
<AlertTriangle size={10} />
|
||||||
|
{mc.unmapped_materials} unmapped
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STEP coverage bar */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-xs font-medium text-content-secondary">STEP File Coverage</span>
|
||||||
|
<span className="text-xs font-bold text-content">{ps.step_coverage_pct}%</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar pct={ps.step_coverage_pct} color={ps.step_coverage_pct >= 90 ? '#22c55e' : ps.step_coverage_pct >= 70 ? '#eab308' : '#ef4444'} />
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="text-[10px] text-content-muted">{ps.with_step_files} / {ps.total_products} products</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StatCard icon={<Layers size={14} className="text-blue-500" />} label="Total Materials" value={mc.total_unique_materials} />
|
||||||
|
<StatCard icon={<BookOpen size={14} className="text-indigo-500" />} label="Library Materials" value={mc.library_material_count} />
|
||||||
|
<StatCard icon={<Link2 size={14} className="text-teal-500" />} label="Aliases" value={mc.alias_count} />
|
||||||
|
<StatCard icon={<Package size={14} className="text-purple-500" />} label="Products" value={ps.total_products} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order status summary */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-content-secondary mb-2">Orders ({os.total})</p>
|
||||||
|
<div className="flex gap-1 h-5 rounded-full overflow-hidden">
|
||||||
|
{os.completed > 0 && <StatusSlice count={os.completed} total={os.total} color="#22c55e" label="Completed" />}
|
||||||
|
{os.processing > 0 && <StatusSlice count={os.processing} total={os.total} color="#3b82f6" label="Processing" />}
|
||||||
|
{os.submitted > 0 && <StatusSlice count={os.submitted} total={os.total} color="#eab308" label="Submitted" />}
|
||||||
|
{os.draft > 0 && <StatusSlice count={os.draft} total={os.total} color="#9ca3af" label="Draft" />}
|
||||||
|
{os.rejected > 0 && <StatusSlice count={os.rejected} total={os.total} color="#ef4444" label="Rejected" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1.5">
|
||||||
|
{os.completed > 0 && <LegendItem color="#22c55e" label="Completed" count={os.completed} />}
|
||||||
|
{os.processing > 0 && <LegendItem color="#3b82f6" label="Processing" count={os.processing} />}
|
||||||
|
{os.submitted > 0 && <LegendItem color="#eab308" label="Submitted" count={os.submitted} />}
|
||||||
|
{os.draft > 0 && <LegendItem color="#9ca3af" label="Draft" count={os.draft} />}
|
||||||
|
{os.rejected > 0 && <LegendItem color="#ef4444" label="Rejected" count={os.rejected} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-border-default p-2.5"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">{icon}</div>
|
||||||
|
<p className="text-lg font-bold text-content">{value}</p>
|
||||||
|
<p className="text-[10px] text-content-muted">{label}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusSlice({ count, total, color, label }: { count: number; total: number; color: string; label: string }) {
|
||||||
|
const pct = total > 0 ? (count / total) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={`${label}: ${count}`}
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: color, minWidth: pct > 0 ? '4px' : '0' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendItem({ color, label, count }: { color: string; label: string; count: number }) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-content-muted">
|
||||||
|
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: color }} />
|
||||||
|
{label} ({count})
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-12 rounded-lg bg-surface-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Skeleton />
|
||||||
|
if (error) return <p className="text-xs text-red-500">Failed to load render throughput</p>
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
const t = data.render_throughput
|
||||||
|
|
||||||
|
const periods = [
|
||||||
|
{ label: 'Today', completed: t.completed_today, failed: t.failed_today, icon: <Clock size={14} className="text-blue-500" /> },
|
||||||
|
{ label: 'This Week', completed: t.completed_this_week, failed: t.failed_this_week, icon: <CalendarDays size={14} className="text-indigo-500" /> },
|
||||||
|
{ label: 'This Month', completed: t.completed_this_month, failed: t.failed_this_month, icon: <CalendarRange size={14} className="text-purple-500" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{periods.map(({ label, completed, failed, icon }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border-default p-3"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span className="text-sm text-content-secondary">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1 text-sm font-semibold text-green-600">
|
||||||
|
<CheckCircle2 size={13} />
|
||||||
|
{completed}
|
||||||
|
</span>
|
||||||
|
{failed > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-sm font-semibold text-red-500">
|
||||||
|
<XCircle size={13} />
|
||||||
|
{failed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Render time stats */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border-default p-3"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Timer size={14} className="text-amber-500" />
|
||||||
|
<span className="text-sm text-content-secondary">Avg / Median</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-content">
|
||||||
|
{formatTime(t.avg_render_time_s)} / {formatTime(t.median_render_time_s)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
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 { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
|
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
|
||||||
LayoutGrid, List, Trash2, X, ArrowUpDown,
|
LayoutGrid, List, Trash2, X, ArrowUpDown,
|
||||||
|
CheckSquare, Square, EyeOff, Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { listProducts, deleteProduct } from '../api/products'
|
import { listProducts, batchDeleteProducts } from '../api/products'
|
||||||
import type { Product } from '../api/products'
|
import type { Product } from '../api/products'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
TRB: 'TRB',
|
TRB: 'TRB',
|
||||||
@@ -123,7 +123,6 @@ export default function ProductLibraryPage() {
|
|||||||
const [sortBy, setSortBy] = useState<'pim_id' | 'name' | 'status'>('pim_id')
|
const [sortBy, setSortBy] = useState<'pim_id' | 'name' | 'status'>('pim_id')
|
||||||
const [view, setView] = useState<'grid' | 'table'>('grid')
|
const [view, setView] = useState<'grid' | 'table'>('grid')
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
|
|
||||||
|
|
||||||
// Debounce search input (300ms)
|
// Debounce search input (300ms)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -169,31 +168,41 @@ export default function ProductLibraryPage() {
|
|||||||
setSelected(allSelected ? new Set() : new Set(products.map((p) => p.id)))
|
setSelected(allSelected ? new Set() : new Set(products.map((p) => p.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk delete ────────────────────────────────────────────────────────
|
// ── Bulk actions ──────────────────────────────────────────────────────
|
||||||
const deleteMut = useMutation({
|
const [batchBusy, setBatchBusy] = useState(false)
|
||||||
mutationFn: async (ids: string[]) => {
|
const [confirmDeleteHard, setConfirmDeleteHard] = useState(false)
|
||||||
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'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDeleteSelected = () => {
|
const handleDeactivateSelected = async () => {
|
||||||
const ids = [...selected]
|
const ids = [...selected]
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
setConfirmState({
|
setBatchBusy(true)
|
||||||
open: true,
|
try {
|
||||||
title: `Delete ${ids.length} product${ids.length > 1 ? 's' : ''}`,
|
const result = await batchDeleteProducts(ids, false)
|
||||||
message: 'This cannot be undone.',
|
toast.success(`Deactivated ${result.deleted} product${result.deleted !== 1 ? 's' : ''}`)
|
||||||
onConfirm: () => {
|
setSelected(new Set())
|
||||||
deleteMut.mutate(ids)
|
qc.invalidateQueries({ queryKey: ['products'] })
|
||||||
setConfirmState((s) => ({ ...s, open: false }))
|
} 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 (
|
return (
|
||||||
@@ -204,9 +213,21 @@ export default function ProductLibraryPage() {
|
|||||||
<Library size={22} className="text-accent" />
|
<Library size={22} className="text-accent" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-content">Product Library</h1>
|
<h1 className="text-2xl font-bold text-content">Product Library</h1>
|
||||||
<p className="text-sm text-content-muted">
|
<div className="flex items-center gap-3 text-sm text-content-muted">
|
||||||
{products ? `${products.length} products` : isLoading ? 'Loading…' : '0 products'}
|
<span>{products ? `${products.length} products` : isLoading ? 'Loading\u2026' : '0 products'}</span>
|
||||||
</p>
|
{products && products.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={toggleAll}
|
||||||
|
className="flex items-center gap-1 hover:text-content transition-colors"
|
||||||
|
>
|
||||||
|
{allSelected ? <CheckSquare size={13} /> : <Square size={13} />}
|
||||||
|
{allSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<span className="text-accent font-medium">{selected.size} selected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -433,35 +454,63 @@ export default function ProductLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
open={confirmState.open}
|
|
||||||
title={confirmState.title}
|
|
||||||
message={confirmState.message}
|
|
||||||
onConfirm={confirmState.onConfirm}
|
|
||||||
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Floating action bar ───────────────────────────────────────── */}
|
{/* ── Floating selection action bar (MediaBrowser pattern) ───── */}
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-6 z-50 flex items-center gap-4 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 px-5 py-3 rounded-xl shadow-2xl border border-border-default"
|
||||||
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
|
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium text-content">
|
||||||
{selected.size} selected
|
{selected.size} product{selected.size !== 1 ? 's' : ''} selected
|
||||||
</span>
|
</span>
|
||||||
|
<div className="w-px h-5 bg-border-default" />
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteSelected}
|
onClick={handleDeactivateSelected}
|
||||||
disabled={deleteMut.isPending}
|
disabled={batchBusy}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-sm font-medium transition-colors disabled:opacity-50"
|
className="flex items-center gap-1.5 text-sm font-medium text-content-secondary hover:text-content transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
{batchBusy && !confirmDeleteHard
|
||||||
{deleteMut.isPending ? 'Deleting\u2026' : 'Delete'}
|
? <><Loader2 size={14} className="animate-spin" /> Deactivating…</>
|
||||||
|
: <><EyeOff size={14} /> Deactivate</>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="w-px h-5 bg-border-default" />
|
||||||
|
{confirmDeleteHard ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-red-500 font-medium">
|
||||||
|
Delete {selected.size} permanently?
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleHardDeleteSelected}
|
||||||
|
disabled={batchBusy}
|
||||||
|
className="flex items-center gap-1 text-sm font-medium text-white bg-red-500 hover:bg-red-600 px-2.5 py-1 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{batchBusy
|
||||||
|
? <><Loader2 size={12} className="animate-spin" /> Deleting…</>
|
||||||
|
: <><Trash2 size={12} /> Confirm</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteHard(false)}
|
||||||
|
disabled={batchBusy}
|
||||||
|
className="text-sm text-content-muted hover:text-content transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteHard(true)}
|
||||||
|
className="flex items-center gap-1.5 text-sm font-medium text-red-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Delete permanently
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="w-px h-5 bg-border-default" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelected(new Set())}
|
onClick={() => { setSelected(new Set()); setConfirmDeleteHard(false) }}
|
||||||
className="flex items-center gap-1 px-2 py-1.5 text-gray-400 hover:text-white text-sm transition-colors"
|
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
|
||||||
title="Clear selection"
|
|
||||||
>
|
>
|
||||||
<X size={14} /> Clear
|
<X size={14} /> Clear
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user