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:
2026-03-14 12:45:41 +01:00
parent 4f4a128e08
commit f0dd952f63
10 changed files with 1470 additions and 54 deletions
+212 -4
View File
@@ -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,
)
+112
View File
@@ -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.
+752 -1
View File
@@ -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",
],
},
]
+52
View File
@@ -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<DashboardStats> {
const { data } = await api.get<DashboardStats>('/admin/dashboard-stats')
return data
}
+11
View File
@@ -109,6 +109,17 @@ export async function deleteProduct(id: string, hard = false): Promise<void> {
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
@@ -21,6 +21,8 @@ const WIDGET_LABELS: Record<WidgetType, string> = {
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[] = [
@@ -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<WidgetType, { title: string; icon: React.ReactNode }> = {
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} /> },
OrdersByUser: { title: 'Orders by User', icon: <Users 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
@@ -77,6 +81,8 @@ function WidgetBody({ type }: { type: WidgetType }) {
case 'TopProducts': return <TopProductsWidget />
case 'OrdersByUser': return <OrdersByUserWidget />
case 'RenderBackendStats': return <RenderBackendStatsWidget />
case 'RenderThroughput': return <RenderThroughputWidget />
case 'MaterialCoverage': return <MaterialCoverageWidget />
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>
)
}
+98 -49
View File
@@ -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<string, string> = {
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<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)
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() {
<Library size={22} className="text-accent" />
<div>
<h1 className="text-2xl font-bold text-content">Product Library</h1>
<p className="text-sm text-content-muted">
{products ? `${products.length} products` : isLoading ? 'Loading' : '0 products'}
</p>
<div className="flex items-center gap-3 text-sm text-content-muted">
<span>{products ? `${products.length} products` : isLoading ? 'Loading\u2026' : '0 products'}</span>
{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>
@@ -433,35 +454,63 @@ export default function ProductLibraryPage() {
</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 && (
<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"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
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={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<span className="text-sm font-medium">
{selected.size} selected
<span className="text-sm font-medium text-content">
{selected.size} product{selected.size !== 1 ? 's' : ''} selected
</span>
<div className="w-px h-5 bg-border-default" />
<button
onClick={handleDeleteSelected}
disabled={deleteMut.isPending}
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"
onClick={handleDeactivateSelected}
disabled={batchBusy}
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} />
{deleteMut.isPending ? 'Deleting\u2026' : 'Delete'}
{batchBusy && !confirmDeleteHard
? <><Loader2 size={14} className="animate-spin" /> Deactivating&hellip;</>
: <><EyeOff size={14} /> Deactivate</>
}
</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&hellip;</>
: <><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
onClick={() => setSelected(new Set())}
className="flex items-center gap-1 px-2 py-1.5 text-gray-400 hover:text-white text-sm transition-colors"
title="Clear selection"
onClick={() => { setSelected(new Set()); setConfirmDeleteHard(false) }}
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
>
<X size={14} /> Clear
</button>