b53c7fb736
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>
363 lines
16 KiB
Vue
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>
|