Refine admin workflows and team access
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user