Expand admin navigation and pages
This commit is contained in:
@@ -2,11 +2,18 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
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 AdminLayoutView from './views/admin/AdminLayoutView.vue'
|
||||
import AdminNominationsView from './views/admin/AdminNominationsView.vue'
|
||||
import AdminReviewsView from './views/admin/AdminReviewsView.vue'
|
||||
import AdminRiskView from './views/admin/AdminRiskView.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 NominationsView from './views/NominationsView.vue'
|
||||
import VotingView from './views/VotingView.vue'
|
||||
@@ -85,6 +92,30 @@ const router = createRouter({
|
||||
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',
|
||||
name: 'admin-candidates',
|
||||
@@ -93,6 +124,14 @@ const router = createRouter({
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'clips',
|
||||
name: 'admin-clips',
|
||||
component: AdminClipsView,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'reviews',
|
||||
name: 'admin-reviews',
|
||||
@@ -109,6 +148,30 @@ const router = createRouter({
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -1,7 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
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 { useAwardsStore } from '../../stores/awards'
|
||||
@@ -11,12 +24,39 @@ const route = useRoute()
|
||||
const store = useAwardsStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const navItems = [
|
||||
{ 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: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pro Jahr pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
|
||||
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Nominierungen bearbeiten', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Hinweise pruefen, Aktionen nachvollziehen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
|
||||
const navGroups = [
|
||||
{
|
||||
label: 'Betrieb',
|
||||
items: [
|
||||
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, badge: () => null },
|
||||
{ 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)
|
||||
@@ -26,6 +66,10 @@ const seasonSummary = computed(() => [
|
||||
{ label: 'Reviews', value: currentSeason.value.pendingNominations.length },
|
||||
])
|
||||
|
||||
function isActive(to: string) {
|
||||
return route.path === to
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.isAdmin) return
|
||||
await store.initializeAdminWorkspace()
|
||||
@@ -34,21 +78,23 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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
|
||||
v-for="item in navItems"
|
||||
v-for="item in group.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="group block rounded-2xl border px-3.5 py-3 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="group block rounded-2xl border px-3 py-2.5 transition"
|
||||
: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="grid h-10 w-10 shrink-0 place-items-center rounded-2xl transition"
|
||||
:class="route.path === item.to ? 'bg-white text-violet-700 shadow-sm' : 'bg-violet-50 text-violet-600 group-hover:bg-white'"
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
|
||||
: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" />
|
||||
</div>
|
||||
@@ -57,7 +103,7 @@ onMounted(async () => {
|
||||
<p class="truncate text-sm font-semibold leading-5">{{ item.label }}</p>
|
||||
<span
|
||||
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() }}
|
||||
</span>
|
||||
@@ -66,6 +112,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</section>
|
||||
</nav>
|
||||
</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>
|
||||
Reference in New Issue
Block a user