Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -1,65 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BarChart3, Clock3, Sparkles, Tags, Users, Vote } from '@lucide/vue'
|
||||
import { BarChart3, CheckCircle2 } 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 { getVoteMetricValue } from '../../lib/adminMetrics'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
|
||||
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
|
||||
const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1))
|
||||
const categoryHealth = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => ({
|
||||
name: category.name,
|
||||
groupName: category.groupName,
|
||||
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||
reviews: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||
}))
|
||||
.sort((a, b) => b.candidates - a.candidates || b.reviews - a.reviews),
|
||||
)
|
||||
const metricCards = computed(() => [
|
||||
{ label: 'Nominierungen', value: totalNominations.value, note: 'gesamt im Jahr', icon: Sparkles },
|
||||
{ label: 'Stimmen', value: totalVotes.value, note: 'alle Votes', icon: Vote },
|
||||
{ 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 },
|
||||
])
|
||||
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 einzuschätzen, 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.',
|
||||
},
|
||||
]
|
||||
})
|
||||
const {
|
||||
categoryHealth,
|
||||
metricCards,
|
||||
readinessCards,
|
||||
insightCards,
|
||||
attentionItems,
|
||||
topCategories,
|
||||
maxVotes,
|
||||
winnerCoveragePct,
|
||||
} = useAdminAnalyticsManager()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Analytics"
|
||||
title="Zahlen, die Entscheidungen helfen"
|
||||
description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Lücken oder Backlog entstehen."
|
||||
description="Jahresmetriken, Readiness und Kategoriegesundheit auf einen Blick."
|
||||
:icon="BarChart3"
|
||||
/>
|
||||
|
||||
@@ -69,63 +33,134 @@ const insights = computed(() => {
|
||||
<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-violet-500">{{ metric.label }}</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
|
||||
<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>
|
||||
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<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" />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<Card class="p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Vote Performance</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top-Kategorien</h2>
|
||||
<div class="mt-6 space-y-4">
|
||||
<div v-for="category in store.admin.topCategories" :key="category.category" class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p class="font-semibold text-slate-900">{{ category.category }}</p>
|
||||
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="mt-3 h-3 overflow-hidden rounded-full bg-[#f3ecff]">
|
||||
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6,#f8d7a4)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie Health</p>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Abdeckung</h2>
|
||||
</div>
|
||||
<Tags class="h-6 w-6 text-violet-500" />
|
||||
</div>
|
||||
<Card class="p-6">
|
||||
<div>
|
||||
<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="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
|
||||
<div v-for="category in categoryHealth" :key="category.name" class="grid grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.reviews }} offene Reviews</p>
|
||||
</div>
|
||||
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">
|
||||
{{ category.candidates }} Kandidaten
|
||||
</span>
|
||||
|
||||
<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-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>
|
||||
<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">
|
||||
<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]"
|
||||
>
|
||||
<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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user