Iterate admin pages with smarter workflows
This commit is contained in:
@@ -28,6 +28,29 @@ const metricCards = computed(() => [
|
|||||||
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'in allen Kategorien', icon: Users },
|
{ 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 },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,5 +119,13 @@ const metricCards = computed(() => [
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const store = useAwardsStore()
|
|||||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
|
const statusFilter = ref<'all' | 'empty' | 'reviews' | 'thin'>('all')
|
||||||
const selectedCategoryId = ref<number | null>(null)
|
const selectedCategoryId = ref<number | null>(null)
|
||||||
const saving = ref<number | 'new' | null>(null)
|
const saving = ref<number | 'new' | null>(null)
|
||||||
const adminMessage = ref('')
|
const adminMessage = ref('')
|
||||||
@@ -45,13 +46,18 @@ const categoriesWithState = computed(() =>
|
|||||||
)
|
)
|
||||||
const filteredCategories = computed(() => {
|
const filteredCategories = computed(() => {
|
||||||
const search = query.value.trim().toLowerCase()
|
const search = query.value.trim().toLowerCase()
|
||||||
if (!search) return categoriesWithState.value
|
return categoriesWithState.value.filter((category) => {
|
||||||
return categoriesWithState.value.filter((category) =>
|
const matchesStatus =
|
||||||
[category.groupName, category.name, category.slug, category.description]
|
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(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(search),
|
.includes(search)
|
||||||
)
|
return matchesStatus && matchesSearch
|
||||||
|
})
|
||||||
})
|
})
|
||||||
const selectedCategory = computed(() =>
|
const selectedCategory = computed(() =>
|
||||||
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
|
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: 'Kandidaten', value: seasonDetail.value.candidates.length },
|
||||||
{ label: 'Reviews', value: seasonDetail.value.pendingNominations.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(
|
watch(
|
||||||
seasonDetail,
|
seasonDetail,
|
||||||
@@ -124,6 +136,20 @@ async function createCategory() {
|
|||||||
saving.value = null
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -151,6 +177,18 @@ async function createCategory() {
|
|||||||
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
|
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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-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 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.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>
|
||||||
</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>
|
<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">
|
<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.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.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">
|
<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.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" />
|
<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" />
|
||||||
|
|||||||
@@ -21,10 +21,28 @@ const clipCandidates = computed(() => {
|
|||||||
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search))
|
.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 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(() => [
|
const stats = computed(() => [
|
||||||
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags },
|
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags },
|
||||||
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
@@ -58,16 +76,17 @@ const stats = computed(() => [
|
|||||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p>
|
<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>
|
<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="mt-5 space-y-3">
|
||||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
<div
|
||||||
<p class="font-semibold text-slate-900">1. Clip-Kategorien vorhanden?</p>
|
v-for="item in clipReadiness"
|
||||||
<p class="mt-1 text-sm leading-6 text-slate-500">Kategorien mit “Clip” im Namen werden hier automatisch gebuendelt.</p>
|
:key="item.label"
|
||||||
</div>
|
class="rounded-2xl border p-4"
|
||||||
<div class="rounded-2xl border border-violet-100 bg-white/90 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">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>
|
<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>
|
||||||
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 p-4">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
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 { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
@@ -109,7 +109,7 @@ const priorityActions = computed(() => [
|
|||||||
{
|
{
|
||||||
label: 'Kategorien pflegen',
|
label: 'Kategorien pflegen',
|
||||||
value: store.adminSeasonDetail.categories.length,
|
value: store.adminSeasonDetail.categories.length,
|
||||||
to: '/admin/years',
|
to: '/admin/categories',
|
||||||
hint: 'Texte, Limits und Reihenfolge aktuell halten',
|
hint: 'Texte, Limits und Reihenfolge aktuell halten',
|
||||||
icon: Tags,
|
icon: Tags,
|
||||||
tone: 'amber',
|
tone: 'amber',
|
||||||
@@ -123,6 +123,38 @@ const priorityActions = computed(() => [
|
|||||||
tone: 'emerald',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -131,6 +163,7 @@ const priorityActions = computed(() => [
|
|||||||
eyebrow="Dashboard"
|
eyebrow="Dashboard"
|
||||||
title="Was braucht gerade Aufmerksamkeit?"
|
title="Was braucht gerade Aufmerksamkeit?"
|
||||||
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebuendelt, damit du schneller entscheiden kannst, was als Naechstes drankommt."
|
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]">
|
<section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
@@ -182,7 +215,7 @@ const priorityActions = computed(() => [
|
|||||||
<span
|
<span
|
||||||
v-for="(value, index) in metric.sparkline"
|
v-for="(value, index) in metric.sparkline"
|
||||||
:key="`${metric.label}-${index}`"
|
: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}%` }"
|
:style="{ height: `${value}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,6 +264,30 @@ const priorityActions = computed(() => [
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</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]">
|
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||||
<Card class="p-7">
|
<Card class="p-7">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
@@ -285,7 +342,7 @@ const priorityActions = computed(() => [
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
|
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
|
||||||
<div
|
<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}%` }"
|
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,18 +11,27 @@ import { useAwardsStore } from '../../stores/awards'
|
|||||||
const store = useAwardsStore()
|
const store = useAwardsStore()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const categoryFilter = ref<number | null>(null)
|
const categoryFilter = ref<number | null>(null)
|
||||||
|
const statusFilter = ref<'all' | 'selected' | 'empty-category' | 'heavy'>('all')
|
||||||
|
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
|
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
|
||||||
const filteredNominations = computed(() => {
|
const filteredNominations = computed(() => {
|
||||||
const search = query.value.trim().toLowerCase()
|
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) => {
|
return seasonDetail.value.pendingNominations.filter((nomination) => {
|
||||||
const matchesCategory = !categoryFilter.value || nomination.categoryId === categoryFilter.value
|
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]
|
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(search)
|
.includes(search)
|
||||||
return matchesCategory && matchesSearch
|
return matchesCategory && matchesStatus && matchesSearch && !!category
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const categoryStats = computed(() =>
|
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: 'Betroffene Kategorien', value: categoryStats.value.filter((category) => category.pending > 0).length, icon: Tags },
|
||||||
{ label: 'Kandidatenbasis', value: seasonDetail.value.candidates.length, icon: Users },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -105,6 +119,18 @@ const nominationStats = computed(() => [
|
|||||||
Reviews öffnen
|
Reviews öffnen
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
|
<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">
|
<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 ?? '-' }}
|
Limit {{ categoryMap[nomination.categoryId]?.maxNomineesPerUser ?? '-' }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||||
|
|||||||
@@ -22,15 +22,16 @@ const reviewForms = reactive<Record<number, {
|
|||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
const reviewFilter = ref('')
|
const reviewFilter = ref('')
|
||||||
|
const categoryFilter = ref<number | null>(null)
|
||||||
const selectedNominationId = ref<number | null>(null)
|
const selectedNominationId = ref<number | null>(null)
|
||||||
const filteredNominations = computed(() => {
|
const filteredNominations = computed(() => {
|
||||||
const query = reviewFilter.value.trim().toLowerCase()
|
const query = reviewFilter.value.trim().toLowerCase()
|
||||||
if (!query) return seasonDetail.value.pendingNominations
|
|
||||||
return seasonDetail.value.pendingNominations.filter((nomination) =>
|
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(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query),
|
.includes(query)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const selectedNomination = computed(() =>
|
const selectedNomination = computed(() =>
|
||||||
@@ -41,6 +42,26 @@ const reviewStats = computed(() => [
|
|||||||
{ label: 'Sichtbar', value: filteredNominations.value.length },
|
{ label: 'Sichtbar', value: filteredNominations.value.length },
|
||||||
{ label: 'Kategorien', value: new Set(seasonDetail.value.pendingNominations.map((nomination) => nomination.categoryName)).size },
|
{ 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(
|
watch(
|
||||||
seasonDetail,
|
seasonDetail,
|
||||||
@@ -103,6 +124,11 @@ async function rejectNomination(nominationId: number) {
|
|||||||
reviewSaving.value = null
|
reviewSaving.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPlatform(platform: string) {
|
||||||
|
if (!selectedNomination.value) return
|
||||||
|
reviewForms[selectedNomination.value.id].platform = platform
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -148,6 +174,26 @@ async function rejectNomination(nominationId: number) {
|
|||||||
{{ filteredNominations.length }} / {{ seasonDetail.pendingNominations.length }} sichtbar
|
{{ filteredNominations.length }} / {{ seasonDetail.pendingNominations.length }} sichtbar
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="space-y-4 p-6">
|
<div class="space-y-4 p-6">
|
||||||
@@ -235,6 +281,21 @@ async function rejectNomination(nominationId: number) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { Search, ShieldAlert } from '@lucide/vue'
|
||||||
|
|
||||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import Button from '../../components/ui/Button.vue'
|
import Button from '../../components/ui/Button.vue'
|
||||||
@@ -12,17 +13,18 @@ const adminMessage = ref('')
|
|||||||
const adminError = ref('')
|
const adminError = ref('')
|
||||||
const riskFilter = ref('')
|
const riskFilter = ref('')
|
||||||
const auditFilter = ref('')
|
const auditFilter = ref('')
|
||||||
|
const severityFilter = ref<'all' | 'high' | 'medium' | 'low'>('all')
|
||||||
|
|
||||||
const riskFlags = computed(() => store.admin.riskFlags)
|
const riskFlags = computed(() => store.admin.riskFlags)
|
||||||
const auditEntries = computed(() => store.admin.auditEntries)
|
const auditEntries = computed(() => store.admin.auditEntries)
|
||||||
const filteredRiskFlags = computed(() => {
|
const filteredRiskFlags = computed(() => {
|
||||||
const query = riskFilter.value.trim().toLowerCase()
|
const query = riskFilter.value.trim().toLowerCase()
|
||||||
if (!query) return riskFlags.value
|
|
||||||
return riskFlags.value.filter((flag) =>
|
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(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query),
|
.includes(query)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const filteredAuditEntries = computed(() => {
|
const filteredAuditEntries = computed(() => {
|
||||||
@@ -35,6 +37,17 @@ const filteredAuditEntries = computed(() => {
|
|||||||
.includes(query),
|
.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') {
|
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||||
riskSaving.value = riskFlagId
|
riskSaving.value = riskFlagId
|
||||||
@@ -58,6 +71,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|||||||
eyebrow="Risiko & Audit"
|
eyebrow="Risiko & Audit"
|
||||||
title="Auffaellige Muster und Admin-Aktionen verfolgen"
|
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."
|
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]">
|
<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
|
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<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 }}
|
{{ adminMessage }}
|
||||||
@@ -80,16 +100,31 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
<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
|
<input
|
||||||
v-model="riskFilter"
|
v-model="riskFilter"
|
||||||
type="text"
|
type="text"
|
||||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
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="Risikohinweise nach Typ, Nutzer oder IP filtern"
|
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">
|
<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.
|
Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprueften Fall.
|
||||||
</div>
|
</div>
|
||||||
</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 class="mt-6 space-y-4">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -39,6 +39,28 @@ const checks = computed(() => [
|
|||||||
icon: ShieldCheck,
|
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(
|
watch(
|
||||||
seasonDetail,
|
seasonDetail,
|
||||||
@@ -125,5 +147,28 @@ async function saveSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ const adminCounts = computed(() => {
|
|||||||
for (const entry of auditEntries.value) counts.set(entry.adminTwitchUserId, (counts.get(entry.adminTwitchUserId) ?? 0) + 1)
|
for (const entry of auditEntries.value) counts.set(entry.adminTwitchUserId, (counts.get(entry.adminTwitchUserId) ?? 0) + 1)
|
||||||
return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -51,10 +56,18 @@ const adminCounts = computed(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Card class="p-5">
|
<Card class="p-5">
|
||||||
|
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
<label class="relative block">
|
<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" />
|
<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" />
|
<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>
|
</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>
|
</Card>
|
||||||
|
|
||||||
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
|
<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="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>
|
<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>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { BarChart3, CheckCircle2, Tags, Users, Vote } from '@lucide/vue'
|
import { BarChart3, CheckCircle2, Tags, Users, Vote } from '@lucide/vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.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 readyCount = computed(() => votingReadiness.value.filter((category) => category.ready).length)
|
||||||
|
const notReadyCategories = computed(() => votingReadiness.value.filter((category) => !category.ready))
|
||||||
const stats = computed(() => [
|
const stats = computed(() => [
|
||||||
{ label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote },
|
{ label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote },
|
||||||
{ label: 'Voting-ready', value: readyCount.value, icon: CheckCircle2 },
|
{ label: 'Voting-ready', value: readyCount.value, icon: CheckCircle2 },
|
||||||
{ label: 'Kategorien', value: seasonDetail.value.categories.length, icon: Tags },
|
{ label: 'Kategorien', value: seasonDetail.value.categories.length, icon: Tags },
|
||||||
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, icon: Users },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -102,5 +124,29 @@ const stats = computed(() => [
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user