Files
hms-licht-ton/frontend/pages/mietkatalog.vue
T
Implementation Engineer e9da21b57e feat(T06): Mietkatalog frontend – equipment list, detail, search/filter, SSR
- composables/useApi.ts: base API client with runtimeConfig
- composables/useEquipment.ts: equipment API wrapper (list, detail, categories)
- components/EquipmentCard.vue: card with image/placeholder, badge, name, price, add-to-cart
- pages/mietkatalog.vue: SSR list with search, category chips, sort, pagination, skeleton, empty/error states
- pages/mietkatalog/[id].vue: SSR detail with specs table, related items, breadcrumb, add-to-cart
- nuxt.config.ts: runtimeConfig with apiBase
- 101 vitest tests passing (5 files)
- Build: 0 errors, 2.09 MB
2026-07-10 00:54:40 +02:00

252 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-text mb-6">Mietkatalog</h1>
<!-- Search & Filter Bar -->
<div class="panel rounded-lg p-4 mb-6 space-y-4">
<!-- Search Input + Sort + Reset -->
<div class="flex flex-col sm:flex-row gap-3 sm:items-center">
<div class="relative flex-grow">
<input
v-model="searchInput"
type="text"
placeholder="Equipment suchen…"
class="w-full bg-surface border border-border-default rounded-md px-4 py-2.5 pl-10 text-text placeholder:text-secondary focus:outline-none focus:border-accent transition-colors duration-fast"
aria-label="Equipment suchen"
data-testid="equipment-search"
@input="onSearchInput"
/>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
</div>
<!-- Sort Dropdown -->
<select
v-model="sortSelected"
class="bg-surface border border-border-default rounded-md px-4 py-2.5 text-text focus:outline-none focus:border-accent transition-colors duration-fast"
aria-label="Sortieren nach"
data-testid="sort-select"
@change="onSortChange"
>
<option value="name_asc">Name (AZ)</option>
<option value="name_desc">Name (ZA)</option>
</select>
<!-- Reset Button -->
<button
class="btn-secondary whitespace-nowrap"
data-testid="reset-filters"
@click="resetFilters"
>
Filter zurücksetzen
</button>
</div>
<!-- Category Filter Chips -->
<div v-if="categories.length > 0" class="flex flex-wrap gap-2" data-testid="category-chips">
<button
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors duration-fast"
:class="selectedCategory === ''
? 'bg-accent text-white border-accent'
: 'bg-surface text-text-muted border-border-default hover:border-accent-border hover:text-text'"
@click="selectCategory('')"
>
Alle
</button>
<button
v-for="cat in categories"
:key="cat"
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors duration-fast"
:class="selectedCategory === cat
? 'bg-accent text-white border-accent'
: 'bg-surface text-text-muted border-border-default hover:border-accent-border hover:text-text'"
@click="selectCategory(cat)"
>
{{ cat }}
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="pending" data-testid="loading-state">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="n in 6" :key="n" class="card rounded-lg overflow-hidden border border-border-default">
<div class="skeleton-shimmer w-full h-48" />
<div class="p-4 space-y-3">
<div class="skeleton-shimmer h-5 w-3/4 rounded" />
<div class="skeleton-shimmer h-4 w-full rounded" />
<div class="skeleton-shimmer h-4 w-1/2 rounded" />
</div>
</div>
</div>
</div>
<!-- Error State -->
<ErrorState
v-else-if="error"
title="Equipment konnte nicht geladen werden"
message="Bitte versuchen Sie es erneut."
retry-text="Erneut versuchen"
@retry="refresh()"
/>
<!-- Empty State -->
<EmptyState
v-else-if="!data || data.items.length === 0"
title="Keine Artikel gefunden"
message="Versuchen Sie andere Suchbegriffe oder setzen Sie die Filter zurück."
cta-text="Filter zurücksetzen"
@action="resetFilters"
/>
<!-- Results -->
<template v-else>
<!-- Result Count -->
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-text-muted" data-testid="result-count">
{{ data.total }} {{ data.total === 1 ? "Artikel" : "Artikel" }} gefunden
</p>
</div>
<!-- Equipment Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6" data-testid="equipment-grid">
<EquipmentCard
v-for="item in data.items"
:key="item.id"
:equipment="item"
@add-to-cart="onAddToCart"
/>
</div>
<!-- Pagination -->
<nav v-if="data.total_pages > 1" class="flex items-center justify-center gap-2" data-testid="pagination" aria-label="Pagination">
<button
class="btn-secondary px-3 py-2 disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="currentPage <= 1"
data-testid="prev-page"
@click="changePage(currentPage - 1)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
v-for="p in pageNumbers"
:key="p"
class="px-3 py-2 rounded-md text-sm font-medium transition-colors duration-fast"
:class="p === currentPage
? 'bg-accent text-white'
: 'bg-surface text-text-muted border border-border-default hover:border-accent-border hover:text-text'"
:data-testid="`page-${p}`"
@click="changePage(p)"
>
{{ p }}
</button>
<button
class="btn-secondary px-3 py-2 disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="currentPage >= data.total_pages"
data-testid="next-page"
@click="changePage(currentPage + 1)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</nav>
</template>
</div>
</template>
<script setup lang="ts">
import type { PaginatedEquipment, SortOption } from "~/composables/useEquipment";
const equipmentApi = useEquipment();
const searchInput = ref("");
const selectedCategory = ref("");
const sortSelected = ref<SortOption>("name_asc");
const currentPage = ref(1);
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
// Fetch categories once
const { data: categories } = await useAsyncData<string[]>(
"equipment-categories",
() => equipmentApi.categories(),
{ default: () => [] }
);
// Fetch equipment list with reactive params
const { data, pending, error, refresh } = await useAsyncData<PaginatedEquipment>(
"equipment-list",
() => equipmentApi.list({
search: searchInput.value || undefined,
category: selectedCategory.value || undefined,
sort: sortSelected.value,
page: currentPage.value,
page_size: 20,
}),
{
watch: [searchInput, selectedCategory, sortSelected, currentPage],
}
);
const pageNumbers = computed(() => {
if (!data.value) return [];
const total = data.value.total_pages;
const current = currentPage.value;
const pages: number[] = [];
const maxButtons = 5;
let start = Math.max(1, current - Math.floor(maxButtons / 2));
let end = Math.min(total, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
});
function onSearchInput() {
if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
currentPage.value = 1;
}, 300);
}
function onSortChange() {
currentPage.value = 1;
}
function selectCategory(cat: string) {
selectedCategory.value = cat;
currentPage.value = 1;
}
function changePage(page: number) {
currentPage.value = page;
if (import.meta.client) {
window.scrollTo({ top: 0, behavior: "smooth" });
}
}
function resetFilters() {
searchInput.value = "";
selectedCategory.value = "";
sortSelected.value = "name_asc";
currentPage.value = 1;
}
function onAddToCart(item: { id: number; name: string }) {
// Emit handled by parent app / cart composable
console.log("Add to cart:", item);
}
useHead({
title: "Mietkatalog HMS Licht & Ton",
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
});
</script>