Refine admin workflows and team access

This commit is contained in:
AzuTear
2026-06-26 18:09:29 +02:00
parent b7804e10ea
commit 8b69dfbafb
71 changed files with 5066 additions and 751 deletions
+233 -85
View File
@@ -1,22 +1,47 @@
<script setup lang="ts">
import { BarChart3, CheckCircle2 } from '@lucide/vue'
import { computed, ref } from 'vue'
import { BarChart3, CheckCircle2, ExternalLink, TrendingUp } from '@lucide/vue'
import { RouterLink } 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,
topCategories,
maxVotes,
topCategoriesEnriched,
winnerCoveragePct,
readinessPct,
healthSummary,
totalVotes,
} = useAdminAnalyticsManager()
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' },
]
</script>
<template>
@@ -29,6 +54,7 @@ const {
<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">
@@ -44,15 +70,46 @@ const {
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<!-- 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="rounded-2xl border border-violet-100 bg-violet-50 px-4 py-3 text-sm font-bold text-violet-800">
{{ winnerCoveragePct }}% Gewinner
<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>
@@ -75,93 +132,184 @@ const {
</div>
</Card>
<Card class="p-6">
<div>
<!-- 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 Admins zuerst prüfen sollten</h2>
</div>
<div class="mt-5 grid gap-3 md: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>
<div class="mt-6 grid gap-3 md: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>
</div>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<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">Kandidaten, Reviews und Gewinnerstatus</h2>
</div>
<div class="max-h-[520px] overflow-y-auto p-4">
<div class="grid gap-3">
<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="category in categoryHealth"
:key="category.id"
to="/admin/categories"
class="grid gap-3 rounded-[22px] border border-violet-100 bg-white p-4 transition hover:bg-violet-50/60 md:grid-cols-[minmax(0,1fr)_110px_110px_130px]"
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"
>
<span class="min-w-0">
<strong class="block truncate text-slate-950">{{ category.name }}</strong>
<span class="mt-1 block text-sm text-slate-500">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
Stimmen
</span>
<span class="inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
{{ category.status }}
</span>
<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>
</div>
</Card>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Top Kategorien</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Stimmenverteilung</h2>
<div class="mt-5 space-y-4">
<div v-for="category in topCategories.slice(0, 8)" :key="category.category">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="truncate font-semibold text-slate-800">{{ category.category }}</span>
<span class="shrink-0 text-slate-500">{{ category.votes.toLocaleString('de-DE') }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-violet-100">
<div class="h-full rounded-full bg-violet-600" :style="{ width: `${Math.max(4, Math.round((category.votes / maxVotes) * 100))}%` }"></div>
<!-- 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>
</div>
</div>
<div v-if="topCategories.length === 0" class="rounded-[22px] border border-dashed border-violet-100 p-5 text-sm text-slate-500">
Noch keine Stimmenverteilung vorhanden.
</div>
</div>
<div class="mt-6 flex items-center gap-2 rounded-[22px] border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-800">
<CheckCircle2 class="h-5 w-5 shrink-0" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
</div>
</Card>
</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"
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>