@@ -151,6 +177,18 @@ async function createCategory() {
{{ stat.value }}
+
+
+
@@ -171,6 +209,7 @@ async function createCategory() {
{{ category.candidates }} Kandidaten
Limit {{ category.maxNomineesPerUser }}
{{ category.pending }} Reviews
+ leer
#{{ category.sortOrder }}
@@ -246,7 +285,12 @@ async function createCategory() {
-
+
+
+
+
diff --git a/frontend/src/views/admin/AdminClipsView.vue b/frontend/src/views/admin/AdminClipsView.vue
index 52dd96c..ea18497 100644
--- a/frontend/src/views/admin/AdminClipsView.vue
+++ b/frontend/src/views/admin/AdminClipsView.vue
@@ -21,10 +21,28 @@ const clipCandidates = computed(() => {
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search))
})
const candidateCategory = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])))
+const clipReviewCount = computed(() => seasonDetail.value.pendingNominations.filter((nomination) => clipCategoryIds.value.has(nomination.categoryId)).length)
+const clipReadiness = computed(() => [
+ {
+ label: 'Clip-Kategorie existiert',
+ done: clipCategories.value.length > 0,
+ note: clipCategories.value.length > 0 ? `${clipCategories.value.length} Clip-Kategorien gefunden.` : 'Lege mindestens eine Clip-Kategorie an.',
+ },
+ {
+ label: 'Kandidaten vorhanden',
+ done: clipCandidates.value.length > 0,
+ note: clipCandidates.value.length > 0 ? `${clipCandidates.value.length} Clip-Kandidaten gepflegt.` : 'Noch keine Clip-Kandidaten vorhanden.',
+ },
+ {
+ label: 'Review-Restbestand',
+ done: clipReviewCount.value === 0,
+ note: clipReviewCount.value === 0 ? 'Keine offenen Clip-Reviews.' : `${clipReviewCount.value} Clip-Reviews offen.`,
+ },
+])
const stats = computed(() => [
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags },
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users },
- { label: 'Offene Reviews', value: seasonDetail.value.pendingNominations.filter((nomination) => clipCategoryIds.value.has(nomination.categoryId)).length, icon: Film },
+ { label: 'Offene Reviews', value: clipReviewCount.value, icon: Film },
])
@@ -58,16 +76,17 @@ const stats = computed(() => [
Workflow
Was hier geprueft wird
-
-
1. Clip-Kategorien vorhanden?
-
Kategorien mit “Clip” im Namen werden hier automatisch gebuendelt.
-
-
-
2. Kandidaten gepflegt?
-
Ohne Kandidaten kann die Community spaeter keine saubere Clip-Auswahl treffen.
+
+
{{ item.label }}
+
{{ item.note }}
-
3. Backend-Ausbau offen
+
Naechster Backend-Ausbau
Fuer echte Clip-Moderation brauchen wir spaeter eine ClipSubmission-Admin-API mit Status, Duplikaten und Entscheidung.
diff --git a/frontend/src/views/admin/AdminDashboardView.vue b/frontend/src/views/admin/AdminDashboardView.vue
index 255a37d..b6ccc5b 100644
--- a/frontend/src/views/admin/AdminDashboardView.vue
+++ b/frontend/src/views/admin/AdminDashboardView.vue
@@ -1,6 +1,6 @@
@@ -131,6 +163,7 @@ const priorityActions = computed(() => [
eyebrow="Dashboard"
title="Was braucht gerade Aufmerksamkeit?"
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebuendelt, damit du schneller entscheiden kannst, was als Naechstes drankommt."
+ :icon="LayoutDashboard"
/>
@@ -182,7 +215,7 @@ const priorityActions = computed(() => [
@@ -231,6 +264,30 @@ const priorityActions = computed(() => [
+
+
+
+
+
{{ check.label }}
+
+ {{ check.value }}
+
+
{{ check.note }}
+
+
+
+
+
@@ -285,7 +342,7 @@ const priorityActions = computed(() => [
diff --git a/frontend/src/views/admin/AdminNominationsView.vue b/frontend/src/views/admin/AdminNominationsView.vue
index cb48c59..56ccb01 100644
--- a/frontend/src/views/admin/AdminNominationsView.vue
+++ b/frontend/src/views/admin/AdminNominationsView.vue
@@ -11,18 +11,27 @@ import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const query = ref('')
const categoryFilter = ref(null)
+const statusFilter = ref<'all' | 'selected' | 'empty-category' | 'heavy'>('all')
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
const filteredNominations = computed(() => {
const search = query.value.trim().toLowerCase()
+ const heavyCategoryIds = new Set(categoryStats.value.filter((category) => category.pending >= 3).map((category) => category.id))
return seasonDetail.value.pendingNominations.filter((nomination) => {
const matchesCategory = !categoryFilter.value || nomination.categoryId === categoryFilter.value
+ const category = categoryMap.value[nomination.categoryId]
+ const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === nomination.categoryId).length
+ const matchesStatus =
+ statusFilter.value === 'all' ||
+ (statusFilter.value === 'selected' && !!categoryFilter.value) ||
+ (statusFilter.value === 'empty-category' && candidateCount === 0) ||
+ (statusFilter.value === 'heavy' && heavyCategoryIds.has(nomination.categoryId))
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
.join(' ')
.toLowerCase()
.includes(search)
- return matchesCategory && matchesSearch
+ return matchesCategory && matchesStatus && matchesSearch && !!category
})
})
const categoryStats = computed(() =>
@@ -39,6 +48,11 @@ const nominationStats = computed(() => [
{ label: 'Betroffene Kategorien', value: categoryStats.value.filter((category) => category.pending > 0).length, icon: Tags },
{ label: 'Kandidatenbasis', value: seasonDetail.value.candidates.length, icon: Users },
])
+const statusFilters = computed(() => [
+ { key: 'all' as const, label: 'Alle', count: seasonDetail.value.pendingNominations.length },
+ { key: 'empty-category' as const, label: 'Ohne Kandidatenbasis', count: seasonDetail.value.pendingNominations.filter((nomination) => !seasonDetail.value.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)).length },
+ { key: 'heavy' as const, label: 'Hoher Druck', count: categoryStats.value.filter((category) => category.pending >= 3).reduce((sum, category) => sum + category.pending, 0) },
+])
@@ -105,6 +119,18 @@ const nominationStats = computed(() => [
Reviews öffnen
+
+
+
@@ -120,6 +146,12 @@ const nominationStats = computed(() => [
Limit {{ categoryMap[nomination.categoryId]?.maxNomineesPerUser ?? '-' }}
+
+ erst Kandidatenbasis klaeren
+
diff --git a/frontend/src/views/admin/AdminReviewsView.vue b/frontend/src/views/admin/AdminReviewsView.vue
index d54c956..a7a7fa9 100644
--- a/frontend/src/views/admin/AdminReviewsView.vue
+++ b/frontend/src/views/admin/AdminReviewsView.vue
@@ -22,15 +22,16 @@ const reviewForms = reactive store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const reviewFilter = ref('')
+const categoryFilter = ref(null)
const selectedNominationId = ref(null)
const filteredNominations = computed(() => {
const query = reviewFilter.value.trim().toLowerCase()
- if (!query) return seasonDetail.value.pendingNominations
return seasonDetail.value.pendingNominations.filter((nomination) =>
- [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
+ (!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
+ (!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
.join(' ')
.toLowerCase()
- .includes(query),
+ .includes(query)),
)
})
const selectedNomination = computed(() =>
@@ -41,6 +42,26 @@ const reviewStats = computed(() => [
{ label: 'Sichtbar', value: filteredNominations.value.length },
{ label: 'Kategorien', value: new Set(seasonDetail.value.pendingNominations.map((nomination) => nomination.categoryName)).size },
])
+const categoryOptions = computed(() =>
+ seasonDetail.value.categories
+ .filter((category) => seasonDetail.value.pendingNominations.some((nomination) => nomination.categoryId === category.id))
+ .map((category) => ({
+ id: category.id,
+ label: category.name,
+ count: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
+ })),
+)
+const selectedCandidateCollision = computed(() => {
+ if (!selectedNomination.value) return null
+ const form = reviewForms[selectedNomination.value.id]
+ if (!form) return null
+ const normalizedName = form.displayName.trim().toLowerCase()
+ const normalizedSlug = form.channelSlug.trim().toLowerCase()
+ return seasonDetail.value.candidates.find((candidate) =>
+ candidate.categoryId === selectedNomination.value?.categoryId &&
+ (candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)),
+ ) ?? null
+})
watch(
seasonDetail,
@@ -103,6 +124,11 @@ async function rejectNomination(nominationId: number) {
reviewSaving.value = null
}
}
+
+function setPlatform(platform: string) {
+ if (!selectedNomination.value) return
+ reviewForms[selectedNomination.value.id].platform = platform
+}
@@ -148,6 +174,26 @@ async function rejectNomination(nominationId: number) {
{{ filteredNominations.length }} / {{ seasonDetail.pendingNominations.length }} sichtbar
+
+
+
+
@@ -235,6 +281,21 @@ async function rejectNomination(nominationId: number) {
/>
+
+
+
+
+ Moegliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
+
diff --git a/frontend/src/views/admin/AdminRiskView.vue b/frontend/src/views/admin/AdminRiskView.vue
index 282f5b2..1bc6c1e 100644
--- a/frontend/src/views/admin/AdminRiskView.vue
+++ b/frontend/src/views/admin/AdminRiskView.vue
@@ -1,5 +1,6 @@
@@ -51,10 +56,18 @@ const adminCounts = computed(() => {
/>
-
+
+
+
+
+
{{ stat.label }}
+
{{ stat.value }}
+
+
+
@@ -113,6 +126,9 @@ const adminCounts = computed(() => {
{{ new Date(user.createdAt).toLocaleString('de-DE') }}
{{ user.severity }}
+
+ Keine auffaelligen User fuer den aktuellen Filter.
+
diff --git a/frontend/src/views/admin/AdminVotingView.vue b/frontend/src/views/admin/AdminVotingView.vue
index 96bb61b..324a07d 100644
--- a/frontend/src/views/admin/AdminVotingView.vue
+++ b/frontend/src/views/admin/AdminVotingView.vue
@@ -1,6 +1,7 @@
@@ -102,5 +124,29 @@ const stats = computed(() => [
+
+
+ Voting Checkliste
+ Vor dem Public Push
+
+
+
+
+
{{ item.label }}
+
{{ item.note }}
+
+
+ {{ item.done ? 'ok' : 'pruefen' }}
+
+
+
+
+