Expand admin navigation and pages

This commit is contained in:
AzuTear
2026-06-18 00:21:40 +02:00
parent 178f014a4a
commit 0fa763667d
9 changed files with 1097 additions and 35 deletions
+63
View File
@@ -2,11 +2,18 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import AdminCandidatesView from './views/admin/AdminCandidatesView.vue' import AdminCandidatesView from './views/admin/AdminCandidatesView.vue'
import AdminAnalyticsView from './views/admin/AdminAnalyticsView.vue'
import AdminCategoriesView from './views/admin/AdminCategoriesView.vue'
import AdminClipsView from './views/admin/AdminClipsView.vue'
import AdminDashboardView from './views/admin/AdminDashboardView.vue' import AdminDashboardView from './views/admin/AdminDashboardView.vue'
import AdminLayoutView from './views/admin/AdminLayoutView.vue' import AdminLayoutView from './views/admin/AdminLayoutView.vue'
import AdminNominationsView from './views/admin/AdminNominationsView.vue'
import AdminReviewsView from './views/admin/AdminReviewsView.vue' import AdminReviewsView from './views/admin/AdminReviewsView.vue'
import AdminRiskView from './views/admin/AdminRiskView.vue' import AdminRiskView from './views/admin/AdminRiskView.vue'
import AdminSeasonsView from './views/admin/AdminSeasonsView.vue' import AdminSeasonsView from './views/admin/AdminSeasonsView.vue'
import AdminSettingsView from './views/admin/AdminSettingsView.vue'
import AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue'
import AdminVotingView from './views/admin/AdminVotingView.vue'
import HomeView from './views/HomeView.vue' import HomeView from './views/HomeView.vue'
import NominationsView from './views/NominationsView.vue' import NominationsView from './views/NominationsView.vue'
import VotingView from './views/VotingView.vue' import VotingView from './views/VotingView.vue'
@@ -85,6 +92,30 @@ const router = createRouter({
keepAlive: true, keepAlive: true,
}, },
}, },
{
path: 'nominations',
name: 'admin-nominations',
component: AdminNominationsView,
meta: {
keepAlive: true,
},
},
{
path: 'voting',
name: 'admin-voting',
component: AdminVotingView,
meta: {
keepAlive: true,
},
},
{
path: 'categories',
name: 'admin-categories',
component: AdminCategoriesView,
meta: {
keepAlive: true,
},
},
{ {
path: 'candidates', path: 'candidates',
name: 'admin-candidates', name: 'admin-candidates',
@@ -93,6 +124,14 @@ const router = createRouter({
keepAlive: true, keepAlive: true,
}, },
}, },
{
path: 'clips',
name: 'admin-clips',
component: AdminClipsView,
meta: {
keepAlive: true,
},
},
{ {
path: 'reviews', path: 'reviews',
name: 'admin-reviews', name: 'admin-reviews',
@@ -109,6 +148,30 @@ const router = createRouter({
keepAlive: true, keepAlive: true,
}, },
}, },
{
path: 'users-logs',
name: 'admin-users-logs',
component: AdminUsersLogsView,
meta: {
keepAlive: true,
},
},
{
path: 'analytics',
name: 'admin-analytics',
component: AdminAnalyticsView,
meta: {
keepAlive: true,
},
},
{
path: 'settings',
name: 'admin-settings',
component: AdminSettingsView,
meta: {
keepAlive: true,
},
},
], ],
}, },
], ],
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BarChart3, Clock3, Sparkles, Tags, Users, Vote } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const totalVotes = computed(() => store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0)
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 },
])
</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, Luecken oder Backlog entstehen."
:icon="BarChart3"
/>
<AdminSeasonToolbar />
<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-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>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="metric.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
<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>
</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>
</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>
</div>
</Card>
</section>
</div>
</template>
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { Layers3, PlusCircle, Search, Tags } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const seasonDetail = computed(() => store.adminSeasonDetail)
const query = ref('')
const selectedCategoryId = ref<number | null>(null)
const saving = ref<number | 'new' | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const editForms = reactive<Record<number, {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}>>({})
const newCategoryForm = reactive({
groupName: '',
name: '',
slug: '',
description: '',
sortOrder: 1,
maxNomineesPerUser: 3,
})
const categoriesWithState = computed(() =>
seasonDetail.value.categories
.map((category) => ({
...category,
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
}))
.sort((a, b) => a.sortOrder - b.sortOrder),
)
const filteredCategories = computed(() => {
const search = query.value.trim().toLowerCase()
if (!search) return categoriesWithState.value
return categoriesWithState.value.filter((category) =>
[category.groupName, category.name, category.slug, category.description]
.join(' ')
.toLowerCase()
.includes(search),
)
})
const selectedCategory = computed(() =>
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
)
const categoryStats = computed(() => [
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
{ label: 'Reviews', value: seasonDetail.value.pendingNominations.length },
])
watch(
seasonDetail,
(detail) => {
for (const category of detail.categories) {
editForms[category.id] = {
groupName: category.groupName,
name: category.name,
slug: category.slug,
description: category.description,
sortOrder: category.sortOrder,
maxNomineesPerUser: category.maxNomineesPerUser,
}
}
newCategoryForm.sortOrder = detail.categories.length + 1
if (!detail.categories.some((category) => category.id === selectedCategoryId.value)) {
selectedCategoryId.value = detail.categories[0]?.id ?? null
}
},
{ immediate: true },
)
watch(filteredCategories, (categories) => {
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
selectedCategoryId.value = categories[0]?.id ?? null
}
})
async function saveCategory(categoryId: number) {
if (!selectedSeasonId.value) return
saving.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
adminMessage.value = 'Kategorie gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
} finally {
saving.value = null
}
}
async function createCategory() {
if (!selectedSeasonId.value) return
saving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
adminMessage.value = 'Kategorie angelegt.'
newCategoryForm.groupName = ''
newCategoryForm.name = ''
newCategoryForm.slug = ''
newCategoryForm.description = ''
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
newCategoryForm.maxNomineesPerUser = 3
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
} finally {
saving.value = null
}
}
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Kategorien"
title="Award-Struktur pflegen"
description="Eine kompakte Arbeitsansicht fuer viele Kategorien: links filtern und auswaehlen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten."
:icon="Tags"
/>
<AdminSeasonToolbar />
<section class="grid gap-6 xl:grid-cols-[minmax(320px,0.82fr)_minmax(0,1.18fr)]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="grid gap-3">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kategorie, Gruppe oder Slug suchen" />
</label>
<div class="grid grid-cols-3 gap-2">
<div v-for="stat in categoryStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
</div>
</div>
<div class="max-h-[700px] space-y-2 overflow-y-auto p-4">
<button
v-for="category in filteredCategories"
:key="category.id"
type="button"
class="w-full rounded-2xl border p-3 text-left transition"
:class="selectedCategory?.id === category.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:bg-violet-50/50'"
@click="selectedCategoryId = category.id"
>
<div class="flex items-start justify-between gap-3">
<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.slug }}</p>
<div class="mt-2 flex flex-wrap gap-1.5">
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-700">{{ category.candidates }} Kandidaten</span>
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">Limit {{ category.maxNomineesPerUser }}</span>
<span v-if="category.pending" class="rounded-full bg-amber-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-700">{{ category.pending }} Reviews</span>
</div>
</div>
<span class="rounded-xl border border-violet-100 bg-white px-2.5 py-1 text-xs font-semibold text-violet-800">#{{ category.sortOrder }}</span>
</div>
</button>
</div>
</Card>
<div class="space-y-6">
<Card v-if="selectedCategory" class="p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie bearbeiten</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ selectedCategory.description }}</p>
</div>
<div class="grid h-12 w-12 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<Layers3 class="h-5 w-5" />
</div>
</div>
<p v-if="adminMessage" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
<input v-model="editForms[selectedCategory.id].groupName" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input v-model="editForms[selectedCategory.id].name" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<input v-model="editForms[selectedCategory.id].slug" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input v-model="editForms[selectedCategory.id].sortOrder" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
<input v-model="editForms[selectedCategory.id].maxNomineesPerUser" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
</div>
</div>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<div class="mt-5 flex justify-end">
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
{{ saving === selectedCategory.id ? 'Speichert ...' : 'Kategorie speichern' }}
</Button>
</div>
</Card>
<Card class="p-6">
<div class="flex items-start gap-4">
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<PlusCircle class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
<h2 class="mt-1 font-[Cormorant_Garamond] text-3xl text-violet-800">Kategorie anlegen</h2>
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<input v-model="newCategoryForm.groupName" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Gruppe" />
<input v-model="newCategoryForm.name" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kategorie" />
<input v-model="newCategoryForm.slug" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="slug" />
<div class="grid gap-4 sm:grid-cols-2">
<input v-model="newCategoryForm.sortOrder" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Reihenfolge" />
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Limit" />
</div>
</div>
<textarea v-model="newCategoryForm.description" class="mt-4 min-h-20 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Beschreibung" />
<div class="mt-4 flex justify-end">
<Button :disabled="saving === 'new' || !selectedSeasonId" @click="createCategory">
{{ saving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
</Button>
</div>
</Card>
</div>
</section>
</div>
</template>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ExternalLink, Film, Search, Tags, Users } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const query = ref('')
const seasonDetail = computed(() => store.adminSeasonDetail)
const clipCategories = computed(() =>
seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')),
)
const clipCategoryIds = computed(() => new Set(clipCategories.value.map((category) => category.id)))
const clipCandidates = computed(() => {
const search = query.value.trim().toLowerCase()
return seasonDetail.value.candidates
.filter((candidate) => clipCategoryIds.value.has(candidate.categoryId))
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search))
})
const candidateCategory = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])))
const stats = computed(() => [
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags },
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users },
{ label: 'Offene Reviews', value: seasonDetail.value.pendingNominations.filter((nomination) => clipCategoryIds.value.has(nomination.categoryId)).length, icon: Film },
])
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Clips"
title="Clip-Kategorien im Blick behalten"
description="Bis ein eigener Clip-Review-Endpunkt existiert, zeigt diese Seite die operativen Clip-Kategorien, Kandidaten und offenen Review-Faelle kompakt an."
:icon="Film"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 lg:grid-cols-3">
<Card v-for="stat in stats" :key="stat.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">{{ stat.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ stat.value }}</strong>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="stat.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was hier geprueft wird</h2>
<div class="mt-5 space-y-3">
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
<p class="font-semibold text-slate-900">1. Clip-Kategorien vorhanden?</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Kategorien mit Clip im Namen werden hier automatisch gebuendelt.</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-white/90 p-4">
<p class="font-semibold text-slate-900">2. Kandidaten gepflegt?</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Ohne Kandidaten kann die Community spaeter keine saubere Clip-Auswahl treffen.</p>
</div>
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 p-4">
<p class="font-semibold text-amber-800">3. Backend-Ausbau offen</p>
<p class="mt-1 text-sm leading-6 text-amber-700">Fuer echte Clip-Moderation brauchen wir spaeter eine ClipSubmission-Admin-API mit Status, Duplikaten und Entscheidung.</p>
</div>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Clip-Kandidat, Handle oder Plattform suchen" />
</label>
</div>
<div class="divide-y divide-violet-50">
<div v-for="candidate in clipCandidates" :key="candidate.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ candidate.displayName }}</p>
<p class="mt-1 text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }} · {{ candidateCategory[candidate.categoryId] }}</p>
</div>
<span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
<ExternalLink class="h-3.5 w-3.5" />
Clip-Link spaeter
</span>
</div>
<p v-if="clipCandidates.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Keine Clip-Kandidaten gefunden. Lege zuerst eine Clip-Kategorie und passende Kandidaten an.
</p>
</div>
</Card>
</section>
</div>
</template>
+62 -15
View File
@@ -1,7 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router' import { RouterLink, RouterView, useRoute } from 'vue-router'
import { AlertTriangle, CalendarCog, LayoutDashboard, Sparkles, Users } from '@lucide/vue' import {
AlertTriangle,
BarChart3,
CalendarCog,
ClipboardList,
Film,
LayoutDashboard,
Settings,
Sparkles,
Tags,
UserCog,
Users,
Vote,
} from '@lucide/vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
@@ -11,12 +24,39 @@ const route = useRoute()
const store = useAwardsStore() const store = useAwardsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const navItems = [ const navGroups = [
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Status, Trends, Schnellzugriffe', icon: LayoutDashboard, badge: () => null }, {
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus, Kategorien, Limits', icon: CalendarCog, badge: () => `${store.adminSeasonDetail.categories.length}` }, label: 'Betrieb',
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pro Jahr pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` }, items: [
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Nominierungen bearbeiten', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` }, { label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, badge: () => null },
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Hinweise pruefen, Aktionen nachvollziehen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` }, { label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Voting', to: '/admin/voting', description: 'Stimmen und Readiness', icon: Vote, badge: () => `${store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0}` },
],
},
{
label: 'Inhalte',
items: [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Kategorien pruefen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length}` },
],
},
{
label: 'Kontrolle',
items: [
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Faelle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags pruefen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
{ label: 'User & Logs', to: '/admin/users-logs', description: 'User-Spuren und Aktionen', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` },
],
},
{
label: 'Auswertung',
items: [
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, badge: () => `${store.admin.topCategories.length}` },
{ label: 'Einstellungen', to: '/admin/settings', description: 'Public-Status und Checks', icon: Settings, badge: () => null },
],
},
] ]
const currentSeason = computed(() => store.adminSeasonDetail) const currentSeason = computed(() => store.adminSeasonDetail)
@@ -26,6 +66,10 @@ const seasonSummary = computed(() => [
{ label: 'Reviews', value: currentSeason.value.pendingNominations.length }, { label: 'Reviews', value: currentSeason.value.pendingNominations.length },
]) ])
function isActive(to: string) {
return route.path === to
}
onMounted(async () => { onMounted(async () => {
if (!authStore.isAdmin) return if (!authStore.isAdmin) return
await store.initializeAdminWorkspace() await store.initializeAdminWorkspace()
@@ -34,21 +78,23 @@ onMounted(async () => {
<template> <template>
<div class="pb-10"> <div class="pb-10">
<div class="grid gap-6 xl:grid-cols-[260px_minmax(0,1fr)]"> <div class="grid gap-6 xl:grid-cols-[292px_minmax(0,1fr)]">
<aside class="space-y-3 xl:sticky xl:top-4 xl:h-fit"> <aside class="space-y-3 xl:sticky xl:top-4 xl:h-fit">
<Card class="p-3"> <Card class="p-3">
<nav class="space-y-2"> <nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
<p class="px-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-400">{{ group.label }}</p>
<RouterLink <RouterLink
v-for="item in navItems" v-for="item in group.items"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="group block rounded-2xl border px-3.5 py-3 transition" class="group block rounded-2xl border px-3 py-2.5 transition"
:class="route.path === item.to ? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#f7eef8] text-violet-950 shadow-[0_14px_34px_rgba(168,145,214,0.14)]' : 'border-transparent bg-white/55 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'" :class="isActive(item.to) ? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#f7eef8] text-violet-950 shadow-[0_14px_34px_rgba(168,145,214,0.14)]' : 'border-transparent bg-white/55 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl transition" class="grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
:class="route.path === item.to ? 'bg-white text-violet-700 shadow-sm' : 'bg-violet-50 text-violet-600 group-hover:bg-white'" :class="isActive(item.to) ? 'bg-white text-violet-700 shadow-sm' : 'bg-violet-50 text-violet-600 group-hover:bg-white'"
> >
<component :is="item.icon" class="h-4.5 w-4.5" /> <component :is="item.icon" class="h-4.5 w-4.5" />
</div> </div>
@@ -57,7 +103,7 @@ onMounted(async () => {
<p class="truncate text-sm font-semibold leading-5">{{ item.label }}</p> <p class="truncate text-sm font-semibold leading-5">{{ item.label }}</p>
<span <span
v-if="item.badge()" v-if="item.badge()"
class="grid h-7 min-w-7 shrink-0 place-items-center rounded-full border border-violet-200 bg-white px-2 text-xs font-semibold text-violet-700 shadow-sm" class="grid h-6 min-w-6 shrink-0 place-items-center rounded-full border border-violet-200 bg-white px-2 text-[11px] font-semibold text-violet-700 shadow-sm"
> >
{{ item.badge() }} {{ item.badge() }}
</span> </span>
@@ -66,6 +112,7 @@ onMounted(async () => {
</div> </div>
</div> </div>
</RouterLink> </RouterLink>
</section>
</nav> </nav>
</Card> </Card>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ClipboardList, Search, Sparkles, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const query = ref('')
const categoryFilter = ref<number | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
const filteredNominations = computed(() => {
const search = query.value.trim().toLowerCase()
return seasonDetail.value.pendingNominations.filter((nomination) => {
const matchesCategory = !categoryFilter.value || nomination.categoryId === categoryFilter.value
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
.join(' ')
.toLowerCase()
.includes(search)
return matchesCategory && matchesSearch
})
})
const categoryStats = computed(() =>
seasonDetail.value.categories
.map((category) => ({
...category,
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
}))
.sort((a, b) => b.pending - a.pending || a.sortOrder - b.sortOrder),
)
const nominationStats = computed(() => [
{ label: 'Offene Nominierungen', value: seasonDetail.value.pendingNominations.length, icon: Sparkles },
{ label: 'Betroffene Kategorien', value: categoryStats.value.filter((category) => category.pending > 0).length, icon: Tags },
{ label: 'Kandidatenbasis', value: seasonDetail.value.candidates.length, icon: Users },
])
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Nominierungen"
title="Eingang und Backlog verstehen"
description="Hier siehst du, wo Freitext-Nominierungen auflaufen. Die eigentliche Entscheidung bleibt im Review-Bereich, aber diese Ansicht zeigt dir schneller, welche Kategorien Aufmerksamkeit brauchen."
:icon="ClipboardList"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 lg:grid-cols-3">
<Card v-for="stat in nominationStats" :key="stat.label" class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ stat.label }}</p>
<strong class="mt-3 block text-4xl text-violet-900">{{ stat.value.toLocaleString('de-DE') }}</strong>
</div>
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="stat.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Wo staut es sich?</h2>
</div>
<div class="divide-y divide-violet-50">
<button
v-for="category in categoryStats"
:key="category.id"
type="button"
class="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4 text-left transition hover:bg-violet-50/50"
:class="categoryFilter === category.id ? 'bg-violet-50/80' : ''"
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
>
<span class="min-w-0">
<span class="block truncate font-semibold text-slate-900">{{ category.name }}</span>
<span class="mt-1 block truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidates }} Kandidaten</span>
</span>
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-sm font-semibold text-violet-800">{{ category.pending }}</span>
</button>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="query"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Nach Kandidat, User oder Kategorie suchen"
/>
</label>
<RouterLink to="/admin/reviews" class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500">
Reviews öffnen
</RouterLink>
</div>
</div>
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="nomination in filteredNominations" :key="nomination.id" class="px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ nomination.categoryName }}</p>
<h3 class="mt-1 truncate text-lg font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
<p class="mt-1 text-sm text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<span class="rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
Limit {{ categoryMap[nomination.categoryId]?.maxNomineesPerUser ?? '-' }}
</span>
</div>
</div>
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Keine Nominierungen passen zum aktuellen Filter.
</p>
</div>
</Card>
</section>
</div>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Database, Settings, ShieldCheck } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const saving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const seasonDetail = computed(() => store.adminSeasonDetail)
const form = reactive({
currentPhase: '',
isCurrent: false,
})
const checks = computed(() => [
{
label: 'Backend verbunden',
value: store.apiMode === 'api',
note: store.apiMode === 'api' ? 'Admin-Daten kommen aus der API.' : 'Fallback-Daten aktiv oder API nicht erreichbar.',
icon: Database,
},
{
label: 'Public-Jahr gesetzt',
value: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist oeffentlich markiert.' : 'Dieses Jahr ist aktuell intern.',
icon: CheckCircle2,
},
{
label: 'Review-Schutz aktiv',
value: store.admin.riskFlags.length >= 0,
note: `${store.admin.riskFlags.length} Risikohinweise im Admin-Kontext.`,
icon: ShieldCheck,
},
])
watch(
seasonDetail,
(detail) => {
form.currentPhase = detail.currentPhase
form.isCurrent = detail.isCurrent
},
{ immediate: true },
)
async function saveSettings() {
if (!selectedSeasonId.value) return
saving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: form.currentPhase,
isCurrent: form.isCurrent,
})
adminMessage.value = 'Einstellungen gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Einstellungen konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Einstellungen"
title="Public-Status und Systemchecks"
description="Hier liegen bewusst nur Einstellungen, die das aktuelle Award-Jahr oder die Admin-Betriebsbereitschaft betreffen. Kategorie-Inhalte bleiben in Kategorien/Jahre."
:icon="Settings"
/>
<AdminSeasonToolbar />
<section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Award-Jahr</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Sichtbarkeit steuern</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Diese Einstellungen werden gespeichert und beeinflussen, welches Jahr als aktueller Public-Kontext gilt.
</p>
<div class="mt-6 space-y-5">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Phase</span>
<input v-model="form.currentPhase" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Community Voting" />
</label>
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span>
<span class="block font-semibold text-slate-900">Dieses Award-Jahr oeffentlich markieren</span>
<span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext fuer Community, Voting und spaeter Archiv.</span>
</span>
</label>
</div>
<p v-if="adminMessage" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="mt-6 flex justify-end">
<Button :disabled="saving || !selectedSeasonId" @click="saveSettings">{{ saving ? 'Speichert ...' : 'Einstellungen speichern' }}</Button>
</div>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Checks</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Betriebsstatus</h2>
<div class="mt-6 space-y-3">
<div v-for="check in checks" :key="check.label" class="flex gap-4 rounded-[22px] border border-violet-100 bg-white/90 p-4">
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl" :class="check.value ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
<component :is="check.icon" class="h-5 w-5" />
</div>
<div>
<p class="font-semibold text-slate-900">{{ check.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ check.note }}</p>
</div>
</div>
</div>
</Card>
</section>
</div>
</template>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Search, ShieldAlert, UserCog } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const query = ref('')
const auditEntries = computed(() => store.admin.auditEntries)
const riskFlags = computed(() => store.admin.riskFlags)
const filteredAuditEntries = computed(() => {
const search = query.value.trim().toLowerCase()
if (!search) return auditEntries.value
return auditEntries.value.filter((entry) =>
[entry.adminTwitchUserId, entry.actionType, entry.entityType, entry.entityId, entry.summary]
.join(' ')
.toLowerCase()
.includes(search),
)
})
const filteredRiskUsers = computed(() => {
const search = query.value.trim().toLowerCase()
const users = riskFlags.value.map((flag) => ({
id: flag.id,
twitchUserId: flag.twitchUserId ?? 'unbekannt',
source: flag.source,
type: flag.type,
severity: flag.severity,
ip: flag.createdFromIp,
createdAt: flag.createdAt,
}))
if (!search) return users
return users.filter((user) => [user.twitchUserId, user.source, user.type, user.ip].join(' ').toLowerCase().includes(search))
})
const adminCounts = computed(() => {
const counts = new Map<string, number>()
for (const entry of auditEntries.value) counts.set(entry.adminTwitchUserId, (counts.get(entry.adminTwitchUserId) ?? 0) + 1)
return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
})
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="User & Logs"
title="User-Spuren und Admin-Aktionen"
description="Eine kompakte Kontrollansicht fuer Audit-Eintraege, auffaellige User und Admin-Aktivitaet. Fuer Detailentscheidungen bleibt Risiko & Audit der Hauptbereich."
:icon="UserCog"
/>
<Card class="p-5">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Nach Admin, Aktion, User, IP oder Objekt suchen" />
</label>
</Card>
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Admins</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Aktivitaet</h2>
<div class="mt-5 space-y-3">
<div v-for="item in adminCounts" :key="item.admin" class="flex items-center justify-between rounded-2xl border border-violet-100 bg-white/90 px-4 py-3">
<span class="font-semibold text-slate-900">{{ item.admin }}</span>
<span class="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">{{ item.count }}</span>
</div>
<p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-8 text-center text-sm text-slate-500">
Noch keine Admin-Aktivitaet vorhanden.
</p>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Audit Log</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Letzte Aktionen</h2>
</div>
<div class="max-h-[520px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="entry in filteredAuditEntries" :key="entry.id" class="px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ entry.summary }}</p>
<p class="mt-1 text-sm text-slate-500">{{ entry.adminTwitchUserId }} · {{ entry.actionType }} · {{ entry.entityType }} {{ entry.entityId }}</p>
</div>
<span class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</span>
</div>
</div>
<p v-if="filteredAuditEntries.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">Keine Log-Eintraege gefunden.</p>
</div>
</Card>
</section>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex items-center gap-3">
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-rose-50 text-rose-600">
<ShieldAlert class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffaellige User</p>
<h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Aus Risk-Flags abgeleitet</h2>
</div>
</div>
</div>
<div class="divide-y divide-violet-50">
<div v-for="user in filteredRiskUsers" :key="user.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_180px_140px] lg:items-center">
<div>
<p class="font-semibold text-slate-900">{{ user.twitchUserId }}</p>
<p class="mt-1 text-sm text-slate-500">{{ user.type }} · {{ user.ip }}</p>
</div>
<span class="text-sm text-slate-500">{{ new Date(user.createdAt).toLocaleString('de-DE') }}</span>
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span>
</div>
</div>
</Card>
</div>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BarChart3, CheckCircle2, Tags, Users, Vote } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const totalVotes = computed(() => store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0)
const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1))
const votingReadiness = computed(() =>
seasonDetail.value.categories.map((category) => {
const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
return {
...category,
candidateCount,
ready: candidateCount > 0 && seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
}
}),
)
const readyCount = computed(() => votingReadiness.value.filter((category) => category.ready).length)
const stats = computed(() => [
{ label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote },
{ label: 'Voting-ready', value: readyCount.value, icon: CheckCircle2 },
{ label: 'Kategorien', value: seasonDetail.value.categories.length, icon: Tags },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, icon: Users },
])
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Voting"
title="Voting-Status und Rankings"
description="Pruefe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivitaet erzeugen."
:icon="Vote"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card v-for="stat in stats" :key="stat.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">{{ stat.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ stat.value.toLocaleString('de-DE') }}</strong>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="stat.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
<Card class="p-6">
<div class="flex items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Ranking</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien</h2>
</div>
<BarChart3 class="h-6 w-6 text-violet-500" />
</div>
<div class="mt-6 space-y-4">
<div v-for="(category, index) 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">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-violet-500">#{{ index + 1 }}</p>
<h3 class="mt-1 font-semibold text-slate-900">{{ category.category }}</h3>
</div>
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
</div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-violet-50">
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
</div>
</div>
<p v-if="store.admin.topCategories.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-8 text-center text-sm text-slate-500">
Noch keine Voting-Daten vorhanden.
</p>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie-Check</h2>
</div>
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="category in votingReadiness" :key="category.id" 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.candidateCount }} Kandidaten</p>
</div>
<span class="h-fit rounded-full border px-3 py-1 text-xs font-semibold" :class="category.ready ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-amber-100 bg-amber-50 text-amber-700'">
{{ category.ready ? 'bereit' : 'pruefen' }}
</span>
</div>
</div>
</Card>
</section>
</div>
</template>