diff --git a/frontend/src/views/admin/AdminAnalyticsView.vue b/frontend/src/views/admin/AdminAnalyticsView.vue index ee47d9b..7b18662 100644 --- a/frontend/src/views/admin/AdminAnalyticsView.vue +++ b/frontend/src/views/admin/AdminAnalyticsView.vue @@ -28,6 +28,29 @@ const metricCards = computed(() => [ { label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'in allen Kategorien', icon: Users }, { label: 'Reviews offen', value: seasonDetail.value.pendingNominations.length, note: 'Backlog', icon: Clock3 }, ]) +const insights = computed(() => { + const categoriesWithoutCandidates = categoryHealth.value.filter((category) => category.candidates === 0).length + const busiestReviewCategory = [...categoryHealth.value].sort((a, b) => b.reviews - a.reviews)[0] + const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length) + + return [ + { + label: 'Votes pro Kandidat', + value: votesPerCandidate, + note: 'Hilft einzuschaetzen, ob die Kandidatenbasis breit genug ist.', + }, + { + label: 'Leere Kategorien', + value: categoriesWithoutCandidates, + note: categoriesWithoutCandidates === 0 ? 'Alle Kategorien sind besetzt.' : 'Diese Kategorien brauchen Kandidatenpflege.', + }, + { + label: 'Review-Hotspot', + value: busiestReviewCategory?.reviews ?? 0, + note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine Review-Daten vorhanden.', + }, + ] +}) diff --git a/frontend/src/views/admin/AdminCategoriesView.vue b/frontend/src/views/admin/AdminCategoriesView.vue index a40bcd6..2ce3719 100644 --- a/frontend/src/views/admin/AdminCategoriesView.vue +++ b/frontend/src/views/admin/AdminCategoriesView.vue @@ -12,6 +12,7 @@ const store = useAwardsStore() const selectedSeasonId = computed(() => store.adminSelectedSeasonId) const seasonDetail = computed(() => store.adminSeasonDetail) const query = ref('') +const statusFilter = ref<'all' | 'empty' | 'reviews' | 'thin'>('all') const selectedCategoryId = ref(null) const saving = ref(null) const adminMessage = ref('') @@ -45,13 +46,18 @@ const categoriesWithState = computed(() => ) const filteredCategories = computed(() => { const search = query.value.trim().toLowerCase() - if (!search) return categoriesWithState.value - return categoriesWithState.value.filter((category) => - [category.groupName, category.name, category.slug, category.description] + return categoriesWithState.value.filter((category) => { + const matchesStatus = + statusFilter.value === 'all' || + (statusFilter.value === 'empty' && category.candidates === 0) || + (statusFilter.value === 'reviews' && category.pending > 0) || + (statusFilter.value === 'thin' && category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)) + const matchesSearch = !search || [category.groupName, category.name, category.slug, category.description] .join(' ') .toLowerCase() - .includes(search), - ) + .includes(search) + return matchesStatus && matchesSearch + }) }) const selectedCategory = computed(() => filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null, @@ -61,6 +67,12 @@ const categoryStats = computed(() => [ { label: 'Kandidaten', value: seasonDetail.value.candidates.length }, { label: 'Reviews', value: seasonDetail.value.pendingNominations.length }, ]) +const statusFilters = computed(() => [ + { key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length }, + { key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length }, + { key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length }, + { key: 'thin' as const, label: 'Duenn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length }, +]) watch( seasonDetail, @@ -124,6 +136,20 @@ async function createCategory() { saving.value = null } } + +function slugify(value: string) { + return value + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function fillNewSlug() { + newCategoryForm.slug = slugify(newCategoryForm.name) +}