fix: repair Rentman equipment sync with real API field mapping

- Map real Rentman fields: price -> rental_price, folder -> category/subcategory
  (resolved via folder hierarchy to top-level categories), code -> number,
  shop_description_long (fallback short/external_remark) -> description (HTML stripped)
- Sync only in_shop items; exclude archived/temporary from public catalog
- Extract known brands from equipment names (d&b, Shure, Pioneer, ...)
- Build specifications from physical fields (power, weight, dimensions)
- Persist equipment images in docker volume (survive redeploys)
- Router: filter available items, add price_asc/price_desc sorting
- Frontend: show prices in catalog card, detail page and cart with
  per-day subtotal; price sorting options
- Tests: use real Rentman field names in fixtures, mock folder_map
This commit is contained in:
Agent Zero
2026-09-24 21:26:18 +02:00
parent 654ec09ce1
commit df50ea8b4b
10 changed files with 233 additions and 39 deletions
+9 -3
View File
@@ -30,8 +30,8 @@ async def list_equipment(
if cached: if cached:
return cached return cached
query = select(EquipmentCache) query = select(EquipmentCache).where(EquipmentCache.available == True) # noqa: E712
count_query = select(func.count(EquipmentCache.id)) count_query = select(func.count(EquipmentCache.id)).where(EquipmentCache.available == True) # noqa: E712
if search: if search:
query = query.where(EquipmentCache.name.ilike(f"%{search}%")) query = query.where(EquipmentCache.name.ilike(f"%{search}%"))
@@ -42,6 +42,10 @@ async def list_equipment(
if sort == "name_desc": if sort == "name_desc":
query = query.order_by(EquipmentCache.name.desc()) query = query.order_by(EquipmentCache.name.desc())
elif sort == "price_asc":
query = query.order_by(EquipmentCache.rental_price.asc().nulls_last(), EquipmentCache.name.asc())
elif sort == "price_desc":
query = query.order_by(EquipmentCache.rental_price.desc().nulls_last(), EquipmentCache.name.asc())
else: else:
query = query.order_by(EquipmentCache.name.asc()) query = query.order_by(EquipmentCache.name.asc())
@@ -70,7 +74,9 @@ async def list_categories(db: AsyncSession = Depends(get_db)) -> Any:
if cached: if cached:
return cached return cached
result = await db.execute( result = await db.execute(
select(EquipmentCache.category).distinct().where(EquipmentCache.category.isnot(None)) select(EquipmentCache.category).distinct().where(
EquipmentCache.category.isnot(None), EquipmentCache.available == True # noqa: E712
)
) )
categories = [row[0] for row in result.fetchall() if row[0]] categories = [row[0] for row in result.fetchall() if row[0]]
await cache.set("equipment:categories", categories, ttl=3600) await cache.set("equipment:categories", categories, ttl=3600)
+144 -16
View File
@@ -50,6 +50,53 @@ class RentmanService:
offset += limit offset += limit
return all_items return all_items
async def get_all_folders(self) -> list[dict[str, Any]]:
"""Paginate through all folders until data is empty."""
all_folders: list[dict[str, Any]] = []
offset = 0
while True:
url = f"{self._base_url}/folders"
params = {"limit": 100, "offset": offset}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(url, headers=self._headers(), params=params)
resp.raise_for_status()
page = resp.json()
data = page.get("data", [])
if not data:
break
all_folders.extend(data)
offset += 100
return all_folders
async def get_folder_map(self) -> dict[str, dict[str, str]]:
"""Build a map: folder path -> {category: top-level name, subcategory: folder name}.
Walks the parent chain so every folder resolves to its top-level ancestor.
"""
folders = await self.get_all_folders()
by_path = {f"/folders/{f.get('id')}": f for f in folders}
def top_ancestor(folder: dict) -> dict:
current = folder
seen = set()
while current.get("parent") and current["parent"] not in seen:
seen.add(current["parent"])
parent = by_path.get(current["parent"])
if not parent:
break
current = parent
return current
folder_map: dict[str, dict[str, str]] = {}
for path, folder in by_path.items():
ancestor = top_ancestor(folder)
folder_map[path] = {
"category": ancestor.get("name", ""),
"subcategory": folder.get("name", ""),
}
return folder_map
async def get_file_url(self, file_id: str | int) -> str | None: async def get_file_url(self, file_id: str | int) -> str | None:
"""Fetch the S3 URL for a file from Rentman. """Fetch the S3 URL for a file from Rentman.
@@ -71,41 +118,122 @@ class RentmanService:
logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc) logger.warning("Failed to fetch file URL for file_id=%s: %s", file_id, exc)
return None return None
async def transform_equipment(self, raw: dict[str, Any]) -> dict[str, Any]: # Well-known rental brands for extraction from equipment names
KNOWN_BRANDS = [
"d&b audiotechnik", "d&b", "L-Acoustics", "Shure", "Sennheiser", "Pioneer",
"RCF", "Soundcraft", "Cameo", "Eurolite", "Globaltruss", "Klotz",
"Martin", "JBL", "QSC", "Yamaha", "Behringer", "ROBE", "Chauvet",
"PL-Audio", "Showtec", "American DJ", "ADJ", "GLP", "Wireless Solution",
"Sennheiser", "AKG", "Rode", "Audix", "Palmer", "Neutrik", "Layher",
]
@staticmethod
def _strip_html(text: str) -> str:
"""Remove HTML tags and normalize whitespace in description fields."""
if not text:
return ""
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
text = text.replace("&nbsp;", " ").replace("&amp;", "&")
text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"')
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n\s*\n+", "\n\n", text)
return text.strip()
def _extract_brand(self, name: str) -> str:
"""Extract a known brand from the equipment name, if present."""
lowered = (name or "").lower()
for brand in self.KNOWN_BRANDS:
if lowered.startswith(brand.lower() + " ") or lowered.startswith(brand.lower() + "-"):
return brand
return ""
def _resolve_category(self, folder_path: str | None, folder_map: dict[str, dict[str, str]] | None) -> tuple[str, str]:
"""Resolve (category, subcategory) from the Rentman folder path.
category = top-level folder name (e.g. "Tontechnik")
subcategory = direct folder name (e.g. "Mikrofone")
"""
if not folder_path or not folder_map:
return "", ""
info = folder_map.get(folder_path)
if not info:
return "", ""
return info.get("category", ""), info.get("subcategory", "")
def _build_specifications(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Collect physical specs from flat Rentman fields when set."""
specs: dict[str, Any] = {}
field_map = [
("Leistung (W)", "power"),
("Gewicht (kg)", "empty_weight"),
("Hoehe (mm)", "height"),
("Breite (mm)", "width"),
("Laenge (mm)", "length"),
("Volumen (m3)", "volume"),
]
for label, key in field_map:
value = raw.get(key)
if isinstance(value, (int, float)) and value > 0:
specs[label] = value
return specs
async def transform_equipment(
self,
raw: dict[str, Any],
folder_map: dict[str, dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Map a raw Rentman equipment object to equipment_cache schema. """Map a raw Rentman equipment object to equipment_cache schema.
Uses 'image' (singular) field which contains a relative path like '/files/3173'. Uses the real Rentman field names:
Fetches the actual S3 URL via GET /files/{file_id}. - price -> rental_price (per day, shop price)
- folder ("/folders/N") -> category/subcategory via folder_map
- shop_description_long (fallbacks: shop_description_short,
external_remark) -> description (HTML stripped)
- code -> number (article number)
- in_shop/in_archive -> availability for the public catalog
""" """
image_path = raw.get("image") image_path = raw.get("image")
image_urls: list[str] = [] image_urls: list[str] = []
if image_path and isinstance(image_path, 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) match = re.search(r"/files/(\d+)", image_path)
if match: if match:
file_id = match.group(1) file_id = match.group(1)
s3_url = await self.get_file_url(file_id) s3_url = await self.get_file_url(file_id)
if s3_url: if s3_url:
image_urls = [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, subcategory = self._resolve_category(raw.get("folder"), folder_map)
category = group.get("name", "") if isinstance(group, dict) else str(group or "")
description = self._strip_html(
raw.get("shop_description_long")
or raw.get("shop_description_short")
or raw.get("external_remark")
or ""
)
price = raw.get("price")
try:
price = float(price) if price is not None else None
except (TypeError, ValueError):
price = None
available = bool(raw.get("in_shop")) and not bool(raw.get("in_archive")) and not bool(raw.get("temporary"))
return { return {
"rentman_id": str(raw.get("id", "")), "rentman_id": str(raw.get("id", "")),
"name": raw.get("name", ""), "name": raw.get("name", "") or raw.get("displayname", ""),
"number": raw.get("number") or raw.get("code", ""), "number": raw.get("code", "") or raw.get("number", ""),
"category": category, "category": category,
"subcategory": raw.get("subcategory", ""), "subcategory": subcategory,
"description": raw.get("description", ""), "description": description,
"specifications": raw.get("specifications", {}), "specifications": self._build_specifications(raw) or None,
"images": image_urls, "images": image_urls,
"rental_price": raw.get("rental_price"), "rental_price": price,
"brand": raw.get("brand", ""), "brand": self._extract_brand(raw.get("name", "")),
"available": raw.get("available", True), "available": available,
"update_hash": raw.get("updateHash", ""), "update_hash": raw.get("updateHash", ""),
} }
+3 -2
View File
@@ -53,8 +53,9 @@ class SyncService:
# Ensure images directory exists # Ensure images directory exists
os.makedirs(IMAGES_DIR, exist_ok=True) os.makedirs(IMAGES_DIR, exist_ok=True)
# Fetch all equipment from Rentman # Fetch all equipment and folders from Rentman
all_equipment = await self.rentman.get_all_equipment(limit=100) all_equipment = await self.rentman.get_all_equipment(limit=100)
folder_map = await self.rentman.get_folder_map()
# Build set of rentman_ids from API for availability check # Build set of rentman_ids from API for availability check
api_rentman_ids = set() api_rentman_ids = set()
@@ -75,7 +76,7 @@ class SyncService:
continue continue
try: try:
transformed = await self.rentman.transform_equipment(raw_item) transformed = await self.rentman.transform_equipment(raw_item, folder_map=folder_map)
await self._upsert_equipment(transformed) await self._upsert_equipment(transformed)
# Download image if S3 URL available # Download image if S3 URL available
+48 -12
View File
@@ -10,31 +10,63 @@ from app.services.rentman_service import RentmanService
def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict: def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict:
"""Raw equipment item using REAL Rentman API field names."""
return { return {
"id": rid, "id": rid,
"name": name, "name": name,
"number": f"{name[:3].upper()}-001", "displayname": name,
"code": f"{name[:3].upper()}-001", "code": f"{name[:3].upper()}-001",
"equipment_group": {"name": category}, "folder": f"/folders/{int(rid) % 5 + 1}",
"description": f"Description for {name}", "shop_description_long": f"<p>Description for {name}</p>",
"specifications": {"weight": 50, "power": 750}, "external_remark": "",
"images": [{"url": f"https://example.com/{rid}.jpg"}], "power": 750,
"weight": 50,
"empty_weight": 50,
"image": f"/files/{rid}",
"price": 150.00,
"in_shop": True,
"in_archive": False,
"temporary": False,
"updateHash": f"hash-{rid}",
}
def make_transformed(raw: dict, folder_map=None) -> dict:
"""Transformed equipment dict as produced by the real Rentman mapping (no network)."""
return {
"rentman_id": str(raw["id"]),
"name": raw["name"],
"number": raw.get("code", ""),
"category": "Tontechnik",
"subcategory": "Mikrofone",
"description": f"Description for {raw['name']}",
"specifications": None,
"images": [],
"rental_price": 150.00, "rental_price": 150.00,
"brand": "L-Acoustics", "brand": "",
"available": True, "available": True,
"update_hash": raw.get("updateHash", ""),
} }
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transform_equipment(): async def test_transform_equipment():
raw = make_raw_equipment("42", "K2 Line Array", "Lautsprecher") raw = make_raw_equipment("42", "Shure SM58 Mikrofon dynamisch")
result = RentmanService.transform_equipment(raw) service = RentmanService.__new__(RentmanService) # no network on init path
folder_map = {
f"/folders/{42 % 5 + 1}": {"category": "Tontechnik", "subcategory": "Mikrofone"},
}
with patch.object(RentmanService, "get_file_url", new=AsyncMock(return_value="https://example.com/42.jpg")):
result = await service.transform_equipment(raw, folder_map=folder_map)
assert result["rentman_id"] == "42" assert result["rentman_id"] == "42"
assert result["name"] == "K2 Line Array" assert result["name"] == "Shure SM58 Mikrofon dynamisch"
assert result["category"] == "Lautsprecher" assert result["category"] == "Tontechnik"
assert result["images"] == ["https://example.com/42.jpg"] assert result["subcategory"] == "Mikrofone"
assert result["brand"] == "L-Acoustics" assert result["rental_price"] == 150.00
assert result["brand"] == "Shure"
assert result["available"] is True assert result["available"] is True
assert result["description"] == "Description for Shure SM58 Mikrofon dynamisch"
assert result["images"] == ["https://example.com/42.jpg"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -49,6 +81,8 @@ async def test_paginated_import(test_db):
mock_rentman.get_all_equipment = AsyncMock(return_value=[ mock_rentman.get_all_equipment = AsyncMock(return_value=[
*[make_raw_equipment(str(i), f"Item {i}") for i in range(250)] *[make_raw_equipment(str(i), f"Item {i}") for i in range(250)]
]) ])
mock_rentman.get_folder_map = AsyncMock(return_value={})
mock_rentman.transform_equipment = AsyncMock(side_effect=make_transformed)
with patch("app.services.sync_service.cache") as mock_cache: with patch("app.services.sync_service.cache") as mock_cache:
mock_cache.delete_pattern = AsyncMock(return_value=0) mock_cache.delete_pattern = AsyncMock(return_value=0)
@@ -83,6 +117,8 @@ async def test_sync_upsert_existing(test_db):
mock_rentman.get_all_equipment = AsyncMock(return_value=[ mock_rentman.get_all_equipment = AsyncMock(return_value=[
make_raw_equipment("100", "New Name", "Lautsprecher") make_raw_equipment("100", "New Name", "Lautsprecher")
]) ])
mock_rentman.get_folder_map = AsyncMock(return_value={})
mock_rentman.transform_equipment = AsyncMock(side_effect=make_transformed)
with patch("app.services.sync_service.cache") as mock_cache: with patch("app.services.sync_service.cache") as mock_cache:
mock_cache.delete_pattern = AsyncMock(return_value=0) mock_cache.delete_pattern = AsyncMock(return_value=0)
+2
View File
@@ -41,6 +41,7 @@ services:
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-} - MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- equipment_images:/data/images
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
@@ -98,3 +99,4 @@ networks:
volumes: volumes:
postgres_data: null postgres_data: null
redis_data: null redis_data: null
equipment_images: null
+4 -2
View File
@@ -20,8 +20,10 @@ export default function EquipmentCard({ item, onClick, onAddToCart }: Props) {
<div className="p-4"> <div className="p-4">
<h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3> <h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3>
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p> <p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<span className="text-xs" style={{ color: 'var(--secondary)' }}>{item.code}</span> <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
{item.rental_price != null ? `${Number(item.rental_price).toFixed(2).replace('.', ',')} € / Tag` : item.code}
</span>
<button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button> <button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button>
</div> </div>
</div> </div>
+8 -2
View File
@@ -41,7 +41,7 @@ export default function EquipmentDetail() {
addItemByFields({ addItemByFields({
equipment_id: item.id, equipment_id: item.id,
name: item.name, name: item.name,
rental_price: null, rental_price: item.rental_price ?? null,
image_url: item.image_url || null, image_url: item.image_url || null,
}); });
navigateTo('/warenkorb'); navigateTo('/warenkorb');
@@ -99,6 +99,12 @@ export default function EquipmentDetail() {
)} )}
<div className="hms-card p-6"> <div className="hms-card p-6">
<h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Mietanfrage</h2> <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Mietanfrage</h2>
{item.rental_price != null && (
<div className="flex items-baseline justify-between mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)' }}>
<span className="text-sm" style={{ color: 'var(--secondary)' }}>Mietpreis pro Tag</span>
<span className="text-2xl font-bold" style={{ color: 'var(--color-accent)' }}>{Number(item.rental_price).toFixed(2).replace('.', ',')} €</span>
</div>
)}
<div className="grid grid-cols-2 gap-3 mb-4"> <div className="grid grid-cols-2 gap-3 mb-4">
<div> <div>
<label htmlFor="rental-start" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietbeginn</label> <label htmlFor="rental-start" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietbeginn</label>
@@ -121,7 +127,7 @@ export default function EquipmentDetail() {
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
Zur Mietanfrage hinzufügen Zur Mietanfrage hinzufügen
</button> </button>
<p className="text-xs mt-3 text-center" style={{ color: 'var(--secondary)' }}>Preise auf Anfrage – unverbindliche Mietanfrage</p> <p className="text-xs mt-3 text-center" style={{ color: 'var(--secondary)' }}>{item.rental_price != null ? 'Unverbindliche Mietanfrage – Endpreis inkl. Rabatt nach Prüfung' : 'Preise auf Anfrage – unverbindliche Mietanfrage'}</p>
</div> </div>
</div> </div>
</div> </div>
+3 -1
View File
@@ -85,7 +85,7 @@ export default function Mietkatalog() {
addItemByFields({ addItemByFields({
equipment_id: item.id, equipment_id: item.id,
name: item.name, name: item.name,
rental_price: null, rental_price: item.rental_price ?? null,
image_url: item.image_url || null, image_url: item.image_url || null,
}); });
} }
@@ -120,6 +120,8 @@ export default function Mietkatalog() {
<select value={sortBy} onChange={e => { setSortBy(e.target.value as SortOption); setCurrentPage(1); reload(); }} className="hms-input lg:w-48" aria-label="Sortieren nach"> <select value={sortBy} onChange={e => { setSortBy(e.target.value as SortOption); setCurrentPage(1); reload(); }} className="hms-input lg:w-48" aria-label="Sortieren nach">
<option value="name_asc">Sortieren: Name (A-Z)</option> <option value="name_asc">Sortieren: Name (A-Z)</option>
<option value="name_desc">Sortieren: Name (Z-A)</option> <option value="name_desc">Sortieren: Name (Z-A)</option>
<option value="price_asc">Sortieren: Preis (aufsteigend)</option>
<option value="price_desc">Sortieren: Preis (absteigend)</option>
</select> </select>
</div> </div>
{categories.length > 1 && ( {categories.length > 1 && (
+11
View File
@@ -74,6 +74,11 @@ export default function Warenkorb() {
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-3 mt-3"> <div className="flex flex-wrap items-center gap-3 mt-3">
{item.rental_price != null && (
<span className="text-xs font-medium" style={{ color: 'var(--color-accent)' }}>
{Number(item.rental_price).toFixed(2).replace('.', ',')} € / Tag × {item.quantity}
</span>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => decrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl verringern">−</button> <button onClick={() => decrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl verringern">−</button>
<span className="w-8 text-center text-sm font-medium" style={{ color: 'var(--text)' }}>{item.quantity}</span> <span className="w-8 text-center text-sm font-medium" style={{ color: 'var(--text)' }}>{item.quantity}</span>
@@ -92,6 +97,12 @@ export default function Warenkorb() {
<div className="text-sm mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}> <div className="text-sm mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
<div className="flex justify-between mb-1"><span>Geräte gesamt:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{totalCount}</span></div> <div className="flex justify-between mb-1"><span>Geräte gesamt:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{totalCount}</span></div>
<div className="flex justify-between"><span>Positionen:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{cartItems.length}</span></div> <div className="flex justify-between"><span>Positionen:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{cartItems.length}</span></div>
{(() => {
const known = cartItems.filter(i => i.rental_price != null);
if (known.length === 0) return null;
const sum = known.reduce((acc, i) => acc + Number(i.rental_price) * i.quantity, 0);
return <div className="flex justify-between mt-1"><span>Zwischensumme (pro Tag, ohne MwSt.):</span><span className="font-medium" style={{ color: 'var(--text)' }}>{sum.toFixed(2).replace('.', ',')} €</span></div>;
})()}
</div> </div>
<form onSubmit={submitRequest} noValidate> <form onSubmit={submitRequest} noValidate>
<div className="space-y-3"> <div className="space-y-3">
+1 -1
View File
@@ -33,7 +33,7 @@ export interface PaginatedEquipment {
total_pages: number; total_pages: number;
} }
export type SortOption = "name_asc" | "name_desc"; export type SortOption = "name_asc" | "name_desc" | "price_asc" | "price_desc";
export interface CartItem { export interface CartItem {
equipment_id: number; equipment_id: number;