Merge branch 'feature/T04-rentman-integration'

# Conflicts:
#	backend/app/services/sync_service.py
This commit is contained in:
Agent Zero
2026-07-11 18:23:56 +02:00
10 changed files with 288 additions and 150 deletions
+1
View File
@@ -9,3 +9,4 @@ htmlcov/
node_modules/ node_modules/
.nuxt/ .nuxt/
dist/ dist/
images/
+8
View File
@@ -15,12 +15,20 @@ class EquipmentCache(Base):
description = Column(Text) description = Column(Text)
specifications = Column(JSON) specifications = Column(JSON)
images = Column(JSON) images = Column(JSON)
update_hash = Column(String(128))
rental_price = Column(DECIMAL(10, 2)) rental_price = Column(DECIMAL(10, 2))
brand = Column(String(128)) brand = Column(String(128))
available = Column(Boolean, default=True) available = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, server_default=func.now()) created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=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__ = ( __table_args__ = (
Index("idx_equipment_category", "category"), Index("idx_equipment_category", "category"),
Index("idx_equipment_name", "name"), 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 import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import select, func, or_ from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import Any from typing import Any
@@ -10,6 +12,8 @@ from app.cache import cache
router = APIRouter(prefix="/api/equipment", tags=["equipment"]) router = APIRouter(prefix="/api/equipment", tags=["equipment"])
IMAGES_DIR = "/data/images/equipment"
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=PaginatedResponse)
async def list_equipment( async def list_equipment(
@@ -49,7 +53,7 @@ async def list_equipment(
items = result.scalars().all() items = result.scalars().all()
response = { response = {
"items": [EquipmentItem.model_validate(item) for item in items], "items": [EquipmentItem.model_validate(item).model_dump() for item in items],
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -73,6 +77,15 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
return categories 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) @router.get("/{equipment_id}", response_model=EquipmentDetail)
async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any: async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any:
"""Return a single equipment detail by ID.""" """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() item = result.scalar_one_or_none()
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
response = EquipmentDetail.model_validate(item) response = EquipmentDetail.model_validate(item).model_dump()
await cache.set(cache_key, response.model_dump(), ttl=3600) await cache.set(cache_key, response, ttl=3600)
return response return response
+59 -29
View File
@@ -50,6 +50,65 @@ class RentmanService:
offset += limit offset += limit
return all_items 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]: async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]:
"""POST /projectrequests to create a new project request in Rentman.""" """POST /projectrequests to create a new project request in Rentman."""
url = f"{self._base_url}/projectrequests" url = f"{self._base_url}/projectrequests"
@@ -68,35 +127,6 @@ class RentmanService:
resp.raise_for_status() resp.raise_for_status()
return resp.json() 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 @staticmethod
def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]: def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Map frontend rental request data to Rentman POST /projectrequests payload.""" """Map frontend rental request data to Rentman POST /projectrequests payload."""
+87 -6
View File
@@ -1,5 +1,7 @@
"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache.""" """Equipment sync service: import from Rentman, upsert into DB, invalidate cache."""
import logging import logging
import os
import httpx
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import select
@@ -11,6 +13,8 @@ from app.cache import cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
IMAGES_DIR = "/data/images/equipment"
class SyncService: class SyncService:
"""Orchestrates equipment import from Rentman into the local database.""" """Orchestrates equipment import from Rentman into the local database."""
@@ -20,13 +24,15 @@ class SyncService:
self.rentman = rentman or RentmanService() self.rentman = rentman or RentmanService()
async def run_sync(self) -> dict[str, Any]: 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) 1. Create sync_log entry (status=running)
2. Paginate GET /equipment from Rentman 2. Paginate GET /equipment from Rentman
3. Upsert each item into equipment_cache 3. Compare updateHash with DB, only process changed items
4. Invalidate Redis cache (equipment:*) 4. Download images only for changed items
5. Update sync_log (status=completed or failed) 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. Returns dict with sync_id, items_processed, status.
""" """
log_entry = SyncLog( log_entry = SyncLog(
@@ -44,16 +50,53 @@ class SyncService:
error_message: str | None = None error_message: str | None = None
try: 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) 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: 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: try:
transformed = RentmanService.transform_equipment(raw_item) transformed = await self.rentman.transform_equipment(raw_item)
await self._upsert_equipment(transformed) 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 items_processed += 1
except Exception as exc: 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 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() await self.db.commit()
# Invalidate Redis cache # Invalidate Redis cache
@@ -95,6 +138,7 @@ class SyncService:
existing.description = data.get("description", "") existing.description = data.get("description", "")
existing.specifications = data.get("specifications") existing.specifications = data.get("specifications")
existing.images = data.get("images") existing.images = data.get("images")
existing.update_hash = data.get("update_hash")
existing.rental_price = data.get("rental_price") existing.rental_price = data.get("rental_price")
existing.brand = data.get("brand", "") existing.brand = data.get("brand", "")
existing.available = data.get("available", True) existing.available = data.get("available", True)
@@ -108,12 +152,49 @@ class SyncService:
description=data.get("description", ""), description=data.get("description", ""),
specifications=data.get("specifications"), specifications=data.get("specifications"),
images=data.get("images"), images=data.get("images"),
update_hash=data.get("update_hash"),
rental_price=data.get("rental_price"), rental_price=data.get("rental_price"),
brand=data.get("brand", ""), brand=data.get("brand", ""),
available=data.get("available", True), available=data.get("available", True),
) )
self.db.add(new_item) 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]: async def get_last_sync(self) -> dict[str, Any]:
"""Return the most recent sync_log entry summary.""" """Return the most recent sync_log entry summary."""
result = await self.db.execute( result = await self.db.execute(
+19 -3
View File
@@ -7,7 +7,20 @@ services:
backend: backend:
condition: service_healthy condition: service_healthy
environment: 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 restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] 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 start_period: 15s
networks: networks:
- hms-network - hms-network
- coolify
backend: backend:
build: ./backend build: ./backend
ports:
- "8000:8000"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -40,6 +52,8 @@ services:
- CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000} - CORS_ORIGINS=${CORS_ORIGINS:-https://hms.media-on.de,http://localhost:3000}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
volumes:
- ./images:/data/images
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
@@ -85,6 +99,8 @@ services:
networks: networks:
hms-network: hms-network:
driver: bridge driver: bridge
coolify:
external: true
volumes: volumes:
postgres_data: postgres_data:
+4 -6
View File
@@ -2,16 +2,14 @@ import type { $Fetch } from "nitropack";
/** /**
* Base API client composable. * Base API client composable.
* Wraps $fetch with the configured API base URL from runtime config. * Uses server-side apiBase for SSR requests, public apiBase for client-side.
* Provides typed GET, POST, PUT, DELETE helpers.
*/ */
export function useApi() { export function useApi() {
const config = useRuntimeConfig(); 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({ const client: $Fetch = $fetch.create({
baseURL: apiBase, baseURL: apiBase,
headers: { headers: {
+9 -2
View File
@@ -51,9 +51,11 @@ export default defineNuxtConfig({
}, },
}, },
runtimeConfig: { 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: { public: {
apiBase: process.env.API_BASE_URL || "http://localhost:8000", // Client-side: relative URL goes through Nuxt server proxy
apiBase: "/api",
}, },
}, },
routeRules: { routeRules: {
@@ -73,5 +75,10 @@ export default defineNuxtConfig({
}, },
nitro: { nitro: {
compressPublicAssets: true, compressPublicAssets: true,
routeRules: {
"/api/**": {
proxy: "http://backend:8000/api/**",
},
},
}, },
}); });
+67 -44
View File
@@ -6,61 +6,85 @@
</div> </div>
<div class="hms-card p-4 mb-6"> <div class="hms-card p-4 mb-6">
<div class="flex flex-col lg:flex-row gap-4"> <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> <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">Sortieren: Name (A-Z)</option><option value="brand">Sortieren: Marke (A-Z)</option><option value="code">Sortieren: Artikelnummer</option></select> <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>
<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>
<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> </div>
</template> </template>
<script setup lang="ts"> <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' import { useCart } from '~/composables/useCart'
const { list, categories: fetchCategories } = useEquipment()
const { addItemByFields } = useCart() const { addItemByFields } = useCart()
const loading = ref(true)
const error = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const activeCategory = ref('alle') const activeCategory = ref('alle')
const sortBy = ref('name') const sortBy = ref('name_asc')
const categories = ['alle', 'Tontechnik', 'Lichttechnik', 'Rigging', 'Video', 'Strom', 'Zubehör'] const currentPage = ref(1)
const allEquipment = [ let searchTimeout: ReturnType<typeof setTimeout> | null = null
{ 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: '' }, const categories = ref<string[]>(['alle'])
{ 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: '' }, const { data, pending, error, refresh } = await useAsyncData(
{ 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: '' }, 'equipment-list',
{ 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: '' }, () => list({
{ 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: '' }, search: searchQuery.value.trim() || undefined,
{ 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: '' }, category: activeCategory.value !== 'alle' ? activeCategory.value : undefined,
{ 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: '' }, sort: sortBy.value as any,
{ 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: '' }, page: currentPage.value,
{ 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: '' }, page_size: 24
{ 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: '' }, { default: () => ({ items: [], total: 0, page: 1, page_size: 24, total_pages: 0 }) }
{ 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: '' }, const equipment = computed(() => data.value?.items || [])
{ 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: '' }, const total = computed(() => data.value?.total || 0)
{ 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 totalPages = computed(() => data.value?.total_pages || 0)
]
const filteredEquipment = computed(() => { function reload() {
let items = allEquipment refresh()
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)) function onSearchInput() {
else if (sortBy.value === 'brand') items = [...items].sort((a, b) => a.brand.localeCompare(b.brand)) if (searchTimeout) clearTimeout(searchTimeout)
else if (sortBy.value === 'code') items = [...items].sort((a, b) => a.code.localeCompare(b.code)) searchTimeout = setTimeout(() => {
return items 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) { function navigate(route: string) {
navigateTo(route) navigateTo(route)
if (import.meta.client) window.scrollTo(0, 0) if (import.meta.client) window.scrollTo(0, 0)
@@ -69,13 +93,12 @@ function navigateToDetail(item: any) {
navigateTo('/mietkatalog/' + item.id) navigateTo('/mietkatalog/' + item.id)
if (import.meta.client) window.scrollTo(0, 0) 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) { function addToCart(item: any) {
addItemByFields({ addItemByFields({
equipment_id: item.id, equipment_id: item.id,
name: item.name, name: item.name,
rental_price: null, rental_price: null,
image_url: item.image || null image_url: item.image_url || null
}) })
} }
</script> </script>
+17 -56
View File
@@ -1,21 +1,21 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <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> <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')" /> <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 v-else-if="item">
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12"> <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)' }"> <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> <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> <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> <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> <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 class="leading-relaxed mb-6" style="color: var(--text-muted)">{{ item.description }}</p> <p v-if="item.description" 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.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"> <div class="hms-card p-6">
<h2 class="text-sm font-semibold mb-4" style="color: var(--text)">Mietanfrage</h2> <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> <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> </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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useEquipment } from '~/composables/useEquipment'
import { useCart } from '~/composables/useCart' import { useCart } from '~/composables/useCart'
const route = useRoute() const route = useRoute()
const { detail } = useEquipment()
const { addItemByFields } = useCart() const { addItemByFields } = useCart()
const loading = ref(true)
const error = ref(false)
const quantity = ref(1) const quantity = ref(1)
const rentalStart = ref('') const rentalStart = ref('')
const rentalEnd = ref('') const rentalEnd = ref('')
const item = ref<any>(null)
const equipmentData = [ const { data: item, pending, error } = await useAsyncData(
{ 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] }, 'equipment-detail',
{ 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] }, () => detail(route.params.id as string)
{ 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)
})
function navigate(route: string) { function navigate(route: string) {
navigateTo(route) navigateTo(route)
@@ -85,19 +54,11 @@ function navigate(route: string) {
} }
function addToCart() { function addToCart() {
addItemByFields({ addItemByFields({
equipment_id: item.value.id, equipment_id: item.value!.id,
name: item.value.name, name: item.value!.name,
rental_price: null, rental_price: null,
image_url: item.value.image || null image_url: item.value!.image_url || null
}) })
navigate('/warenkorb') navigate('/warenkorb')
} }
function addToCartSimple(e: any) {
addItemByFields({
equipment_id: e.id,
name: e.name,
rental_price: null,
image_url: e.image || null
})
}
</script> </script>