Iterate admin pages with smarter workflows

This commit is contained in:
AzuTear
2026-06-18 01:00:58 +02:00
parent 0fa763667d
commit a2d6a5edc0
10 changed files with 422 additions and 36 deletions
@@ -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.',
},
]
})
</script>
<template>
@@ -96,5 +119,13 @@ const metricCards = computed(() => [
</div>
</Card>
</section>
<section class="grid gap-4 lg:grid-cols-3">
<Card v-for="insight in insights" :key="insight.label" class="p-5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ insight.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ insight.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ insight.note }}</p>
</Card>
</section>
</div>
</template>
@@ -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<number | null>(null)
const saving = ref<number | 'new' | null>(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)
}
</script>
<template>
@@ -151,6 +177,18 @@ async function createCategory() {
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="filter in statusFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="statusFilter = filter.key"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
</div>
</div>
@@ -171,6 +209,7 @@ async function createCategory() {
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-700">{{ category.candidates }} Kandidaten</span>
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">Limit {{ category.maxNomineesPerUser }}</span>
<span v-if="category.pending" class="rounded-full bg-amber-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-700">{{ category.pending }} Reviews</span>
<span v-if="category.candidates === 0" class="rounded-full bg-rose-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-rose-700">leer</span>
</div>
</div>
<span class="rounded-xl border border-violet-100 bg-white px-2.5 py-1 text-xs font-semibold text-violet-800">#{{ category.sortOrder }}</span>
@@ -246,7 +285,12 @@ async function createCategory() {
<div class="mt-5 grid gap-4 md:grid-cols-2">
<input v-model="newCategoryForm.groupName" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Gruppe" />
<input v-model="newCategoryForm.name" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kategorie" />
<input v-model="newCategoryForm.slug" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="slug" />
<div class="flex gap-2">
<input v-model="newCategoryForm.slug" class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="slug" />
<button type="button" class="h-12 rounded-2xl border border-violet-100 bg-violet-50 px-4 text-xs font-semibold text-violet-700 transition hover:bg-violet-100" @click="fillNewSlug">
Auto
</button>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<input v-model="newCategoryForm.sortOrder" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Reihenfolge" />
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Limit" />
+28 -9
View File
@@ -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 },
])
</script>
@@ -58,16 +76,17 @@ const stats = computed(() => [
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was hier geprueft wird</h2>
<div class="mt-5 space-y-3">
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
<p class="font-semibold text-slate-900">1. Clip-Kategorien vorhanden?</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Kategorien mit Clip im Namen werden hier automatisch gebuendelt.</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-white/90 p-4">
<p class="font-semibold text-slate-900">2. Kandidaten gepflegt?</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Ohne Kandidaten kann die Community spaeter keine saubere Clip-Auswahl treffen.</p>
<div
v-for="item in clipReadiness"
:key="item.label"
class="rounded-2xl border p-4"
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/60'"
>
<p class="font-semibold text-slate-900">{{ item.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ item.note }}</p>
</div>
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 p-4">
<p class="font-semibold text-amber-800">3. Backend-Ausbau offen</p>
<p class="font-semibold text-amber-800">Naechster Backend-Ausbau</p>
<p class="mt-1 text-sm leading-6 text-amber-700">Fuer echte Clip-Moderation brauchen wir spaeter eine ClipSubmission-Admin-API mit Status, Duplikaten und Entscheidung.</p>
</div>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowDownRight, ArrowUpRight, BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
import { ArrowDownRight, ArrowUpRight, BarChart3, Clock3, LayoutDashboard, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
@@ -109,7 +109,7 @@ const priorityActions = computed(() => [
{
label: 'Kategorien pflegen',
value: store.adminSeasonDetail.categories.length,
to: '/admin/years',
to: '/admin/categories',
hint: 'Texte, Limits und Reihenfolge aktuell halten',
icon: Tags,
tone: 'amber',
@@ -123,6 +123,38 @@ const priorityActions = computed(() => [
tone: 'emerald',
},
])
const operationChecks = computed(() => {
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
)
const categoriesWithReviews = store.adminSeasonDetail.categories.filter((category) =>
store.adminSeasonDetail.pendingNominations.some((nomination) => nomination.categoryId === category.id),
)
return [
{
label: 'Kategorien ohne Kandidaten',
value: categoriesWithoutCandidates.length,
to: '/admin/categories',
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt pruefen.',
},
{
label: 'Review-Backlog verteilt',
value: categoriesWithReviews.length,
to: '/admin/nominations',
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
},
{
label: 'Risk Flags offen',
value: store.admin.riskFlags.length,
to: '/admin/risk',
state: store.admin.riskFlags.length === 0 ? 'ok' : 'danger',
note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst pruefen.',
},
]
})
</script>
<template>
@@ -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"
/>
<section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
@@ -182,7 +215,7 @@ const priorityActions = computed(() => [
<span
v-for="(value, index) in metric.sparkline"
:key="`${metric.label}-${index}`"
class="flex-1 rounded-t-full bg-gradient-to-t from-[#d8c7ff] via-[#f8c8dc] to-[#b8f1e6]"
class="flex-1 rounded-t-full bg-gradient-to-t from-[#7c5cff] to-[#c4b5fd]"
:style="{ height: `${value}%` }"
/>
</div>
@@ -231,6 +264,30 @@ const priorityActions = computed(() => [
</Card>
</section>
<section class="grid gap-4 lg:grid-cols-3">
<RouterLink
v-for="check in operationChecks"
:key="check.label"
:to="check.to"
class="rounded-[24px] border bg-white/85 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)] transition hover:-translate-y-0.5 hover:bg-violet-50/50"
:class="{
'border-emerald-100': check.state === 'ok',
'border-amber-100': check.state === 'warn',
'border-rose-100': check.state === 'danger',
}"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{{ check.label }}</p>
<strong class="mt-3 block text-3xl" :class="check.state === 'danger' ? 'text-rose-700' : check.state === 'warn' ? 'text-amber-700' : 'text-emerald-700'">
{{ check.value }}
</strong>
<p class="mt-2 text-sm leading-5 text-slate-500">{{ check.note }}</p>
</div>
</div>
</RouterLink>
</section>
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
@@ -285,7 +342,7 @@ const priorityActions = computed(() => [
</div>
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
<div
class="h-3 rounded-full bg-gradient-to-r from-[#d8c7ff] via-[#f8c8dc] to-[#b8f1e6]"
class="h-3 rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]"
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
/>
</div>
@@ -11,18 +11,27 @@ import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const query = ref('')
const categoryFilter = ref<number | null>(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) },
])
</script>
<template>
@@ -105,6 +119,18 @@ const nominationStats = computed(() => [
Reviews öffnen
</RouterLink>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="filter in statusFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="statusFilter = filter.key"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
</div>
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
@@ -120,6 +146,12 @@ const nominationStats = computed(() => [
<span class="rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
Limit {{ categoryMap[nomination.categoryId]?.maxNomineesPerUser ?? '-' }}
</span>
<span
v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)"
class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
>
erst Kandidatenbasis klaeren
</span>
</div>
</div>
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
+64 -3
View File
@@ -22,15 +22,16 @@ const reviewForms = reactive<Record<number, {
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const reviewFilter = ref('')
const categoryFilter = ref<number | null>(null)
const selectedNominationId = ref<number | null>(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
}
</script>
<template>
@@ -148,6 +174,26 @@ async function rejectNomination(nominationId: number) {
{{ filteredNominations.length }} / {{ seasonDetail.pendingNominations.length }} sichtbar
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="categoryFilter === null ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="categoryFilter = null"
>
Alle Kategorien
</button>
<button
v-for="category in categoryOptions"
:key="category.id"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="categoryFilter === category.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="categoryFilter = category.id"
>
{{ category.label }} · {{ category.count }}
</button>
</div>
</div>
<div class="space-y-4 p-6">
@@ -235,6 +281,21 @@ async function rejectNomination(nominationId: number) {
/>
</label>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="platform in ['Twitch', 'YouTube', 'TikTok']"
:key="platform"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="reviewForms[selectedNomination.id].platform === platform ? 'border-violet-200 bg-white text-violet-800' : 'border-violet-100 bg-white/70 text-slate-600 hover:bg-white'"
@click="setPlatform(platform)"
>
{{ platform }}
</button>
</div>
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Moegliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
</p>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
+40 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Search, ShieldAlert } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Button from '../../components/ui/Button.vue'
@@ -12,17 +13,18 @@ const adminMessage = ref('')
const adminError = ref('')
const riskFilter = ref('')
const auditFilter = ref('')
const severityFilter = ref<'all' | 'high' | 'medium' | 'low'>('all')
const riskFlags = computed(() => store.admin.riskFlags)
const auditEntries = computed(() => store.admin.auditEntries)
const filteredRiskFlags = computed(() => {
const query = riskFilter.value.trim().toLowerCase()
if (!query) return riskFlags.value
return riskFlags.value.filter((flag) =>
[flag.source, flag.type, flag.summary, flag.twitchUserId ?? '', flag.createdFromIp]
(severityFilter.value === 'all' || flag.severity.toLowerCase() === severityFilter.value) &&
(!query || [flag.source, flag.type, flag.summary, flag.twitchUserId ?? '', flag.createdFromIp]
.join(' ')
.toLowerCase()
.includes(query),
.includes(query)),
)
})
const filteredAuditEntries = computed(() => {
@@ -35,6 +37,17 @@ const filteredAuditEntries = computed(() => {
.includes(query),
)
})
const riskStats = computed(() => [
{ label: 'Offen', value: riskFlags.value.length },
{ label: 'High', value: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
{ label: 'User betroffen', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
])
const severityFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: riskFlags.value.length },
{ key: 'high' as const, label: 'High', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
{ key: 'medium' as const, label: 'Medium', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'medium').length },
{ key: 'low' as const, label: 'Low', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'low').length },
])
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
riskSaving.value = riskFlagId
@@ -58,6 +71,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
eyebrow="Risiko & Audit"
title="Auffaellige Muster und Admin-Aktionen verfolgen"
description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgefuehrte Eingriffe deutlich schneller."
:icon="ShieldAlert"
/>
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
@@ -71,6 +85,12 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
</span>
</div>
<div class="mt-5 grid gap-2 sm:grid-cols-3">
<div v-for="stat in riskStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
@@ -80,16 +100,31 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
</p>
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="riskFilter"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Risikohinweise nach Typ, Nutzer oder IP filtern"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Typ, Nutzer oder IP filtern"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprueften Fall.
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="filter in severityFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="severityFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="severityFilter = filter.key"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
<div class="mt-6 space-y-4">
<div
@@ -39,6 +39,28 @@ const checks = computed(() => [
icon: ShieldCheck,
},
])
const featureGates = computed(() => [
{
label: 'Nominierungen',
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
note: 'Public-Nominierungen sollten nur im passenden Zeitraum aktiv sein.',
},
{
label: 'Voting',
state: seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
note: 'Voting sollte erst aktiv sein, wenn Kategorien und Kandidaten gepflegt sind.',
},
{
label: 'Community-only Ergebnis',
state: true,
note: 'Aktuell als Community-basierte Auswertung geplant.',
},
{
label: 'Clip-Moderation',
state: false,
note: 'Admin-API fuer ClipSubmissions fehlt noch und sollte spaeter ergaenzt werden.',
},
])
watch(
seasonDetail,
@@ -125,5 +147,28 @@ async function saveSettings() {
</div>
</Card>
</section>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Feature Gates</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was ist aktuell aktiv?</h2>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<div
v-for="gate in featureGates"
:key="gate.label"
class="rounded-[22px] border p-4"
:class="gate.state ? 'border-emerald-100 bg-emerald-50/40' : 'border-slate-100 bg-slate-50/70'"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ gate.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ gate.note }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="gate.state ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600'">
{{ gate.state ? 'aktiv' : 'inaktiv' }}
</span>
</div>
</div>
</div>
</Card>
</div>
</template>
@@ -39,6 +39,11 @@ const adminCounts = computed(() => {
for (const entry of auditEntries.value) counts.set(entry.adminTwitchUserId, (counts.get(entry.adminTwitchUserId) ?? 0) + 1)
return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
})
const logStats = computed(() => [
{ label: 'Audit-Eintraege', value: auditEntries.value.length },
{ label: 'Admins aktiv', value: adminCounts.value.length },
{ label: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
])
</script>
<template>
@@ -51,10 +56,18 @@ const adminCounts = computed(() => {
/>
<Card class="p-5">
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_420px]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Nach Admin, Aktion, User, IP oder Objekt suchen" />
</label>
<div class="grid grid-cols-3 gap-2">
<div v-for="stat in logStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
</div>
</Card>
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
@@ -113,6 +126,9 @@ const adminCounts = computed(() => {
<span class="text-sm text-slate-500">{{ new Date(user.createdAt).toLocaleString('de-DE') }}</span>
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span>
</div>
<p v-if="filteredRiskUsers.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Keine auffaelligen User fuer den aktuellen Filter.
</p>
</div>
</Card>
</div>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BarChart3, CheckCircle2, Tags, Users, Vote } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
@@ -22,12 +23,33 @@ const votingReadiness = computed(() =>
}),
)
const readyCount = computed(() => votingReadiness.value.filter((category) => category.ready).length)
const notReadyCategories = computed(() => votingReadiness.value.filter((category) => !category.ready))
const stats = computed(() => [
{ label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote },
{ label: 'Voting-ready', value: readyCount.value, icon: CheckCircle2 },
{ label: 'Kategorien', value: seasonDetail.value.categories.length, icon: Tags },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, icon: Users },
])
const votingChecklist = computed(() => [
{
label: 'Voting-Phase aktiv',
done: seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
note: seasonDetail.value.currentPhase || 'Keine Phase gesetzt',
to: '/admin/settings',
},
{
label: 'Alle Kategorien haben Kandidaten',
done: notReadyCategories.value.every((category) => category.candidateCount > 0) && seasonDetail.value.categories.length > 0,
note: `${notReadyCategories.value.filter((category) => category.candidateCount === 0).length} Kategorien ohne Kandidaten`,
to: '/admin/categories',
},
{
label: 'Offene Reviews niedrig',
done: seasonDetail.value.pendingNominations.length === 0,
note: `${seasonDetail.value.pendingNominations.length} offene Reviews`,
to: '/admin/reviews',
},
])
</script>
<template>
@@ -102,5 +124,29 @@ const stats = computed(() => [
</div>
</Card>
</section>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Voting Checkliste</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Vor dem Public Push</h2>
<div class="mt-5 grid gap-3 lg:grid-cols-3">
<RouterLink
v-for="item in votingChecklist"
:key="item.label"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:-translate-y-0.5 hover:bg-violet-50/50"
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/50'"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ item.label }}</p>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.done ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'">
{{ item.done ? 'ok' : 'pruefen' }}
</span>
</div>
</RouterLink>
</div>
</Card>
</div>
</template>