feat: layout hamburger, media browser filters+previews, billing fixes

- Layout: mobile hamburger menu + overlay backdrop + close button; content area always full-width
- Media browser: filter chips (default still+turntable); advanced toggle for GLB/STL; thumbnail_url previews for non-image types; video hover-play for turntable
- Backend: asset_types multi-filter, thumbnail_url in MediaAssetOut, download proxy endpoint for MinIO/local files
- Admin: "Import Existing Media" button → POST /api/admin/import-media-assets
- Billing: fix invoice create 500 (MissingGreenlet — use selectinload after commit); PDF download uses axios blob instead of bare <a href> (auth header missing); fix storage.upload() accepting str|Path
- SSE task logs: task_logs.py core + router, LiveRenderLog component
- CadPreview: fix infinite loop when no gltf_geometry assets; loading screen before ThreeDViewer render
- render-worker: add trimesh layer to Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 00:09:27 +01:00
parent 9bf6e72718
commit f5ca91ee02
25 changed files with 792 additions and 299 deletions
+14
View File
@@ -329,6 +329,20 @@ SQLAlchemy `Enum(create_type=False)` funktioniert nicht zuverlässig mit asyncpg
---
### 2026-03-07 | SQLAlchemy Async | db.refresh() lädt keine Relationships
**Problem:** `create_invoice` rief `await db.refresh(invoice)` — lädt nur skalare Spalten, nicht `invoice.lines` (Relationship). FastAPI serialisiert danach `lines` → SQLAlchemy versucht lazy-load außerhalb eines Greenlets → `MissingGreenlet`-Exception, HTTP 500.
**Lösung:** Statt `db.refresh()` die bestehende `get_invoice(db, invoice.id)` Funktion aufrufen, die `selectinload(Invoice.lines)` verwendet und alle Relationships korrekt vorlädt.
**Regel:** Nach `db.commit()` in Diensten die Relationships brauchen, immer eine separate select-Query mit `selectinload` machen anstatt `db.refresh()`.
---
### 2026-03-07 | Frontend Auth | Bearer-Token bei direktem Link-Download fehlt
**Problem:** `<a href="/api/billing/invoices/{id}/pdf" target="_blank">` öffnet den Link direkt im Browser ohne `Authorization`-Header → Backend gibt 401/403 zurück.
**Lösung:** API-Call via `api.get(..., { responseType: 'blob' })` (axios-Client mit automatischem Auth-Header), dann `URL.createObjectURL()` + programmatischer `<a>.click()`. So geht der Auth-Token mit.
**Gilt für:** Alle geschützten Download-Endpoints (PDFs, ZIPs, etc.) die via direkten Link nicht erreichbar sind.
---
### 2026-03-06 | TypeScript | Test-Dateien aus Haupt-tsconfig ausschließen
**Problem:** `vitest`- und `msw`-Imports in `src/__tests__/` erzeugen TypeScript-Fehler in `tsc --noEmit` weil diese Packages ihre Typen nur im Test-Kontext (über vitest globals) bereitstellen. `tsc` kennt die Types nicht, obwohl die Packages installiert sind.
**Lösung:** In `tsconfig.json` ein `"exclude": ["src/__tests__"]` hinzufügen. Vitest führt seine eigene Typ-Prüfung durch; der Haupt-Build braucht nur Produktionscode zu prüfen.
+73
View File
@@ -470,3 +470,76 @@ async def renderer_status(
}
@router.post("/import-media-assets")
async def import_existing_media_assets(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Import existing cad thumbnails and order line renders as MediaAsset records."""
from app.domains.media.models import MediaAsset, MediaAssetType
from sqlalchemy import text
created = 0
skipped = 0
# 1. CadFiles with thumbnail_path
cad_result = await db.execute(
text("SELECT id, thumbnail_path FROM cad_files WHERE thumbnail_path IS NOT NULL AND status = 'completed'")
)
for row in cad_result.fetchall():
cad_id, thumb_path = row
# De-dup check
existing = await db.execute(
select(MediaAsset.id).where(MediaAsset.storage_key == thumb_path).limit(1)
)
if existing.scalar_one_or_none():
skipped += 1
continue
ext = str(thumb_path).lower()
mime = "image/jpeg" if ext.endswith(".jpg") or ext.endswith(".jpeg") else "image/png"
asset = MediaAsset(
cad_file_id=uuid.UUID(str(cad_id)),
asset_type=MediaAssetType.thumbnail,
storage_key=str(thumb_path),
mime_type=mime,
)
db.add(asset)
created += 1
# 2. OrderLines with result_path
ol_result = await db.execute(
text("""
SELECT ol.id, ol.result_path, ol.product_id, COALESCE(ot.is_animation, false) as is_animation
FROM order_lines ol
LEFT JOIN output_types ot ON ot.id = ol.output_type_id
WHERE ol.result_path IS NOT NULL AND ol.render_status = 'completed'
""")
)
for row in ol_result.fetchall():
ol_id, result_path, product_id, is_animation = row
existing = await db.execute(
select(MediaAsset.id).where(MediaAsset.storage_key == result_path).limit(1)
)
if existing.scalar_one_or_none():
skipped += 1
continue
ext = str(result_path).lower()
if ext.endswith(".mp4") or ext.endswith(".webm"):
mime = "video/mp4"
asset_type = MediaAssetType.turntable
else:
mime = "image/png" if ext.endswith(".png") else "image/jpeg"
asset_type = MediaAssetType.turntable if is_animation else MediaAssetType.still
asset = MediaAsset(
order_line_id=uuid.UUID(str(ol_id)),
product_id=uuid.UUID(str(product_id)) if product_id else None,
asset_type=asset_type,
storage_key=str(result_path),
mime_type=mime,
)
db.add(asset)
created += 1
await db.commit()
return {"created": created, "skipped": skipped}
+78
View File
@@ -0,0 +1,78 @@
"""SSE endpoint for live task log streaming."""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from app.utils.auth import get_current_user
from app.config import settings
router = APIRouter(prefix="/tasks", tags=["task-logs"])
logger = logging.getLogger(__name__)
@router.get("/{task_id}/logs")
async def stream_task_logs(
task_id: str,
current_user=Depends(get_current_user),
):
"""SSE stream of task log lines. Use fetch() with Authorization header on the frontend."""
import redis.asyncio as aioredis
async def event_stream():
r = aioredis.from_url(settings.redis_url)
try:
# Send heartbeat first
yield "data: {\"type\":\"connected\"}\n\n"
# Send existing log lines
existing = await r.lrange(f"task_logs:{task_id}", 0, -1)
for line in existing:
data = line.decode() if isinstance(line, bytes) else line
yield f"data: {data}\n\n"
# Subscribe and stream new entries
pubsub = r.pubsub()
await pubsub.subscribe(f"task_logs_ch:{task_id}")
timeout_seconds = 600 # 10 minutes max
deadline = asyncio.get_event_loop().time() + timeout_seconds
while asyncio.get_event_loop().time() < deadline:
try:
msg = await asyncio.wait_for(
pubsub.get_message(ignore_subscribe_messages=True),
timeout=2.0
)
if msg and msg["type"] == "message":
data = msg["data"].decode() if isinstance(msg["data"], bytes) else msg["data"]
yield f"data: {data}\n\n"
# Check if task completed
try:
parsed = json.loads(data)
if parsed.get("level") == "done":
break
except Exception:
pass
else:
# Heartbeat every 2s
yield ": heartbeat\n\n"
except asyncio.TimeoutError:
yield ": heartbeat\n\n"
except Exception as exc:
logger.error("SSE stream error for task %s: %s", task_id, exc)
finally:
try:
await r.aclose()
except Exception:
pass
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
+2 -1
View File
@@ -56,8 +56,9 @@ class MinIOStorage:
except Exception as exc:
logger.warning("Could not create MinIO bucket %s: %s", self._bucket, exc)
def upload(self, local_path: Path, object_key: str) -> str:
def upload(self, local_path: Path | str, object_key: str) -> str:
"""Upload a local file to MinIO. Returns the object_key."""
local_path = Path(local_path)
self._client.upload_file(str(local_path), self._bucket, object_key)
logger.debug("Uploaded %s → minio://%s/%s", local_path.name, self._bucket, object_key)
return object_key
+24
View File
@@ -0,0 +1,24 @@
"""Redis-backed task log store for SSE streaming."""
import json
import time
import logging
from app.config import settings
logger = logging.getLogger(__name__)
TASK_LOG_TTL = 3600 # 1 hour
def log_task_event(task_id: str, message: str, level: str = "info") -> None:
"""Append a log line to Redis list and publish to channel. Safe to call from Celery tasks."""
try:
import redis
r = redis.from_url(settings.redis_url)
entry = json.dumps({"ts": time.time(), "level": level, "msg": message, "task_id": task_id})
pipe = r.pipeline()
pipe.rpush(f"task_logs:{task_id}", entry)
pipe.expire(f"task_logs:{task_id}", TASK_LOG_TTL)
pipe.publish(f"task_logs_ch:{task_id}", entry)
pipe.execute()
r.close()
except Exception as exc:
logger.debug("log_task_event failed: %s", exc)
+7 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -83,8 +83,12 @@ async def download_invoice_pdf(
if not key:
raise HTTPException(status_code=503, detail="PDF generation unavailable (WeasyPrint not installed)")
from app.core.storage import get_storage
url = get_storage().get_url(key)
return RedirectResponse(url=url)
pdf_bytes = get_storage().download_bytes(key)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=invoice-{invoice_id}.pdf"},
)
@invoice_router.delete("/invoices/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT)
+8 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel
from pydantic import BaseModel, computed_field
class InvoiceLineCreate(BaseModel):
@@ -54,4 +54,11 @@ class InvoiceOut(BaseModel):
created_at: datetime
lines: list[InvoiceLineOut] = []
@computed_field # type: ignore[misc]
@property
def pdf_url(self) -> str | None:
if self.pdf_key:
return f"/api/billing/invoices/{self.id}/pdf"
return None
model_config = {"from_attributes": True}
+1 -2
View File
@@ -267,8 +267,7 @@ async def create_invoice(
invoice.total_net = total_net
invoice.total_vat = (total_net * vat_rate).quantize(Decimal("0.01"))
await db.commit()
await db.refresh(invoice)
return invoice
return await get_invoice(db, invoice.id)
async def get_invoices(
+38 -8
View File
@@ -12,15 +12,17 @@ from app.domains.media.models import MediaAssetType
from app.domains.media.schemas import MediaAssetOut
from app.domains.media import service
router = APIRouter(prefix="/api/media", tags=["media"])
router = APIRouter(prefix="/api/media", tags=["media"], redirect_slashes=False)
@router.get("/", response_model=list[MediaAssetOut])
@router.get("", response_model=list[MediaAssetOut])
@router.get("/", response_model=list[MediaAssetOut], include_in_schema=False)
async def list_assets(
product_id: uuid.UUID | None = None,
order_line_id: uuid.UUID | None = None,
cad_file_id: uuid.UUID | None = None,
asset_type: MediaAssetType | None = None,
asset_types: list[MediaAssetType] = Query(default=[]),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
db: AsyncSession = Depends(get_db),
@@ -31,11 +33,13 @@ async def list_assets(
order_line_id=order_line_id,
cad_file_id=cad_file_id,
asset_type=asset_type,
asset_types=asset_types if asset_types else None,
skip=skip,
limit=limit,
)
for a in assets:
a.download_url = service.get_download_url(a)
a.thumbnail_url = service.get_thumbnail_url(a)
return assets
@@ -45,19 +49,45 @@ async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
if not asset:
raise HTTPException(404, "Asset not found")
asset.download_url = service.get_download_url(asset)
asset.thumbnail_url = service.get_thumbnail_url(asset)
return asset
@router.get("/{asset_id}/download")
@router.api_route("/{asset_id}/download", methods=["GET", "HEAD"])
async def download_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
from fastapi.responses import RedirectResponse
"""Proxy file content directly — avoids internal MinIO hostname issues."""
from fastapi.responses import FileResponse, Response
from pathlib import Path
asset = await service.get_media_asset(db, asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
url = service.get_download_url(asset)
if url:
return RedirectResponse(url)
raise HTTPException(404, "File not available")
key = asset.storage_key
mime = asset.mime_type or "application/octet-stream"
# Local file path (absolute or relative to UPLOAD_DIR)
candidate = Path(key)
if not candidate.is_absolute():
from app.config import settings
candidate = Path(settings.UPLOAD_DIR) / key
if candidate.exists():
ext = candidate.suffix.lstrip(".")
fname = f"{asset.asset_type.value}_{asset_id}.{ext or 'bin'}"
return FileResponse(str(candidate), media_type=mime, filename=fname)
# Fall back to MinIO
try:
from app.core.storage import get_storage
data = get_storage().download_bytes(key)
ext = key.rsplit(".", 1)[-1] if "." in key else "bin"
fname = f"{asset.asset_type.value}_{asset_id}.{ext}"
return Response(
content=data,
media_type=mime,
headers={"Content-Disposition": f"attachment; filename={fname}"},
)
except Exception:
raise HTTPException(404, "File not available")
@router.post("/zip")
+1
View File
@@ -22,5 +22,6 @@ class MediaAssetOut(BaseModel):
is_archived: bool
created_at: datetime
download_url: str | None = None
thumbnail_url: str | None = None
model_config = {"from_attributes": True}
+13 -8
View File
@@ -11,6 +11,7 @@ async def list_media_assets(
order_line_id: uuid.UUID | None = None,
cad_file_id: uuid.UUID | None = None,
asset_type: MediaAssetType | None = None,
asset_types: list[MediaAssetType] | None = None,
is_archived: bool | None = False,
skip: int = 0,
limit: int = 50,
@@ -22,7 +23,9 @@ async def list_media_assets(
q = q.where(MediaAsset.order_line_id == order_line_id)
if cad_file_id:
q = q.where(MediaAsset.cad_file_id == cad_file_id)
if asset_type:
if asset_types:
q = q.where(MediaAsset.asset_type.in_(asset_types))
elif asset_type is not None:
q = q.where(MediaAsset.asset_type == asset_type)
if is_archived is not None:
q = q.where(MediaAsset.is_archived == is_archived)
@@ -62,10 +65,12 @@ async def delete_media_asset(db: AsyncSession, asset_id: uuid.UUID) -> bool:
def get_download_url(asset: MediaAsset) -> str | None:
"""Get presigned URL from MinIO or local path."""
try:
from app.core.storage import get_storage
storage = get_storage()
return storage.get_url(asset.storage_key)
except Exception:
return f"/uploads/{asset.storage_key}"
"""Return a backend proxy URL so the browser can always download the file."""
return f"/api/media/{asset.id}/download"
def get_thumbnail_url(asset: MediaAsset) -> str | None:
"""Return CAD thumbnail URL if asset has a cad_file_id."""
if asset.cad_file_id:
return f"/api/cad/{asset.cad_file_id}/thumbnail"
return None
+64
View File
@@ -10,6 +10,7 @@ import logging
from pathlib import Path
from app.tasks.celery_app import celery_app
from app.core.task_logs import log_task_event
logger = logging.getLogger(__name__)
@@ -55,6 +56,7 @@ def render_still_task(
Returns render metadata dict on success.
Retries up to 2 times on failure (30s countdown).
"""
log_task_event(self.request.id, f"Starting render_still_task: {Path(step_path).name}", "info")
try:
from app.services.render_blender import render_still
result = render_still(
@@ -86,14 +88,34 @@ def render_still_task(
denoising_use_gpu=denoising_use_gpu,
mesh_attributes=mesh_attributes or {},
)
log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done")
logger.info(
"render_still_task completed: %s%s in %.1fs",
Path(step_path).name, Path(output_path).name,
result.get("total_duration_s", 0),
)
try:
from app.core.websocket import publish_event_sync
publish_event_sync(None, {
"type": "render.still.completed",
"step_path": Path(step_path).name,
"output": Path(output_path).name,
})
except Exception:
pass
return result
except Exception as exc:
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("render_still_task failed for %s: %s", step_path, exc)
try:
from app.core.websocket import publish_event_sync
publish_event_sync(None, {
"type": "render.still.failed",
"step_path": Path(step_path).name,
"error": str(exc),
})
except Exception:
pass
raise self.retry(exc=exc, countdown=30)
@@ -136,6 +158,7 @@ def render_turntable_task(
Returns render metadata dict on success.
"""
log_task_event(self.request.id, f"Starting render_turntable_task: {Path(step_path).name}", "info")
import json
import os
import shutil
@@ -211,7 +234,17 @@ def render_turntable_task(
f"Blender turntable exited {result.returncode}:\n{result.stdout[-2000:]}"
)
except Exception as exc:
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("render_turntable_task failed: %s", exc)
try:
from app.core.websocket import publish_event_sync
publish_event_sync(None, {
"type": "render.turntable.failed",
"step_path": Path(step_path).name,
"error": str(exc),
})
except Exception:
pass
raise self.retry(exc=exc, countdown=60)
# FFmpeg composite: frames → MP4 with optional background
@@ -224,6 +257,16 @@ def render_turntable_task(
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"FFmpeg composite failed: {exc.stderr[-500:]}")
log_task_event(self.request.id, "Completed successfully", "done")
try:
from app.core.websocket import publish_event_sync
publish_event_sync(None, {
"type": "render.turntable.completed",
"step_path": Path(step_path).name,
"output": Path(output_mp4).name,
})
except Exception:
pass
return {
"output_mp4": str(output_mp4),
"frame_count": frame_count,
@@ -313,8 +356,10 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
Wraps render_still_task logic but accepts order_line_id instead of step_path.
On success, creates a MediaAsset record via publish_asset.
"""
log_task_event(self.request.id, f"Starting render_order_line_still_task: order_line={order_line_id}", "info")
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str:
log_task_event(self.request.id, f"Failed: cannot resolve STEP path for order_line {order_line_id}", "error")
raise RuntimeError(
f"Cannot resolve STEP path for order_line {order_line_id}: "
"product missing or has no linked CAD file"
@@ -338,13 +383,32 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
str(output_path),
render_config=result,
)
log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done")
logger.info(
"render_order_line_still_task completed for line %s in %.1fs",
order_line_id, result.get("total_duration_s", 0),
)
try:
from app.core.websocket import publish_event_sync
publish_event_sync(None, {
"type": "render.order_line.completed",
"order_line_id": order_line_id,
})
except Exception:
pass
return result
except Exception as exc:
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("render_order_line_still_task failed for %s: %s", order_line_id, exc)
try:
from app.core.websocket import publish_event_sync
publish_event_sync(None, {
"type": "render.order_line.failed",
"order_line_id": order_line_id,
"error": str(exc),
})
except Exception:
pass
raise self.retry(exc=exc, countdown=30)
+2
View File
@@ -24,6 +24,7 @@ from app.domains.rendering.workflow_router import router as workflows_router
from app.domains.media.router import router as media_router
from app.api.routers.asset_libraries import router as asset_libraries_router
from app.domains.admin.dashboard_router import router as dashboard_router
from app.api.routers.task_logs import router as task_logs_router
@asynccontextmanager
@@ -90,6 +91,7 @@ app.include_router(workflows_router)
app.include_router(media_router)
app.include_router(asset_libraries_router, prefix="/api")
app.include_router(dashboard_router, prefix="/api")
app.include_router(task_logs_router, prefix="/api")
@app.get("/health")
+5
View File
@@ -1,6 +1,7 @@
"""Celery tasks for STEP file processing and thumbnail generation."""
import logging
from app.tasks.celery_app import celery_app
from app.core.task_logs import log_task_event
logger = logging.getLogger(__name__)
@@ -268,9 +269,11 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
step_path_str = cad_file.stored_path
eng.dispose()
log_task_event(self.request.id, f"Starting generate_gltf_geometry_task: cad_file={cad_file_id}", "info")
step = _Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
log_task_event(self.request.id, f"Failed: STL cache not found: {stl_path}", "error")
logger.error("generate_gltf_geometry_task: STL not found %s", stl_path)
raise RuntimeError(f"STL cache not found: {stl_path}")
@@ -279,8 +282,10 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
import trimesh
mesh = trimesh.load(str(stl_path))
mesh.export(str(output_path))
log_task_event(self.request.id, f"Completed successfully: {output_path.name}", "done")
logger.info("generate_gltf_geometry_task: exported %s", output_path.name)
except Exception as exc:
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("generate_gltf_geometry_task failed for %s: %s", cad_file_id, exc)
raise self.retry(exc=exc, countdown=15)
+8 -2
View File
@@ -60,6 +60,12 @@ export async function deleteInvoice(id: string): Promise<void> {
await api.delete(`/billing/invoices/${id}`)
}
export function getInvoicePdfUrl(id: string): string {
return `/api/billing/invoices/${id}/pdf`
export async function downloadInvoicePdf(id: string): Promise<void> {
const res = await api.get(`/billing/invoices/${id}/pdf`, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url
a.download = `invoice-${id}.pdf`
a.click()
URL.revokeObjectURL(url)
}
+4 -3
View File
@@ -28,13 +28,14 @@ export interface MediaAsset {
is_archived: boolean
created_at: string
download_url: string | null
thumbnail_url: string | null
}
export interface MediaFilter {
product_id?: string
order_line_id?: string
cad_file_id?: string
asset_type?: MediaAssetType
asset_types?: MediaAssetType[]
skip?: number
limit?: number
}
@@ -44,10 +45,10 @@ export const getMediaAssets = (filters: MediaFilter = {}): Promise<MediaAsset[]>
if (filters.product_id) params.set('product_id', filters.product_id)
if (filters.order_line_id) params.set('order_line_id', filters.order_line_id)
if (filters.cad_file_id) params.set('cad_file_id', filters.cad_file_id)
if (filters.asset_type) params.set('asset_type', filters.asset_type)
if (filters.asset_types?.length) filters.asset_types.forEach(t => params.append('asset_types', t))
if (filters.skip !== undefined) params.set('skip', String(filters.skip))
if (filters.limit !== undefined) params.set('limit', String(filters.limit))
return api.get(`/media?${params}`).then(r => r.data)
return api.get(`/media/?${params}`).then(r => r.data)
}
export const getMediaAsset = (id: string): Promise<MediaAsset> =>
+13 -13
View File
@@ -218,8 +218,6 @@ export default function ThreeDViewer({
productionGltfUrl,
downloadUrls,
}: ThreeDViewerProps) {
const defaultUrl = `/api/cad/${cadFileId}/model`
const [mode, setMode] = useState<ViewMode>('geometry')
const [wireframe, setWireframe] = useState(false)
const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
@@ -231,7 +229,7 @@ export default function ThreeDViewer({
const activeUrl =
mode === 'production' && productionGltfUrl
? productionGltfUrl
: geometryGltfUrl || defaultUrl
: geometryGltfUrl
const handleModelReady = useCallback(() => setModelReady(true), [])
const handleError = useCallback((msg: string) => setLoadError(msg), [])
@@ -372,16 +370,18 @@ export default function ThreeDViewer({
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
<directionalLight position={[-5, -5, -5]} intensity={0.25} />
<GltfErrorBoundary onError={handleError}>
<Suspense fallback={null}>
<ModelWithReady
key={activeUrl}
url={activeUrl}
wireframe={wireframe}
onReady={handleModelReady}
/>
</Suspense>
</GltfErrorBoundary>
{activeUrl && (
<GltfErrorBoundary onError={handleError}>
<Suspense fallback={null}>
<ModelWithReady
key={activeUrl}
url={activeUrl}
wireframe={wireframe}
onReady={handleModelReady}
/>
</Suspense>
</GltfErrorBoundary>
)}
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
<Environment preset={envPreset} />
+56 -5
View File
@@ -1,7 +1,8 @@
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom'
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload } from 'lucide-react'
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch, Image, BellRing, Receipt, Server, Upload, Menu, X } from 'lucide-react'
import { useAuthStore } from '../../store/auth'
import { clsx } from 'clsx'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getWorkerActivity } from '../../api/worker'
import { listOrders } from '../../api/orders'
@@ -20,6 +21,7 @@ const nav = [
export default function Layout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(false)
const { data: activity } = useQuery({
queryKey: ['worker-activity'],
@@ -43,8 +45,36 @@ export default function Layout() {
return (
<div className="flex h-screen overflow-hidden bg-surface-alt">
{/* Mobile top header bar */}
<header className="fixed top-0 left-0 right-0 z-30 md:hidden bg-surface border-b border-border-default h-12 flex items-center px-4 gap-3">
<button
onClick={() => setSidebarOpen(true)}
className="text-content-secondary hover:text-content transition-colors"
aria-label="Open navigation"
>
<Menu size={20} />
</button>
<span className="flex-1 text-sm font-semibold text-content">Schaeffler Automat</span>
<NotificationCenter />
</header>
{/* Overlay backdrop (mobile only) */}
{sidebarOpen && (
<div
className="fixed inset-0 z-30 md:hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside className="w-60 flex-shrink-0 bg-surface border-r border-border-default flex flex-col">
<aside
className={clsx(
'fixed left-0 top-0 h-full z-40 w-60 bg-surface border-r border-border-default flex flex-col transform transition-transform duration-200',
'md:relative md:translate-x-0 md:flex-shrink-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="p-5 border-b border-border-default">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent rounded flex items-center justify-center">
@@ -54,14 +84,26 @@ export default function Layout() {
<p className="font-semibold text-content text-sm">Schaeffler</p>
<p className="text-xs text-content-muted">Automat</p>
</div>
<NotificationCenter />
{/* NotificationCenter in sidebar header (desktop); hidden on mobile (shown in top bar) */}
<span className="hidden md:block">
<NotificationCenter />
</span>
{/* Close button — mobile only */}
<button
onClick={() => setSidebarOpen(false)}
className="md:hidden text-content-secondary hover:text-content transition-colors"
aria-label="Close navigation"
>
<X size={20} />
</button>
</div>
</div>
<nav className="flex-1 p-3 space-y-1">
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
{/* New Order — primary CTA at the top */}
<Link
to="/orders/new"
onClick={() => setSidebarOpen(false)}
className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-md text-sm font-semibold bg-accent text-accent-text hover:bg-accent-hover transition-colors shadow-sm"
>
<Plus size={18} />
@@ -79,6 +121,7 @@ export default function Layout() {
key={to}
to={to}
end={end}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -108,6 +151,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/admin"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -124,6 +168,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/billing"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -140,6 +185,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/media"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -156,6 +202,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/workers"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -172,6 +219,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/workflows"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -188,6 +236,7 @@ export default function Layout() {
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/asset-libraries"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -204,6 +253,7 @@ export default function Layout() {
{user?.role === 'admin' && (
<NavLink
to="/notification-settings"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -220,6 +270,7 @@ export default function Layout() {
{user?.role === 'admin' && (
<NavLink
to="/tenants"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
@@ -258,7 +309,7 @@ export default function Layout() {
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<main className="flex-1 overflow-auto min-w-0 pt-12 md:pt-0">
<Outlet />
</main>
</div>
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react'
import { Loader2, Terminal } from 'lucide-react'
interface LogEntry {
ts: number
level: 'info' | 'error' | 'done' | 'warning'
msg: string
task_id?: string
}
interface LiveRenderLogProps {
taskId: string | null
title?: string
maxLines?: number
}
export default function LiveRenderLog({ taskId, title = 'Task Log', maxLines = 200 }: LiveRenderLogProps) {
const [logs, setLogs] = useState<LogEntry[]>([])
const [connected, setConnected] = useState(false)
const [done, setDone] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!taskId) return
setLogs([])
setConnected(false)
setDone(false)
const controller = new AbortController()
const token = localStorage.getItem('token') ?? ''
fetch(`/api/tasks/${taskId}/logs`, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
}).then(async (res) => {
if (!res.ok || !res.body) return
setConnected(true)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done: streamDone, value } = await reader.read()
if (streamDone) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() ?? ''
for (const part of parts) {
const line = part.trim()
if (!line.startsWith('data:')) continue
const raw = line.slice(5).trim()
try {
const entry = JSON.parse(raw) as LogEntry & { type?: string }
if (entry.type === 'connected') continue
setLogs((prev) => [...prev.slice(-maxLines + 1), entry])
if (entry.level === 'done') setDone(true)
} catch {}
}
}
}).catch(() => {})
return () => controller.abort()
}, [taskId, maxLines])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs])
if (!taskId) return null
const levelColor = (level: string) => {
if (level === 'error') return 'text-red-400'
if (level === 'done') return 'text-green-400'
if (level === 'warning') return 'text-yellow-400'
return 'text-gray-300'
}
return (
<div className="rounded-lg border border-gray-700 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-gray-800 border-b border-gray-700">
<Terminal size={14} className="text-gray-400" />
<span className="text-xs font-medium text-gray-300">{title}</span>
{!done && !connected && <Loader2 size={12} className="animate-spin text-gray-400 ml-auto" />}
{done && <span className="ml-auto text-xs text-green-400">Done</span>}
{connected && !done && <span className="ml-auto text-xs text-blue-400">Live</span>}
</div>
<div className="bg-gray-950 p-3 max-h-64 overflow-y-auto font-mono text-xs">
{logs.length === 0 && (
<span className="text-gray-600">Waiting for log output</span>
)}
{logs.map((entry, i) => (
<div key={i} className={`${levelColor(entry.level)}`}>
<span className="text-gray-600 mr-2">
{new Date(entry.ts * 1000).toLocaleTimeString()}
</span>
{entry.msg}
</div>
))}
<div ref={bottomRef} />
</div>
</div>
)
}
+20
View File
@@ -141,6 +141,14 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const importMediaAssetsMut = useMutation({
mutationFn: () => api.post('/admin/import-media-assets'),
onSuccess: (res) => {
toast.success(`Imported: ${res.data.created} created, ${res.data.skipped} skipped`)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
})
const generateMissingStlsMut = useMutation({
mutationFn: () => api.post('/admin/settings/generate-missing-stls'),
onSuccess: (res) => {
@@ -666,6 +674,18 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => importMediaAssetsMut.mutate()}
disabled={importMediaAssetsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Create MediaAsset records for all existing CAD thumbnails and order line renders"
>
<RefreshCw size={14} className={importMediaAssetsMut.isPending ? 'animate-spin' : ''} />
{importMediaAssetsMut.isPending ? 'Importing…' : 'Import Existing Media'}
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingStlsMut.mutate()}
+4 -6
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Receipt, Download, Trash2, Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import {
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, getInvoicePdfUrl,
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
type Invoice, type InvoiceCreate,
} from '../api/billing'
@@ -196,15 +196,13 @@ export default function BillingPage() {
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td>
<td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td>
<td className="px-4 py-3 flex items-center gap-1">
<a
href={getInvoicePdfUrl(inv.id)}
target="_blank"
rel="noopener noreferrer"
<button
onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))}
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="Download PDF"
>
<Download size={15} />
</a>
</button>
{inv.status === 'draft' && (
<button
onClick={() => {
+79 -4
View File
@@ -1,24 +1,30 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation } from '@tanstack/react-query'
import { Box, Loader2, X } from 'lucide-react'
import ThreeDViewer from '../components/cad/ThreeDViewer'
import { getMediaAssets } from '../api/media'
import { generateGltfGeometry } from '../api/cad'
/**
* Route: /cad/:id
*
* Full-screen 3D viewer for a CAD file.
* Passes production GLB URL if a gltf_geometry MediaAsset exists for this CAD file.
* If no geometry GLB exists yet, offers to generate one on demand.
*/
export default function CadPreviewPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [generating, setGenerating] = useState(false)
// Load any geometry GLB that was generated for this CAD file
const { data: gltfAssets } = useQuery({
// Poll every 3s while generating so it appears automatically
const { data: gltfAssets, isLoading: gltfLoading } = useQuery({
queryKey: ['media-assets', id, 'gltf_geometry'],
queryFn: () => getMediaAssets({ cad_file_id: id!, asset_type: 'gltf_geometry' }),
enabled: !!id,
staleTime: 30_000,
staleTime: 5_000,
refetchInterval: generating ? 3_000 : false,
})
// Load production GLB if available
@@ -37,6 +43,20 @@ export default function CadPreviewPage() {
staleTime: 30_000,
})
const generateMutation = useMutation({
mutationFn: () => generateGltfGeometry(id!),
onSuccess: () => {
setGenerating(true)
},
})
// Stop polling once asset appears
useEffect(() => {
if (generating && gltfAssets && gltfAssets.length > 0) {
setGenerating(false)
}
}, [generating, gltfAssets])
if (!id) {
return (
<div className="flex items-center justify-center h-full text-content-muted p-8">
@@ -49,6 +69,61 @@ export default function CadPreviewPage() {
const latestProduction = productionAssets?.[0]
const latestBlend = blendAssets?.[0]
// While checking for assets, show a neutral loading screen (don't attempt to render ThreeDViewer)
if (gltfLoading) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gray-950 gap-3">
<Loader2 size={36} className="animate-spin text-gray-400" />
<p className="text-gray-400 text-sm">Checking for 3D model</p>
</div>
)
}
// No GLB available yet — show generate prompt
if (!latestGltf) {
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800">
<span className="text-white font-semibold tracking-wide">3D Viewer</span>
<button
onClick={() => navigate(-1)}
className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-8">
<Box size={48} className="text-gray-600" />
<p className="text-white text-lg font-semibold">No 3D model available yet</p>
<p className="text-gray-400 text-sm max-w-sm">
Generate a GLB file from the STEP cache to enable the 3D viewer.
The STL cache must exist (process the STEP file first).
</p>
{generating ? (
<div className="flex items-center gap-2 text-gray-300 text-sm">
<Loader2 size={16} className="animate-spin" />
Generating checking every 3s
</div>
) : (
<button
onClick={() => generateMutation.mutate()}
disabled={generateMutation.isPending}
className="px-5 py-2 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors flex items-center gap-2"
>
{generateMutation.isPending && <Loader2 size={14} className="animate-spin" />}
Generate 3D Model
</button>
)}
{generateMutation.isError && (
<p className="text-red-400 text-sm">
Failed to start generation. Check that the STL cache exists.
</p>
)}
</div>
</div>
)
}
return (
<ThreeDViewer
cadFileId={id}
+85 -30
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
LayoutGrid, LayoutList, Download, Archive, Image, Film, Box, FileCode2, Layers,
ChevronLeft, ChevronRight, Search,
ChevronLeft, ChevronRight, Search, ChevronDown, ChevronUp,
} from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -32,11 +32,10 @@ const TYPE_COLORS: Record<MediaAssetType, string> = {
blend_production: 'bg-pink-100 text-pink-700',
}
const ALL_TYPES: MediaAssetType[] = [
'thumbnail', 'still', 'turntable',
'stl_low', 'stl_high',
'gltf_geometry', 'gltf_production', 'blend_production',
]
const PRIMARY_TYPES: MediaAssetType[] = ['still', 'turntable', 'thumbnail']
const ADVANCED_TYPES: MediaAssetType[] = ['gltf_geometry', 'gltf_production', 'blend_production', 'stl_low', 'stl_high']
const ALL_TYPES: MediaAssetType[] = [...PRIMARY_TYPES, ...ADVANCED_TYPES]
const DEFAULT_TYPES: Set<MediaAssetType> = new Set(['still', 'turntable'])
const isImageAsset = (type: MediaAssetType) => type === 'thumbnail' || type === 'still'
const isVideoAsset = (type: MediaAssetType) => type === 'turntable'
@@ -82,6 +81,22 @@ function AssetCard({
alt={asset.asset_type}
className="w-full h-40 object-cover bg-gray-50"
/>
) : isVideoAsset(asset.asset_type) && asset.download_url ? (
<video
src={asset.download_url}
poster={asset.thumbnail_url ?? undefined}
className="w-full h-40 object-cover bg-gray-900"
loop
muted
onMouseEnter={e => (e.currentTarget as HTMLVideoElement).play()}
onMouseLeave={e => { (e.currentTarget as HTMLVideoElement).pause(); (e.currentTarget as HTMLVideoElement).currentTime = 0 }}
/>
) : asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-40 object-cover bg-gray-50 opacity-80"
/>
) : (
<div className="w-full h-40 flex items-center justify-center bg-gray-50">
<TypeIcon type={asset.asset_type} />
@@ -169,13 +184,23 @@ export default function MediaBrowserPage() {
const qc = useQueryClient()
const [view, setView] = useState<'grid' | 'list'>('grid')
const [assetType, setAssetType] = useState<MediaAssetType | ''>('')
const [activeTypes, setActiveTypes] = useState<Set<MediaAssetType>>(new Set(DEFAULT_TYPES))
const [showAdvanced, setShowAdvanced] = useState(false)
const [productIdInput, setProductIdInput] = useState('')
const [page, setPage] = useState(0)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const toggleType = (t: MediaAssetType) => {
setActiveTypes(prev => {
const next = new Set(prev)
next.has(t) ? next.delete(t) : next.add(t)
return next
})
setPage(0)
}
const filter: MediaFilter = {
asset_type: assetType || undefined,
asset_types: activeTypes.size > 0 ? [...activeTypes] : ALL_TYPES,
product_id: productIdInput.trim() || undefined,
skip: page * PAGE_SIZE,
limit: PAGE_SIZE,
@@ -266,29 +291,59 @@ export default function MediaBrowserPage() {
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<div className="relative">
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Filter by product ID..."
value={productIdInput}
onChange={e => { setProductIdInput(e.target.value); setPage(0) }}
className="pl-8 pr-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent w-64"
/>
</div>
<select
value={assetType}
onChange={e => { setAssetType(e.target.value as MediaAssetType | ''); setPage(0) }}
className="px-3 py-2 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent"
>
<option value="">All types</option>
{ALL_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
<div className="space-y-2">
<div className="flex flex-wrap gap-2 items-center">
<div className="relative">
<Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Filter by product ID..."
value={productIdInput}
onChange={e => { setProductIdInput(e.target.value); setPage(0) }}
className="pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md bg-surface focus:outline-none focus:ring-1 focus:ring-accent w-56"
/>
</div>
{/* Primary type chips */}
{PRIMARY_TYPES.map(t => (
<button
key={t}
onClick={() => toggleType(t)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
activeTypes.has(t)
? `${TYPE_COLORS[t]} border-transparent`
: 'bg-gray-50 text-gray-400 border-gray-200 hover:border-gray-300'
}`}
>
{t}
</button>
))}
</select>
{selectedIds.size > 0 && (
<span className="text-sm text-content-muted">{selectedIds.size} selected</span>
<button
onClick={() => setShowAdvanced(v => !v)}
className="flex items-center gap-1 px-3 py-1 text-xs text-content-secondary border border-border-default rounded-full hover:bg-surface-hover transition-colors"
>
Advanced
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{selectedIds.size > 0 && (
<span className="text-sm text-content-muted ml-1">{selectedIds.size} selected</span>
)}
</div>
{showAdvanced && (
<div className="flex flex-wrap gap-2">
{ADVANCED_TYPES.map(t => (
<button
key={t}
onClick={() => toggleType(t)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
activeTypes.has(t)
? `${TYPE_COLORS[t]} border-transparent`
: 'bg-gray-50 text-gray-400 border-gray-200 hover:border-gray-300'
}`}
>
{t}
</button>
))}
</div>
)}
</div>
+88 -213
View File
@@ -1,6 +1,12 @@
# Plan: UI-Vollständigkeit + Workflows — Phase O
# Plan: Layout Hamburger + Media Browser Fixes + Retroactive Import
**Ziel**: Alle implementierten Backend-Features im UI zugänglich machen + v3-Workflows vollständig verdrahten.
## Kontext
Vier unabhängige Bereiche:
1. **Layout**: Sidebar hat kein Mobile-Support, kein Hamburger-Menü → Content füllt nicht volle Breite auf kleinen Screens
2. **Media Browser Previews**: glTF-Assets zeigen nur Icon-Placeholder; CadFile-Thumbnails wären als Preview nutzbar
3. **Media Browser Filter-Defaults**: Aktuell kein Default-Filter → alle Types (inkl. GLB/STL) sichtbar; gewünscht: Default nur still + turntable
4. **Retroactive Import**: Bestehende `cad_files.thumbnail_path` und `order_lines.result_path` sind nicht als `media_assets` erfasst
---
@@ -8,232 +14,101 @@
| Datei | Änderung |
|-------|----------|
| `frontend/src/components/layout/Layout.tsx` | Upload-Link hinzufügen |
| `frontend/src/pages/Admin.tsx` | OutputType-Tabelle: Workflow-Dropdown |
| `frontend/src/pages/AssetLibrary.tsx` | NEU: Asset Library Management UI |
| `frontend/src/api/asset_libraries.ts` | NEU: API-Client |
| `frontend/src/pages/ProductDetail.tsx` | Mesh-Attribute-Anzeige |
| `frontend/src/pages/Upload.tsx` | Sanity-Check-Dialog nach Import |
| `frontend/src/api/imports.ts` | NEU: import_validation API |
| `frontend/src/App.tsx` | Route /asset-libraries |
| `backend/app/api/routers/notification_configs.py` | NEU: notification_configs CRUD |
| `backend/app/main.py` | notification_configs router registrieren |
| `backend/app/api/routers/orders.py` | dispatch_renders → dispatch_render_with_workflow |
| `backend/app/api/routers/output_types.py` | workflow_definition_id im PATCH |
| `backend/app/schemas/output_type.py` | workflow_definition_id im Schema |
| `backend/app/domains/rendering/tasks.py` | K3: apply_asset_library_materials_task |
| `backend/app/tasks/step_tasks.py` | OCC sharp edge extraction in render_step_thumbnail |
| `render-worker/scripts/still_render.py` | mark_sharp / UV seams support |
| `render-worker/scripts/blender_render.py` | mark_sharp / UV seams support |
| `backend/app/services/step_processor.py` | extract_mesh_edge_data() für sharp edges |
| `frontend/src/components/layout/Layout.tsx` | Hamburger-Menü + Mobile-Overlay |
| `frontend/src/pages/MediaBrowser.tsx` | Filter-Chips + Previews + Default-Filter |
| `frontend/src/api/media.ts` | `asset_types[]` statt `asset_type` + `thumbnail_url` Feld |
| `backend/app/domains/media/schemas.py` | `thumbnail_url: str | None` Feld |
| `backend/app/domains/media/router.py` | `asset_types` Multi-Query-Param + thumbnail_url befüllen |
| `backend/app/domains/media/service.py` | `get_thumbnail_url(asset)` Helper |
| `backend/app/api/routers/admin.py` | `POST /api/admin/import-media-assets` Endpoint |
| `frontend/src/pages/Admin.tsx` | Button "Import Existing Media" |
---
## Tasks
## Tasks (in Reihenfolge)
### Task 1: Upload-Link in Sidebar [QUICK WIN]
### Task 1: Layout — Hamburger-Menü + Mobile Sidebar
- **Datei**: `frontend/src/components/layout/Layout.tsx`
- **Was**: `Upload`-Icon + NavLink zu `/upload` in der Sidebar für alle eingeloggten User
- **Akzeptanzkriterium**: Upload-Link sichtbar in Sidebar
### Task 2: notification_configs Backend-Router [Phase I]
- **Datei**: `backend/app/api/routers/notification_configs.py` (NEU), `backend/app/main.py`
- **Was**: REST-Endpoints für `notification_configs` Tabelle (044 bereits migriert):
- `GET /api/notification-configs` — gibt configs für aktuellen User zurück (mit Defaults falls keine Zeilen)
- `PUT /api/notification-configs/{event_type}/{channel}` — setzt enabled=true/false
- `POST /api/notification-configs/reset` — löscht alle configs des Users → Defaults gelten wieder
- Response: `[{event_type, channel, enabled}]`
- Auth: `get_current_user` (jeder kann seine eigenen Configs verwalten)
- **Akzeptanzkriterium**: NotificationSettings.tsx zeigt Toggle-Matrix und speichert korrekt
### Task 3: OutputType → WorkflowDefinition — Schema + API
- **Datei**: `backend/app/schemas/output_type.py`, `backend/app/api/routers/output_types.py`
- **Was**:
- `OutputTypeOut` + `OutputTypePatch`: `workflow_definition_id: uuid.UUID | None` hinzufügen
- PATCH-Handler: `workflow_definition_id` setzen wenn in body
- `OutputTypeOut` soll `workflow_name: str | None` als convenience field enthalten
- **Akzeptanzkriterium**: `PATCH /api/output-types/{id}` mit `{"workflow_definition_id": "..."}` funktioniert
- State `sidebarOpen: boolean` (default: `false` auf mobile, `true` auf desktop via window.innerWidth)
- Hamburger-Button (`Menu`-Icon aus lucide) in einem mobilen Header-Bar (nur sichtbar `< md`, also `md:hidden`)
- Sidebar: auf mobile `fixed left-0 top-0 h-full z-40 transform transition-transform`, bei `sidebarOpen`: `translate-x-0`, sonst `-translate-x-full`; auf Desktop immer sichtbar (`md:relative md:translate-x-0`)
- Overlay-Backdrop: halbtransparentes `div` hinter Sidebar, nur auf mobile sichtbar wenn open, click schließt Sidebar
- Close-Button (X) oben in Sidebar auf mobile
- Content-Bereich: `flex-1 overflow-auto min-w-0` damit er immer volle restliche Breite nutzt
- **Akzeptanzkriterium**: Auf <768px Hamburger sichtbar, Sidebar aus-/einblendbar; auf ≥768px Sidebar immer sichtbar
### Task 4: Workflow-Dispatch Integration
- **Datei**: `backend/app/api/routers/orders.py`
- **Was**: In `dispatch_renders()` (Zeile 910):
- Statt `dispatch_order_line_render.delay(str(line.id))` aufrufen:
- `from app.domains.rendering.dispatch_service import dispatch_render_with_workflow`
- `dispatch_render_with_workflow(str(line.id))` aufrufen
- Das dispatch_service lädt OutputType.workflow_definition_id und nutzt Celery Canvas falls verknüpft; fällt auf Legacy zurück wenn nicht.
- **Akzeptanzkriterium**: Dispatch nutzt neuen Pfad; Legacy-Fallback bleibt erhalten
### Task 5: Asset Library API-Client (Frontend)
- **Datei**: `frontend/src/api/asset_libraries.ts` (NEU)
### Task 2: Backend — `asset_types[]` Multi-Filter + `thumbnail_url`
- **Datei**: `backend/app/domains/media/router.py`, `backend/app/domains/media/schemas.py`, `backend/app/domains/media/service.py`
- **Was**:
```typescript
export interface AssetLibrary { id, name, description, original_filename, catalog: {materials: string[], node_groups: string[]}, is_active, created_at }
export async function listAssetLibraries(): Promise<AssetLibrary[]>
export async function uploadAssetLibrary(name: string, file: File, description?: string): Promise<AssetLibrary>
export async function refreshLibraryCatalog(id: string): Promise<AssetLibrary>
export async function deleteAssetLibrary(id: string): Promise<void>
export async function updateAssetLibrary(id: string, data: Partial<AssetLibrary>): Promise<AssetLibrary>
```
- **Akzeptanzkriterium**: TypeScript kompiliert fehlerfrei
- `list_assets` Endpoint: Zusätzlichen Query-Param `asset_types: list[MediaAssetType] = Query(default=[])` hinzufügen
- Filter-Logik: wenn `asset_types` nicht leer → `WHERE asset_type IN (asset_types)`; sonst wenn `asset_type` gesetzt → wie bisher
- `MediaAssetOut`: neues Feld `thumbnail_url: str | None = None`
- `service.py`: neue Funktion `get_thumbnail_url(asset) -> str | None` — gibt `/api/cad/{cad_file_id}/thumbnail` zurück wenn `cad_file_id` gesetzt (unabhängig von asset_type)
- In `list_assets` und `get_asset`: `a.thumbnail_url = service.get_thumbnail_url(a)` setzen (analog zu `download_url`)
- **Akzeptanzkriterium**: `GET /api/media/?asset_types=still&asset_types=turntable` gibt nur still+turntable zurück; jedes Asset mit `cad_file_id` hat `thumbnail_url` gesetzt
### Task 6: Asset Library Management Page (K2)
- **Datei**: `frontend/src/pages/AssetLibrary.tsx` (NEU)
- **Was**: Seite `/asset-libraries` (admin/PM):
- Liste der Asset Libraries als Karten: Name, Filename, Badge-Grid mit Materialien/Node-Groups aus `catalog`
- Upload-Button: Datei-Input für `.blend` + Name-Feld → `uploadAssetLibrary()`
- "Refresh Catalog" Button je Library → `refreshLibraryCatalog(id)` → Toast
- Toggle `is_active` → `updateAssetLibrary()`
- Delete-Button → `deleteAssetLibrary()`
- Leer-Zustand: "No asset libraries yet — upload a .blend file"
- **Akzeptanzkriterium**: Libraries hochladen, Katalog anzeigen, löschen
### Task 7: Asset Library Route + Sidebar-Link
- **Datei**: `frontend/src/App.tsx`, `frontend/src/components/layout/Layout.tsx`
### Task 3: Frontend — Media Browser Filter-Chips + Previews
- **Datei**: `frontend/src/pages/MediaBrowser.tsx`, `frontend/src/api/media.ts`
- **Was**:
- App.tsx: Route `/asset-libraries` → `<AssetLibraryPage />` (AdminRoute)
- Layout.tsx: Sidebar-Link "Asset Libraries" mit `Library`-Icon (admin/PM)
- **Abhängigkeiten**: Task 6
- `api/media.ts`: `MediaFilter.asset_types?: MediaAssetType[]` (statt `asset_type`); `getMediaAssets` sendet `asset_types` als repeated params; `MediaAsset` bekommt `thumbnail_url: string | null`
- `MediaBrowser.tsx`:
- State: `activeTypes: Set<MediaAssetType>` — Default: `new Set(['still', 'turntable'])`
- Filter-UI: Chip-Grid mit allen Types; `still`/`turntable`/`thumbnail` in der Hauptreihe; `gltf_geometry`/`gltf_production`/`blend_production`/`stl_low`/`stl_high` hinter "Advanced" Toggle (collapsed by default)
- Chip aktiv = farbiger Hintergrund entsprechend `TYPE_COLORS`; inaktiv = grau
- Chip-Klick toggled den Type aus `activeTypes`
- `getMediaAssets({ asset_types: [...activeTypes], ... })`
- `AssetCard`: wenn `isImageAsset(type)``download_url`; wenn `thumbnail_url` vorhanden → `thumbnail_url` als Preview; sonst Icon
- Video-Assets (`turntable`): Video-Poster via `thumbnail_url` (falls vorhanden) mit `<video>`-Tag anzeigen oder Bild
- **Akzeptanzkriterium**: Default zeigt nur still+turntable; Chip-Klick filtert korrekt; GLB-Assets zeigen CadFile-Thumbnail
### Task 8: OutputType Workflow-Dropdown (Frontend)
- **Datei**: `frontend/src/pages/Admin.tsx` (OutputTypeTable-Bereich)
- **Was**: In der OutputType-Tabelle eine neue Spalte "Workflow":
- Dropdown mit allen WorkflowDefinitions (aus `GET /api/workflows`) + "— None —"
- Bei Änderung: `PATCH /api/output-types/{id}` mit `{workflow_definition_id: ...}`
- Wenn kein Workflow: zeige "Legacy" Badge; wenn Workflow: zeige Workflow-Name als grünes Badge
- **Akzeptanzkriterium**: Workflow kann pro OutputType zugewiesen werden
### Task 9: Excel Sanity-Check Backend (Phase H)
- **Datei**: `backend/app/domains/imports/sanity_check.py` (NEU), `backend/app/domains/imports/router.py`
- **Was**:
- Sync-Funktion `run_sanity_check(import_validation_id: str)`:
- Lädt ImportValidation-Record
- Iteriert über `rows` (ParsedRows aus Excel)
- Für jede Zeile: prüft ob `name_cad_modell` eine CadFile zugeordnet hat (`cad_files.original_name ILIKE`)
- Prüft ob `cad_part_materials` alle Materialien in `materials`-Tabelle (via Alias-Lookup) auflösbar sind
- Erstellt `summary: {total_rows, rows_with_cad, rows_without_cad, material_gaps: [{product, missing_material}]}`
- Status → 'completed'
- Celery-Task `validate_excel_import_task(import_validation_id)` Queue `step_processing`
- Endpoint `GET /api/imports/{id}/validation` — gibt ImportValidation zurück
- Endpoint `POST /api/imports/{id}/add-alias` — schnell einen Alias hinzufügen (part_name → material)
- ImportValidation DB-Zugriif: sync SQLAlchemy (Celery-kompatibel)
- **Akzeptanzkriterium**: Nach Excel-Upload wird Import-Validierung automatisch gequeuet; `summary` liefert Material-Lücken
### Task 10: Upload.tsx — Sanity-Check-Dialog (Phase H)
- **Datei**: `frontend/src/pages/Upload.tsx`
- **Was**: Nach erfolgreichem Excel-Upload:
- `GET /api/imports/{id}/validation` pollen (alle 3s, max 30s)
- Wenn status='completed': Ampel-Dialog anzeigen:
- Grün-Badge: "X Produkte mit STEP-Datei"
- Gelb-Badge: "Y Produkte ohne STEP-Datei"
- Rote Liste: Material-Lücken (Part-Name → fehlendes Material, mit "Add Alias" Button)
- "Proceed" Button schließt Dialog
- Import API erweitern: `api/imports.ts` mit `getImportValidation(id)`, `addMaterialAlias()`
- **Akzeptanzkriterium**: Nach Upload erscheint Dialog mit Produktions-Readiness
### Task 11: Mesh-Attribute Anzeige in ProductDetail (Phase D)
- **Datei**: `frontend/src/pages/ProductDetail.tsx`
- **Was**: Im CAD-File-Bereich, nach dem Status-Badge:
- Wenn `product.cad_file.mesh_attributes` vorhanden: kleine Info-Karte
- Felder: `volume_cm3` (aus `mesh_attributes.volume_mm3 / 1000` → "12.5 cm³"),
`surface_area_cm2`, `bounding_box` ("W×H×D mm"), `sharp_angle_deg` (aus `suggested_smooth_angle`)
- Label "Geometry" mit `Ruler`-Icon
- **API-Änderung**: Product-API gibt `cad_file.mesh_attributes` zurück (prüfen ob vorhanden)
- **Akzeptanzkriterium**: Volumen, Oberfläche, BBox in ProductDetail sichtbar (wenn vorhanden)
### Task 12: OCC Edge-Analyse → mesh_attributes (Sharp/Seam)
- **Datei**: `backend/app/services/step_processor.py`
- **Was**: Neue Funktion `extract_mesh_edge_data(step_path: str) -> dict`:
- Öffnet STEP via OCC
- Iteriert über alle Faces und deren Edges
- Berechnet Winkel zwischen adjazenten Faces per Edge (Dihedralwinkel)
- Sammelt:
- `suggested_smooth_angle`: Median-Winkel aller Kanten wo Winkel > 5° (typisch 3060°)
- `has_mechanical_edges`: bool (True wenn mehrere Kanten mit Winkel > 60° → Lagerkante)
- `sharp_edge_midpoints`: Liste von `[x,y,z]` mm-Koordinaten der scharfen Kanten-Mittelpunkte (max 500 Stück, für Winkel > 45°)
- Integriert in `extract_cad_metadata()`: nach `_extract_step_objects()` aufrufen, Ergebnis in `mesh_attributes` mergen
- Fallback: bei OCC-Fehler gracefully `{}` zurückgeben
- **Akzeptanzkriterium**: `cad_files.mesh_attributes` enthält `suggested_smooth_angle` nach Verarbeitung
### Task 13: Blender-Scripts — mark_sharp + UV-Seams
- **Dateien**: `render-worker/scripts/still_render.py`, `render-worker/scripts/blender_render.py`
- **Was**: Nach STL-Import, vor dem Render:
1. Wenn `mesh_attributes.suggested_smooth_angle` vorhanden: diesen Winkel statt globalem `smooth_angle` nutzen
2. Neue Funktion `_mark_sharp_edges(obj, smooth_angle_deg, sharp_edge_midpoints=None)`:
- Setzt `obj.data.auto_smooth_angle = math.radians(smooth_angle_deg)`
- Wählt Kanten aus: `bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(smooth_angle_deg))`
- Ruft `bpy.ops.mesh.mark_sharp()` auf
- Wenn `sharp_edge_midpoints` vorhanden: KD-Tree matching → zusätzliche Kanten markieren
3. Neue Funktion `_create_uv_seams_from_sharps(obj)`:
- Startet Edit-Mode
- Selektiert alle Sharp-Kanten: `[e for e in mesh.edges if e.use_edge_sharp]`
- Markiert diese als Seams: `edge.use_seam = True`
- Ruft `bpy.ops.uv.smart_project(angle_limit=math.radians(smooth_angle_deg))` auf
4. Beide Funktionen nach `_import_stl()` aufrufen (Mode A + Mode B)
- **Akzeptanzkriterium**: Gerenderte Bilder zeigen korrekte Kanten für Lager (30° Winkel scharf sichtbar)
### Task 14: K3 — apply_asset_library_materials_task
- **Datei**: `backend/app/domains/rendering/tasks.py`
- **Was**: Neuer Celery-Task:
### Task 4: Backend — Retroactive MediaAsset Import Endpoint
- **Datei**: `backend/app/api/routers/admin.py`
- **Was**: Neuer Endpoint `POST /api/admin/import-media-assets` (require_admin):
```python
@celery_app.task(name="...apply_asset_library_materials_task", queue="thumbnail_rendering")
def apply_asset_library_materials_task(order_line_id: str, asset_library_id: str) -> dict:
# Lädt OrderLine, CadFile, AssetLibrary
# Prüft ob asset_library.blend_file_path existiert
# Ruft Blender subprocess auf mit asset_library.py:
# blender --background --python asset_library.py -- --stl_path X --asset_library_blend Y --material_map '{...}'
# Returns {'status': 'applied', 'materials_count': N}
```
Skript `render-worker/scripts/asset_library.py` existiert bereits.
- **Akzeptanzkriterium**: Task läuft ohne Fehler wenn Blender verfügbar
# 1. CadFiles mit thumbnail_path + status='completed'
SELECT id, thumbnail_path FROM cad_files
WHERE thumbnail_path IS NOT NULL AND status = 'completed'
### Task 15: K4/K5 — export_gltf + export_blend via Blender
- **Datei**: `backend/app/domains/rendering/tasks.py`
- **Was**: `export_gltf_for_order_line_task` und `export_blend_for_order_line_task` überarbeiten:
- Statt trimesh: Blender subprocess mit `export_gltf.py` / `export_blend.py`
- Asset Library path aus LinkedAssetLibrary (via OutputType) übergeben falls vorhanden
- GLB → MinIO `production-exports/{cad_file_id}/{order_line_id}.glb`
- .blend → MinIO `production-exports/{cad_file_id}/{order_line_id}.blend`
- MediaAsset erstellen mit `gltf_production` / `blend_production` type
- **Akzeptanzkriterium**: Export-Tasks produzieren GLB/BLEND-Dateien in MinIO
# 2. OrderLines mit result_path + render_status='completed' + output_type
SELECT ol.id, ol.result_path, ol.product_id, ol.output_type_id, ot.is_animation
FROM order_lines ol LEFT JOIN output_types ot ON ot.id = ol.output_type_id
WHERE ol.result_path IS NOT NULL AND ol.render_status = 'completed'
```
- De-dup: `SELECT id FROM media_assets WHERE storage_key = ?` vor jedem Insert
- CadFile → `MediaAsset(asset_type='thumbnail', cad_file_id=..., storage_key=thumbnail_path, mime_type='image/jpeg')`
- OrderLine → `MediaAsset(asset_type='turntable' if is_animation else 'still', order_line_id=..., storage_key=result_path)`
- Returns: `{"created": N, "skipped": N}`
- **Akzeptanzkriterium**: Nach Aufruf erscheinen alle bestehenden Thumbnails + Renders im Media Browser
### Task 5: Frontend — Admin "Import Existing Media" Button
- **Datei**: `frontend/src/pages/Admin.tsx`
- **Was**: Im Admin-Panel (Media/Settings-Bereich) neuer Button "Import Existing Media" → `POST /api/admin/import-media-assets` → Toast mit `{created, skipped}` Ergebnis
- **Abhängigkeiten**: Task 4
- **Akzeptanzkriterium**: Button klickbar, zeigt Ergebnis
---
## Abhängigkeiten
```
Sofort (parallel):
Task 1 (Upload Link)
Task 2 (Notification Config Backend)
Task 3 (OutputType Schema)
Task 5 (Asset Library API)
Task 9 (Sanity Check Backend)
Task 12 (OCC Edge Analyse)
Nach Task 3:
Task 4 (Dispatch Integration)
Task 8 (OutputType Workflow Dropdown)
Nach Task 5+6:
Task 6 (Asset Library Page) — braucht Task 5
Task 7 (Route + Sidebar) — braucht Task 6
Nach Task 9:
Task 10 (Upload Sanity Dialog)
Nach Task 11:
Task 11 (Mesh Display) — unabhängig
Nach Task 12:
Task 13 (Blender Scripts)
Nach Task 14:
Task 15 (K4/K5 Exports)
```
## Migrations-Check
Alle benötigten Migrationen existieren bereits:
- 043: import_validations ✅
- 044: notification_configs ✅
- 045: asset_libraries ✅
Keine neue Migration nötig.
Keine neue Migration nötig — alle Felder bereits vorhanden.
---
## Reihenfolge-Empfehlung
Task 1 (Layout) + Task 2 (Backend) parallel →
Task 3 (Frontend MediaBrowser, braucht Task 2) + Task 4 (Backend Admin) parallel →
Task 5 (Frontend Admin Button, braucht Task 4)
Tasks 1 + 2 + 4 können vollständig parallel implementiert werden.
Task 3 + 5 können dann parallel implementiert werden.
---
## Risiken / Offene Fragen
- `thumbnail_url` für GLBs zeigt immer das CadFile-Thumbnail — das ist korrekt (kein spezifisches Render vorhanden)
- `result_path` bei OrderLines kann Pfad zu PNG oder MP4 sein — kein Media-Type prüfen, einfach MIME aus Extension ableiten
- Bestehende `thumbnail_path` Werte sind absolute Paths (`/app/uploads/...`) — gleicher Proxy-Mechanismus wie bei GLBs nötig (der download endpoint kann damit umgehen)
- Video-Preview (turntable): `<video>` Tag mit `thumbnail_url` als Poster + `download_url` als src — falls download_url MP4 ist
+3
View File
@@ -61,6 +61,9 @@ RUN pip3 install --no-cache-dir -e .
# Install cadquery (heavy — installed after backend deps for better layer caching)
RUN pip3 install --no-cache-dir "cadquery>=2.4.0"
# Install trimesh for STL→GLB geometry export (separate layer to avoid cache invalidation)
RUN pip3 install --no-cache-dir "trimesh>=4.2.0"
# Copy render scripts
COPY render-worker/scripts/ /render-scripts/