2025-11-26 10:05:43 +01:00
|
|
|
<script setup lang="ts">
|
2025-11-28 09:19:28 +01:00
|
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
2025-11-26 10:05:43 +01:00
|
|
|
import AppLayout from '@/layouts/AppLayout.vue'
|
|
|
|
|
import AppHeader from '@/components/AppHeader.vue'
|
|
|
|
|
import { Product } from '@/types';
|
|
|
|
|
import Fuse from 'fuse.js';
|
|
|
|
|
import { Input } from '@/components/ui/crm-input'
|
|
|
|
|
import { Button } from '@/components/ui/crm-button'
|
|
|
|
|
import { Delete, Search, Plus } from "lucide-vue-next"
|
|
|
|
|
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
|
|
|
|
import { toCurrency, toRoundedCurrency } from '@/lib/utils';
|
|
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
|
|
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
2025-11-28 09:19:28 +01:00
|
|
|
import { Toggle } from '@/components/ui/toggle'
|
2025-11-26 10:05:43 +01:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
productsData: Product[];
|
|
|
|
|
}
|
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
const searchField = ref()
|
2025-11-28 09:19:28 +01:00
|
|
|
|
|
|
|
|
const categoryVisibility = ref<Record<string, boolean>>({});
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
initializeCategoryVisibility();
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Watcher für Änderungen in productsData
|
|
|
|
|
watch(() => props.productsData, () => {
|
|
|
|
|
initializeCategoryVisibility();
|
|
|
|
|
}, { deep: true });
|
|
|
|
|
|
2025-11-26 10:05:43 +01:00
|
|
|
const fuse = computed(() => {
|
|
|
|
|
return new Fuse(props.productsData, {
|
|
|
|
|
keys: ['title', 'description', 'notes'],
|
|
|
|
|
threshold: 0.3
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const filteredProducts = computed(() => {
|
2025-11-28 09:19:28 +01:00
|
|
|
const visibleCategories = Object.entries(categoryVisibility.value)
|
|
|
|
|
.filter(([_, isVisible]) => isVisible)
|
|
|
|
|
.map(([categoryName]) => categoryName);
|
|
|
|
|
|
|
|
|
|
if (visibleCategories.length == 0) return []
|
|
|
|
|
|
|
|
|
|
let products = props.productsData;
|
|
|
|
|
|
|
|
|
|
// Filter nach Suchanfrage
|
|
|
|
|
if (searchQuery.value) {
|
|
|
|
|
products = fuse.value.search(searchQuery.value).map(result => result.item);
|
2025-11-26 10:05:43 +01:00
|
|
|
}
|
|
|
|
|
|
2025-11-28 09:19:28 +01:00
|
|
|
// Filter nach Kategorien
|
|
|
|
|
products = products.filter(product =>
|
|
|
|
|
product.category?.name && visibleCategories.includes(product.category.name)
|
|
|
|
|
);
|
2025-11-26 10:05:43 +01:00
|
|
|
|
2025-11-28 09:19:28 +01:00
|
|
|
return products;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const categories = computed(() => {
|
|
|
|
|
const categorySet = new Set<string>();
|
|
|
|
|
props.productsData.forEach(product => {
|
|
|
|
|
if (product.category?.name) {
|
|
|
|
|
categorySet.add(product.category.name);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return Array.from(categorySet).sort();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const initializeCategoryVisibility = () => {
|
|
|
|
|
const visibility: Record<string, boolean> = {};
|
|
|
|
|
props.productsData.forEach(product => {
|
|
|
|
|
if (product.category?.name) {
|
|
|
|
|
visibility[product.category.name] = true;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
categoryVisibility.value = visibility;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const allCategoriesVisible = computed(() => {
|
|
|
|
|
return Object.values(categoryVisibility.value).every(isVisible => isVisible);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const toggleAllCategories = () => {
|
|
|
|
|
const newVisibility = !allCategoriesVisible.value;
|
|
|
|
|
|
|
|
|
|
Object.keys(categoryVisibility.value).forEach(categoryName => {
|
|
|
|
|
categoryVisibility.value[categoryName] = newVisibility;
|
|
|
|
|
});
|
|
|
|
|
};
|
2025-11-26 10:05:43 +01:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
|
|
|
|
<AppLayout title="Produkte">
|
|
|
|
|
|
|
|
|
|
<AppHeader>
|
|
|
|
|
<template #left></template>
|
|
|
|
|
<template #middle>
|
|
|
|
|
<!-- Search field -->
|
|
|
|
|
<Input ref="search-field" id="search" type="text" placeholder="Filtern" class="px-8 bg-background"
|
|
|
|
|
v-model="searchQuery" />
|
|
|
|
|
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
|
|
|
|
|
<Search class="size-4 text-muted-foreground" :stroke-width="1.5" />
|
|
|
|
|
</span>
|
|
|
|
|
<span class="absolute end-0 inset-y-0 flex items-center justify-center px-0 mr-1">
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'" @click="searchQuery = ''; searchField.focus()">
|
|
|
|
|
<Delete class="size-4 text-muted-foreground" :stroke-width="1.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
<template #right>
|
|
|
|
|
<!-- New button -->
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
<Button size="sm" variant="action" @click="">
|
|
|
|
|
<Plus />
|
|
|
|
|
Neu
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
<span>Neuen Kunden anlegen</span>
|
|
|
|
|
<KbdGroup class="ml-2">
|
|
|
|
|
<Kbd class="visible-mac">⌘ N</Kbd>
|
|
|
|
|
<Kbd class="visible-pc">Ctrl N</Kbd>
|
|
|
|
|
</KbdGroup>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
</template>
|
|
|
|
|
</AppHeader>
|
|
|
|
|
|
2025-11-28 09:19:28 +01:00
|
|
|
<div class="flex items-center justify-center mb-8">
|
|
|
|
|
<Toggle :modelValue="allCategoriesVisible" variant="outline" size="sm" aria-label="Alle Kategorien"
|
|
|
|
|
@click="toggleAllCategories">Alle</Toggle>
|
|
|
|
|
<Toggle v-for="category in categories" v-model="categoryVisibility[category]" variant="outline" size="sm"
|
|
|
|
|
:aria-label="'Kategorie ' + category">{{ category }}</Toggle>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-26 10:05:43 +01:00
|
|
|
<div class="flex flex-wrap gap-8 lg:gap-12">
|
|
|
|
|
<div v-for="product in filteredProducts"
|
|
|
|
|
class="w-[calc(50%-4*var(--spacing))] md:w-[calc(33%-5.333*var(--spacing))] lg:w-[calc(25%-9*var(--spacing))] xl:w-[calc(20%-9.6*var(--spacing))] flex flex-col gap-4">
|
|
|
|
|
<div class="w-full relative aspect-4/3 overflow-hidden rounded-lg">
|
|
|
|
|
<img v-if="product.image" :src="'storage/uploads/products/' + product.image"
|
|
|
|
|
class="size-full object-cover" loading="lazy" />
|
|
|
|
|
<PlaceholderPattern v-else />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<h2 class="truncate">{{ product.title }}</h2>
|
|
|
|
|
<p class="text-muted-foreground text-sm truncate">{{ product.description }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<span class="grow flex items-end text-sm font-bold">{{ toCurrency((product?.price || 0) * (1 +
|
|
|
|
|
Number(product.margin))) }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</AppLayout>
|
|
|
|
|
</template>
|