Files
vtuber-awards/frontend/src/views/admin/AdminAnalyticsView.vue
T
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:32:21 +02:00

363 lines
16 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { BarChart3, CheckCircle2, ExternalLink, TrendingUp } from '@lucide/vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminAnalyticsManager } from '../../components/admin/useAdminAnalyticsManager'
import Card from '../../components/ui/Card.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
const {
categoryHealth,
categoryGroups,
metricCards,
readinessCards,
insightCards,
attentionItems,
topCategoriesEnriched,
winnerCoveragePct,
readinessPct,
healthSummary,
totalVotes,
} = useAdminAnalyticsManager()
const route = useRoute()
const router = useRouter()
const healthFilter = ref<string>('Alle')
const groupFilter = ref<string>('Alle')
const groupOptions = computed(() => ['Alle', ...categoryGroups.value.map((g) => g.name)])
const filteredHealth = computed(() => {
let list = categoryHealth.value
if (healthFilter.value !== 'Alle') list = list.filter((c) => c.status === healthFilter.value)
if (groupFilter.value !== 'Alle') list = list.filter((c) => (c.groupName || 'Ohne Gruppe') === groupFilter.value)
return list
})
const healthStatusOptions = [
{ key: 'Alle', label: 'Alle' },
{ key: 'Leer', label: 'Leer', dot: 'bg-rose-400' },
{ key: 'Review offen', label: 'Review offen', dot: 'bg-amber-400' },
{ key: 'Bereit', label: 'Bereit', dot: 'bg-sky-400' },
{ key: 'Gewinner gesetzt', label: 'Gewinner', dot: 'bg-emerald-400' },
]
function applyRouteFilters() {
const rawHealth = Array.isArray(route.query.health) ? route.query.health[0] : route.query.health
const rawGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
healthFilter.value = healthStatusOptions.some((option) => option.key === rawHealth) ? String(rawHealth) : 'Alle'
groupFilter.value = groupOptions.value.includes(String(rawGroup)) ? String(rawGroup) : 'Alle'
}
watch(
() => [route.query.health, route.query.group, groupOptions.value.join('|')] as const,
() => {
applyRouteFilters()
},
{ immediate: true },
)
watch([healthFilter, groupFilter], ([nextHealth, nextGroup]) => {
const currentHealth = Array.isArray(route.query.health) ? route.query.health[0] : route.query.health
const currentGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
const normalizedHealth = nextHealth === 'Alle' ? undefined : nextHealth
const normalizedGroup = nextGroup === 'Alle' ? undefined : nextGroup
if (currentHealth === normalizedHealth && currentGroup === normalizedGroup) {
return
}
const nextQuery = { ...route.query } as Record<string, string>
if (normalizedHealth) nextQuery.health = normalizedHealth
else delete nextQuery.health
if (normalizedGroup) nextQuery.group = normalizedGroup
else delete nextQuery.group
void router.replace({ query: nextQuery })
})
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Analytics"
description="Jahresmetriken, Readiness und Kategoriegesundheit auf einen Blick."
:icon="BarChart3"
/>
<AdminSeasonToolbar />
<!-- Metric cards -->
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card v-for="metric in metricCards" :key="metric.label" class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{{ metric.label }}</p>
<strong class="mt-2 block text-3xl text-slate-950">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-1 text-sm text-slate-500">{{ metric.note }}</p>
</div>
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl border" :class="metric.tone">
<component :is="metric.icon" class="h-5 w-5" />
</span>
</div>
</Card>
</section>
<!-- Readiness + Attention row -->
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<!-- Readiness -->
<Card class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Finale Vollständigkeit</h2>
</div>
<div class="text-right">
<div class="text-2xl font-black text-violet-700">{{ winnerCoveragePct }}%</div>
<div class="text-xs font-semibold text-slate-500">Gewinner</div>
</div>
</div>
<!-- Progress bar -->
<div class="mt-4 space-y-2">
<div class="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Gesamt-Readiness</span><span>{{ readinessPct }}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div
class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all duration-500"
:style="{ width: `${readinessPct}%` }"
/>
</div>
<!-- Health segments legend -->
<div class="flex flex-wrap gap-x-4 gap-y-1 pt-1">
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-rose-600">
<span class="h-2 w-2 rounded-full bg-rose-400" />{{ healthSummary.leer }} Leer
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-amber-600">
<span class="h-2 w-2 rounded-full bg-amber-400" />{{ healthSummary.reviewOffen }} Review offen
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-sky-600">
<span class="h-2 w-2 rounded-full bg-sky-400" />{{ healthSummary.bereit }} Bereit
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600">
<span class="h-2 w-2 rounded-full bg-emerald-400" />{{ healthSummary.gewinner }} Gewinner
</span>
</div>
</div>
<div class="mt-5 grid gap-3">
<RouterLink
v-for="item in readinessCards"
:key="item.label"
:to="item.to"
class="flex items-start gap-4 rounded-[22px] border border-violet-100 bg-white p-4 transition hover:bg-violet-50/60"
>
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="item.icon" class="h-5 w-5" />
</span>
<span class="min-w-0">
<span class="block text-sm font-semibold text-slate-500">{{ item.label }}</span>
<strong class="mt-1 block text-2xl leading-tight text-slate-950">{{ item.value }}</strong>
<span class="mt-1 block text-sm leading-5 text-slate-500">{{ item.note }}</span>
</span>
</RouterLink>
</div>
</Card>
<!-- Attention + Insights -->
<div class="space-y-6">
<!-- Attention items -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aufmerksamkeit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was zuerst prüfen</h2>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<RouterLink
v-for="item in attentionItems"
:key="item.key"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:translate-y-[-1px]"
:class="item.tone"
>
<strong class="block text-3xl leading-none">{{ item.value }}</strong>
<span class="mt-3 block text-sm font-bold">{{ item.label }}</span>
<span class="mt-1 block text-xs leading-5 opacity-80">{{ item.note }}</span>
</RouterLink>
</div>
</Card>
<!-- Insight cards -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Einblicke</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kennzahlen</h2>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<div v-for="insight in insightCards" :key="insight.label" class="rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<component :is="insight.icon" class="h-5 w-5 text-violet-600" />
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
<RouterLink
v-if="'to' in insight && insight.to"
:to="insight.to"
class="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-violet-700 hover:text-violet-800 hover:underline"
>
Öffnen
<ExternalLink class="h-3.5 w-3.5" />
</RouterLink>
</div>
</div>
</Card>
</div>
</section>
<!-- Vote distribution bar chart (improved) -->
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] p-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Stimmenverteilung</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Top Kategorien nach Votes</h2>
</div>
<div class="flex items-center gap-2 rounded-2xl border border-violet-100 bg-white px-4 py-2.5">
<TrendingUp class="h-4 w-4 text-violet-600" />
<span class="text-sm font-bold text-violet-800">{{ totalVotes.toLocaleString('de-DE') }} Gesamt</span>
</div>
</div>
</div>
<div v-if="topCategoriesEnriched.length > 0" class="divide-y divide-violet-50">
<div
v-for="(cat, index) in topCategoriesEnriched"
:key="cat.category"
class="group/bar px-6 py-4 transition hover:bg-violet-50/40"
>
<div class="flex items-center gap-4">
<!-- Rank -->
<span class="w-6 shrink-0 text-center text-xs font-black text-slate-400">{{ index + 1 }}</span>
<!-- Name + bar -->
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-4">
<span class="truncate text-sm font-semibold text-slate-800">{{ cat.category }}</span>
<div class="flex shrink-0 items-center gap-3">
<!-- Pct badge -->
<span class="min-w-[44px] text-right text-xs font-bold text-violet-600 opacity-0 transition group-hover/bar:opacity-100">
{{ cat.pct }}%
</span>
<!-- Vote count -->
<span class="min-w-[56px] text-right text-sm font-semibold text-slate-600">
{{ cat.votes.toLocaleString('de-DE') }}
</span>
</div>
</div>
<div class="relative mt-2 h-2.5 overflow-hidden rounded-full bg-violet-100">
<div
class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#a78bfa)] transition-all duration-500"
:style="{ width: `${cat.barWidth}%` }"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="px-6 py-12 text-center text-sm text-slate-500">
Noch keine Stimmenverteilung vorhanden.
</div>
</Card>
<!-- Category health -->
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Status aller Kategorien</h2>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2">
<NativeSelect
v-model="groupFilter"
class="w-44"
:options="groupOptions.map((group) => ({ label: group, value: group }))"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="opt in healthStatusOptions"
:key="opt.key"
type="button"
class="flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition"
:class="healthFilter === opt.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="healthFilter = opt.key"
>
<span v-if="opt.dot" class="h-2 w-2 rounded-full" :class="opt.dot" />
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<div v-if="filteredHealth.length > 0" class="divide-y divide-violet-50">
<RouterLink
v-for="category in filteredHealth"
:key="category.id"
:to="`/admin/categories?group=${encodeURIComponent(category.groupName || 'Ohne Gruppe')}`"
class="grid items-center gap-3 px-5 py-3.5 transition hover:bg-violet-50/50 sm:grid-cols-[minmax(0,1fr)_80px_80px_100px_140px]"
>
<!-- Name + group -->
<span class="min-w-0">
<span class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" :class="category.statusDot" />
<strong class="truncate text-sm text-slate-900">{{ category.name }}</strong>
</span>
<span class="mt-0.5 block truncate text-xs text-slate-500 pl-4">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<!-- Candidates -->
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<!-- Reviews -->
<span class="text-sm" :class="category.reviews > 0 ? 'text-amber-600 font-semibold' : 'text-slate-500'">
<strong class="block" :class="category.reviews > 0 ? 'text-amber-700' : 'text-slate-900'">{{ category.reviews }}</strong>
Reviews
</span>
<!-- Votes + pct -->
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
<span class="text-violet-600 font-semibold">{{ category.votePct }}%</span>
</span>
<!-- Status badge -->
<span class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
<CheckCircle2 v-if="category.status === 'Gewinner gesetzt'" class="h-3 w-3" />
{{ category.status }}
</span>
</RouterLink>
</div>
<div v-else class="px-5 py-12 text-center text-sm text-slate-500">
Keine Kategorien für diesen Filter.
</div>
<div class="flex items-center gap-2 border-t border-violet-100 bg-emerald-50/60 px-5 py-3 text-sm text-emerald-800">
<CheckCircle2 class="h-4 w-4 shrink-0 text-emerald-500" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
<RouterLink to="/admin/categories" class="ml-auto flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800">
Kategorien bearbeiten <ExternalLink class="h-3 w-3" />
</RouterLink>
</div>
</Card>
</div>
</template>