Compare commits

...

4 Commits

Author SHA1 Message Date
Agent Zero 9a269aa54f feat(T04): Rentman integration – equipment sync, API endpoints, frontend updates
- Update equipment model with Rentman-compatible fields
- Add/Update equipment router endpoints for sync operations
- Enhance rentman_service with full API client logic
- Improve sync_service for bidirectional equipment sync
- Update docker-compose with Rentman env vars
- Update frontend useApi composable and nuxt config
- Update mietkatalog pages with Rentman-integrated data display
- Add images/ to .gitignore (binary assets, 36MB)
2026-07-11 18:17:55 +02:00
Agent Zero 30db3491c0 fix: use model_dump() for Redis cache serialization - prevents 500 on cached equipment API calls 2026-07-11 18:17:55 +02:00
Implementation Engineer 8dae2c0301 docs: add design-rules reference to AGENTS.md 2026-07-10 23:08:59 +02:00
Implementation Engineer 16fde9fb7d docs: add design-rules.md – binding design guidelines for AI agents 2026-07-10 23:08:41 +02:00
12 changed files with 802 additions and 153 deletions
+1
View File
@@ -9,3 +9,4 @@ htmlcov/
node_modules/
.nuxt/
dist/
images/
+3
View File
@@ -3,6 +3,9 @@
> **Projekt:** hms-licht-ton (ID 30)
> **Stack:** Nuxt 3 (Frontend) + FastAPI (Backend) + PostgreSQL + Redis
> **Deployment:** Coolify (Docker) auf coolify-01
>
> **DESIGN-RICHTLINIEN:** `docs/design-rules.md` VERBINDLICH für alle Erweiterungen!
> Jeder KI-Agent MUSS diese Regeln vor der Implementierung lesen und einhalten.
---
+8
View File
@@ -15,12 +15,20 @@ class EquipmentCache(Base):
description = Column(Text)
specifications = Column(JSON)
images = Column(JSON)
update_hash = Column(String(128))
rental_price = Column(DECIMAL(10, 2))
brand = Column(String(128))
available = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
@property
def image_url(self) -> str | None:
"""Return local image URL if images exist, else None."""
if self.images and len(self.images) > 0:
return f"/api/equipment/{self.id}/image"
return None
__table_args__ = (
Index("idx_equipment_category", "category"),
Index("idx_equipment_name", "name"),
+17 -4
View File
@@ -1,5 +1,7 @@
"""Equipment API router: list, detail, categories."""
"""Equipment API router: list, detail, categories, image."""
import os
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any
@@ -10,6 +12,8 @@ from app.cache import cache
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
IMAGES_DIR = "/data/images/equipment"
@router.get("", response_model=PaginatedResponse)
async def list_equipment(
@@ -49,7 +53,7 @@ async def list_equipment(
items = result.scalars().all()
response = {
"items": [EquipmentItem.model_validate(item) for item in items],
"items": [EquipmentItem.model_validate(item).model_dump() for item in items],
"total": total,
"page": page,
"page_size": page_size,
@@ -73,6 +77,15 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
return categories
@router.get("/{equipment_id}/image")
async def get_equipment_image(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
"""Serve the locally stored image for an equipment item."""
image_path = os.path.join(IMAGES_DIR, f"{equipment_id}.jpg")
if os.path.exists(image_path):
return FileResponse(image_path, media_type="image/jpeg")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
@router.get("/{equipment_id}", response_model=EquipmentDetail)
async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
"""Return a single equipment detail by ID."""
@@ -85,6 +98,6 @@ async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
response = EquipmentDetail.model_validate(item)
await cache.set(cache_key, response.model_dump(), ttl=3600)
response = EquipmentDetail.model_validate(item).model_dump()
await cache.set(cache_key, response, ttl=3600)
return response
+59 -29
View File
@@ -50,6 +50,65 @@ class RentmanService:
offset += limit
return all_items
async def get_file_url(self, file_id: str | int) -> str | None:
"""Fetch the S3 URL for a file from Rentman.
GET /files/{file_id} → response contains 'url' field with S3 link.
"""
url = f"{self._base_url}/files/{file_id}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(url, headers=self._headers())
resp.raise_for_status()
data = resp.json()
file_data = data.get("data", data)
file_url = file_data.get("url")
if file_url:
return file_url
logger.warning("No url field in file response for file_id=%s", file_id)
return None
except Exception as exc:
logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc)
return None
async def transform_equipment(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Map a raw Rentman equipment object to equipment_cache schema.
Uses 'image' (singular) field which contains a relative path like '/files/3173'.
Fetches the actual S3 URL via GET /files/{file_id}.
"""
image_path = raw.get("image")
image_urls: list[str] = []
if image_path and isinstance(image_path, str):
# Extract file_id from path like /files/3173
match = re.search(r"/files/(\d+)", image_path)
if match:
file_id = match.group(1)
s3_url = await self.get_file_url(file_id)
if s3_url:
image_urls = [s3_url]
else:
logger.debug("Could not extract file_id from image path: %s", image_path)
group = raw.get("equipment_group") or {}
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
return {
"rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""),
"number": raw.get("number") or raw.get("code", ""),
"category": category,
"subcategory": raw.get("subcategory", ""),
"description": raw.get("description", ""),
"specifications": raw.get("specifications", {}),
"images": image_urls,
"rental_price": raw.get("rental_price"),
"brand": raw.get("brand", ""),
"available": raw.get("available", True),
"update_hash": raw.get("updateHash", ""),
}
async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]:
"""POST /projectrequests to create a new project request in Rentman."""
url = f"{self._base_url}/projectrequests"
@@ -68,35 +127,6 @@ class RentmanService:
resp.raise_for_status()
return resp.json()
@staticmethod
def transform_equipment(raw: dict[str, Any]) -> dict[str, Any]:
"""Map a raw Rentman equipment object to equipment_cache schema."""
images = raw.get("images") or raw.get("files") or []
if isinstance(images, list):
image_urls = [
img.get("url", img.get("filename", "")) if isinstance(img, dict) else str(img)
for img in images
]
else:
image_urls = []
group = raw.get("equipment_group") or {}
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
return {
"rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""),
"number": raw.get("number") or raw.get("code", ""),
"category": category,
"subcategory": raw.get("subcategory", ""),
"description": raw.get("description", ""),
"specifications": raw.get("specifications", {}),
"images": image_urls,
"rental_price": raw.get("rental_price"),
"brand": raw.get("brand", ""),
"available": raw.get("available", True),
}
@staticmethod
def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Map frontend rental request data to Rentman POST /projectrequests payload."""
+90 -9
View File
@@ -1,6 +1,8 @@
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
import logging
from datetime import datetime, timezone
import os
import httpx
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +13,8 @@ from app.cache import cache
logger = logging.getLogger(__name__)
IMAGES_DIR = "/data/images/equipment"
class SyncService:
"""Orchestrates equipment import from Rentman into the local database."""
@@ -20,19 +24,21 @@ class SyncService:
self.rentman = rentman or RentmanService()
async def run_sync(self) -> dict[str, Any]:
"""Execute a full equipment sync.
"""Execute an incremental equipment sync.
1. Create sync_log entry (status=running)
2. Paginate GET /equipment from Rentman
3. Upsert each item into equipment_cache
4. Invalidate Redis cache (equipment:*)
5. Update sync_log (status=completed or failed)
3. Compare updateHash with DB, only process changed items
4. Download images only for changed items
5. Mark missing items as unavailable
6. Invalidate Redis cache (equipment:*)
7. Update sync_log (status=completed or failed)
Returns dict with sync_id, items_processed, status.
"""
log_entry = SyncLog(
sync_type="equipment",
status="running",
started_at=datetime.now(timezone.utc),
started_at=datetime.utcnow(),
)
self.db.add(log_entry)
await self.db.commit()
@@ -44,16 +50,53 @@ class SyncService:
error_message: str | None = None
try:
# Ensure images directory exists
os.makedirs(IMAGES_DIR, exist_ok=True)
# Fetch all equipment from Rentman
all_equipment = await self.rentman.get_all_equipment(limit=100)
# Build set of rentman_ids from API for availability check
api_rentman_ids = set()
# Load all existing equipment from DB for hash comparison
result = await self.db.execute(select(EquipmentCache))
existing_items = {item.rentman_id: item for item in result.scalars().all()}
for raw_item in all_equipment:
rentman_id = str(raw_item.get("id", ""))
api_rentman_ids.add(rentman_id)
new_hash = raw_item.get("updateHash", "")
existing = existing_items.get(rentman_id)
# Skip if hash unchanged and item exists
if existing and existing.update_hash == new_hash and new_hash:
continue
try:
transformed = RentmanService.transform_equipment(raw_item)
transformed = await self.rentman.transform_equipment(raw_item)
await self._upsert_equipment(transformed)
# Download image if S3 URL available
if transformed.get("images"):
await self._download_image(
transformed["rentman_id"],
transformed["images"][0],
existing.id if existing else None,
)
items_processed += 1
except Exception as exc:
logger.warning("Failed to upsert equipment item: %s", exc)
logger.warning("Failed to upsert equipment item %s: %s", rentman_id, exc)
items_failed += 1
# Mark missing items as unavailable
for rentman_id, existing in existing_items.items():
if rentman_id not in api_rentman_ids and existing.available:
existing.available = False
logger.info("Marked equipment %s as unavailable (not in API)", rentman_id)
await self.db.commit()
# Invalidate Redis cache
@@ -70,7 +113,7 @@ class SyncService:
log_entry.items_processed = items_processed
log_entry.items_failed = items_failed
log_entry.error_message = error_message
log_entry.completed_at = datetime.now(timezone.utc)
log_entry.completed_at = datetime.utcnow()
await self.db.commit()
return {
@@ -95,6 +138,7 @@ class SyncService:
existing.description = data.get("description", "")
existing.specifications = data.get("specifications")
existing.images = data.get("images")
existing.update_hash = data.get("update_hash")
existing.rental_price = data.get("rental_price")
existing.brand = data.get("brand", "")
existing.available = data.get("available", True)
@@ -108,12 +152,49 @@ class SyncService:
description=data.get("description", ""),
specifications=data.get("specifications"),
images=data.get("images"),
update_hash=data.get("update_hash"),
rental_price=data.get("rental_price"),
brand=data.get("brand", ""),
available=data.get("available", True),
)
self.db.add(new_item)
async def _download_image(self, rentman_id: str, s3_url: str, db_id: int | None) -> None:
"""Download image from S3 URL and save locally.
File path: /data/images/equipment/{db_id or rentman_id}.jpg
"""
# Determine the file identifier - prefer DB id if available
# We need to flush to get the ID for new items
if db_id is None:
await self.db.flush()
result = await self.db.execute(
select(EquipmentCache).where(EquipmentCache.rentman_id == rentman_id)
)
item = result.scalar_one_or_none()
if item:
db_id = item.id
if db_id is None:
logger.warning("Could not determine DB id for image download: %s", rentman_id)
return
image_path = os.path.join(IMAGES_DIR, f"{db_id}.jpg")
# Skip if already exists
if os.path.exists(image_path):
return
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.get(s3_url)
resp.raise_for_status()
with open(image_path, "wb") as f:
f.write(resp.content)
logger.info("Downloaded image for equipment %s -> %s", db_id, image_path)
except Exception as exc:
logger.warning("Failed to download image for %s: %s", rentman_id, exc)
async def get_last_sync(self) -> dict[str, Any]:
"""Return the most recent sync_log entry summary."""
result = await self.db.execute(
+19 -3
View File
@@ -7,7 +7,20 @@ services:
backend:
condition: service_healthy
environment:
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE:-http://backend:8000}
- NUXT_PUBLIC_API_BASE=/api
labels:
- "traefik.enable=true"
- "traefik.http.middlewares.hms-gzip.compress=true"
- "traefik.http.middlewares.hms-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.hms-http.entryPoints=http"
- "traefik.http.routers.hms-http.middlewares=hms-redirect"
- "traefik.http.routers.hms-http.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
- "traefik.http.routers.hms-https.entryPoints=https"
- "traefik.http.routers.hms-https.middlewares=hms-gzip"
- "traefik.http.routers.hms-https.rule=Host(`hms.media-on.de`) && PathPrefix(`/`)"
- "traefik.http.routers.hms-https.tls=true"
- "traefik.http.routers.hms-https.tls.certresolver=letsencrypt"
- "traefik.http.services.hms.loadbalancer.server.port=3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
@@ -17,11 +30,10 @@ services:
start_period: 15s
networks:
- hms-network
- coolify
backend:
build: ./backend
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
@@ -40,6 +52,8 @@ services:
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
volumes:
- ./images:/data/images
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
@@ -85,6 +99,8 @@ services:
networks:
hms-network:
driver: bridge
coolify:
external: true
volumes:
postgres_data:
+508
View File
@@ -0,0 +1,508 @@
# HMS Licht & Ton Design-Richtlinien für KI-Agenten
> **VERBINDLICH:** Diese Regeln MÜSSEN bei jeder Erweiterung der Website eingehalten werden.
> Abweichungen sind Fehler. Der freigegebene Prototyp ist die Quelle der Wahrheit.
> Prototyp: `https://webspace.media-on.de/hms-prototype/index.html?v=8`
> Referenz-Dateien: `docs/prototype-app.js`, `docs/prototype-index.html`, `frontend/assets/css/prototype-style.css`
---
## 1. Farbsystem (Dark Theme + Orange Akzent)
### Hauptfarben (Agent Zero Dark Theme Grautöne)
| Token | Hex | Verwendung |
|-------|-----|------------|
| `--bg` | `#131313` | Seiten-Hintergrund |
| `--panel` | `#1a1a1a` | Panels, Cards, Header |
| `--surface` | `#212121` | Input-Felder, Surface-Level |
| `--row` | `#272727` | List-Items, Zwischenebenen |
| `--card` | `#2d2d2d` | Card-Hintergrund |
| `--border` | `#444444a8` | Borders (mit Alpha) |
| `--border-strong` | `#555555` | Strong Borders (Inputs) |
| `--secondary` | `#656565` | Sekundärfarbe, Icons, grauer Text |
| `--primary` | `#737a81` | Primärfarbe (leicht bläuliches Grau) |
| `--text` | `#ffffff` | Haupttext |
| `--text-muted` | `#d4d4d4` | Gedimmter Text |
### Orange Akzent (DEZENT keine großen Flächen!)
| Token | Wert | Verwendung |
|-------|------|------------|
| `--color-accent` | `#EC6925` | Buttons, Badges, Text-Akzente, Logo-Border |
| `--color-accent-hover` | `#d4581a` | Button Hover |
| `--color-accent-light` | `rgba(236, 105, 37, 0.08)` | Badge-Hintergrund, Nav-Active |
| `--color-accent-border` | `rgba(236, 105, 37, 0.25)` | Card Hover Border |
| `--color-accent-dark` | `#b8461a` | Dunkle Variante |
### Status-Farben
| Token | Hex/RGBA | Verwendung |
|-------|----------|------------|
| `--color-success` | `#4ade80` | Erfolg-Meldungen |
| `--color-success-bg` | `rgba(74, 222, 128, 0.08)` | Erfolg-Hintergrund |
| `--color-error` | `#f87171` | Fehler, Pflichtfelder |
| `--color-error-bg` | `rgba(248, 113, 113, 0.08)` | Fehler-Hintergrund |
| `--color-warning` | `#fbbf24` | Warnungen |
| `--color-info` | `#60a5fa` | Info |
### FARB-REGELN
- **Orange NIEMALS als große Fläche** nur für Buttons, Badges, kleine Text-Highlights, Border
- **Hintergründe IMMER Grautöne** (#131313 bis #2d2d2d)
- **Text IMMER `var(--text)` (#ffffff) oder `var(--text-muted)` (#d4d4d4)**
- **Sekundärer Text `var(--secondary)` (#656565)** für sehr gedimmte Infos
- **Inline Styles mit CSS Variablen:** `style="color: var(--text)"`, `style="background: var(--panel)"`
---
## 2. Typography
- **Font Family:** Inter (Google Fonts), Weights: 400, 500, 600, 700, 800
- **Größen:** Tailwind Utilities (`text-xs`, `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`, `text-4xl`, `text-5xl`, `text-6xl`)
- **Headings:** `font-bold` oder `font-semibold`, `leading-tight` für große Headlines
- **Body:** `leading-relaxed` für längere Texte
- **Line Height:** 1.6 (Body), 1.1-1.3 (Headings)
---
## 3. Layout-System
### Container
- **Max-Width:** `max-w-7xl` (1280px)
- **Padding:** `px-4 sm:px-6 lg:px-8`
- **Center:** `mx-auto`
### Sections
- **Vertical Padding:** `py-12 sm:py-16` (Standard), `py-16 sm:py-20` (große Sections), `py-20 sm:py-32` (Hero)
- **Background Alternation:** `var(--bg)``var(--panel)``var(--bg)` für visuelle Trennung
- **Section Dividers:** `.hms-section-divider` (subtiler Gradient)
### Grid
- **Cards Grid:** `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6`
- **Speaker Grid:** `.hms-speaker-grid` (auto-fill, minmax 160px)
- **Gallery Grid:** `.hms-gallery` (auto-fill, minmax 300px)
- **2-Spalten Layout:** `grid lg:grid-cols-2 gap-8` oder `gap-12`
### Responsive Breakpoints
- `sm:` 640px (Tablet Portrait)
- `md:` 768px (Desktop Nav visible, Mobile Burger hidden)
- `lg:` 1024px (Desktop, 3-4 Column Grids)
- **Mobile-First:** Alle Layouts starten 1-Spalte
---
## 4. CSS Komponenten-Klassen (hms-*)
### ÜBERSICHT: Alle hms-* Klassen aus prototype-style.css
Diese Klassen sind in `assets/css/main.css` definiert und MÜSSEN verwendet werden.
Keine eigenen Klassen erfinden!
### Buttons
```html
<!-- Primary: Orange bg, white text -->
<button class="hms-btn hms-btn-primary">Button Text</button>
<!-- Secondary: Surface bg, muted text, border -->
<button class="hms-btn hms-btn-secondary">Button Text</button>
<!-- Ghost: Transparent, muted text -->
<button class="hms-btn hms-btn-ghost">Button Text</button>
```
- **Padding:** `px-8 py-4` für große Buttons, `px-4 py-2` für kleine, `px-3 py-1.5` für Mini
- **Font:** `font-semibold` oder `font-medium`, `text-sm` für Standard
- **Hover-Effekte:** Primary → box-shadow glow + translateY(-1px); Secondary → border accent
- **Disabled:** `--secondary` bg, `cursor: not-allowed`
### Cards
```html
<div class="hms-card p-6">
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Title</h3>
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">Description</p>
</div>
```
- **Background:** `var(--panel)` (#1a1a1a)
- **Border:** `1px solid var(--border)` (#444444a8)
- **Border-Radius:** `4px` (--radius-lg)
- **Hover:** Box-shadow wird stärker, Border wird heller
### Equipment Cards
```html
<div class="hms-eq-card" @click="..." role="article" tabindex="0">
<div class="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style="background: var(--surface)">
<img v-if="item.image" :src="item.image" class="absolute inset-0 w-full h-full object-cover" />
<div v-else class="text-4xl" style="color: var(--secondary)">📦</div>
<span v-if="item.category" class="hms-badge hms-badge-primary absolute top-3 left-3">{{ item.category }}</span>
</div>
<div class="p-4">
<h3 class="font-semibold text-sm mb-1 truncate" style="color: var(--text)">{{ item.name }}</h3>
<p class="text-xs mb-3 line-clamp-2" style="color: var(--secondary)">{{ item.description }}</p>
<div class="flex items-center justify-between">
<span class="text-xs" style="color: var(--secondary)">{{ item.code }}</span>
<button class="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style="color: var(--color-accent)">+ Hinzufügen</button>
</div>
</div>
</div>
```
### Badges
```html
<span class="hms-badge hms-badge-primary">Badge Text</span>
<span class="hms-badge hms-badge-gray">Gray Badge</span>
<span class="hms-badge hms-badge-success">Success</span>
<span class="hms-badge hms-badge-error">Error</span>
```
- **Border-Radius:** `9999px` (pill shape)
- **Primary:** Orange bg (12% alpha), orange text, orange border (20% alpha)
### Inputs
```html
<input class="hms-input" type="text" placeholder="..." />
<input class="hms-input hms-input-error" /> <!-- mit Fehler -->
<select class="hms-input"><option>...</option></select>
<textarea class="hms-input" rows="5"></textarea>
```
- **Background:** `var(--surface)` (#212121)
- **Border:** `1px solid var(--border-strong)` (#555555)
- **Focus:** Orange border + subtle orange box-shadow
- **Error:** Red border + red box-shadow
- **Padding:** `var(--space-md) var(--space-lg)`
### Filter Chips
```html
<button class="hms-chip" :class="{ active: isActive }">Filter</button>
```
- **Inactive:** Surface bg, muted text, border
- **Active:** Orange bg, white text, orange border
### Navigation Buttons
```html
<button class="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
:style="isActive ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }"
:aria-current="isActive ? 'page' : undefined">
Nav Item
</button>
```
- **Hover:** Orange text + accent-light bg + Unterstrich-Animation
- **Active:** Orange text + accent-light bg
### Skeleton Loading
```html
<div class="hms-skeleton h-4 w-3/4"></div>
```
- Shimmer Animation von `prototype-style.css`
### Spinner
```html
<span class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span>
```
### Icon Circle (Service Icons)
```html
<div class="hms-icon-circle mb-4"><span aria-hidden="true">🔊</span></div>
```
- 48x48px, surface bg, border, centered content
### Hero
```html
<section class="hms-hero py-20 sm:py-32">
<div class="hms-hero-bg" style="background-image: url('...')"></div>
<div class="hms-hero-overlay"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<!-- Content -->
</div>
</section>
```
- **hms-hero-bg:** Absolute, background-size cover, opacity 0.35
- **hms-hero-overlay:** Linear gradient von rgba(19,19,19,0.6) zu var(--bg)
- **Content:** `relative z-10` über Overlay
---
## 5. Inline Style Pattern
### REGEL: Inline Styles verwenden IMMER CSS Variablen
```html
<!-- RICHTIG -->
<h1 style="color: var(--text)">Title</h1>
<p style="color: var(--text-muted)">Text</p>
<div :style="{ background: 'var(--panel)', borderColor: 'var(--border)' }">
<!-- FALSCH -->
<h1 style="color: #ffffff">Title</h1>
<p style="color: #d4d4d4">Text</p>
```
### Häufige Inline Styles aus dem Prototyp
- `style="color: var(--text)"` Haupttext
- `style="color: var(--text-muted)"` Gedimmter Text
- `style="color: var(--secondary)"` Sehr gedimmter Text
- `style="color: var(--color-accent)"` Orange Akzent
- `style="background: var(--panel)"` Panel BG
- `style="background: var(--bg)"` Seiten BG
- `style="background: var(--surface)"` Surface BG
- `:style="{ borderColor: 'var(--border)' }"` Border Color
- `:style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"` Success State
---
## 6. Komponenten-Struktur
### Bestehende Komponenten (NICHT neu erfinden)
| Komponente | Datei | Zweck |
|---|---|---|
| HmsLogo | `components/HmsLogo.vue` | Echtes SVG Logo (H + orange Border) |
| SpeakerIcon | `components/SpeakerIcon.vue` | 6 Lautsprecher-Typ SVG Icons |
| AppHeader | `components/AppHeader.vue` | Sticky Header, Nav, Cart, Mobile Burger |
| AppFooter | `components/AppFooter.vue` | 4-Spalten Footer |
| ServiceCard | `components/ServiceCard.vue` | Service Card mit Icon Circle |
| EquipmentCard | `components/EquipmentCard.vue` | Equipment Card mit Badge, Image, Add-to-Cart |
| LoadingSkeleton | `components/LoadingSkeleton.vue` | Skeleton Loading Cards |
| EmptyState | `components/EmptyState.vue` | Empty State mit Icon + Action |
| ErrorState | `components/ErrorState.vue` | Error State mit Retry |
### Neue Komponenten erstellen Checklist
1. CSS Klassen aus `prototype-style.css` verwenden (hms-*)
2. Inline Styles mit CSS Variablen
3. Tailwind Utilities für Layout/Spacing
4. TypeScript mit `lang="ts"`
5. `useHead()` für Meta Tags
6. ARIA Attributes (role, aria-label, aria-current, etc.)
7. Responsive (mobile-first, sm/md/lg breakpoints)
---
## 7. UX States (MUSS in jeder View implementiert)
### Loading State
```html
<loading-skeleton :count="6" />
```
### Empty State
```html
<empty-state icon="🔍" title="Keine Ergebnisse" message="..." action-label="Zurück" @action="..." />
```
### Error State
```html
<error-state title="Fehler" message="..." @retry="..." />
```
### Success State
```html
<div class="text-center py-8" role="status">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4"
:style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }">
<svg class="w-8 h-8" ...></svg>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Erfolg</h3>
<p class="text-sm mb-6" style="color: var(--secondary)">Nachricht</p>
</div>
```
### Submitting State
```html
<button :disabled="submitting" class="hms-btn hms-btn-primary">
<span v-if="submitting" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span>
<span v-else>Senden</span>
</button>
```
---
## 8. Accessibility (WCAG 2.1 AA)
### MUSS-Regeln
- **Skip Link:** `<a class="skip-link" href="#main">Zum Inhalt springen</a>` am Seitenanfang
- **Semantic HTML:** `<header>`, `<main>`, `<footer>`, `<nav>`, `<section>`, `<article>`
- **ARIA:** `role`, `aria-label`, `aria-current="page"`, `aria-expanded`, `aria-invalid`, `aria-describedby`, `role="alert"`, `role="status"`
- **Focus Visible:** `*:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }`
- **Form Labels:** Jedes Input hat `<label for="...">`, Pflichtfelder mit `<span style="color: var(--color-error)">*</span>`
- **Heading Hierarchy:** h1 > h2 > h3, keine Levels überspringen
- **Keyboard Navigation:** Alle interaktiven Elemente per Tab erreichbar, `tabindex="0"` auf klickbare Cards
- **Alt Text:** Alle Bilder haben `alt="..."`
- `aria-hidden="true"` auf dekorativen Icons
- `loading="lazy"` auf alle Bilder
---
## 9. Meta Tags & SEO
### MUSS pro Seite
```typescript
useHead({
title: 'Seite HMS Licht & Ton',
meta: [
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet' },
],
});
```
### JSON-LD (nur Home)
- `LocalBusiness` Schema mit Adresse, Öffnungszeiten, Services
### OpenGraph
- `og:type`, `og:title`, `og:description`, `og:locale`, `og:site_name`, `og:url`
### REGELN
- **noindex** auf ALLEN Seiten (Domain nicht öffentlich indexiert)
- **KEINE sitemap.xml**
- Inter Font via Google Fonts preconnect
- `theme-color: #EC6925` (nur im Head, nicht als Design-Fläche!)
---
## 10. Bilder
### Hero Background
- Unsplash Konzert-Bild: `background-image: url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')`
- **Opacity:** 0.35 (über `.hms-hero-bg`)
- **Overlay:** Linear gradient zu `var(--bg)`
### Referenzen
- 9 echte HMS-Fotos: `img/ref1.jpg` bis `img/ref9.jpg`
- **Hover:** `group-hover:scale-110` (Zoom-Effekt)
- **Gradient Overlay:** `bg-gradient-to-t from-black/50 to-transparent` bei Hover
### Equipment
- Platzhalter `📦` Emoji wenn kein Bild vorhanden
- `loading="lazy"` auf allen Bildern
### About Section
- Unsplash Setup-Bild: `https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80`
---
## 11. Spacing & Radius
### Spacing Tokens
| Token | Wert |
|-------|-------|
| `--space-xs` | 0.25rem (4px) |
| `--space-sm` | 0.5rem (8px) |
| `--space-md` | 1rem (16px) |
| `--space-lg` | 1.5rem (24px) |
| `--space-xl` | 2rem (32px) |
| `--space-2xl` | 3rem (48px) |
| `--space-3xl` | 4rem (64px) |
| `--space-4xl` | 6rem (96px) |
### Border Radius (MINIMAL!)
| Token | Wert |
|-------|-------|
| `--radius-sm` | 2px |
| `--radius-md` | 3px |
| `--radius-lg` | 4px |
| `--radius-xl` | 4px |
| `--radius-full` | 9999px (nur Badges, Pills) |
### Shadows
| Token | Wert |
|-------|-------|
| `--shadow-sm` | `0 1px 2px 0 rgb(0 0 0 / 0.3)` |
| `--shadow-md` | `0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)` |
| `--shadow-lg` | `0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)` |
| `--shadow-xl` | `0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3)` |
---
## 12. Transitions
| Token | Dauer |
|-------|-------|
| `--transition-fast` | 150ms ease |
| `--transition-base` | 250ms ease |
| `--transition-slow` | 400ms ease |
---
## 13. Build & Test
### Frontend
```bash
cd frontend && npm run build # Build (MUSS 0 errors)
cd frontend && npx vitest run # Unit Tests (MUSS alle pass)
cd frontend && npx playwright test --grep 'Pattern' # E2E Tests
curl -s http://localhost:3000/ # Smoke Test
```
### Backend
```bash
cd backend && python -m pytest tests/ -v --cov=app # Tests (MUSS alle pass)
curl -s http://localhost:8000/api/health # Health Check
```
### Deployment
- Server: coolify-01 (46.225.91.159)
- Pfad: `/data/hms-licht-ton/`
- Branch: `feature/T04-rentman-integration` (oder `main` nach Merge)
- Docker Compose: `docker compose up -d`
- Domain: `https://hms.media-on.de` (Traefik + Let's Encrypt)
---
## 14. VERBOTEN
- ❌ Eigene CSS Klassen erfinden (nur `hms-*` verwenden)
- ❌ Generische `.btn-primary` oder `.nav-link` Klassen
- ❌ Hardcoded Hex-Werte in Inline Styles (immer `var(--...)`)
- ❌ Fake-Logo (immer echtes SVG aus `assets/logo.svg`)
- ❌ Große Orange-Flächen
- ❌ Abgerundete Ecken > 4px (außer Badges/Pills = 9999px)
- ❌ Client-only rendering für SSR Pages (`/mietkatalog`, `/mietkatalog/**`)
- ❌ Fehlende ARIA Attributes
- ❌ Fehlende UX States (Loading, Empty, Error, Success)
- ❌ Fehlendes `noindex` Meta Tag
- ❌ Eigene Templates die nicht dem Prototyp entsprechen
---
## 15. Referenz-Dateien
| Datei | Zweck |
|---|---|
| `docs/prototype-app.js` | Original Vue 3 SPA Prototyp (761 Zeilen, 19 Komponenten) |
| `docs/prototype-index.html` | Original HTML mit Meta Tags, JSON-LD |
| `frontend/assets/css/prototype-style.css` | Original CSS mit allen hms-* Klassen (13KB) |
| `frontend/assets/css/main.css` | Aktive CSS (enthält prototype-style.css + Tailwind directives) |
| `frontend/assets/logo.svg` | Echtes HMS Logo (503 bytes, weißes H + orange Border) |
| `frontend/public/logo.svg` | Static copy für Nuxt public dir |
| `docs/ui_design.md` | Original UI Design Dokumentation |
| `docs/design-rules.md` | DIES DATEI Design Richtlinien für KI-Agenten |
---
## 16. Schnell-Referenz: Neue Seite erstellen
```vue
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Seiten-Titel</h1>
<p class="mb-8" style="color: var(--secondary)">Untertitel</p>
<!-- Loading -->
<loading-skeleton v-if="loading" :count="6" />
<!-- Error -->
<error-state v-else-if="error" title="Fehler" message="..." @retry="loadData" />
<!-- Content -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="item in items" :key="item.id" class="hms-card p-6">
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">{{ item.title }}</h3>
<p class="text-sm leading-relaxed" style="color: var(--text-muted)">{{ item.description }}</p>
<button class="hms-btn hms-btn-primary mt-4">Aktion</button>
</div>
</div>
<!-- Empty -->
<empty-state v-if="!loading && !error && items.length === 0"
icon="🔍" title="Keine Daten" message="..." action-label="Zurück" @action="..." />
</div>
</template>
<script setup lang="ts">
useHead({
title: 'Seite HMS Licht & Ton',
meta: [{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet' }],
});
</script>
```
---
*Dieses Dokument ist die verbindliche Design-Richtlinie für alle Erweiterungen der HMS Licht & Ton Website.*
+4 -6
View File
@@ -2,16 +2,14 @@ import type { $Fetch } from "nitropack";
/**
* Base API client composable.
* Wraps $fetch with the configured API base URL from runtime config.
* Provides typed GET, POST, PUT, DELETE helpers.
* Uses server-side apiBase for SSR requests, public apiBase for client-side.
*/
export function useApi() {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
// On server: config.apiBase (http://backend:8000)
// On client: config.public.apiBase (/api)
const apiBase = import.meta.server ? config.apiBase : config.public.apiBase;
/**
* Typed $fetch wrapper bound to API base URL.
*/
const client: $Fetch = $fetch.create({
baseURL: apiBase,
headers: {
+9 -2
View File
@@ -51,9 +51,11 @@ export default defineNuxtConfig({
},
},
runtimeConfig: {
apiBase: process.env.API_BASE_URL || "http://localhost:8000",
// Server-only: used by SSR to call backend in Docker network
apiBase: process.env.API_BASE_URL || "http://backend:8000",
public: {
apiBase: process.env.API_BASE_URL || "http://localhost:8000",
// Client-side: relative URL goes through Nuxt server proxy
apiBase: "/api",
},
},
routeRules: {
@@ -73,5 +75,10 @@ export default defineNuxtConfig({
},
nitro: {
compressPublicAssets: true,
routeRules: {
"/api/**": {
proxy: "http://backend:8000/api/**",
},
},
},
});
+67 -44
View File
@@ -6,61 +6,85 @@
</div>
<div class="hms-card p-4 mb-6">
<div class="flex flex-col lg:flex-row gap-4">
<div class="relative flex-1"><svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style="color: var(--secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg><input v-model="searchQuery" type="search" class="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" /></div>
<select v-model="sortBy" class="hms-input lg:w-48" aria-label="Sortieren nach"><option value="name">Sortieren: Name (A-Z)</option><option value="brand">Sortieren: Marke (A-Z)</option><option value="code">Sortieren: Artikelnummer</option></select>
<div class="relative flex-1"><svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style="color: var(--secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg><input v-model="searchQuery" type="search" class="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" @input="onSearchInput" /></div>
<select v-model="sortBy" class="hms-input lg:w-48" aria-label="Sortieren nach"><option value="name_asc">Sortieren: Name (A-Z)</option><option value="name_desc">Sortieren: Name (Z-A)</option></select>
</div>
<div class="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter"><button v-for="cat in categories" :key="cat" @click="activeCategory = cat" :class="['hms-chip', activeCategory === cat ? 'active' : '']" role="tab" :aria-selected="activeCategory === cat">{{ cat === 'alle' ? 'Alle Kategorien' : cat }}</button></div>
<div v-if="categories.length > 1" class="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter"><button v-for="cat in categories" :key="cat" @click="activeCategory = cat; reload()" :class="['hms-chip', activeCategory === cat ? 'active' : '']" role="tab" :aria-selected="activeCategory === cat">{{ cat === 'alle' ? 'Alle Kategorien' : cat }}</button></div>
</div>
<div v-if="!pending && !error" class="text-sm mb-4" style="color: var(--secondary)" aria-live="polite">{{ total }} {{ total === 1 ? 'Gerät' : 'Geräte' }} gefunden</div>
<LoadingSkeleton v-if="pending" :count="6" />
<ErrorState v-else-if="error" title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." @retry="reload" />
<EmptyState v-else-if="equipment.length === 0" icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." action-label="Filter zurücksetzen" @action="searchQuery=''; activeCategory='alle'; reload()" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"><EquipmentCard v-for="item in equipment" :key="item.id" :item="mapItem(item)" @click="navigateToDetail" @add-to-cart="addToCart" /></div>
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 mt-8">
<button :disabled="currentPage <= 1" @click="currentPage--; reload()" class="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Vorherige Seite"></button>
<span class="text-sm" style="color: var(--secondary)">Seite {{ currentPage }} von {{ totalPages }}</span>
<button :disabled="currentPage >= totalPages" @click="currentPage++; reload()" class="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Nächste Seite"></button>
</div>
<div v-if="!loading && !error" class="text-sm mb-4" style="color: var(--secondary)" aria-live="polite">{{ filteredEquipment.length }} {{ filteredEquipment.length === 1 ? 'Gerät' : 'Geräte' }} gefunden</div>
<LoadingSkeleton v-if="loading" :count="6" />
<ErrorState v-else-if="error" title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." @retry="retry" />
<EmptyState v-else-if="filteredEquipment.length === 0" icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." action-label="Filter zurücksetzen" @action="searchQuery=''; activeCategory='alle'" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"><EquipmentCard v-for="item in filteredEquipment" :key="item.id" :item="item" @click="navigateToDetail" @add-to-cart="addToCart" /></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, watch } from 'vue'
import { useEquipment } from '~/composables/useEquipment'
import { useCart } from '~/composables/useCart'
const { list, categories: fetchCategories } = useEquipment()
const { addItemByFields } = useCart()
const loading = ref(true)
const error = ref(false)
const searchQuery = ref('')
const activeCategory = ref('alle')
const sortBy = ref('name')
const categories = ['alle', 'Tontechnik', 'Lichttechnik', 'Rigging', 'Video', 'Strom', 'Zubehör']
const allEquipment = [
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'High-Performance Line-Array Element mit variabler Krümmung für große Open-Air-Produktionen und Hallenbeschallung', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz' }, image: '' },
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker, 80° horizontal, für mittlere bis große Events', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz' }, image: '' },
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Dynamisches Gesangsmikrofon, Kardioid-Richtcharakteristik, Industrie-Standard seit Jahrzehnten', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid' }, image: '' },
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Digitales Mischpult mit 96 Input Fadern, 64 Mix Buses, Dante-kompatibel für große Live-Produktionen', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines' }, image: '' },
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom, 16-bit Dimming, vielseitig für Beam- und Wash-Anwendungen', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°' }, image: '' },
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 pixel-mappable Zellen, Zoom 4°-60°, Art-Net steuerbar', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°' }, image: '' },
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head, 9 rotierende + 9 feste Gobos, 8-fach Prisma', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°' }, image: '' },
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m, batteriebetrieben mit 20 Std. Laufzeit, DMX/CRMX-Steuerung, RGBW', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel' }, image: '' },
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge, belastbar bis 450kg freitragend', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg' }, image: '' },
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg, D8+ Plus geprüft, inklusive Steuerkabel und Lasthaken', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control' }, image: '' },
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate, 1500 nits für Bühnenhintergründe', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz' }, image: '' },
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording, Multiview und Direct Streaming', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording' }, image: '' },
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase, 4x Schuko Ausgang, für LED- und Steuerungstechnik', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54' }, image: '' },
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko, mit FI-Schutzschalter, professionelle Bühnenstromversorgung', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A' }, image: '' },
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE, Front- und Rücktür, 4x 100mm Rollen, Birkenholz mit Alu-Kanten', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm' }, image: '' },
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker, doppelt geschirmt', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double' }, image: '' },
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und integrierter LED-Beleuchtung, transportiert im Flightcase', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer' }, image: '' },
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set, Handheld + Empfänger, 100m Reichweite, 12 Std. Akkulaufzeit', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std' }, image: '' }
]
const filteredEquipment = computed(() => {
let items = allEquipment
if (activeCategory.value !== 'alle') items = items.filter(i => i.category === activeCategory.value)
if (searchQuery.value.trim()) { const q = searchQuery.value.toLowerCase(); items = items.filter(i => i.name.toLowerCase().includes(q) || i.code.toLowerCase().includes(q) || i.brand.toLowerCase().includes(q)) }
if (sortBy.value === 'name') items = [...items].sort((a, b) => a.name.localeCompare(b.name))
else if (sortBy.value === 'brand') items = [...items].sort((a, b) => a.brand.localeCompare(b.brand))
else if (sortBy.value === 'code') items = [...items].sort((a, b) => a.code.localeCompare(b.code))
return items
const sortBy = ref('name_asc')
const currentPage = ref(1)
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const categories = ref<string[]>(['alle'])
const { data, pending, error, refresh } = await useAsyncData(
'equipment-list',
() => list({
search: searchQuery.value.trim() || undefined,
category: activeCategory.value !== 'alle' ? activeCategory.value : undefined,
sort: sortBy.value as any,
page: currentPage.value,
page_size: 24
}),
{ default: () => ({ items: [], total: 0, page: 1, page_size: 24, total_pages: 0 }) }
)
const equipment = computed(() => data.value?.items || [])
const total = computed(() => data.value?.total || 0)
const totalPages = computed(() => data.value?.total_pages || 0)
function reload() {
refresh()
}
function onSearchInput() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
currentPage.value = 1
reload()
}, 300)
}
useAsyncData('equipment-categories', async () => {
try {
const cats = await fetchCategories()
if (cats && cats.length > 0) {
categories.value = ['alle', ...cats.filter((c: string) => c && c.trim() !== '')]
}
} catch {}
})
onMounted(() => { setTimeout(() => { loading.value = false }, 1000) })
function mapItem(item: any) {
return {
...item,
code: item.number || item.rentman_id,
image: item.image_url || ''
}
}
function navigate(route: string) {
navigateTo(route)
if (import.meta.client) window.scrollTo(0, 0)
@@ -69,13 +93,12 @@ function navigateToDetail(item: any) {
navigateTo('/mietkatalog/' + item.id)
if (import.meta.client) window.scrollTo(0, 0)
}
function retry() { loading.value = true; error.value = false; setTimeout(() => { loading.value = false }, 800) }
function addToCart(item: any) {
addItemByFields({
equipment_id: item.id,
name: item.name,
rental_price: null,
image_url: item.image || null
image_url: item.image_url || null
})
}
</script>
+17 -56
View File
@@ -1,21 +1,21 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<nav class="text-sm mb-6" aria-label="Breadcrumb"><NuxtLink to="/mietkatalog" style="color: var(--secondary)"> Zurück zum Katalog</NuxtLink></nav>
<div v-if="loading" class="grid lg:grid-cols-2 gap-8" aria-live="polite" aria-busy="true"><div class="hms-skeleton aspect-square rounded-lg"></div><div class="space-y-4"><div class="hms-skeleton h-8 w-3/4"></div><div class="hms-skeleton h-4 w-1/4"></div><div class="hms-skeleton h-24 w-full"></div><div class="hms-skeleton h-32 w-full"></div><div class="hms-skeleton h-12 w-full" style="border-radius:var(--radius-md)"></div></div></div>
<div v-if="pending" class="grid lg:grid-cols-2 gap-8" aria-live="polite" aria-busy="true"><div class="hms-skeleton aspect-square rounded-lg"></div><div class="space-y-4"><div class="hms-skeleton h-8 w-3/4"></div><div class="hms-skeleton h-4 w-1/4"></div><div class="hms-skeleton h-24 w-full"></div><div class="hms-skeleton h-32 w-full"></div><div class="hms-skeleton h-12 w-full" style="border-radius:var(--radius-md)"></div></div></div>
<ErrorState v-else-if="error" title="Gerät nicht gefunden" message="Das angeforderte Gerät konnte nicht gefunden werden." @retry="navigate('/mietkatalog')" />
<div v-else-if="item">
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12">
<div class="aspect-square rounded-lg flex items-center justify-center relative overflow-hidden border" :style="{ background: 'var(--surface)', borderColor: 'var(--border)' }">
<img v-if="item.image" :src="item.image" :alt="item.name" class="absolute inset-0 w-full h-full object-cover" />
<img v-if="item.image_url" :src="item.image_url" :alt="item.name" class="absolute inset-0 w-full h-full object-cover" />
<div v-else class="text-center"><div class="text-8xl mb-2" style="color: var(--secondary)">📦</div><div class="text-sm" style="color: var(--secondary)">Kein Bild verfügbar</div></div>
<span class="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{{ item.category }}</span>
<span v-if="item.category" class="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{{ item.category }}</span>
</div>
<div>
<div class="text-sm mb-1" style="color: var(--secondary)">Artikelnummer: {{ item.code }}</div>
<div class="text-sm mb-1" style="color: var(--secondary)">Artikelnummer: {{ item.number || item.rentman_id }}</div>
<h1 class="text-2xl sm:text-3xl font-bold mb-2" style="color: var(--text)">{{ item.name }}</h1>
<div class="text-sm mb-4" style="color: var(--text-muted)">Marke: <span class="font-medium" style="color: var(--text)">{{ item.brand }}</span></div>
<p class="leading-relaxed mb-6" style="color: var(--text-muted)">{{ item.description }}</p>
<div class="hms-card p-4 mb-6"><h2 class="text-sm font-semibold mb-3" style="color: var(--text)">Technische Daten</h2><dl class="divide-y" :style="{ borderColor: 'var(--border)' }"><div v-for="(value, key) in item.specs" :key="key" class="flex justify-between py-2 text-sm" :style="{ borderColor: 'var(--border)' }"><dt class="capitalize" style="color: var(--secondary)">{{ key.replace(/_/g, ' ') }}</dt><dd class="font-medium text-right" style="color: var(--text)">{{ value }}</dd></div></dl></div>
<div v-if="item.brand" class="text-sm mb-4" style="color: var(--text-muted)">Marke: <span class="font-medium" style="color: var(--text)">{{ item.brand }}</span></div>
<p v-if="item.description" class="leading-relaxed mb-6" style="color: var(--text-muted)">{{ item.description }}</p>
<div v-if="item.specifications" class="hms-card p-4 mb-6"><h2 class="text-sm font-semibold mb-3" style="color: var(--text)">Technische Daten</h2><dl class="divide-y" :style="{ borderColor: 'var(--border)' }"><div v-for="(value, key) in item.specifications" :key="key" class="flex justify-between py-2 text-sm" :style="{ borderColor: 'var(--border)' }"><dt class="capitalize" style="color: var(--secondary)">{{ key.replace(/_/g, ' ') }}</dt><dd class="font-medium text-right" style="color: var(--text)">{{ value }}</dd></div></dl></div>
<div class="hms-card p-6">
<h2 class="text-sm font-semibold mb-4" style="color: var(--text)">Mietanfrage</h2>
<div class="grid grid-cols-2 gap-3 mb-4"><div><label for="rental-start" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietbeginn</label><input id="rental-start" v-model="rentalStart" type="date" class="hms-input text-sm" /></div><div><label for="rental-end" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietende</label><input id="rental-end" v-model="rentalEnd" type="date" class="hms-input text-sm" /></div></div>
@@ -25,59 +25,28 @@
</div>
</div>
</div>
<section v-if="relatedItems.length" class="mt-16" aria-labelledby="related-title"><h2 id="related-title" class="text-xl font-semibold mb-6" style="color: var(--text)">Ähnliche Geräte</h2><div class="grid grid-cols-1 sm:grid-cols-3 gap-6"><EquipmentCard v-for="ri in relatedItems" :key="ri.id" :item="ri" @click="navigate('/mietkatalog/' + ri.id)" @add-to-cart="addToCartSimple" /></div></section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useEquipment } from '~/composables/useEquipment'
import { useCart } from '~/composables/useCart'
const route = useRoute()
const { detail } = useEquipment()
const { addItemByFields } = useCart()
const loading = ref(true)
const error = ref(false)
const quantity = ref(1)
const rentalStart = ref('')
const rentalEnd = ref('')
const item = ref<any>(null)
const equipmentData = [
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'Das L-Acoustics K2 ist das Flaggschiff der Line-Array-Serie und eignet sich für große Open-Air-Veranstaltungen und Hallenbeschallung.', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz', max_spl: '142 dB', coverage: '10°-110° variable' }, image: '', related: [2, 4, 3] },
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker mit 80° horizontaler Abstrahlung.', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz', max_spl: '138 dB', coverage: '80° horizontal' }, image: '', related: [1, 4, 18] },
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Das weltweit meistverkaufte dynamische Gesangsmikrofon.', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid', freq_range: '50 Hz - 15 kHz', connector: 'XLR3' }, image: '', related: [4, 18, 1] },
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Professionelles digitales Mischpult mit 96 Input Fadern und 64 Mix Buses.', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines', screens: '2x 15" Touch', i_o: '128x128 Dante' }, image: '', related: [1, 2, 3] },
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom und 16-bit Dimming.', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°', dimming: '16-bit', pan: '540°', tilt: '270°' }, image: '', related: [6, 7, 8] },
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 einzelnen pixel-mappable Zellen.', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°', control: 'DMX 512, Art-Net', color: 'RGBW' }, image: '', related: [5, 7, 8] },
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head mit 9 rotierenden und 9 festen Gobos.', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°', color: '7+1 Farbrad', prism: '8-fach rotierend' }, image: '', related: [5, 6, 8] },
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m Länge, batteriebetrieben mit 20 Std. Laufzeit.', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel', control: 'DMX, CRMX, App', color: 'RGBW' }, image: '', related: [5, 6, 7] },
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge.', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg', material: 'Aluminium EN AW-6082' }, image: '', related: [10, 14, 15] },
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg Tragkraft, D8+ Plus geprüft.', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control', certification: 'D8+ Plus' }, image: '', related: [9, 14, 13] },
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate.', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz', brightness: '1500 nits', ip: 'IP30 Indoor' }, image: '', related: [12, 5, 6] },
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording und Multiview.', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording', streaming: 'Direct Streaming', audio: '2x 3.5mm' }, image: '', related: [11, 5, 7] },
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase mit 4x Schuko Ausgang.', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54', cooling: 'Forced Air' }, image: '', related: [14, 10, 9] },
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko mit FI-Schutzschalter.', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A', cable: 'H07BQ-F 5G6' }, image: '', related: [13, 10, 9] },
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE mit Front- und Rücktür.', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm', material: 'Birkenholz + Alu-Kanten' }, image: '', related: [16, 4, 15] },
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker.', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double', cable: 'Tasker C118' }, image: '', related: [3, 18, 4] },
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und LED-Beleuchtung.', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer', weight: '22 kg' }, image: '', related: [12, 5, 15] },
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set mit Handheld und Empfänger.', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std', freq_range: '563-608 MHz', channels: 'gleichzeitig 95' }, image: '', related: [3, 4, 16] }
]
const relatedItems = computed(() => {
if (!item.value || !item.value.related) return []
return item.value.related.map((id: number) => equipmentData.find(e => e.id === id)).filter(Boolean)
})
onMounted(() => {
setTimeout(() => {
const found = equipmentData.find(e => e.id === Number(route.params.id))
if (found) { item.value = found } else { error.value = true }
loading.value = false
}, 800)
})
const { data: item, pending, error } = await useAsyncData(
'equipment-detail',
() => detail(route.params.id as string)
)
function navigate(route: string) {
navigateTo(route)
@@ -85,19 +54,11 @@ function navigate(route: string) {
}
function addToCart() {
addItemByFields({
equipment_id: item.value.id,
name: item.value.name,
equipment_id: item.value!.id,
name: item.value!.name,
rental_price: null,
image_url: item.value.image || null
image_url: item.value!.image_url || null
})
navigate('/warenkorb')
}
function addToCartSimple(e: any) {
addItemByFields({
equipment_id: e.id,
name: e.name,
rental_price: null,
image_url: e.image || null
})
}
</script>